mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-14 20:12:25 +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:
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;
|
||||
Reference in New Issue
Block a user