mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +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>
402 lines
15 KiB
TypeScript
402 lines
15 KiB
TypeScript
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
|
|
import { Input } from '@/components/base/input/input';
|
|
import { Select } from '@/components/base/select/select';
|
|
import { Toggle } from '@/components/base/toggle/toggle';
|
|
import { Button } from '@/components/base/buttons/button';
|
|
import { TimePicker } from '@/components/application/date-picker/time-picker';
|
|
|
|
// Doctor form — hospital-wide profile with multi-clinic, multi-day
|
|
// visiting schedule. Each row in the "visiting schedule" section maps
|
|
// to one DoctorVisitSlot child record. The parent component owns the
|
|
// mutation orchestration (create doctor, then create each slot).
|
|
//
|
|
// Previously the form had a single `clinicId` dropdown + a free-text
|
|
// `visitingHours` textarea. Both dropped — doctors are now hospital-
|
|
// wide, and their presence at each clinic is expressed via the
|
|
// DoctorVisitSlot records.
|
|
|
|
export type DoctorDepartment =
|
|
| 'CARDIOLOGY'
|
|
| 'GYNECOLOGY'
|
|
| 'ORTHOPEDICS'
|
|
| 'GENERAL_MEDICINE'
|
|
| 'ENT'
|
|
| 'DERMATOLOGY'
|
|
| 'PEDIATRICS'
|
|
| 'ONCOLOGY';
|
|
|
|
// Matches the DoctorVisitSlot.dayOfWeek SELECT enum on the SDK entity.
|
|
export type DayOfWeek =
|
|
| 'MONDAY'
|
|
| 'TUESDAY'
|
|
| 'WEDNESDAY'
|
|
| 'THURSDAY'
|
|
| 'FRIDAY'
|
|
| 'SATURDAY'
|
|
| 'SUNDAY';
|
|
|
|
export type DoctorVisitSlotEntry = {
|
|
// Populated on existing records when editing; undefined for
|
|
// freshly-added rows. Used by the parent to decide create vs
|
|
// update vs delete on save.
|
|
id?: string;
|
|
clinicId: string;
|
|
dayOfWeek: DayOfWeek | '';
|
|
startTime: string | null;
|
|
endTime: string | null;
|
|
};
|
|
|
|
export type DoctorFormValues = {
|
|
firstName: string;
|
|
lastName: string;
|
|
department: DoctorDepartment | '';
|
|
specialty: string;
|
|
qualifications: string;
|
|
yearsOfExperience: string;
|
|
consultationFeeNew: string;
|
|
consultationFeeFollowUp: string;
|
|
phone: string;
|
|
email: string;
|
|
registrationNumber: string;
|
|
active: boolean;
|
|
// Multi-clinic, multi-day visiting schedule. One entry per slot.
|
|
visitSlots: DoctorVisitSlotEntry[];
|
|
};
|
|
|
|
export const emptyDoctorFormValues = (): DoctorFormValues => ({
|
|
firstName: '',
|
|
lastName: '',
|
|
department: '',
|
|
specialty: '',
|
|
qualifications: '',
|
|
yearsOfExperience: '',
|
|
consultationFeeNew: '',
|
|
consultationFeeFollowUp: '',
|
|
phone: '',
|
|
email: '',
|
|
registrationNumber: '',
|
|
active: true,
|
|
visitSlots: [],
|
|
});
|
|
|
|
const DEPARTMENT_ITEMS: { id: DoctorDepartment; label: string }[] = [
|
|
{ id: 'CARDIOLOGY', label: 'Cardiology' },
|
|
{ id: 'GYNECOLOGY', label: 'Gynecology' },
|
|
{ id: 'ORTHOPEDICS', label: 'Orthopedics' },
|
|
{ id: 'GENERAL_MEDICINE', label: 'General medicine' },
|
|
{ id: 'ENT', label: 'ENT' },
|
|
{ id: 'DERMATOLOGY', label: 'Dermatology' },
|
|
{ id: 'PEDIATRICS', label: 'Pediatrics' },
|
|
{ id: 'ONCOLOGY', label: 'Oncology' },
|
|
];
|
|
|
|
const DAY_ITEMS: { id: DayOfWeek; label: string }[] = [
|
|
{ id: 'MONDAY', label: 'Monday' },
|
|
{ id: 'TUESDAY', label: 'Tuesday' },
|
|
{ id: 'WEDNESDAY', label: 'Wednesday' },
|
|
{ id: 'THURSDAY', label: 'Thursday' },
|
|
{ id: 'FRIDAY', label: 'Friday' },
|
|
{ id: 'SATURDAY', label: 'Saturday' },
|
|
{ id: 'SUNDAY', label: 'Sunday' },
|
|
];
|
|
|
|
// Build the createDoctor / updateDoctor mutation payload. Visit slots
|
|
// are persisted via a separate mutation chain — see the parent
|
|
// component's handleSave.
|
|
export const doctorCoreToGraphQLInput = (v: DoctorFormValues): Record<string, unknown> => {
|
|
const input: Record<string, unknown> = {
|
|
fullName: {
|
|
firstName: v.firstName.trim(),
|
|
lastName: v.lastName.trim(),
|
|
},
|
|
active: v.active,
|
|
};
|
|
|
|
if (v.department) input.department = v.department;
|
|
if (v.specialty.trim()) input.specialty = v.specialty.trim();
|
|
if (v.qualifications.trim()) input.qualifications = v.qualifications.trim();
|
|
if (v.yearsOfExperience.trim()) {
|
|
const n = Number(v.yearsOfExperience);
|
|
if (!Number.isNaN(n)) input.yearsOfExperience = n;
|
|
}
|
|
if (v.consultationFeeNew.trim()) {
|
|
const n = Number(v.consultationFeeNew);
|
|
if (!Number.isNaN(n)) {
|
|
input.consultationFeeNew = {
|
|
amountMicros: Math.round(n * 1_000_000),
|
|
currencyCode: 'INR',
|
|
};
|
|
}
|
|
}
|
|
if (v.consultationFeeFollowUp.trim()) {
|
|
const n = Number(v.consultationFeeFollowUp);
|
|
if (!Number.isNaN(n)) {
|
|
input.consultationFeeFollowUp = {
|
|
amountMicros: Math.round(n * 1_000_000),
|
|
currencyCode: 'INR',
|
|
};
|
|
}
|
|
}
|
|
if (v.phone.trim()) {
|
|
input.phone = {
|
|
primaryPhoneNumber: v.phone.trim(),
|
|
primaryPhoneCountryCode: 'IN',
|
|
primaryPhoneCallingCode: '+91',
|
|
additionalPhones: null,
|
|
};
|
|
}
|
|
if (v.email.trim()) {
|
|
input.email = {
|
|
primaryEmail: v.email.trim(),
|
|
additionalEmails: null,
|
|
};
|
|
}
|
|
if (v.registrationNumber.trim()) input.registrationNumber = v.registrationNumber.trim();
|
|
|
|
return input;
|
|
};
|
|
|
|
// Build one DoctorVisitSlotCreateInput per complete slot. Drops any
|
|
// half-filled rows silently — the form can't validate mid-entry
|
|
// without blocking the user.
|
|
export const visitSlotInputsFromForm = (
|
|
v: DoctorFormValues,
|
|
doctorId: string,
|
|
): Array<Record<string, unknown>> =>
|
|
v.visitSlots
|
|
.filter((s) => s.clinicId && s.dayOfWeek && s.startTime && s.endTime)
|
|
.map((s) => ({
|
|
doctorId,
|
|
clinicId: s.clinicId,
|
|
dayOfWeek: s.dayOfWeek,
|
|
startTime: s.startTime,
|
|
endTime: s.endTime,
|
|
}));
|
|
|
|
type ClinicOption = { id: string; label: string };
|
|
|
|
type DoctorFormProps = {
|
|
value: DoctorFormValues;
|
|
onChange: (value: DoctorFormValues) => void;
|
|
clinics: ClinicOption[];
|
|
};
|
|
|
|
export const DoctorForm = ({ value, onChange, clinics }: DoctorFormProps) => {
|
|
const patch = (updates: Partial<DoctorFormValues>) => onChange({ ...value, ...updates });
|
|
|
|
// Visit-slot handlers — add/edit/remove inline inside the form.
|
|
const addSlot = () => {
|
|
patch({
|
|
visitSlots: [
|
|
...value.visitSlots,
|
|
{ clinicId: clinics[0]?.id ?? '', dayOfWeek: '', startTime: '09:00', endTime: '13:00' },
|
|
],
|
|
});
|
|
};
|
|
|
|
const updateSlot = (index: number, updates: Partial<DoctorVisitSlotEntry>) => {
|
|
const next = [...value.visitSlots];
|
|
next[index] = { ...next[index], ...updates };
|
|
patch({ visitSlots: next });
|
|
};
|
|
|
|
const removeSlot = (index: number) => {
|
|
patch({ visitSlots: value.visitSlots.filter((_, i) => i !== index) });
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Input
|
|
label="First name"
|
|
isRequired
|
|
placeholder="Ananya"
|
|
value={value.firstName}
|
|
onChange={(v) => patch({ firstName: v })}
|
|
/>
|
|
<Input
|
|
label="Last name"
|
|
isRequired
|
|
placeholder="Rao"
|
|
value={value.lastName}
|
|
onChange={(v) => patch({ lastName: v })}
|
|
/>
|
|
</div>
|
|
|
|
<Select
|
|
label="Department"
|
|
placeholder="Select department"
|
|
items={DEPARTMENT_ITEMS}
|
|
selectedKey={value.department || null}
|
|
onSelectionChange={(key) => patch({ department: (key as DoctorDepartment) || '' })}
|
|
>
|
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
|
</Select>
|
|
|
|
<Input
|
|
label="Specialty"
|
|
placeholder="e.g. Interventional cardiology"
|
|
value={value.specialty}
|
|
onChange={(v) => patch({ specialty: v })}
|
|
/>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Input
|
|
label="Qualifications"
|
|
placeholder="MBBS, MD"
|
|
value={value.qualifications}
|
|
onChange={(v) => patch({ qualifications: v })}
|
|
/>
|
|
<Input
|
|
label="Experience (years)"
|
|
type="number"
|
|
value={value.yearsOfExperience}
|
|
onChange={(v) => patch({ yearsOfExperience: v })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Visiting schedule — one row per clinic/day slot */}
|
|
<div className="flex flex-col gap-3">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
|
Visiting schedule
|
|
</p>
|
|
{clinics.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed border-warning bg-warning-primary p-4">
|
|
<p className="text-sm font-semibold text-warning-primary">Add a clinic first</p>
|
|
<p className="mt-1 text-xs text-tertiary">
|
|
You need at least one clinic before you can schedule doctor visits.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{value.visitSlots.length === 0 && (
|
|
<p className="text-xs text-tertiary">
|
|
No visit slots. Add rows for each clinic + day this doctor visits.
|
|
</p>
|
|
)}
|
|
{value.visitSlots.map((slot, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="flex flex-col gap-3 rounded-lg border border-secondary bg-secondary p-3"
|
|
>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Select
|
|
label="Clinic"
|
|
placeholder="Select clinic"
|
|
items={clinics}
|
|
selectedKey={slot.clinicId || null}
|
|
onSelectionChange={(key) =>
|
|
updateSlot(idx, { clinicId: (key as string) || '' })
|
|
}
|
|
>
|
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
|
</Select>
|
|
<Select
|
|
label="Day"
|
|
placeholder="Select day"
|
|
items={DAY_ITEMS}
|
|
selectedKey={slot.dayOfWeek || null}
|
|
onSelectionChange={(key) =>
|
|
updateSlot(idx, { dayOfWeek: (key as DayOfWeek) || '' })
|
|
}
|
|
>
|
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
|
</Select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<TimePicker
|
|
label="Start time"
|
|
value={slot.startTime}
|
|
onChange={(startTime) => updateSlot(idx, { startTime })}
|
|
/>
|
|
<TimePicker
|
|
label="End time"
|
|
value={slot.endTime}
|
|
onChange={(endTime) => updateSlot(idx, { endTime })}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
color="tertiary-destructive"
|
|
iconLeading={({ className }: { className?: string }) => (
|
|
<FontAwesomeIcon icon={faTrash} className={className} />
|
|
)}
|
|
onClick={() => removeSlot(idx)}
|
|
>
|
|
Remove slot
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<Button
|
|
size="sm"
|
|
color="secondary"
|
|
iconLeading={({ className }: { className?: string }) => (
|
|
<FontAwesomeIcon icon={faPlus} className={className} />
|
|
)}
|
|
onClick={addSlot}
|
|
className="self-start"
|
|
>
|
|
Add visit slot
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Input
|
|
label="New consult fee (₹)"
|
|
type="number"
|
|
placeholder="800"
|
|
value={value.consultationFeeNew}
|
|
onChange={(v) => patch({ consultationFeeNew: v })}
|
|
/>
|
|
<Input
|
|
label="Follow-up fee (₹)"
|
|
type="number"
|
|
placeholder="500"
|
|
value={value.consultationFeeFollowUp}
|
|
onChange={(v) => patch({ consultationFeeFollowUp: v })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Contact</p>
|
|
<Input
|
|
label="Phone"
|
|
type="tel"
|
|
placeholder="9876543210"
|
|
value={value.phone}
|
|
onChange={(v) => patch({ phone: v })}
|
|
/>
|
|
<Input
|
|
label="Email"
|
|
type="email"
|
|
placeholder="doctor@hospital.com"
|
|
value={value.email}
|
|
onChange={(v) => patch({ email: v })}
|
|
/>
|
|
<Input
|
|
label="Registration number"
|
|
placeholder="Medical council reg no."
|
|
value={value.registrationNumber}
|
|
onChange={(v) => patch({ registrationNumber: v })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 rounded-lg border border-secondary bg-secondary p-4">
|
|
<Toggle
|
|
label="Accepting appointments"
|
|
isSelected={value.active}
|
|
onChange={(checked) => patch({ active: checked })}
|
|
/>
|
|
<p className="text-xs text-tertiary">
|
|
Inactive doctors are hidden from appointment booking and call-desk transfer lists.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|