mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +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:
205
src/components/forms/employee-create-form.tsx
Normal file
205
src/components/forms/employee-create-form.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEye, faEyeSlash, faRotate } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
|
||||
// In-place employee creation form used by the Team wizard step and
|
||||
// the /settings/team slideout. Replaces the multi-email InviteMemberForm
|
||||
// — this project never uses email invitations, all employees are
|
||||
// created directly with a temp password that the admin hands out.
|
||||
//
|
||||
// Two modes:
|
||||
//
|
||||
// - 'create': all fields editable. The temp password is auto-generated
|
||||
// on form mount (parent does this) and revealed via an eye icon. A
|
||||
// refresh icon next to the eye lets the admin re-roll the password
|
||||
// before saving.
|
||||
//
|
||||
// - 'edit': email is read-only (it's the login id, can't change),
|
||||
// password field is hidden entirely (no reset-password from the
|
||||
// wizard). Only firstName/lastName/role can change.
|
||||
//
|
||||
// SIP seat assignment is intentionally NOT in this form — it lives
|
||||
// exclusively in the Telephony wizard step, so there's a single source
|
||||
// of truth for "who is on which seat" and admins don't have to remember
|
||||
// two places to manage the same thing.
|
||||
|
||||
export type RoleOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
supportingText?: string;
|
||||
};
|
||||
|
||||
export type EmployeeCreateFormValues = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
export const emptyEmployeeCreateFormValues: EmployeeCreateFormValues = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roleId: '',
|
||||
};
|
||||
|
||||
// Random temp password generator. Skips visually-ambiguous chars
|
||||
// (0/O/1/l/I) so admins can read the password back over a phone call
|
||||
// without typo risk. 11 alphanumerics + 1 symbol = 12 chars total.
|
||||
export const generateTempPassword = (): string => {
|
||||
const chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const symbols = '!@#$';
|
||||
let pwd = '';
|
||||
for (let i = 0; i < 11; i++) {
|
||||
pwd += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
pwd += symbols[Math.floor(Math.random() * symbols.length)];
|
||||
return pwd;
|
||||
};
|
||||
|
||||
type EmployeeCreateFormProps = {
|
||||
value: EmployeeCreateFormValues;
|
||||
onChange: (value: EmployeeCreateFormValues) => void;
|
||||
roles: RoleOption[];
|
||||
// 'create' = full form, 'edit' = name + role only.
|
||||
mode?: 'create' | 'edit';
|
||||
};
|
||||
|
||||
// Eye / eye-slash button rendered inside the password field's
|
||||
// trailing slot. Stays internal to this form since password reveal
|
||||
// is the only place we need it right now.
|
||||
const EyeButton = ({ visible, onClick, title }: { visible: boolean; onClick: () => void; title: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
|
||||
>
|
||||
<FontAwesomeIcon icon={visible ? faEyeSlash : faEye} className="size-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
const RegenerateButton = ({ onClick }: { onClick: () => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title="Generate a new password"
|
||||
aria-label="Generate a new password"
|
||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} className="size-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
// Kept simple — name + contact + creds + role. No avatar, no phone,
|
||||
// no title. The goal is to get employees onto the system fast; they
|
||||
// can fill in the rest from their own profile page later.
|
||||
export const EmployeeCreateForm = ({
|
||||
value,
|
||||
onChange,
|
||||
roles,
|
||||
mode = 'create',
|
||||
}: EmployeeCreateFormProps) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const patch = (partial: Partial<EmployeeCreateFormValues>) =>
|
||||
onChange({ ...value, ...partial });
|
||||
|
||||
const isEdit = mode === 'edit';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="First name"
|
||||
placeholder="Priya"
|
||||
value={value.firstName}
|
||||
onChange={(v) => patch({ firstName: v })}
|
||||
isRequired
|
||||
/>
|
||||
<Input
|
||||
label="Last name"
|
||||
placeholder="Sharma"
|
||||
value={value.lastName}
|
||||
onChange={(v) => patch({ lastName: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="priya@hospital.com"
|
||||
value={value.email}
|
||||
onChange={(v) => patch({ email: v })}
|
||||
isRequired={!isEdit}
|
||||
isReadOnly={isEdit}
|
||||
isDisabled={isEdit}
|
||||
hint={
|
||||
isEdit
|
||||
? 'Email is the login id and cannot be changed.'
|
||||
: 'This is the login id for the employee. Cannot be changed later.'
|
||||
}
|
||||
/>
|
||||
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary">
|
||||
Temporary password <span className="text-error-primary">*</span>
|
||||
</label>
|
||||
<div className="mt-1.5 flex items-center gap-2 rounded-lg border border-secondary bg-primary px-3 shadow-xs focus-within:border-brand focus-within:ring-2 focus-within:ring-brand-100">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={value.password}
|
||||
onChange={(e) => patch({ password: e.target.value })}
|
||||
placeholder="Auto-generated"
|
||||
className="flex-1 bg-transparent py-2 font-mono text-sm text-primary placeholder:text-placeholder outline-none"
|
||||
/>
|
||||
<EyeButton
|
||||
visible={showPassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
title={showPassword ? 'Hide password' : 'Show password'}
|
||||
/>
|
||||
<RegenerateButton
|
||||
onClick={() => {
|
||||
patch({ password: generateTempPassword() });
|
||||
setShowPassword(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-tertiary">
|
||||
Auto-generated. Click the refresh icon to roll a new one. Share with the
|
||||
employee directly — they should change it after first login.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Role"
|
||||
placeholder={roles.length === 0 ? 'No roles available' : 'Select a role'}
|
||||
isDisabled={roles.length === 0}
|
||||
items={roles}
|
||||
selectedKey={value.roleId || null}
|
||||
onSelectionChange={(key) => patch({ roleId: (key as string) || '' })}
|
||||
isRequired
|
||||
>
|
||||
{(item) => (
|
||||
<Select.Item
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportingText={item.supportingText}
|
||||
/>
|
||||
)}
|
||||
</Select>
|
||||
|
||||
<div className="rounded-lg border border-dashed border-secondary bg-secondary p-3 text-xs text-tertiary">
|
||||
SIP seats are managed in the <b>Telephony</b> step — create the employee here
|
||||
first, then assign them a seat there.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user