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:
2026-04-10 08:37:34 +05:30
parent efe67dc28b
commit f57fbc1f24
25 changed files with 3461 additions and 706 deletions

View 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;