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:
83
src/components/setup/resume-setup-banner.tsx
Normal file
83
src/components/setup/resume-setup-banner.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
|
||||
// Dismissible banner shown across the top of authenticated pages when
|
||||
// the hospital workspace has incomplete setup steps AND the admin has
|
||||
// already dismissed the auto-wizard. This is the "nudge" layer —
|
||||
// a persistent reminder that setup is still outstanding, without the
|
||||
// intrusion of the full-page wizard.
|
||||
//
|
||||
// Visibility rules:
|
||||
// - Admin users only (other roles can't complete setup)
|
||||
// - At least one setup step is still `completed: false`
|
||||
// - `setup-state.wizardDismissed === true` (otherwise the wizard
|
||||
// auto-shows on next login and this banner would be redundant)
|
||||
// - Not dismissed in the current browser session (resets on reload)
|
||||
export const ResumeSetupBanner = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
const [state, setState] = useState<SetupState | null>(null);
|
||||
const [dismissed, setDismissed] = useState(
|
||||
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || dismissed) return;
|
||||
getSetupState()
|
||||
.then(setState)
|
||||
.catch(() => {
|
||||
// Non-fatal — if setup-state isn't reachable, just
|
||||
// skip the banner. The wizard still works.
|
||||
});
|
||||
}, [isAdmin, dismissed]);
|
||||
|
||||
if (!isAdmin || !state || dismissed) return null;
|
||||
|
||||
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
|
||||
if (incompleteCount === 0) return null;
|
||||
|
||||
// If the wizard hasn't been dismissed yet, the first-run redirect
|
||||
// in login.tsx handles pushing the admin into /setup — no need
|
||||
// for this nudge.
|
||||
if (!state.wizardDismissed) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
sessionStorage.setItem('helix_resume_setup_dismissed', '1');
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-brand bg-brand-primary px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="size-4 text-brand-primary" />
|
||||
<span className="text-sm text-primary">
|
||||
<b>Finish setting up your hospital</b> — {incompleteCount} step
|
||||
{incompleteCount === 1 ? '' : 's'} still need your attention.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
href="/setup"
|
||||
iconTrailing={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faArrowRight} className={className} />
|
||||
)}
|
||||
>
|
||||
Resume setup
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary transition duration-100 ease-linear"
|
||||
title="Dismiss for this session"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
src/components/setup/wizard-layout-context.tsx
Normal file
27
src/components/setup/wizard-layout-context.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
// Context that lets each WizardStep render content into the wizard
|
||||
// shell's right pane via a portal — without lifting per-step data
|
||||
// fetching up to the page. The shell sets `rightPaneEl` to the
|
||||
// `<aside>` DOM node once it mounts; child WizardStep components read
|
||||
// it and createPortal their `rightPane` prop into it.
|
||||
//
|
||||
// Why a portal and not a state-lifted prop on WizardShell:
|
||||
// - The right pane is tightly coupled to the active step's data
|
||||
// (e.g. "list of clinics created so far") which lives in the step
|
||||
// component's state. Lifting that state to the page would mean
|
||||
// duplicating the data-fetching layer, OR re-querying everything
|
||||
// from the page.
|
||||
// - Trying to pass `rightPane: ReactNode` upward via callbacks
|
||||
// either causes a one-frame flash (useEffect) or violates the
|
||||
// "no setState during render" rule.
|
||||
// - Portals are React-native, no extra render cycles, and the
|
||||
// DOM target is already part of the layout.
|
||||
|
||||
export type WizardLayoutContextValue = {
|
||||
rightPaneEl: HTMLElement | null;
|
||||
};
|
||||
|
||||
export const WizardLayoutContext = createContext<WizardLayoutContextValue>({
|
||||
rightPaneEl: null,
|
||||
});
|
||||
426
src/components/setup/wizard-right-panes.tsx
Normal file
426
src/components/setup/wizard-right-panes.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBuilding,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCopy,
|
||||
faHeadset,
|
||||
faPenToSquare,
|
||||
faPhone,
|
||||
faRobot,
|
||||
faStethoscope,
|
||||
faUser,
|
||||
faUsers,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
// Reusable right-pane preview components for the onboarding wizard.
|
||||
// Each one is a pure presentation component that takes already-fetched
|
||||
// data as props — the parent step component owns the state + fetches
|
||||
// + refetches after a successful save. Keeping the panes data-only
|
||||
// means the active step can pass the same source of truth to both
|
||||
// the middle (form) pane and this preview without two GraphQL queries
|
||||
// running side by side.
|
||||
|
||||
// Shared title/empty state primitives so every pane has the same
|
||||
// visual rhythm.
|
||||
const PaneCard = ({
|
||||
title,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="rounded-xl border border-secondary bg-primary shadow-xs">
|
||||
<div className="flex items-center justify-between border-b border-secondary px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">{title}</p>
|
||||
{typeof count === 'number' && (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs font-semibold text-tertiary">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const EmptyState = ({ message }: { message: string }) => (
|
||||
<div className="px-4 py-6 text-center text-xs text-tertiary">{message}</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity step — short "about this step" card. Explains what the
|
||||
// admin is configuring and where it shows up in the staff portal so
|
||||
// the right pane stays useful even when there's nothing to list yet.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IDENTITY_BULLETS: { title: string; body: string }[] = [
|
||||
{
|
||||
title: 'Hospital name',
|
||||
body: 'Shown on the staff portal sidebar, the login screen, and every patient-facing widget greeting.',
|
||||
},
|
||||
{
|
||||
title: 'Logo',
|
||||
body: 'Used as the avatar at the top of the staff portal and on the website widget header. Square images work best.',
|
||||
},
|
||||
{
|
||||
title: 'Brand identity',
|
||||
body: 'Colors, fonts and login copy live on the full Branding page — open it from Settings any time after setup.',
|
||||
},
|
||||
];
|
||||
|
||||
export const IdentityRightPane = () => (
|
||||
<PaneCard title="About this step">
|
||||
<div className="px-4 py-4">
|
||||
<p className="text-sm text-tertiary">
|
||||
This is how patients and staff first see your hospital across Helix Engage.
|
||||
Get the basics in now — you can polish branding later.
|
||||
</p>
|
||||
<ul className="mt-4 flex flex-col gap-3">
|
||||
{IDENTITY_BULLETS.map((b) => (
|
||||
<li key={b.title} className="flex items-start gap-2.5">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleCheck}
|
||||
className="mt-0.5 size-4 shrink-0 text-fg-brand-primary"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">{b.title}</p>
|
||||
<p className="text-xs text-tertiary">{b.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</PaneCard>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clinics step — list of clinics created so far.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ClinicSummary = {
|
||||
id: string;
|
||||
clinicName: string | null;
|
||||
addressCity?: string | null;
|
||||
clinicStatus?: string | null;
|
||||
};
|
||||
|
||||
export const ClinicsRightPane = ({ clinics }: { clinics: ClinicSummary[] }) => (
|
||||
<PaneCard title="Clinics added" count={clinics.length}>
|
||||
{clinics.length === 0 ? (
|
||||
<EmptyState message="No clinics yet — add your first one in the form on the left." />
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary">
|
||||
{clinics.map((c) => (
|
||||
<li key={c.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
|
||||
<FontAwesomeIcon icon={faBuilding} className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-primary">
|
||||
{c.clinicName ?? 'Unnamed clinic'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-tertiary">
|
||||
{c.addressCity ?? 'No city'}
|
||||
{c.clinicStatus && ` · ${c.clinicStatus.toLowerCase()}`}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</PaneCard>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Doctors step — grouped by department, since the user explicitly asked
|
||||
// for "doctors grouped by department" earlier in the design discussion.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DoctorSummary = {
|
||||
id: string;
|
||||
fullName: { firstName: string | null; lastName: string | null } | null;
|
||||
department?: string | null;
|
||||
specialty?: string | null;
|
||||
};
|
||||
|
||||
const doctorDisplayName = (d: DoctorSummary): string => {
|
||||
const first = d.fullName?.firstName?.trim() ?? '';
|
||||
const last = d.fullName?.lastName?.trim() ?? '';
|
||||
const full = `${first} ${last}`.trim();
|
||||
return full.length > 0 ? full : 'Unnamed';
|
||||
};
|
||||
|
||||
export const DoctorsRightPane = ({ doctors }: { doctors: DoctorSummary[] }) => {
|
||||
// Group by department. Doctors with no department land in
|
||||
// "Unassigned" so they're not silently dropped.
|
||||
const grouped: Record<string, DoctorSummary[]> = {};
|
||||
for (const d of doctors) {
|
||||
const key = d.department?.trim() || 'Unassigned';
|
||||
(grouped[key] ??= []).push(d);
|
||||
}
|
||||
const sortedKeys = Object.keys(grouped).sort();
|
||||
|
||||
return (
|
||||
<PaneCard title="Doctors added" count={doctors.length}>
|
||||
{doctors.length === 0 ? (
|
||||
<EmptyState message="No doctors yet — add your first one in the form on the left." />
|
||||
) : (
|
||||
<div className="divide-y divide-secondary">
|
||||
{sortedKeys.map((dept) => (
|
||||
<div key={dept} className="px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
{dept}{' '}
|
||||
<span className="text-tertiary">({grouped[dept].length})</span>
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-col gap-2">
|
||||
{grouped[dept].map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex items-start gap-2.5 text-sm text-primary"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faStethoscope}
|
||||
className="mt-0.5 size-3.5 text-fg-quaternary"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{doctorDisplayName(d)}
|
||||
</p>
|
||||
{d.specialty && (
|
||||
<p className="truncate text-xs text-tertiary">
|
||||
{d.specialty}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PaneCard>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team step — list of employees with role + SIP badge.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TeamMemberSummary = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
roleLabel: string | null;
|
||||
sipExtension: string | null;
|
||||
// True if this row represents the currently logged-in admin —
|
||||
// suppresses the edit/copy icons since admins shouldn't edit
|
||||
// themselves from the wizard.
|
||||
isCurrentUser: boolean;
|
||||
// True if the parent has the plaintext temp password in memory
|
||||
// (i.e. this employee was created in the current session).
|
||||
// Drives whether the copy icon shows.
|
||||
canCopyCredentials: boolean;
|
||||
};
|
||||
|
||||
const memberDisplayName = (m: TeamMemberSummary): 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;
|
||||
};
|
||||
|
||||
// Tiny icon button shared between the edit and copy actions on the
|
||||
// employee row. Kept inline since it's only used here and the styling
|
||||
// matches the existing right-pane density.
|
||||
const RowIconButton = ({
|
||||
icon,
|
||||
title,
|
||||
onClick,
|
||||
}: {
|
||||
icon: typeof faPenToSquare;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className="size-3.5" />
|
||||
</button>
|
||||
);
|
||||
|
||||
export const TeamRightPane = ({
|
||||
members,
|
||||
onEdit,
|
||||
onCopy,
|
||||
}: {
|
||||
members: TeamMemberSummary[];
|
||||
onEdit?: (memberId: string) => void;
|
||||
onCopy?: (memberId: string) => void;
|
||||
}) => (
|
||||
<PaneCard title="Employees" count={members.length}>
|
||||
{members.length === 0 ? (
|
||||
<EmptyState message="No employees yet — create your first one in the form on the left." />
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary">
|
||||
{members.map((m) => (
|
||||
<li key={m.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
|
||||
<FontAwesomeIcon icon={faUser} className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-primary">
|
||||
{memberDisplayName(m)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-tertiary">
|
||||
{m.userEmail}
|
||||
{m.roleLabel && ` · ${m.roleLabel}`}
|
||||
</p>
|
||||
{m.sipExtension && (
|
||||
<span className="mt-1 inline-flex items-center gap-1 rounded-full bg-success-secondary px-2 py-0.5 text-xs font-medium text-success-primary">
|
||||
<FontAwesomeIcon icon={faHeadset} className="size-3" />
|
||||
{m.sipExtension}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Admin row gets neither button — admins
|
||||
shouldn't edit themselves from here, and
|
||||
their password isn't in our session
|
||||
memory anyway. */}
|
||||
{!m.isCurrentUser && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{m.canCopyCredentials && onCopy && (
|
||||
<RowIconButton
|
||||
icon={faCopy}
|
||||
title="Copy login credentials"
|
||||
onClick={() => onCopy(m.id)}
|
||||
/>
|
||||
)}
|
||||
{onEdit && (
|
||||
<RowIconButton
|
||||
icon={faPenToSquare}
|
||||
title="Edit employee"
|
||||
onClick={() => onEdit(m.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</PaneCard>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telephony step — live SIP → member mapping.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SipSeatSummary = {
|
||||
id: string;
|
||||
sipExtension: string | null;
|
||||
ozonetelAgentId: string | null;
|
||||
workspaceMember: {
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
userEmail: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const seatMemberLabel = (m: SipSeatSummary['workspaceMember']): string => {
|
||||
if (!m) return 'Unassigned';
|
||||
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 TelephonyRightPane = ({ seats }: { seats: SipSeatSummary[] }) => (
|
||||
<PaneCard title="SIP seats" count={seats.length}>
|
||||
{seats.length === 0 ? (
|
||||
<EmptyState message="No SIP seats configured — contact support to provision seats." />
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary">
|
||||
{seats.map((seat) => {
|
||||
const isAssigned = seat.workspaceMember !== null;
|
||||
return (
|
||||
<li key={seat.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<div
|
||||
className={`flex size-9 shrink-0 items-center justify-center rounded-full ${
|
||||
isAssigned
|
||||
? 'bg-brand-secondary text-brand-secondary'
|
||||
: 'bg-secondary text-quaternary'
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhone} className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-primary">
|
||||
Ext {seat.sipExtension ?? '—'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-tertiary">
|
||||
{seatMemberLabel(seat.workspaceMember)}
|
||||
</p>
|
||||
</div>
|
||||
{!isAssigned && (
|
||||
<span className="inline-flex shrink-0 items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-tertiary">
|
||||
Available
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</PaneCard>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI step — static cards for each configured actor with last-edited info.
|
||||
// Filled in once the backend prompt config refactor lands.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AiActorSummary = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
lastEditedAt: string | null;
|
||||
isCustom: boolean;
|
||||
};
|
||||
|
||||
export const AiRightPane = ({ actors }: { actors: AiActorSummary[] }) => (
|
||||
<PaneCard title="AI personas" count={actors.length}>
|
||||
{actors.length === 0 ? (
|
||||
<EmptyState message="Loading personas…" />
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary">
|
||||
{actors.map((a) => (
|
||||
<li key={a.key} className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
|
||||
<FontAwesomeIcon icon={faRobot} className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-primary">
|
||||
{a.label}
|
||||
</p>
|
||||
<p className="truncate text-xs text-tertiary">
|
||||
{a.isCustom
|
||||
? `Edited ${a.lastEditedAt ? new Date(a.lastEditedAt).toLocaleDateString() : 'recently'}`
|
||||
: 'Default'}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</PaneCard>
|
||||
);
|
||||
|
||||
// Suppress unused-import warnings for icons reserved for future use.
|
||||
void faCircle;
|
||||
void faUsers;
|
||||
@@ -1,45 +1,76 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircleCheck, faCircle } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { SETUP_STEP_NAMES, SETUP_STEP_LABELS, type SetupStepName, type SetupState } from '@/lib/setup-state';
|
||||
import { WizardLayoutContext } from './wizard-layout-context';
|
||||
|
||||
type WizardShellProps = {
|
||||
state: SetupState;
|
||||
activeStep: SetupStepName;
|
||||
onSelectStep: (step: SetupStepName) => void;
|
||||
onDismiss: () => void;
|
||||
// Form column (middle pane). The active step component renders
|
||||
// its form into this slot. The right pane is filled via the
|
||||
// WizardLayoutContext + a portal — see wizard-step.tsx.
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// Layout shell for the onboarding wizard. Renders a left-side step navigator
|
||||
// (with completed/active/upcoming visual states) and a right-side content
|
||||
// pane fed by the parent. The header has a "Skip for now" affordance that
|
||||
// dismisses the wizard for this workspace — once dismissed it never auto-shows
|
||||
// Layout shell for the onboarding wizard. Three-pane layout:
|
||||
// left — step navigator (fixed width)
|
||||
// middle — form (flexible, the focus column)
|
||||
// right — preview pane fed by the active step component (sticky,
|
||||
// hides below xl breakpoint)
|
||||
//
|
||||
// The whole shell is `fixed inset-0` so the document body cannot
|
||||
// scroll while the wizard is mounted — fixes the double-scrollbar
|
||||
// bug where the body was rendered taller than the viewport and
|
||||
// scrolled alongside the form column. The form and preview columns
|
||||
// each scroll independently inside the shell.
|
||||
//
|
||||
// The header has a "Skip for now" affordance that dismisses the
|
||||
// wizard for this workspace; once dismissed it never auto-shows
|
||||
// again on login.
|
||||
export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, children }: WizardShellProps) => {
|
||||
const completedCount = SETUP_STEP_NAMES.filter(s => state.steps[s].completed).length;
|
||||
export const WizardShell = ({
|
||||
state,
|
||||
activeStep,
|
||||
onSelectStep,
|
||||
onDismiss,
|
||||
children,
|
||||
}: WizardShellProps) => {
|
||||
const completedCount = SETUP_STEP_NAMES.filter((s) => state.steps[s].completed).length;
|
||||
const totalSteps = SETUP_STEP_NAMES.length;
|
||||
const progressPct = Math.round((completedCount / totalSteps) * 100);
|
||||
|
||||
// Callback ref → state — guarantees that consumers re-render once
|
||||
// the aside is mounted (a plain useRef would not propagate the
|
||||
// attached node back through the context). The element is also
|
||||
// updated to null on unmount so the context is always honest about
|
||||
// whether the slot is currently available for portals.
|
||||
const [rightPaneEl, setRightPaneEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-primary">
|
||||
{/* header */}
|
||||
<header className="border-b border-secondary bg-primary px-8 py-5">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-primary">Set up your hospital</h1>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
{completedCount} of {totalSteps} steps complete · finish setup to start using your workspace
|
||||
</p>
|
||||
<WizardLayoutContext.Provider value={{ rightPaneEl }}>
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-primary">
|
||||
{/* Header — pinned. Progress bar always visible (grey
|
||||
track when 0%), sits flush under the title row. */}
|
||||
<header className="shrink-0 border-b border-secondary bg-primary">
|
||||
<div className="mx-auto flex w-full max-w-screen-2xl items-center justify-between gap-6 px-8 pt-4 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-primary">Set up your hospital</h1>
|
||||
<p className="text-xs text-tertiary">
|
||||
{completedCount} of {totalSteps} steps complete · finish setup to start
|
||||
using your workspace
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button color="link-gray" size="sm" onClick={onDismiss}>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
{/* progress bar */}
|
||||
<div className="mx-auto mt-4 max-w-6xl">
|
||||
<div className="mx-auto w-full max-w-screen-2xl px-8 pb-3">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-solid transition-all duration-300"
|
||||
@@ -49,9 +80,13 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* body — step navigator + content */}
|
||||
<div className="mx-auto flex max-w-6xl gap-8 px-8 py-8">
|
||||
<nav className="w-72 shrink-0">
|
||||
{/* Body — three columns inside a fixed-height flex row.
|
||||
min-h-0 on the row + each column lets the inner
|
||||
overflow-y-auto actually take effect. */}
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-screen-2xl flex-1 gap-6 px-8 py-6">
|
||||
{/* Left — step navigator. Scrolls if it overflows on
|
||||
very short viewports, but in practice it fits. */}
|
||||
<nav className="w-60 shrink-0 overflow-y-auto">
|
||||
<ol className="flex flex-col gap-1">
|
||||
{SETUP_STEP_NAMES.map((step, idx) => {
|
||||
const meta = SETUP_STEP_LABELS[step];
|
||||
@@ -64,7 +99,7 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
type="button"
|
||||
onClick={() => onSelectStep(step)}
|
||||
className={cx(
|
||||
'group flex w-full items-start gap-3 rounded-lg border px-3 py-3 text-left transition',
|
||||
'group flex w-full items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition',
|
||||
isActive
|
||||
? 'border-brand bg-brand-primary'
|
||||
: 'border-transparent hover:bg-secondary',
|
||||
@@ -75,7 +110,9 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
icon={isComplete ? faCircleCheck : faCircle}
|
||||
className={cx(
|
||||
'size-5',
|
||||
isComplete ? 'text-success-primary' : 'text-quaternary',
|
||||
isComplete
|
||||
? 'text-success-primary'
|
||||
: 'text-quaternary',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
@@ -99,8 +136,24 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<main className="min-w-0 flex-1">{children}</main>
|
||||
{/* Middle — form column. min-w-0 prevents children from
|
||||
forcing the column wider than its flex basis (long
|
||||
inputs, etc.). overflow-y-auto so it scrolls
|
||||
independently of the right pane. */}
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-y-auto">{children}</main>
|
||||
|
||||
{/* Right — preview pane. Always rendered as a stable
|
||||
portal target (so the active step's WizardStep can
|
||||
createPortal into it via WizardLayoutContext).
|
||||
Hidden below xl breakpoint (1280px) so the wizard
|
||||
collapses cleanly to two columns on smaller screens.
|
||||
Independent scroll. */}
|
||||
<aside
|
||||
ref={setRightPaneEl}
|
||||
className="hidden w-80 shrink-0 overflow-y-auto xl:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</WizardLayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPenToSquare, faRotateLeft, faRobot } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { AiRightPane, type AiActorSummary } from './wizard-right-panes';
|
||||
import { AiForm, emptyAiFormValues, type AiFormValues, type AiProvider } from '@/components/forms/ai-form';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
// AI step (post-prompt-config rework). The middle pane has two sections:
|
||||
//
|
||||
// 1. Provider / model / temperature picker — same as before, drives the
|
||||
// provider that all actors use under the hood.
|
||||
// 2. AI personas — list of 7 actor cards, each with name, description,
|
||||
// truncated current template, and an Edit button. Edit triggers a
|
||||
// confirmation modal warning about unintended consequences, then
|
||||
// opens a slideout with the full template + a "variables you can
|
||||
// use" reference + Save / Reset.
|
||||
//
|
||||
// The right pane shows the same 7 personas as compact "last edited" cards
|
||||
// so the admin can scan recent activity at a glance.
|
||||
//
|
||||
// Backend wiring lives in helix-engage-server/src/config/ai.defaults.ts
|
||||
// (DEFAULT_AI_PROMPTS) + ai-config.service.ts (renderPrompt / updatePrompt
|
||||
// / resetPrompt). The 7 service files (widget chat, CC agent helper,
|
||||
// supervisor, lead enrichment, call insight, call assist, recording
|
||||
// analysis) all call AiConfigService.renderPrompt(actor, vars) so any
|
||||
// edit here lands instantly.
|
||||
|
||||
type ServerPromptConfig = {
|
||||
label: string;
|
||||
description: string;
|
||||
variables: { key: string; description: string }[];
|
||||
template: string;
|
||||
defaultTemplate: string;
|
||||
lastEditedAt: string | null;
|
||||
lastEditedBy: string | null;
|
||||
};
|
||||
|
||||
type ServerAiConfig = {
|
||||
provider?: AiProvider;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
systemPromptAddendum?: string;
|
||||
prompts?: Record<string, ServerPromptConfig>;
|
||||
};
|
||||
|
||||
// AI step — loads the current AI config, lets the admin pick provider and
|
||||
// model, and saves. This is the last step, so on save we fire the finish
|
||||
// flow instead of advancing.
|
||||
// Display order for the actor cards. Mirrors AI_ACTOR_KEYS in
|
||||
// ai.defaults.ts so the wizard renders personas in the same order
|
||||
// admins see them documented elsewhere.
|
||||
const ACTOR_ORDER = [
|
||||
'widgetChat',
|
||||
'ccAgentHelper',
|
||||
'supervisorChat',
|
||||
'leadEnrichment',
|
||||
'callInsight',
|
||||
'callAssist',
|
||||
'recordingAnalysis',
|
||||
] as const;
|
||||
|
||||
const truncate = (s: string, max: number): string =>
|
||||
s.length > max ? s.slice(0, max).trimEnd() + '…' : s;
|
||||
|
||||
export const WizardStepAi = (props: WizardStepComponentProps) => {
|
||||
const { user } = useAuth();
|
||||
const [values, setValues] = useState<AiFormValues>(emptyAiFormValues);
|
||||
const [prompts, setPrompts] = useState<Record<string, ServerPromptConfig>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get<ServerAiConfig>('/api/config/ai', { silent: true })
|
||||
.then((data) => {
|
||||
setValues({
|
||||
provider: data.provider ?? 'openai',
|
||||
model: data.model ?? 'gpt-4o-mini',
|
||||
temperature: data.temperature != null ? String(data.temperature) : '0.7',
|
||||
systemPromptAddendum: data.systemPromptAddendum ?? '',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// non-fatal — defaults will do
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
// Edit flow state — three phases:
|
||||
// 1. confirmingActor: which actor's Edit button was just clicked
|
||||
// (drives the confirmation modal)
|
||||
// 2. editingActor: which actor's slideout is open (only set after
|
||||
// the user confirms past the warning prompt)
|
||||
// 3. draftTemplate: the current textarea contents in the slideout
|
||||
const [confirmingActor, setConfirmingActor] = useState<string | null>(null);
|
||||
const [editingActor, setEditingActor] = useState<string | null>(null);
|
||||
const [draftTemplate, setDraftTemplate] = useState('');
|
||||
const [savingPrompt, setSavingPrompt] = useState(false);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.get<ServerAiConfig>('/api/config/ai', { silent: true });
|
||||
setValues({
|
||||
provider: data.provider ?? 'openai',
|
||||
model: data.model ?? 'gpt-4o-mini',
|
||||
temperature: data.temperature != null ? String(data.temperature) : '0.7',
|
||||
systemPromptAddendum: '',
|
||||
});
|
||||
setPrompts(data.prompts ?? {});
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] fetch failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const handleSaveProviderConfig = async () => {
|
||||
if (!values.model.trim()) {
|
||||
notify.error('Model is required');
|
||||
return;
|
||||
@@ -49,20 +116,92 @@ export const WizardStepAi = (props: WizardStepComponentProps) => {
|
||||
provider: values.provider,
|
||||
model: values.model.trim(),
|
||||
temperature: Number.isNaN(temperature) ? 0.7 : Math.min(2, Math.max(0, temperature)),
|
||||
systemPromptAddendum: values.systemPromptAddendum,
|
||||
});
|
||||
notify.success('AI settings saved', 'Your assistant is ready.');
|
||||
await props.onComplete('ai');
|
||||
// Don't auto-advance — this is the last step, the WizardStep
|
||||
// shell already renders a "Finish setup" button the admin taps
|
||||
// themselves.
|
||||
notify.success('AI settings saved', 'Provider and model updated.');
|
||||
await fetchConfig();
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('ai');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] save failed', err);
|
||||
console.error('[wizard/ai] save provider failed', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Confirmation modal → slideout flow.
|
||||
const handleEditClick = (actor: string) => {
|
||||
setConfirmingActor(actor);
|
||||
};
|
||||
|
||||
const handleConfirmEdit = () => {
|
||||
if (!confirmingActor) return;
|
||||
const prompt = prompts[confirmingActor];
|
||||
if (!prompt) return;
|
||||
setEditingActor(confirmingActor);
|
||||
setDraftTemplate(prompt.template);
|
||||
setConfirmingActor(null);
|
||||
};
|
||||
|
||||
const handleSavePrompt = async (close: () => void) => {
|
||||
if (!editingActor) return;
|
||||
if (!draftTemplate.trim()) {
|
||||
notify.error('Prompt cannot be empty');
|
||||
return;
|
||||
}
|
||||
setSavingPrompt(true);
|
||||
try {
|
||||
await apiClient.put(`/api/config/ai/prompts/${editingActor}`, {
|
||||
template: draftTemplate,
|
||||
editedBy: user?.email ?? null,
|
||||
});
|
||||
notify.success('Prompt updated', `${prompts[editingActor]?.label ?? editingActor} saved`);
|
||||
await fetchConfig();
|
||||
close();
|
||||
setEditingActor(null);
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] save prompt failed', err);
|
||||
} finally {
|
||||
setSavingPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPrompt = async (close: () => void) => {
|
||||
if (!editingActor) return;
|
||||
setSavingPrompt(true);
|
||||
try {
|
||||
await apiClient.post(`/api/config/ai/prompts/${editingActor}/reset`);
|
||||
notify.success('Prompt reset', `${prompts[editingActor]?.label ?? editingActor} restored to default`);
|
||||
await fetchConfig();
|
||||
close();
|
||||
setEditingActor(null);
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] reset prompt failed', err);
|
||||
} finally {
|
||||
setSavingPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Build the right-pane summary entries from the loaded prompts.
|
||||
// `isCustom` is true when the template differs from the shipped
|
||||
// default OR when the audit fields are populated — either way the
|
||||
// admin has touched it.
|
||||
const actorSummaries = useMemo<AiActorSummary[]>(() => {
|
||||
return ACTOR_ORDER.filter((key) => prompts[key]).map((key) => {
|
||||
const p = prompts[key];
|
||||
return {
|
||||
key,
|
||||
label: p.label,
|
||||
description: p.description,
|
||||
lastEditedAt: p.lastEditedAt,
|
||||
isCustom: p.template !== p.defaultTemplate || p.lastEditedAt !== null,
|
||||
};
|
||||
});
|
||||
}, [prompts]);
|
||||
|
||||
const editingPrompt = editingActor ? prompts[editingActor] : null;
|
||||
const confirmingLabel = confirmingActor ? prompts[confirmingActor]?.label : '';
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="ai"
|
||||
@@ -70,15 +209,226 @@ export const WizardStepAi = (props: WizardStepComponentProps) => {
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onMarkComplete={handleSaveProviderConfig}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<AiRightPane actors={actorSummaries} />}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading AI settings…</p>
|
||||
) : (
|
||||
<AiForm value={values} onChange={setValues} />
|
||||
<div className="flex flex-col gap-8">
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-semibold text-primary">Provider & model</h3>
|
||||
<AiForm value={values} onChange={setValues} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-primary">AI personas</h3>
|
||||
<span className="text-xs text-tertiary">
|
||||
{actorSummaries.length} configurable prompts
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-4 text-xs text-tertiary">
|
||||
Each persona below is a different AI surface in Helix Engage. Editing a
|
||||
prompt changes how that persona sounds and what rules it follows. Defaults
|
||||
are tuned for hospital call centers — only edit if you have a specific
|
||||
reason and can test the result.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{ACTOR_ORDER.map((key) => {
|
||||
const prompt = prompts[key];
|
||||
if (!prompt) return null;
|
||||
const isCustom =
|
||||
prompt.template !== prompt.defaultTemplate ||
|
||||
prompt.lastEditedAt !== null;
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className="rounded-xl border border-secondary bg-primary p-4 shadow-xs"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
|
||||
<FontAwesomeIcon icon={faRobot} className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h4 className="truncate text-sm font-semibold text-primary">
|
||||
{prompt.label}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-xs text-tertiary">
|
||||
{prompt.description}
|
||||
</p>
|
||||
</div>
|
||||
{isCustom && (
|
||||
<span className="shrink-0 rounded-full bg-warning-secondary px-2 py-0.5 text-xs font-medium text-warning-primary">
|
||||
Edited
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-3 rounded-lg border border-secondary bg-secondary p-3 font-mono text-xs leading-relaxed text-tertiary">
|
||||
{truncate(prompt.template, 220)}
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={() => handleEditClick(key)}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon
|
||||
icon={faPenToSquare}
|
||||
className={className}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation modal — reused from the patient-edit gate. */}
|
||||
<EditPatientConfirmModal
|
||||
isOpen={confirmingActor !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmingActor(null);
|
||||
}}
|
||||
onConfirm={handleConfirmEdit}
|
||||
title={`Edit ${confirmingLabel} prompt?`}
|
||||
description={
|
||||
<>
|
||||
Modifying this prompt can affect call quality, lead summaries, and supervisor
|
||||
insights in ways that are hard to predict. The defaults are tuned for hospital
|
||||
call centers — only edit if you have a specific reason and can test the
|
||||
result. You can always reset back to default from the editor.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Yes, edit prompt"
|
||||
/>
|
||||
|
||||
{/* Slideout editor — only opens after the warning is confirmed. */}
|
||||
<SlideoutMenu
|
||||
isOpen={editingActor !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingActor(null);
|
||||
}}
|
||||
isDismissable
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<SlideoutMenu.Header onClose={close}>
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
|
||||
<FontAwesomeIcon
|
||||
icon={faRobot}
|
||||
className="size-5 text-fg-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
Edit {editingPrompt?.label}
|
||||
</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
{editingPrompt?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Header>
|
||||
|
||||
<SlideoutMenu.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary">
|
||||
Prompt template
|
||||
</label>
|
||||
<textarea
|
||||
value={draftTemplate}
|
||||
onChange={(e) => setDraftTemplate(e.target.value)}
|
||||
rows={18}
|
||||
className="mt-1.5 w-full resize-y rounded-lg border border-secondary bg-primary p-3 font-mono text-xs text-primary outline-none focus:border-brand focus:ring-2 focus:ring-brand-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-tertiary">
|
||||
Variables wrapped in <code>{'{{double-braces}}'}</code> get
|
||||
substituted at runtime with live data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{editingPrompt?.variables && editingPrompt.variables.length > 0 && (
|
||||
<div className="rounded-lg border border-secondary bg-secondary p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Variables you can use
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-col gap-1.5">
|
||||
{editingPrompt.variables.map((v) => (
|
||||
<li key={v.key} className="flex items-start gap-2 text-xs">
|
||||
<code className="shrink-0 rounded bg-primary px-1.5 py-0.5 font-mono text-brand-primary">
|
||||
{`{{${v.key}}}`}
|
||||
</code>
|
||||
<span className="text-tertiary">
|
||||
{v.description}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingPrompt?.lastEditedAt && (
|
||||
<p className="text-xs text-tertiary">
|
||||
Last edited{' '}
|
||||
{new Date(editingPrompt.lastEditedAt).toLocaleString()}
|
||||
{editingPrompt.lastEditedBy && ` by ${editingPrompt.lastEditedBy}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SlideoutMenu.Content>
|
||||
|
||||
<SlideoutMenu.Footer>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
size="md"
|
||||
color="link-gray"
|
||||
isDisabled={savingPrompt}
|
||||
onClick={() => handleResetPrompt(close)}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faRotateLeft} className={className} />
|
||||
)}
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="md"
|
||||
color="secondary"
|
||||
isDisabled={savingPrompt}
|
||||
onClick={close}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isLoading={savingPrompt}
|
||||
showTextWhileLoading
|
||||
onClick={() => handleSavePrompt(close)}
|
||||
>
|
||||
{savingPrompt ? 'Saving…' : 'Save prompt'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Footer>
|
||||
</>
|
||||
)}
|
||||
</SlideoutMenu>
|
||||
</WizardStep>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { ClinicsRightPane, type ClinicSummary } from './wizard-right-panes';
|
||||
import {
|
||||
ClinicForm,
|
||||
clinicFormToGraphQLInput,
|
||||
clinicCoreToGraphQLInput,
|
||||
holidayInputsFromForm,
|
||||
requiredDocInputsFromForm,
|
||||
emptyClinicFormValues,
|
||||
type ClinicFormValues,
|
||||
} from '@/components/forms/clinic-form';
|
||||
@@ -10,16 +13,67 @@ import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
// Clinic step — presents a single-clinic form. On save, creates the clinic,
|
||||
// marks the step complete, and advances. The admin can come back to
|
||||
// /settings/clinics later to add more branches or edit existing ones.
|
||||
// Clinic step — presents a single-clinic form. On save the wizard runs
|
||||
// a three-stage create chain:
|
||||
// 1. createClinic (main record → get id)
|
||||
// 2. createHoliday × N (one per holiday entry)
|
||||
// 3. createClinicRequiredDocument × N (one per required doc type)
|
||||
//
|
||||
// We don't pre-load the existing clinic list here because we always want the
|
||||
// form to represent "add a new clinic"; the list page is the right surface
|
||||
// for editing.
|
||||
// This mirrors what the /settings/clinics list page does, minus the
|
||||
// delete-old-first step (wizard is always creating, never updating).
|
||||
// Failures inside the chain throw up through onComplete so the user
|
||||
// sees the error loud, and the wizard stays on the current step.
|
||||
export const WizardStepClinics = (props: WizardStepComponentProps) => {
|
||||
const [values, setValues] = useState<ClinicFormValues>(emptyClinicFormValues);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [clinics, setClinics] = useState<ClinicSummary[]>([]);
|
||||
|
||||
const fetchClinics = useCallback(async () => {
|
||||
try {
|
||||
// Field names match what the platform actually exposes:
|
||||
// - the SDK ADDRESS field is named "address" but the
|
||||
// platform mounts it as `addressCustom` (composite type
|
||||
// with addressCity / addressStreet / etc.)
|
||||
// - the SDK SELECT field labelled "Status" lands as plain
|
||||
// `status: ClinicStatusEnum`, NOT `clinicStatus`
|
||||
// Verified via __type introspection — keep this query
|
||||
// pinned to the actual schema to avoid silent empty fetches.
|
||||
type ClinicNode = {
|
||||
id: string;
|
||||
clinicName: string | null;
|
||||
addressCustom: { addressCity: string | null } | null;
|
||||
status: string | null;
|
||||
};
|
||||
const data = await apiClient.graphql<{
|
||||
clinics: { edges: { node: ClinicNode }[] };
|
||||
}>(
|
||||
`{ clinics(first: 100, orderBy: { createdAt: DescNullsLast }) {
|
||||
edges { node {
|
||||
id clinicName
|
||||
addressCustom { addressCity }
|
||||
status
|
||||
} }
|
||||
} }`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
);
|
||||
// Flatten into the shape ClinicsRightPane expects.
|
||||
setClinics(
|
||||
data.clinics.edges.map((e) => ({
|
||||
id: e.node.id,
|
||||
clinicName: e.node.clinicName,
|
||||
addressCity: e.node.addressCustom?.addressCity ?? null,
|
||||
clinicStatus: e.node.status,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[wizard/clinics] fetch failed', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClinics();
|
||||
}, [fetchClinics]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!values.clinicName.trim()) {
|
||||
@@ -28,16 +82,54 @@ export const WizardStepClinics = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
// 1. Core clinic record
|
||||
const res = await apiClient.graphql<{ createClinic: { id: string } }>(
|
||||
`mutation CreateClinic($data: ClinicCreateInput!) {
|
||||
createClinic(data: $data) { id }
|
||||
}`,
|
||||
{ data: clinicFormToGraphQLInput(values) },
|
||||
{ data: clinicCoreToGraphQLInput(values) },
|
||||
);
|
||||
const clinicId = res.createClinic.id;
|
||||
|
||||
// 2. Holidays
|
||||
if (values.holidays.length > 0) {
|
||||
const holidayInputs = holidayInputsFromForm(values, clinicId);
|
||||
await Promise.all(
|
||||
holidayInputs.map((data) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateHoliday($data: HolidayCreateInput!) {
|
||||
createHoliday(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Required documents
|
||||
if (values.requiredDocumentTypes.length > 0) {
|
||||
const docInputs = requiredDocInputsFromForm(values, clinicId);
|
||||
await Promise.all(
|
||||
docInputs.map((data) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateClinicRequiredDocument($data: ClinicRequiredDocumentCreateInput!) {
|
||||
createClinicRequiredDocument(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
notify.success('Clinic added', values.clinicName);
|
||||
await props.onComplete('clinics');
|
||||
await fetchClinics();
|
||||
// Mark complete on first successful create. Don't auto-advance —
|
||||
// admins typically add multiple clinics in one sitting; the
|
||||
// Continue button on the wizard nav handles forward motion.
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('clinics');
|
||||
}
|
||||
setValues(emptyClinicFormValues());
|
||||
props.onAdvance();
|
||||
} catch (err) {
|
||||
console.error('[wizard/clinics] save failed', err);
|
||||
} finally {
|
||||
@@ -45,24 +137,35 @@ export const WizardStepClinics = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Same trick as the Team step: once at least one clinic exists,
|
||||
// flip isCompleted=true so the WizardStep renders the "Continue"
|
||||
// button as the primary action — the form stays open below for
|
||||
// adding more clinics.
|
||||
const pretendCompleted = props.isCompleted || clinics.length > 0;
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="clinics"
|
||||
isCompleted={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<ClinicsRightPane clinics={clinics} />}
|
||||
>
|
||||
{props.isCompleted && (
|
||||
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
You've already added at least one clinic. Fill the form again to add another, or click{' '}
|
||||
<b>Next</b> to continue.
|
||||
</div>
|
||||
)}
|
||||
<ClinicForm value={values} onChange={setValues} />
|
||||
<div className="mt-6 flex justify-end">
|
||||
<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 ? 'Adding…' : 'Add clinic'}
|
||||
</button>
|
||||
</div>
|
||||
</WizardStep>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { DoctorsRightPane, type DoctorSummary } from './wizard-right-panes';
|
||||
import {
|
||||
DoctorForm,
|
||||
doctorFormToGraphQLInput,
|
||||
doctorCoreToGraphQLInput,
|
||||
visitSlotInputsFromForm,
|
||||
emptyDoctorFormValues,
|
||||
type DoctorFormValues,
|
||||
} from '@/components/forms/doctor-form';
|
||||
@@ -20,21 +22,40 @@ type ClinicLite = { id: string; clinicName: string | null };
|
||||
export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
const [values, setValues] = useState<DoctorFormValues>(emptyDoctorFormValues);
|
||||
const [clinics, setClinics] = useState<ClinicLite[]>([]);
|
||||
const [doctors, setDoctors] = useState<DoctorSummary[]>([]);
|
||||
const [loadingClinics, setLoadingClinics] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.graphql<{ clinics: { edges: { node: ClinicLite }[] } }>(
|
||||
`{ clinics(first: 100) { edges { node { id clinicName } } } }`,
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{
|
||||
clinics: { edges: { node: ClinicLite }[] };
|
||||
doctors: { edges: { node: DoctorSummary }[] };
|
||||
}>(
|
||||
`{
|
||||
clinics(first: 100) { edges { node { id clinicName } } }
|
||||
doctors(first: 100, orderBy: { createdAt: DescNullsLast }) {
|
||||
edges { node { id fullName { firstName lastName } department specialty } }
|
||||
}
|
||||
}`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
)
|
||||
.then((data) => setClinics(data.clinics.edges.map((e) => e.node)))
|
||||
.catch(() => setClinics([]))
|
||||
.finally(() => setLoadingClinics(false));
|
||||
);
|
||||
setClinics(data.clinics.edges.map((e) => e.node));
|
||||
setDoctors(data.doctors.edges.map((e) => e.node));
|
||||
} catch (err) {
|
||||
console.error('[wizard/doctors] fetch failed', err);
|
||||
setClinics([]);
|
||||
setDoctors([]);
|
||||
} finally {
|
||||
setLoadingClinics(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const clinicOptions = useMemo(
|
||||
() => clinics.map((c) => ({ id: c.id, label: c.clinicName ?? 'Unnamed clinic' })),
|
||||
[clinics],
|
||||
@@ -47,16 +68,37 @@ export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
// 1. Core doctor record
|
||||
const res = await apiClient.graphql<{ createDoctor: { id: string } }>(
|
||||
`mutation CreateDoctor($data: DoctorCreateInput!) {
|
||||
createDoctor(data: $data) { id }
|
||||
}`,
|
||||
{ data: doctorFormToGraphQLInput(values) },
|
||||
{ data: doctorCoreToGraphQLInput(values) },
|
||||
);
|
||||
const doctorId = res.createDoctor.id;
|
||||
|
||||
// 2. Visit slots (doctor can be at multiple clinics on
|
||||
// multiple days with different times each).
|
||||
const slotInputs = visitSlotInputsFromForm(values, doctorId);
|
||||
if (slotInputs.length > 0) {
|
||||
await Promise.all(
|
||||
slotInputs.map((data) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateDoctorVisitSlot($data: DoctorVisitSlotCreateInput!) {
|
||||
createDoctorVisitSlot(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
notify.success('Doctor added', `Dr. ${values.firstName} ${values.lastName}`);
|
||||
await props.onComplete('doctors');
|
||||
await fetchData();
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('doctors');
|
||||
}
|
||||
setValues(emptyDoctorFormValues());
|
||||
props.onAdvance();
|
||||
} catch (err) {
|
||||
console.error('[wizard/doctors] save failed', err);
|
||||
} finally {
|
||||
@@ -64,16 +106,19 @@ export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pretendCompleted = props.isCompleted || doctors.length > 0;
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="doctors"
|
||||
isCompleted={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<DoctorsRightPane doctors={doctors} />}
|
||||
>
|
||||
{loadingClinics ? (
|
||||
<p className="text-sm text-tertiary">Loading clinics…</p>
|
||||
@@ -87,13 +132,17 @@ export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{props.isCompleted && (
|
||||
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
You've already added at least one doctor. Fill the form again to add another, or
|
||||
click <b>Next</b> to continue.
|
||||
</div>
|
||||
)}
|
||||
<DoctorForm value={values} onChange={setValues} clinics={clinicOptions} />
|
||||
<div className="mt-6 flex justify-end">
|
||||
<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 ? 'Adding…' : 'Add doctor'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</WizardStep>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { IdentityRightPane } from './wizard-right-panes';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
@@ -74,6 +75,7 @@ export const WizardStepIdentity = (props: WizardStepComponentProps) => {
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<IdentityRightPane />}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading current branding…</p>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useContext, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowLeft, faArrowRight, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { SETUP_STEP_LABELS, type SetupStepName } from '@/lib/setup-state';
|
||||
import { WizardLayoutContext } from './wizard-layout-context';
|
||||
|
||||
type WizardStepProps = {
|
||||
step: SetupStepName;
|
||||
@@ -14,6 +16,11 @@ type WizardStepProps = {
|
||||
onFinish: () => void;
|
||||
saving?: boolean;
|
||||
children: ReactNode;
|
||||
// Optional content for the wizard shell's right preview pane.
|
||||
// Portaled into the shell's <aside> via WizardLayoutContext when
|
||||
// both are mounted. Each step component declares this inline so
|
||||
// the per-step data fetching stays in one place.
|
||||
rightPane?: ReactNode;
|
||||
};
|
||||
|
||||
// Single-step wrapper. The parent picks which step is active and supplies
|
||||
@@ -31,10 +38,14 @@ export const WizardStep = ({
|
||||
onFinish,
|
||||
saving = false,
|
||||
children,
|
||||
rightPane,
|
||||
}: WizardStepProps) => {
|
||||
const meta = SETUP_STEP_LABELS[step];
|
||||
const { rightPaneEl } = useContext(WizardLayoutContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
{rightPane && rightPaneEl && createPortal(rightPane, rightPaneEl)}
|
||||
<div className="rounded-xl border border-secondary bg-primary p-8 shadow-xs">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -64,30 +75,55 @@ export const WizardStep = ({
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{/* One primary action at the bottom — never two
|
||||
competing buttons. Previously the wizard showed
|
||||
Mark complete + Next side-by-side, and users
|
||||
naturally clicked Next (rightmost = "continue"),
|
||||
skipping the save+complete chain entirely. Result
|
||||
was every step staying at 0/6.
|
||||
|
||||
New behaviour: a single button whose label and
|
||||
handler depend on completion state.
|
||||
- !isCompleted, not last → "Save and continue"
|
||||
calls onMarkComplete (which does save +
|
||||
complete + advance via the step component's
|
||||
handleSave). Forces the agent through the
|
||||
completion path.
|
||||
- !isCompleted, last → "Save and finish"
|
||||
same chain, plus onFinish at the end.
|
||||
- isCompleted, not last → "Continue"
|
||||
calls onNext (pure navigation).
|
||||
- isCompleted, last → "Finish setup"
|
||||
calls onFinish.
|
||||
|
||||
Free-form navigation is still available via the
|
||||
left-side step nav, so users can revisit completed
|
||||
steps without re-saving. */}
|
||||
<div className="flex items-center gap-3">
|
||||
{!isCompleted && (
|
||||
{!isCompleted ? (
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
isLoading={saving}
|
||||
showTextWhileLoading
|
||||
onClick={onMarkComplete}
|
||||
iconTrailing={
|
||||
isLast
|
||||
? undefined
|
||||
: ({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faArrowRight} className={className} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Mark complete
|
||||
{isLast ? 'Save and finish' : 'Save and continue'}
|
||||
</Button>
|
||||
)}
|
||||
{isLast ? (
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
isDisabled={!isCompleted}
|
||||
onClick={onFinish}
|
||||
>
|
||||
) : isLast ? (
|
||||
<Button color="primary" size="md" onClick={onFinish}>
|
||||
Finish setup
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color={isCompleted ? 'primary' : 'secondary'}
|
||||
color="primary"
|
||||
size="md"
|
||||
isDisabled={!onNext}
|
||||
onClick={onNext ?? undefined}
|
||||
@@ -95,11 +131,12 @@ export const WizardStep = ({
|
||||
<FontAwesomeIcon icon={faArrowRight} className={className} />
|
||||
)}
|
||||
>
|
||||
Next
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user