mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-14 12:12:23 +00:00
- Setup wizard: 3-pane layout with right-side live previews, resume banner, edit/copy icons on team step, AI prompt configuration - Forms: employee-create replaces invite-member (no email invites), clinic form with address/hours/payment, doctor form with visit slots - Seed script: aligned to current SDK schema — doctors created as workspace members (HelixEngage Manager role), visitingHours replaced by doctorVisitSlot entity, clinics seeded, portalUserId linked dynamically, SUB/ORIGIN/GQL configurable via env vars - Pages: clinics + doctors CRUD updated for new schema, team settings with temp password + role assignment - New components: time-picker, day-selector, wizard-right-panes, wizard-layout-context, resume-setup-banner - Removed: invite-member-form (replaced by employee-create-form per no-email-invites rule) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
427 lines
18 KiB
TypeScript
427 lines
18 KiB
TypeScript
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;
|