mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +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:
73
src/components/application/date-picker/time-picker.tsx
Normal file
73
src/components/application/date-picker/time-picker.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Select } from "@/components/base/select/select";
|
||||
|
||||
// 30-minute increments from 05:00 to 23:00 → 37 slots.
|
||||
// Covers every realistic clinic opening / closing time.
|
||||
// Values are 24-hour HH:MM strings — the same format stored on the
|
||||
// Clinic + DoctorVisitSlot entities in the platform. Labels are
|
||||
// 12-hour format with AM/PM for readability.
|
||||
const TIME_SLOTS = Array.from({ length: 37 }, (_, i) => {
|
||||
const totalMinutes = 5 * 60 + i * 30;
|
||||
const hour = Math.floor(totalMinutes / 60);
|
||||
const minute = totalMinutes % 60;
|
||||
const h12 = hour % 12 || 12;
|
||||
const period = hour >= 12 ? "PM" : "AM";
|
||||
const id = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
const label = `${h12}:${String(minute).padStart(2, "0")} ${period}`;
|
||||
return { id, label };
|
||||
});
|
||||
|
||||
type TimePickerProps = {
|
||||
/** Field label rendered above the select. */
|
||||
label?: string;
|
||||
/** Current value in 24-hour HH:MM format, or null when unset. */
|
||||
value: string | null;
|
||||
/** Called with the new HH:MM string when the user picks a slot. */
|
||||
onChange: (value: string) => void;
|
||||
isRequired?: boolean;
|
||||
isDisabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
// A minimal time-of-day picker built on top of the existing base
|
||||
// Select component. Intentionally dropdown-based rather than the
|
||||
// full DateTimePicker popover pattern from the reference demo —
|
||||
// the clinic + doctor flows only need time, not date, and a
|
||||
// dropdown is faster to use when the agent already knows the time.
|
||||
//
|
||||
// Use this for: clinic.opensAt / closesAt, doctorVisitSlot.startTime /
|
||||
// endTime. For time-AND-date (appointment scheduling), stick with the
|
||||
// existing DatePicker in the same directory.
|
||||
export const TimePicker = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
isRequired,
|
||||
isDisabled,
|
||||
placeholder = "Select time",
|
||||
}: TimePickerProps) => (
|
||||
<Select
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
items={TIME_SLOTS}
|
||||
selectedKey={value}
|
||||
onSelectionChange={(key) => {
|
||||
if (key !== null) onChange(String(key));
|
||||
}}
|
||||
isRequired={isRequired}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{(slot) => <Select.Item id={slot.id} label={slot.label} />}
|
||||
</Select>
|
||||
);
|
||||
|
||||
// Format a 24-hour HH:MM string as a 12-hour display label (e.g.
|
||||
// "09:30" → "9:30 AM"). Useful on list/detail pages that render
|
||||
// stored clinic hours without re-mounting the picker.
|
||||
export const formatTimeLabel = (hhmm: string | null | undefined): string => {
|
||||
if (!hhmm) return "—";
|
||||
const [h, m] = hhmm.split(":").map(Number);
|
||||
if (Number.isNaN(h) || Number.isNaN(m)) return hhmm;
|
||||
const h12 = h % 12 || 12;
|
||||
const period = h >= 12 ? "PM" : "AM";
|
||||
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
|
||||
};
|
||||
108
src/components/application/day-selector/day-selector.tsx
Normal file
108
src/components/application/day-selector/day-selector.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
// Keys match the Clinic entity's openMonday..openSunday fields
|
||||
// directly — no translation layer needed when reading/writing the
|
||||
// form value into GraphQL mutations.
|
||||
export type DayKey =
|
||||
| "monday"
|
||||
| "tuesday"
|
||||
| "wednesday"
|
||||
| "thursday"
|
||||
| "friday"
|
||||
| "saturday"
|
||||
| "sunday";
|
||||
|
||||
export type DaySelection = Record<DayKey, boolean>;
|
||||
|
||||
const DAYS: { key: DayKey; label: string }[] = [
|
||||
{ key: "monday", label: "Mon" },
|
||||
{ key: "tuesday", label: "Tue" },
|
||||
{ key: "wednesday", label: "Wed" },
|
||||
{ key: "thursday", label: "Thu" },
|
||||
{ key: "friday", label: "Fri" },
|
||||
{ key: "saturday", label: "Sat" },
|
||||
{ key: "sunday", label: "Sun" },
|
||||
];
|
||||
|
||||
type DaySelectorProps = {
|
||||
/** Selected-state for each weekday. */
|
||||
value: DaySelection;
|
||||
/** Fires with the full updated selection whenever a pill is tapped. */
|
||||
onChange: (value: DaySelection) => void;
|
||||
/** Optional heading above the pills. */
|
||||
label?: string;
|
||||
/** Optional helper text below the pills. */
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
// Seven tappable Mon–Sun pills. Used on the Clinic form to pick which
|
||||
// days the clinic is open, since the Clinic entity has seven separate
|
||||
// BOOLEAN fields (openMonday..openSunday) — SDK has no MULTI_SELECT.
|
||||
// Also reusable anywhere else we need a weekly-recurrence picker
|
||||
// (future: follow-up schedules, on-call rotations).
|
||||
export const DaySelector = ({ value, onChange, label, hint }: DaySelectorProps) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-secondary">{label}</span>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS.map(({ key, label: dayLabel }) => {
|
||||
const isSelected = !!value[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...value, [key]: !isSelected })}
|
||||
className={cx(
|
||||
"flex h-10 min-w-12 items-center justify-center rounded-full border px-4 text-sm font-semibold transition duration-100 ease-linear",
|
||||
isSelected
|
||||
? "border-brand bg-brand-solid text-white hover:bg-brand-solid_hover"
|
||||
: "border-secondary bg-primary text-secondary hover:border-primary hover:bg-secondary_hover",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{dayLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hint && <span className="text-xs text-tertiary">{hint}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Helper factories — use these instead of spelling out the empty
|
||||
// object literal everywhere.
|
||||
export const emptyDaySelection = (): DaySelection => ({
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
});
|
||||
|
||||
// The default new-clinic state: Mon–Sat open, Sun closed. Matches the
|
||||
// typical Indian outpatient hospital schedule.
|
||||
export const defaultDaySelection = (): DaySelection => ({
|
||||
monday: true,
|
||||
tuesday: true,
|
||||
wednesday: true,
|
||||
thursday: true,
|
||||
friday: true,
|
||||
saturday: true,
|
||||
sunday: false,
|
||||
});
|
||||
|
||||
// Format a DaySelection as a compact human-readable string for list
|
||||
// pages (e.g. "Mon–Fri", "Mon–Sat", "Mon Wed Fri"). Collapses
|
||||
// consecutive selected days into ranges.
|
||||
export const formatDaySelection = (sel: DaySelection): string => {
|
||||
const openKeys = DAYS.filter((d) => sel[d.key]).map((d) => d.label);
|
||||
if (openKeys.length === 0) return "Closed";
|
||||
if (openKeys.length === 7) return "Every day";
|
||||
// Monday-Friday, Monday-Saturday shorthand
|
||||
if (openKeys.length === 5 && openKeys.join(",") === "Mon,Tue,Wed,Thu,Fri") return "Mon–Fri";
|
||||
if (openKeys.length === 6 && openKeys.join(",") === "Mon,Tue,Wed,Thu,Fri,Sat") return "Mon–Sat";
|
||||
return openKeys.join(" ");
|
||||
};
|
||||
@@ -121,22 +121,44 @@ export const AppointmentForm = ({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch doctors on mount
|
||||
// Fetch doctors on mount. Doctors are hospital-wide — no single
|
||||
// `clinic` field anymore. We pull the full visit-slot list via the
|
||||
// doctorVisitSlots reverse relation so the agent can see which
|
||||
// clinics + days this doctor covers in the picker.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName } department clinic { id name clinicName }
|
||||
id name fullName { firstName lastName } department
|
||||
doctorVisitSlots(first: 50) {
|
||||
edges { node { id clinic { id clinicName } dayOfWeek startTime endTime } }
|
||||
}
|
||||
} } } }`,
|
||||
).then(data => {
|
||||
const docs = data.doctors.edges.map(e => ({
|
||||
id: e.node.id,
|
||||
name: e.node.fullName
|
||||
? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim()
|
||||
: e.node.name,
|
||||
department: e.node.department ?? '',
|
||||
clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? '',
|
||||
}));
|
||||
const docs = data.doctors.edges.map(e => {
|
||||
// Flatten the visit-slot list into a comma-separated
|
||||
// clinic summary for display. Keep full slot data on
|
||||
// the record in case future UX needs it (e.g., show
|
||||
// only slots matching the selected date's weekday).
|
||||
const slotEdges: Array<{ node: any }> = e.node.doctorVisitSlots?.edges ?? [];
|
||||
const clinicNames = Array.from(
|
||||
new Set(
|
||||
slotEdges
|
||||
.map((se) => se.node.clinic?.clinicName)
|
||||
.filter((n): n is string => !!n),
|
||||
),
|
||||
);
|
||||
return {
|
||||
id: e.node.id,
|
||||
name: e.node.fullName
|
||||
? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim()
|
||||
: e.node.name,
|
||||
department: e.node.department ?? '',
|
||||
// `clinic` here is a display-only summary: "Koramangala, Whitefield"
|
||||
// or empty if the doctor has no slots yet.
|
||||
clinic: clinicNames.join(', '),
|
||||
};
|
||||
});
|
||||
setDoctors(docs);
|
||||
}).catch(() => {});
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -1,21 +1,92 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { parseDate, getLocalTimeZone, today } from '@internationalized/date';
|
||||
import type { DateValue } from 'react-aria-components';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { TextArea } from '@/components/base/textarea/textarea';
|
||||
import { Toggle } from '@/components/base/toggle/toggle';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||
import { TimePicker } from '@/components/application/date-picker/time-picker';
|
||||
import {
|
||||
DaySelector,
|
||||
defaultDaySelection,
|
||||
type DaySelection,
|
||||
} from '@/components/application/day-selector/day-selector';
|
||||
|
||||
// Reusable clinic form used by both the /settings/clinics slideout and the
|
||||
// /setup wizard step. The parent owns the form state so it can also own the
|
||||
// submit button and the loading/error UI.
|
||||
// Reusable clinic form used by /settings/clinics slideout and the /setup
|
||||
// wizard step. The parent owns form state + the save flow so it can decide
|
||||
// how to orchestrate the multi-step create chain (one createClinic, then one
|
||||
// createHoliday per holiday, then one createClinicRequiredDocument per doc).
|
||||
//
|
||||
// Field shapes mirror the platform's ClinicCreateInput (which uses
|
||||
// `addressCustom`, not `address`, and `onlineBooking`, not
|
||||
// `onlineBookingEnabled` as the SDK field). See
|
||||
// FortyTwoApps/helix-engage/src/objects/clinic.object.ts for the SDK source of
|
||||
// truth; GraphQL-level differences are normalised in clinicFormToGraphQLInput.
|
||||
// Schema (matches the Clinic entity in
|
||||
// FortyTwoApps/helix-engage/src/objects/clinic.object.ts, column names
|
||||
// derived from SDK labels — that's why opensAt/closesAt and not openTime/
|
||||
// closeTime):
|
||||
// - clinicName (TEXT)
|
||||
// - address (ADDRESS → addressCustomAddress*)
|
||||
// - phone (PHONES)
|
||||
// - email (EMAILS)
|
||||
// - openMonday..openSunday (7 BOOLEANs)
|
||||
// - opensAt / closesAt (TEXT, HH:MM)
|
||||
// - status (SELECT enum)
|
||||
// - walkInAllowed / onlineBooking (BOOLEAN)
|
||||
// - cancellationWindowHours / arriveEarlyMin (NUMBER)
|
||||
//
|
||||
// Plus two child entities populated separately:
|
||||
// - Holiday (one record per closure date)
|
||||
// - ClinicRequiredDocument (one record per required doc type)
|
||||
|
||||
export type ClinicStatus = 'ACTIVE' | 'TEMPORARILY_CLOSED' | 'PERMANENTLY_CLOSED';
|
||||
|
||||
// Matches the SELECT enum on ClinicRequiredDocument. Keep in sync with
|
||||
// FortyTwoApps/helix-engage/src/objects/clinic-required-document.object.ts.
|
||||
export type DocumentType =
|
||||
| 'ID_PROOF'
|
||||
| 'AADHAAR'
|
||||
| 'PAN'
|
||||
| 'REFERRAL_LETTER'
|
||||
| 'PRESCRIPTION'
|
||||
| 'INSURANCE_CARD'
|
||||
| 'PREVIOUS_REPORTS'
|
||||
| 'PHOTO'
|
||||
| 'OTHER';
|
||||
|
||||
const DOCUMENT_TYPE_LABELS: Record<DocumentType, string> = {
|
||||
ID_PROOF: 'Government ID',
|
||||
AADHAAR: 'Aadhaar Card',
|
||||
PAN: 'PAN Card',
|
||||
REFERRAL_LETTER: 'Referral Letter',
|
||||
PRESCRIPTION: 'Prescription',
|
||||
INSURANCE_CARD: 'Insurance Card',
|
||||
PREVIOUS_REPORTS: 'Previous Reports',
|
||||
PHOTO: 'Passport Photo',
|
||||
OTHER: 'Other',
|
||||
};
|
||||
|
||||
const DOCUMENT_TYPE_ORDER: DocumentType[] = [
|
||||
'ID_PROOF',
|
||||
'AADHAAR',
|
||||
'PAN',
|
||||
'REFERRAL_LETTER',
|
||||
'PRESCRIPTION',
|
||||
'INSURANCE_CARD',
|
||||
'PREVIOUS_REPORTS',
|
||||
'PHOTO',
|
||||
'OTHER',
|
||||
];
|
||||
|
||||
export type ClinicHolidayEntry = {
|
||||
// Populated on the existing record when editing; undefined for freshly
|
||||
// added holidays the user hasn't saved yet. Used by the parent to
|
||||
// decide create vs update vs delete on save.
|
||||
id?: string;
|
||||
date: string; // ISO yyyy-MM-dd
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ClinicFormValues = {
|
||||
// Core clinic fields
|
||||
clinicName: string;
|
||||
addressStreet1: string;
|
||||
addressStreet2: string;
|
||||
@@ -24,15 +95,19 @@ export type ClinicFormValues = {
|
||||
addressPostcode: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
weekdayHours: string;
|
||||
saturdayHours: string;
|
||||
sundayHours: string;
|
||||
// Schedule — simple pattern
|
||||
openDays: DaySelection;
|
||||
opensAt: string | null;
|
||||
closesAt: string | null;
|
||||
// Status + booking policy
|
||||
status: ClinicStatus;
|
||||
walkInAllowed: boolean;
|
||||
onlineBooking: boolean;
|
||||
cancellationWindowHours: string;
|
||||
arriveEarlyMin: string;
|
||||
requiredDocuments: string;
|
||||
// Children (persisted via separate mutations)
|
||||
requiredDocumentTypes: DocumentType[];
|
||||
holidays: ClinicHolidayEntry[];
|
||||
};
|
||||
|
||||
export const emptyClinicFormValues = (): ClinicFormValues => ({
|
||||
@@ -44,15 +119,16 @@ export const emptyClinicFormValues = (): ClinicFormValues => ({
|
||||
addressPostcode: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
weekdayHours: '9:00 AM - 6:00 PM',
|
||||
saturdayHours: '9:00 AM - 2:00 PM',
|
||||
sundayHours: 'Closed',
|
||||
openDays: defaultDaySelection(),
|
||||
opensAt: '09:00',
|
||||
closesAt: '18:00',
|
||||
status: 'ACTIVE',
|
||||
walkInAllowed: true,
|
||||
onlineBooking: true,
|
||||
cancellationWindowHours: '24',
|
||||
arriveEarlyMin: '15',
|
||||
requiredDocuments: '',
|
||||
requiredDocumentTypes: [],
|
||||
holidays: [],
|
||||
});
|
||||
|
||||
const STATUS_ITEMS = [
|
||||
@@ -61,18 +137,32 @@ const STATUS_ITEMS = [
|
||||
{ id: 'PERMANENTLY_CLOSED', label: 'Permanently closed' },
|
||||
];
|
||||
|
||||
// Convert form state into the shape the platform's createClinic / updateClinic
|
||||
// mutations expect. Only non-empty fields are included so the platform can
|
||||
// apply its own defaults for the rest.
|
||||
export const clinicFormToGraphQLInput = (v: ClinicFormValues): Record<string, unknown> => {
|
||||
// Build the payload for `createClinic` / `updateClinic`. Holidays and
|
||||
// required-documents are NOT included here — they're child records with
|
||||
// their own mutations, orchestrated by the parent component after the
|
||||
// clinic itself has been created and its id is known.
|
||||
export const clinicCoreToGraphQLInput = (v: ClinicFormValues): Record<string, unknown> => {
|
||||
const input: Record<string, unknown> = {
|
||||
clinicName: v.clinicName.trim(),
|
||||
status: v.status,
|
||||
walkInAllowed: v.walkInAllowed,
|
||||
onlineBooking: v.onlineBooking,
|
||||
openMonday: v.openDays.monday,
|
||||
openTuesday: v.openDays.tuesday,
|
||||
openWednesday: v.openDays.wednesday,
|
||||
openThursday: v.openDays.thursday,
|
||||
openFriday: v.openDays.friday,
|
||||
openSaturday: v.openDays.saturday,
|
||||
openSunday: v.openDays.sunday,
|
||||
};
|
||||
|
||||
const hasAddress = v.addressStreet1 || v.addressCity || v.addressState || v.addressPostcode;
|
||||
// Column names on the platform come from the SDK `label`, not
|
||||
// `name`. "Opens At" → opensAt, "Closes At" → closesAt.
|
||||
if (v.opensAt) input.opensAt = v.opensAt;
|
||||
if (v.closesAt) input.closesAt = v.closesAt;
|
||||
|
||||
const hasAddress =
|
||||
v.addressStreet1 || v.addressCity || v.addressState || v.addressPostcode;
|
||||
if (hasAddress) {
|
||||
input.addressCustom = {
|
||||
addressStreet1: v.addressStreet1 || null,
|
||||
@@ -100,9 +190,6 @@ export const clinicFormToGraphQLInput = (v: ClinicFormValues): Record<string, un
|
||||
};
|
||||
}
|
||||
|
||||
if (v.weekdayHours.trim()) input.weekdayHours = v.weekdayHours.trim();
|
||||
if (v.saturdayHours.trim()) input.saturdayHours = v.saturdayHours.trim();
|
||||
if (v.sundayHours.trim()) input.sundayHours = v.sundayHours.trim();
|
||||
if (v.cancellationWindowHours.trim()) {
|
||||
const n = Number(v.cancellationWindowHours);
|
||||
if (!Number.isNaN(n)) input.cancellationWindowHours = n;
|
||||
@@ -111,11 +198,33 @@ export const clinicFormToGraphQLInput = (v: ClinicFormValues): Record<string, un
|
||||
const n = Number(v.arriveEarlyMin);
|
||||
if (!Number.isNaN(n)) input.arriveEarlyMin = n;
|
||||
}
|
||||
if (v.requiredDocuments.trim()) input.requiredDocuments = v.requiredDocuments.trim();
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
// Helper: build HolidayCreateInput payloads. Use after the clinic has
|
||||
// been created and its id is known.
|
||||
export const holidayInputsFromForm = (
|
||||
v: ClinicFormValues,
|
||||
clinicId: string,
|
||||
): Array<Record<string, unknown>> =>
|
||||
v.holidays.map((h) => ({
|
||||
date: h.date,
|
||||
reasonLabel: h.label.trim() || null, // column name matches the SDK label "Reason / Label"
|
||||
clinicId,
|
||||
}));
|
||||
|
||||
// Helper: build ClinicRequiredDocumentCreateInput payloads. One per
|
||||
// selected document type.
|
||||
export const requiredDocInputsFromForm = (
|
||||
v: ClinicFormValues,
|
||||
clinicId: string,
|
||||
): Array<Record<string, unknown>> =>
|
||||
v.requiredDocumentTypes.map((t) => ({
|
||||
documentType: t,
|
||||
clinicId,
|
||||
}));
|
||||
|
||||
type ClinicFormProps = {
|
||||
value: ClinicFormValues;
|
||||
onChange: (value: ClinicFormValues) => void;
|
||||
@@ -124,6 +233,42 @@ type ClinicFormProps = {
|
||||
export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||
const patch = (updates: Partial<ClinicFormValues>) => onChange({ ...value, ...updates });
|
||||
|
||||
// Required-docs add/remove handlers. The user picks a type from the
|
||||
// dropdown; it gets added to the list; the pill row below shows
|
||||
// selected types with an X to remove. Dropdown filters out
|
||||
// already-selected types so the user can't pick duplicates.
|
||||
const availableDocTypes = DOCUMENT_TYPE_ORDER.filter(
|
||||
(t) => !value.requiredDocumentTypes.includes(t),
|
||||
).map((t) => ({ id: t, label: DOCUMENT_TYPE_LABELS[t] }));
|
||||
|
||||
const addDocType = (type: DocumentType) => {
|
||||
if (value.requiredDocumentTypes.includes(type)) return;
|
||||
patch({ requiredDocumentTypes: [...value.requiredDocumentTypes, type] });
|
||||
};
|
||||
|
||||
const removeDocType = (type: DocumentType) => {
|
||||
patch({
|
||||
requiredDocumentTypes: value.requiredDocumentTypes.filter((t) => t !== type),
|
||||
});
|
||||
};
|
||||
|
||||
// Holiday add/remove handlers. Freshly-added entries have no `id`
|
||||
// field; the parent's save flow treats those as "create".
|
||||
const addHoliday = () => {
|
||||
const todayIso = today(getLocalTimeZone()).toString();
|
||||
patch({ holidays: [...value.holidays, { date: todayIso, label: '' }] });
|
||||
};
|
||||
|
||||
const updateHoliday = (index: number, updates: Partial<ClinicHolidayEntry>) => {
|
||||
const next = [...value.holidays];
|
||||
next[index] = { ...next[index], ...updates };
|
||||
patch({ holidays: next });
|
||||
};
|
||||
|
||||
const removeHoliday = (index: number) => {
|
||||
patch({ holidays: value.holidays.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
@@ -144,8 +289,11 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
|
||||
{/* Address */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Address</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Address
|
||||
</p>
|
||||
<Input
|
||||
label="Street address"
|
||||
placeholder="Street / building / landmark"
|
||||
@@ -180,8 +328,11 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Contact</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Contact
|
||||
</p>
|
||||
<Input
|
||||
label="Phone"
|
||||
type="tel"
|
||||
@@ -198,32 +349,96 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Visiting hours — day pills + single time range */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Visiting hours</p>
|
||||
<Input
|
||||
label="Weekdays"
|
||||
placeholder="9:00 AM - 6:00 PM"
|
||||
value={value.weekdayHours}
|
||||
onChange={(v) => patch({ weekdayHours: v })}
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Visiting hours
|
||||
</p>
|
||||
<DaySelector
|
||||
label="Open days"
|
||||
hint="Pick the days this clinic is open. The time range below applies to every selected day."
|
||||
value={value.openDays}
|
||||
onChange={(openDays) => patch({ openDays })}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Saturday"
|
||||
placeholder="9:00 AM - 2:00 PM"
|
||||
value={value.saturdayHours}
|
||||
onChange={(v) => patch({ saturdayHours: v })}
|
||||
<TimePicker
|
||||
label="Opens at"
|
||||
value={value.opensAt}
|
||||
onChange={(opensAt) => patch({ opensAt })}
|
||||
/>
|
||||
<Input
|
||||
label="Sunday"
|
||||
placeholder="Closed"
|
||||
value={value.sundayHours}
|
||||
onChange={(v) => patch({ sundayHours: v })}
|
||||
<TimePicker
|
||||
label="Closes at"
|
||||
value={value.closesAt}
|
||||
onChange={(closesAt) => patch({ closesAt })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Holiday closures */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Booking policy</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Holiday closures (optional)
|
||||
</p>
|
||||
{value.holidays.length === 0 && (
|
||||
<p className="text-xs text-tertiary">
|
||||
No holidays configured. Add dates when this clinic is closed (Diwali,
|
||||
Republic Day, maintenance days, etc.).
|
||||
</p>
|
||||
)}
|
||||
{value.holidays.map((h, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-end gap-2 rounded-lg border border-secondary bg-secondary p-3"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<span className="mb-1 block text-xs font-medium text-secondary">
|
||||
Date
|
||||
</span>
|
||||
<DatePicker
|
||||
value={h.date ? parseDate(h.date) : null}
|
||||
onChange={(dv: DateValue | null) =>
|
||||
updateHoliday(idx, { date: dv ? dv.toString() : '' })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Reason"
|
||||
placeholder="e.g. Diwali"
|
||||
value={h.label}
|
||||
onChange={(label) => updateHoliday(idx, { label })}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
color="tertiary-destructive"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faTrash} className={className} />
|
||||
)}
|
||||
onClick={() => removeHoliday(idx)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faPlus} className={className} />
|
||||
)}
|
||||
onClick={addHoliday}
|
||||
className="self-start"
|
||||
>
|
||||
Add holiday
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Booking policy */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Booking policy
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-secondary bg-secondary p-4">
|
||||
<Toggle
|
||||
label="Walk-ins allowed"
|
||||
@@ -250,13 +465,49 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||
onChange={(v) => patch({ arriveEarlyMin: v })}
|
||||
/>
|
||||
</div>
|
||||
<TextArea
|
||||
label="Required documents"
|
||||
placeholder="ID proof, referral letter, previous reports..."
|
||||
value={value.requiredDocuments}
|
||||
onChange={(v) => patch({ requiredDocuments: v })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Required documents — multi-select → pills */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Required documents (optional)
|
||||
</p>
|
||||
{availableDocTypes.length > 0 && (
|
||||
<Select
|
||||
label="Add a required document"
|
||||
placeholder="Pick a document type..."
|
||||
items={availableDocTypes}
|
||||
selectedKey={null}
|
||||
onSelectionChange={(key) => {
|
||||
if (key) addDocType(key as DocumentType);
|
||||
}}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
)}
|
||||
{value.requiredDocumentTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{value.requiredDocumentTypes.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => removeDocType(t)}
|
||||
className="group flex items-center gap-2 rounded-full border border-brand bg-brand-secondary px-3 py-1.5 text-sm font-medium text-brand-secondary transition hover:bg-brand-primary_hover"
|
||||
>
|
||||
{DOCUMENT_TYPE_LABELS[t]}
|
||||
<FontAwesomeIcon
|
||||
icon={faTrash}
|
||||
className="size-3 text-fg-quaternary group-hover:text-fg-error-primary"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{value.requiredDocumentTypes.length === 0 && (
|
||||
<p className="text-xs text-tertiary">
|
||||
No required documents. Patients won't be asked to bring anything.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
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 { TextArea } from '@/components/base/textarea/textarea';
|
||||
import { Toggle } from '@/components/base/toggle/toggle';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { TimePicker } from '@/components/application/date-picker/time-picker';
|
||||
|
||||
// Reusable doctor form used by /settings/doctors and the /setup wizard. The
|
||||
// parent owns both the form state and the list of clinics to populate the
|
||||
// clinic dropdown (since the list page will already have it loaded).
|
||||
// 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).
|
||||
//
|
||||
// Field names mirror the platform's DoctorCreateInput — notably `active`, not
|
||||
// `isActive`, and `clinicId` for the relation.
|
||||
// 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'
|
||||
@@ -20,6 +26,27 @@ export type DoctorDepartment =
|
||||
| '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;
|
||||
@@ -27,14 +54,14 @@ export type DoctorFormValues = {
|
||||
specialty: string;
|
||||
qualifications: string;
|
||||
yearsOfExperience: string;
|
||||
clinicId: string;
|
||||
visitingHours: 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 => ({
|
||||
@@ -44,14 +71,13 @@ export const emptyDoctorFormValues = (): DoctorFormValues => ({
|
||||
specialty: '',
|
||||
qualifications: '',
|
||||
yearsOfExperience: '',
|
||||
clinicId: '',
|
||||
visitingHours: '',
|
||||
consultationFeeNew: '',
|
||||
consultationFeeFollowUp: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
registrationNumber: '',
|
||||
active: true,
|
||||
visitSlots: [],
|
||||
});
|
||||
|
||||
const DEPARTMENT_ITEMS: { id: DoctorDepartment; label: string }[] = [
|
||||
@@ -65,10 +91,20 @@ const DEPARTMENT_ITEMS: { id: DoctorDepartment; label: string }[] = [
|
||||
{ id: 'ONCOLOGY', label: 'Oncology' },
|
||||
];
|
||||
|
||||
// Convert form state into the shape createDoctor/updateDoctor mutations
|
||||
// expect. yearsOfExperience and consultation fees are text fields in the UI
|
||||
// but typed in GraphQL, so we parse + validate here.
|
||||
export const doctorFormToGraphQLInput = (v: DoctorFormValues): Record<string, unknown> => {
|
||||
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(),
|
||||
@@ -84,8 +120,6 @@ export const doctorFormToGraphQLInput = (v: DoctorFormValues): Record<string, un
|
||||
const n = Number(v.yearsOfExperience);
|
||||
if (!Number.isNaN(n)) input.yearsOfExperience = n;
|
||||
}
|
||||
if (v.clinicId) input.clinicId = v.clinicId;
|
||||
if (v.visitingHours.trim()) input.visitingHours = v.visitingHours.trim();
|
||||
if (v.consultationFeeNew.trim()) {
|
||||
const n = Number(v.consultationFeeNew);
|
||||
if (!Number.isNaN(n)) {
|
||||
@@ -123,6 +157,23 @@ export const doctorFormToGraphQLInput = (v: DoctorFormValues): Record<string, un
|
||||
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 = {
|
||||
@@ -134,6 +185,26 @@ type DoctorFormProps = {
|
||||
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">
|
||||
@@ -185,24 +256,94 @@ export const DoctorForm = ({ value, onChange, clinics }: DoctorFormProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Clinic"
|
||||
placeholder={clinics.length === 0 ? 'Add a clinic first' : 'Assign to a clinic'}
|
||||
isDisabled={clinics.length === 0}
|
||||
items={clinics}
|
||||
selectedKey={value.clinicId || null}
|
||||
onSelectionChange={(key) => patch({ clinicId: (key as string) || '' })}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
|
||||
<TextArea
|
||||
label="Visiting hours"
|
||||
placeholder="Mon–Fri 10 AM – 2 PM, Sat 10 AM – 12 PM"
|
||||
value={value.visitingHours}
|
||||
onChange={(v) => patch({ visitingHours: v })}
|
||||
rows={2}
|
||||
/>
|
||||
{/* 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
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserPlus, faTrash, faPlus } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
|
||||
// Multi-email invite form. Uses the platform's sendInvitations mutation which
|
||||
// takes a list of emails; the invited members all get the same role (the
|
||||
// platform doesn't support per-email roles in a single call). If the admin
|
||||
// needs different roles, they invite in multiple batches, or edit roles
|
||||
// afterwards via the team listing.
|
||||
//
|
||||
// Role selection is optional — leaving it blank invites members without a
|
||||
// role, matching the platform's default. When a role is selected, the caller
|
||||
// is expected to apply it via updateWorkspaceMemberRole after the invited
|
||||
// member accepts (this form just reports the selected roleId back).
|
||||
|
||||
export type RoleOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
supportingText?: string;
|
||||
};
|
||||
|
||||
export type InviteMemberFormValues = {
|
||||
emails: string[];
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
type InviteMemberFormProps = {
|
||||
value: InviteMemberFormValues;
|
||||
onChange: (value: InviteMemberFormValues) => void;
|
||||
roles: RoleOption[];
|
||||
};
|
||||
|
||||
export const InviteMemberForm = ({ value, onChange, roles }: InviteMemberFormProps) => {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const addEmail = (rawValue?: string) => {
|
||||
const source = (rawValue ?? draft).trim();
|
||||
if (!source) return;
|
||||
const trimmed = source.replace(/,+$/, '').trim();
|
||||
if (!trimmed) return;
|
||||
if (!EMAIL_REGEX.test(trimmed)) return;
|
||||
if (value.emails.includes(trimmed)) {
|
||||
setDraft('');
|
||||
return;
|
||||
}
|
||||
onChange({ ...value, emails: [...value.emails, trimmed] });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const removeEmail = (email: string) => {
|
||||
onChange({ ...value, emails: value.emails.filter((e) => e !== email) });
|
||||
};
|
||||
|
||||
// The Input component wraps react-aria TextField which doesn't expose
|
||||
// onKeyDown via its typed props — so we commit on comma via the onChange
|
||||
// handler instead. Users can also press the Add button or tab onto it.
|
||||
const handleChange = (next: string) => {
|
||||
if (next.endsWith(',')) {
|
||||
addEmail(next);
|
||||
return;
|
||||
}
|
||||
setDraft(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-secondary">
|
||||
Emails <span className="text-error-primary">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="colleague@hospital.com"
|
||||
type="email"
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faPlus} className={className} />
|
||||
)}
|
||||
onClick={() => addEmail()}
|
||||
isDisabled={!draft.trim() || !EMAIL_REGEX.test(draft.trim())}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-tertiary">
|
||||
Type a comma to add multiple emails, or click Add.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{value.emails.length > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-secondary bg-secondary p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
{value.emails.length} invitee{value.emails.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{value.emails.map((email) => (
|
||||
<li
|
||||
key={email}
|
||||
className="flex items-center justify-between rounded-md bg-primary px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-primary">
|
||||
<FontAwesomeIcon icon={faUserPlus} className="size-3.5 text-fg-brand-primary" />
|
||||
{email}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEmail(email)}
|
||||
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-error-primary"
|
||||
title="Remove"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="size-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Role"
|
||||
placeholder={roles.length === 0 ? 'No roles available' : 'Assign a role'}
|
||||
isDisabled={roles.length === 0}
|
||||
items={roles}
|
||||
selectedKey={value.roleId || null}
|
||||
onSelectionChange={(key) => onChange({ ...value, roleId: (key as string) || '' })}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} supportingText={item.supportingText} />}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { CallWidget } from '@/components/call-desk/call-widget';
|
||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
|
||||
import { NotificationBell } from './notification-bell';
|
||||
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
@@ -141,6 +142,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ResumeSetupBanner />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||
</div>
|
||||
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||
|
||||
83
src/components/setup/resume-setup-banner.tsx
Normal file
83
src/components/setup/resume-setup-banner.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
|
||||
// Dismissible banner shown across the top of authenticated pages when
|
||||
// the hospital workspace has incomplete setup steps AND the admin has
|
||||
// already dismissed the auto-wizard. This is the "nudge" layer —
|
||||
// a persistent reminder that setup is still outstanding, without the
|
||||
// intrusion of the full-page wizard.
|
||||
//
|
||||
// Visibility rules:
|
||||
// - Admin users only (other roles can't complete setup)
|
||||
// - At least one setup step is still `completed: false`
|
||||
// - `setup-state.wizardDismissed === true` (otherwise the wizard
|
||||
// auto-shows on next login and this banner would be redundant)
|
||||
// - Not dismissed in the current browser session (resets on reload)
|
||||
export const ResumeSetupBanner = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
const [state, setState] = useState<SetupState | null>(null);
|
||||
const [dismissed, setDismissed] = useState(
|
||||
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || dismissed) return;
|
||||
getSetupState()
|
||||
.then(setState)
|
||||
.catch(() => {
|
||||
// Non-fatal — if setup-state isn't reachable, just
|
||||
// skip the banner. The wizard still works.
|
||||
});
|
||||
}, [isAdmin, dismissed]);
|
||||
|
||||
if (!isAdmin || !state || dismissed) return null;
|
||||
|
||||
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
|
||||
if (incompleteCount === 0) return null;
|
||||
|
||||
// If the wizard hasn't been dismissed yet, the first-run redirect
|
||||
// in login.tsx handles pushing the admin into /setup — no need
|
||||
// for this nudge.
|
||||
if (!state.wizardDismissed) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
sessionStorage.setItem('helix_resume_setup_dismissed', '1');
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-brand bg-brand-primary px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="size-4 text-brand-primary" />
|
||||
<span className="text-sm text-primary">
|
||||
<b>Finish setting up your hospital</b> — {incompleteCount} step
|
||||
{incompleteCount === 1 ? '' : 's'} still need your attention.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
href="/setup"
|
||||
iconTrailing={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faArrowRight} className={className} />
|
||||
)}
|
||||
>
|
||||
Resume setup
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary transition duration-100 ease-linear"
|
||||
title="Dismiss for this session"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
src/components/setup/wizard-layout-context.tsx
Normal file
27
src/components/setup/wizard-layout-context.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
// Context that lets each WizardStep render content into the wizard
|
||||
// shell's right pane via a portal — without lifting per-step data
|
||||
// fetching up to the page. The shell sets `rightPaneEl` to the
|
||||
// `<aside>` DOM node once it mounts; child WizardStep components read
|
||||
// it and createPortal their `rightPane` prop into it.
|
||||
//
|
||||
// Why a portal and not a state-lifted prop on WizardShell:
|
||||
// - The right pane is tightly coupled to the active step's data
|
||||
// (e.g. "list of clinics created so far") which lives in the step
|
||||
// component's state. Lifting that state to the page would mean
|
||||
// duplicating the data-fetching layer, OR re-querying everything
|
||||
// from the page.
|
||||
// - Trying to pass `rightPane: ReactNode` upward via callbacks
|
||||
// either causes a one-frame flash (useEffect) or violates the
|
||||
// "no setState during render" rule.
|
||||
// - Portals are React-native, no extra render cycles, and the
|
||||
// DOM target is already part of the layout.
|
||||
|
||||
export type WizardLayoutContextValue = {
|
||||
rightPaneEl: HTMLElement | null;
|
||||
};
|
||||
|
||||
export const WizardLayoutContext = createContext<WizardLayoutContextValue>({
|
||||
rightPaneEl: null,
|
||||
});
|
||||
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;
|
||||
@@ -1,45 +1,76 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircleCheck, faCircle } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { SETUP_STEP_NAMES, SETUP_STEP_LABELS, type SetupStepName, type SetupState } from '@/lib/setup-state';
|
||||
import { WizardLayoutContext } from './wizard-layout-context';
|
||||
|
||||
type WizardShellProps = {
|
||||
state: SetupState;
|
||||
activeStep: SetupStepName;
|
||||
onSelectStep: (step: SetupStepName) => void;
|
||||
onDismiss: () => void;
|
||||
// Form column (middle pane). The active step component renders
|
||||
// its form into this slot. The right pane is filled via the
|
||||
// WizardLayoutContext + a portal — see wizard-step.tsx.
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// Layout shell for the onboarding wizard. Renders a left-side step navigator
|
||||
// (with completed/active/upcoming visual states) and a right-side content
|
||||
// pane fed by the parent. The header has a "Skip for now" affordance that
|
||||
// dismisses the wizard for this workspace — once dismissed it never auto-shows
|
||||
// Layout shell for the onboarding wizard. Three-pane layout:
|
||||
// left — step navigator (fixed width)
|
||||
// middle — form (flexible, the focus column)
|
||||
// right — preview pane fed by the active step component (sticky,
|
||||
// hides below xl breakpoint)
|
||||
//
|
||||
// The whole shell is `fixed inset-0` so the document body cannot
|
||||
// scroll while the wizard is mounted — fixes the double-scrollbar
|
||||
// bug where the body was rendered taller than the viewport and
|
||||
// scrolled alongside the form column. The form and preview columns
|
||||
// each scroll independently inside the shell.
|
||||
//
|
||||
// The header has a "Skip for now" affordance that dismisses the
|
||||
// wizard for this workspace; once dismissed it never auto-shows
|
||||
// again on login.
|
||||
export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, children }: WizardShellProps) => {
|
||||
const completedCount = SETUP_STEP_NAMES.filter(s => state.steps[s].completed).length;
|
||||
export const WizardShell = ({
|
||||
state,
|
||||
activeStep,
|
||||
onSelectStep,
|
||||
onDismiss,
|
||||
children,
|
||||
}: WizardShellProps) => {
|
||||
const completedCount = SETUP_STEP_NAMES.filter((s) => state.steps[s].completed).length;
|
||||
const totalSteps = SETUP_STEP_NAMES.length;
|
||||
const progressPct = Math.round((completedCount / totalSteps) * 100);
|
||||
|
||||
// Callback ref → state — guarantees that consumers re-render once
|
||||
// the aside is mounted (a plain useRef would not propagate the
|
||||
// attached node back through the context). The element is also
|
||||
// updated to null on unmount so the context is always honest about
|
||||
// whether the slot is currently available for portals.
|
||||
const [rightPaneEl, setRightPaneEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-primary">
|
||||
{/* header */}
|
||||
<header className="border-b border-secondary bg-primary px-8 py-5">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-primary">Set up your hospital</h1>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
{completedCount} of {totalSteps} steps complete · finish setup to start using your workspace
|
||||
</p>
|
||||
<WizardLayoutContext.Provider value={{ rightPaneEl }}>
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-primary">
|
||||
{/* Header — pinned. Progress bar always visible (grey
|
||||
track when 0%), sits flush under the title row. */}
|
||||
<header className="shrink-0 border-b border-secondary bg-primary">
|
||||
<div className="mx-auto flex w-full max-w-screen-2xl items-center justify-between gap-6 px-8 pt-4 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-primary">Set up your hospital</h1>
|
||||
<p className="text-xs text-tertiary">
|
||||
{completedCount} of {totalSteps} steps complete · finish setup to start
|
||||
using your workspace
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button color="link-gray" size="sm" onClick={onDismiss}>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
{/* progress bar */}
|
||||
<div className="mx-auto mt-4 max-w-6xl">
|
||||
<div className="mx-auto w-full max-w-screen-2xl px-8 pb-3">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-solid transition-all duration-300"
|
||||
@@ -49,9 +80,13 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* body — step navigator + content */}
|
||||
<div className="mx-auto flex max-w-6xl gap-8 px-8 py-8">
|
||||
<nav className="w-72 shrink-0">
|
||||
{/* Body — three columns inside a fixed-height flex row.
|
||||
min-h-0 on the row + each column lets the inner
|
||||
overflow-y-auto actually take effect. */}
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-screen-2xl flex-1 gap-6 px-8 py-6">
|
||||
{/* Left — step navigator. Scrolls if it overflows on
|
||||
very short viewports, but in practice it fits. */}
|
||||
<nav className="w-60 shrink-0 overflow-y-auto">
|
||||
<ol className="flex flex-col gap-1">
|
||||
{SETUP_STEP_NAMES.map((step, idx) => {
|
||||
const meta = SETUP_STEP_LABELS[step];
|
||||
@@ -64,7 +99,7 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
type="button"
|
||||
onClick={() => onSelectStep(step)}
|
||||
className={cx(
|
||||
'group flex w-full items-start gap-3 rounded-lg border px-3 py-3 text-left transition',
|
||||
'group flex w-full items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition',
|
||||
isActive
|
||||
? 'border-brand bg-brand-primary'
|
||||
: 'border-transparent hover:bg-secondary',
|
||||
@@ -75,7 +110,9 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
icon={isComplete ? faCircleCheck : faCircle}
|
||||
className={cx(
|
||||
'size-5',
|
||||
isComplete ? 'text-success-primary' : 'text-quaternary',
|
||||
isComplete
|
||||
? 'text-success-primary'
|
||||
: 'text-quaternary',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
@@ -99,8 +136,24 @@ export const WizardShell = ({ state, activeStep, onSelectStep, onDismiss, childr
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<main className="min-w-0 flex-1">{children}</main>
|
||||
{/* Middle — form column. min-w-0 prevents children from
|
||||
forcing the column wider than its flex basis (long
|
||||
inputs, etc.). overflow-y-auto so it scrolls
|
||||
independently of the right pane. */}
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-y-auto">{children}</main>
|
||||
|
||||
{/* Right — preview pane. Always rendered as a stable
|
||||
portal target (so the active step's WizardStep can
|
||||
createPortal into it via WizardLayoutContext).
|
||||
Hidden below xl breakpoint (1280px) so the wizard
|
||||
collapses cleanly to two columns on smaller screens.
|
||||
Independent scroll. */}
|
||||
<aside
|
||||
ref={setRightPaneEl}
|
||||
className="hidden w-80 shrink-0 overflow-y-auto xl:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</WizardLayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPenToSquare, faRotateLeft, faRobot } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { AiRightPane, type AiActorSummary } from './wizard-right-panes';
|
||||
import { AiForm, emptyAiFormValues, type AiFormValues, type AiProvider } from '@/components/forms/ai-form';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
// AI step (post-prompt-config rework). The middle pane has two sections:
|
||||
//
|
||||
// 1. Provider / model / temperature picker — same as before, drives the
|
||||
// provider that all actors use under the hood.
|
||||
// 2. AI personas — list of 7 actor cards, each with name, description,
|
||||
// truncated current template, and an Edit button. Edit triggers a
|
||||
// confirmation modal warning about unintended consequences, then
|
||||
// opens a slideout with the full template + a "variables you can
|
||||
// use" reference + Save / Reset.
|
||||
//
|
||||
// The right pane shows the same 7 personas as compact "last edited" cards
|
||||
// so the admin can scan recent activity at a glance.
|
||||
//
|
||||
// Backend wiring lives in helix-engage-server/src/config/ai.defaults.ts
|
||||
// (DEFAULT_AI_PROMPTS) + ai-config.service.ts (renderPrompt / updatePrompt
|
||||
// / resetPrompt). The 7 service files (widget chat, CC agent helper,
|
||||
// supervisor, lead enrichment, call insight, call assist, recording
|
||||
// analysis) all call AiConfigService.renderPrompt(actor, vars) so any
|
||||
// edit here lands instantly.
|
||||
|
||||
type ServerPromptConfig = {
|
||||
label: string;
|
||||
description: string;
|
||||
variables: { key: string; description: string }[];
|
||||
template: string;
|
||||
defaultTemplate: string;
|
||||
lastEditedAt: string | null;
|
||||
lastEditedBy: string | null;
|
||||
};
|
||||
|
||||
type ServerAiConfig = {
|
||||
provider?: AiProvider;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
systemPromptAddendum?: string;
|
||||
prompts?: Record<string, ServerPromptConfig>;
|
||||
};
|
||||
|
||||
// AI step — loads the current AI config, lets the admin pick provider and
|
||||
// model, and saves. This is the last step, so on save we fire the finish
|
||||
// flow instead of advancing.
|
||||
// Display order for the actor cards. Mirrors AI_ACTOR_KEYS in
|
||||
// ai.defaults.ts so the wizard renders personas in the same order
|
||||
// admins see them documented elsewhere.
|
||||
const ACTOR_ORDER = [
|
||||
'widgetChat',
|
||||
'ccAgentHelper',
|
||||
'supervisorChat',
|
||||
'leadEnrichment',
|
||||
'callInsight',
|
||||
'callAssist',
|
||||
'recordingAnalysis',
|
||||
] as const;
|
||||
|
||||
const truncate = (s: string, max: number): string =>
|
||||
s.length > max ? s.slice(0, max).trimEnd() + '…' : s;
|
||||
|
||||
export const WizardStepAi = (props: WizardStepComponentProps) => {
|
||||
const { user } = useAuth();
|
||||
const [values, setValues] = useState<AiFormValues>(emptyAiFormValues);
|
||||
const [prompts, setPrompts] = useState<Record<string, ServerPromptConfig>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get<ServerAiConfig>('/api/config/ai', { silent: true })
|
||||
.then((data) => {
|
||||
setValues({
|
||||
provider: data.provider ?? 'openai',
|
||||
model: data.model ?? 'gpt-4o-mini',
|
||||
temperature: data.temperature != null ? String(data.temperature) : '0.7',
|
||||
systemPromptAddendum: data.systemPromptAddendum ?? '',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// non-fatal — defaults will do
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
// Edit flow state — three phases:
|
||||
// 1. confirmingActor: which actor's Edit button was just clicked
|
||||
// (drives the confirmation modal)
|
||||
// 2. editingActor: which actor's slideout is open (only set after
|
||||
// the user confirms past the warning prompt)
|
||||
// 3. draftTemplate: the current textarea contents in the slideout
|
||||
const [confirmingActor, setConfirmingActor] = useState<string | null>(null);
|
||||
const [editingActor, setEditingActor] = useState<string | null>(null);
|
||||
const [draftTemplate, setDraftTemplate] = useState('');
|
||||
const [savingPrompt, setSavingPrompt] = useState(false);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.get<ServerAiConfig>('/api/config/ai', { silent: true });
|
||||
setValues({
|
||||
provider: data.provider ?? 'openai',
|
||||
model: data.model ?? 'gpt-4o-mini',
|
||||
temperature: data.temperature != null ? String(data.temperature) : '0.7',
|
||||
systemPromptAddendum: '',
|
||||
});
|
||||
setPrompts(data.prompts ?? {});
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] fetch failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const handleSaveProviderConfig = async () => {
|
||||
if (!values.model.trim()) {
|
||||
notify.error('Model is required');
|
||||
return;
|
||||
@@ -49,20 +116,92 @@ export const WizardStepAi = (props: WizardStepComponentProps) => {
|
||||
provider: values.provider,
|
||||
model: values.model.trim(),
|
||||
temperature: Number.isNaN(temperature) ? 0.7 : Math.min(2, Math.max(0, temperature)),
|
||||
systemPromptAddendum: values.systemPromptAddendum,
|
||||
});
|
||||
notify.success('AI settings saved', 'Your assistant is ready.');
|
||||
await props.onComplete('ai');
|
||||
// Don't auto-advance — this is the last step, the WizardStep
|
||||
// shell already renders a "Finish setup" button the admin taps
|
||||
// themselves.
|
||||
notify.success('AI settings saved', 'Provider and model updated.');
|
||||
await fetchConfig();
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('ai');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] save failed', err);
|
||||
console.error('[wizard/ai] save provider failed', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Confirmation modal → slideout flow.
|
||||
const handleEditClick = (actor: string) => {
|
||||
setConfirmingActor(actor);
|
||||
};
|
||||
|
||||
const handleConfirmEdit = () => {
|
||||
if (!confirmingActor) return;
|
||||
const prompt = prompts[confirmingActor];
|
||||
if (!prompt) return;
|
||||
setEditingActor(confirmingActor);
|
||||
setDraftTemplate(prompt.template);
|
||||
setConfirmingActor(null);
|
||||
};
|
||||
|
||||
const handleSavePrompt = async (close: () => void) => {
|
||||
if (!editingActor) return;
|
||||
if (!draftTemplate.trim()) {
|
||||
notify.error('Prompt cannot be empty');
|
||||
return;
|
||||
}
|
||||
setSavingPrompt(true);
|
||||
try {
|
||||
await apiClient.put(`/api/config/ai/prompts/${editingActor}`, {
|
||||
template: draftTemplate,
|
||||
editedBy: user?.email ?? null,
|
||||
});
|
||||
notify.success('Prompt updated', `${prompts[editingActor]?.label ?? editingActor} saved`);
|
||||
await fetchConfig();
|
||||
close();
|
||||
setEditingActor(null);
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] save prompt failed', err);
|
||||
} finally {
|
||||
setSavingPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPrompt = async (close: () => void) => {
|
||||
if (!editingActor) return;
|
||||
setSavingPrompt(true);
|
||||
try {
|
||||
await apiClient.post(`/api/config/ai/prompts/${editingActor}/reset`);
|
||||
notify.success('Prompt reset', `${prompts[editingActor]?.label ?? editingActor} restored to default`);
|
||||
await fetchConfig();
|
||||
close();
|
||||
setEditingActor(null);
|
||||
} catch (err) {
|
||||
console.error('[wizard/ai] reset prompt failed', err);
|
||||
} finally {
|
||||
setSavingPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Build the right-pane summary entries from the loaded prompts.
|
||||
// `isCustom` is true when the template differs from the shipped
|
||||
// default OR when the audit fields are populated — either way the
|
||||
// admin has touched it.
|
||||
const actorSummaries = useMemo<AiActorSummary[]>(() => {
|
||||
return ACTOR_ORDER.filter((key) => prompts[key]).map((key) => {
|
||||
const p = prompts[key];
|
||||
return {
|
||||
key,
|
||||
label: p.label,
|
||||
description: p.description,
|
||||
lastEditedAt: p.lastEditedAt,
|
||||
isCustom: p.template !== p.defaultTemplate || p.lastEditedAt !== null,
|
||||
};
|
||||
});
|
||||
}, [prompts]);
|
||||
|
||||
const editingPrompt = editingActor ? prompts[editingActor] : null;
|
||||
const confirmingLabel = confirmingActor ? prompts[confirmingActor]?.label : '';
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="ai"
|
||||
@@ -70,15 +209,226 @@ export const WizardStepAi = (props: WizardStepComponentProps) => {
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onMarkComplete={handleSaveProviderConfig}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<AiRightPane actors={actorSummaries} />}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading AI settings…</p>
|
||||
) : (
|
||||
<AiForm value={values} onChange={setValues} />
|
||||
<div className="flex flex-col gap-8">
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-semibold text-primary">Provider & model</h3>
|
||||
<AiForm value={values} onChange={setValues} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-primary">AI personas</h3>
|
||||
<span className="text-xs text-tertiary">
|
||||
{actorSummaries.length} configurable prompts
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-4 text-xs text-tertiary">
|
||||
Each persona below is a different AI surface in Helix Engage. Editing a
|
||||
prompt changes how that persona sounds and what rules it follows. Defaults
|
||||
are tuned for hospital call centers — only edit if you have a specific
|
||||
reason and can test the result.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{ACTOR_ORDER.map((key) => {
|
||||
const prompt = prompts[key];
|
||||
if (!prompt) return null;
|
||||
const isCustom =
|
||||
prompt.template !== prompt.defaultTemplate ||
|
||||
prompt.lastEditedAt !== null;
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className="rounded-xl border border-secondary bg-primary p-4 shadow-xs"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
|
||||
<FontAwesomeIcon icon={faRobot} className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h4 className="truncate text-sm font-semibold text-primary">
|
||||
{prompt.label}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-xs text-tertiary">
|
||||
{prompt.description}
|
||||
</p>
|
||||
</div>
|
||||
{isCustom && (
|
||||
<span className="shrink-0 rounded-full bg-warning-secondary px-2 py-0.5 text-xs font-medium text-warning-primary">
|
||||
Edited
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-3 rounded-lg border border-secondary bg-secondary p-3 font-mono text-xs leading-relaxed text-tertiary">
|
||||
{truncate(prompt.template, 220)}
|
||||
</p>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={() => handleEditClick(key)}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon
|
||||
icon={faPenToSquare}
|
||||
className={className}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation modal — reused from the patient-edit gate. */}
|
||||
<EditPatientConfirmModal
|
||||
isOpen={confirmingActor !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmingActor(null);
|
||||
}}
|
||||
onConfirm={handleConfirmEdit}
|
||||
title={`Edit ${confirmingLabel} prompt?`}
|
||||
description={
|
||||
<>
|
||||
Modifying this prompt can affect call quality, lead summaries, and supervisor
|
||||
insights in ways that are hard to predict. The defaults are tuned for hospital
|
||||
call centers — only edit if you have a specific reason and can test the
|
||||
result. You can always reset back to default from the editor.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Yes, edit prompt"
|
||||
/>
|
||||
|
||||
{/* Slideout editor — only opens after the warning is confirmed. */}
|
||||
<SlideoutMenu
|
||||
isOpen={editingActor !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingActor(null);
|
||||
}}
|
||||
isDismissable
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<SlideoutMenu.Header onClose={close}>
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
|
||||
<FontAwesomeIcon
|
||||
icon={faRobot}
|
||||
className="size-5 text-fg-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
Edit {editingPrompt?.label}
|
||||
</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
{editingPrompt?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Header>
|
||||
|
||||
<SlideoutMenu.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary">
|
||||
Prompt template
|
||||
</label>
|
||||
<textarea
|
||||
value={draftTemplate}
|
||||
onChange={(e) => setDraftTemplate(e.target.value)}
|
||||
rows={18}
|
||||
className="mt-1.5 w-full resize-y rounded-lg border border-secondary bg-primary p-3 font-mono text-xs text-primary outline-none focus:border-brand focus:ring-2 focus:ring-brand-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-tertiary">
|
||||
Variables wrapped in <code>{'{{double-braces}}'}</code> get
|
||||
substituted at runtime with live data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{editingPrompt?.variables && editingPrompt.variables.length > 0 && (
|
||||
<div className="rounded-lg border border-secondary bg-secondary p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||
Variables you can use
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-col gap-1.5">
|
||||
{editingPrompt.variables.map((v) => (
|
||||
<li key={v.key} className="flex items-start gap-2 text-xs">
|
||||
<code className="shrink-0 rounded bg-primary px-1.5 py-0.5 font-mono text-brand-primary">
|
||||
{`{{${v.key}}}`}
|
||||
</code>
|
||||
<span className="text-tertiary">
|
||||
{v.description}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingPrompt?.lastEditedAt && (
|
||||
<p className="text-xs text-tertiary">
|
||||
Last edited{' '}
|
||||
{new Date(editingPrompt.lastEditedAt).toLocaleString()}
|
||||
{editingPrompt.lastEditedBy && ` by ${editingPrompt.lastEditedBy}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SlideoutMenu.Content>
|
||||
|
||||
<SlideoutMenu.Footer>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
size="md"
|
||||
color="link-gray"
|
||||
isDisabled={savingPrompt}
|
||||
onClick={() => handleResetPrompt(close)}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faRotateLeft} className={className} />
|
||||
)}
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="md"
|
||||
color="secondary"
|
||||
isDisabled={savingPrompt}
|
||||
onClick={close}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isLoading={savingPrompt}
|
||||
showTextWhileLoading
|
||||
onClick={() => handleSavePrompt(close)}
|
||||
>
|
||||
{savingPrompt ? 'Saving…' : 'Save prompt'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Footer>
|
||||
</>
|
||||
)}
|
||||
</SlideoutMenu>
|
||||
</WizardStep>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { ClinicsRightPane, type ClinicSummary } from './wizard-right-panes';
|
||||
import {
|
||||
ClinicForm,
|
||||
clinicFormToGraphQLInput,
|
||||
clinicCoreToGraphQLInput,
|
||||
holidayInputsFromForm,
|
||||
requiredDocInputsFromForm,
|
||||
emptyClinicFormValues,
|
||||
type ClinicFormValues,
|
||||
} from '@/components/forms/clinic-form';
|
||||
@@ -10,16 +13,67 @@ import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
// Clinic step — presents a single-clinic form. On save, creates the clinic,
|
||||
// marks the step complete, and advances. The admin can come back to
|
||||
// /settings/clinics later to add more branches or edit existing ones.
|
||||
// Clinic step — presents a single-clinic form. On save the wizard runs
|
||||
// a three-stage create chain:
|
||||
// 1. createClinic (main record → get id)
|
||||
// 2. createHoliday × N (one per holiday entry)
|
||||
// 3. createClinicRequiredDocument × N (one per required doc type)
|
||||
//
|
||||
// We don't pre-load the existing clinic list here because we always want the
|
||||
// form to represent "add a new clinic"; the list page is the right surface
|
||||
// for editing.
|
||||
// This mirrors what the /settings/clinics list page does, minus the
|
||||
// delete-old-first step (wizard is always creating, never updating).
|
||||
// Failures inside the chain throw up through onComplete so the user
|
||||
// sees the error loud, and the wizard stays on the current step.
|
||||
export const WizardStepClinics = (props: WizardStepComponentProps) => {
|
||||
const [values, setValues] = useState<ClinicFormValues>(emptyClinicFormValues);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [clinics, setClinics] = useState<ClinicSummary[]>([]);
|
||||
|
||||
const fetchClinics = useCallback(async () => {
|
||||
try {
|
||||
// Field names match what the platform actually exposes:
|
||||
// - the SDK ADDRESS field is named "address" but the
|
||||
// platform mounts it as `addressCustom` (composite type
|
||||
// with addressCity / addressStreet / etc.)
|
||||
// - the SDK SELECT field labelled "Status" lands as plain
|
||||
// `status: ClinicStatusEnum`, NOT `clinicStatus`
|
||||
// Verified via __type introspection — keep this query
|
||||
// pinned to the actual schema to avoid silent empty fetches.
|
||||
type ClinicNode = {
|
||||
id: string;
|
||||
clinicName: string | null;
|
||||
addressCustom: { addressCity: string | null } | null;
|
||||
status: string | null;
|
||||
};
|
||||
const data = await apiClient.graphql<{
|
||||
clinics: { edges: { node: ClinicNode }[] };
|
||||
}>(
|
||||
`{ clinics(first: 100, orderBy: { createdAt: DescNullsLast }) {
|
||||
edges { node {
|
||||
id clinicName
|
||||
addressCustom { addressCity }
|
||||
status
|
||||
} }
|
||||
} }`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
);
|
||||
// Flatten into the shape ClinicsRightPane expects.
|
||||
setClinics(
|
||||
data.clinics.edges.map((e) => ({
|
||||
id: e.node.id,
|
||||
clinicName: e.node.clinicName,
|
||||
addressCity: e.node.addressCustom?.addressCity ?? null,
|
||||
clinicStatus: e.node.status,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[wizard/clinics] fetch failed', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClinics();
|
||||
}, [fetchClinics]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!values.clinicName.trim()) {
|
||||
@@ -28,16 +82,54 @@ export const WizardStepClinics = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
// 1. Core clinic record
|
||||
const res = await apiClient.graphql<{ createClinic: { id: string } }>(
|
||||
`mutation CreateClinic($data: ClinicCreateInput!) {
|
||||
createClinic(data: $data) { id }
|
||||
}`,
|
||||
{ data: clinicFormToGraphQLInput(values) },
|
||||
{ data: clinicCoreToGraphQLInput(values) },
|
||||
);
|
||||
const clinicId = res.createClinic.id;
|
||||
|
||||
// 2. Holidays
|
||||
if (values.holidays.length > 0) {
|
||||
const holidayInputs = holidayInputsFromForm(values, clinicId);
|
||||
await Promise.all(
|
||||
holidayInputs.map((data) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateHoliday($data: HolidayCreateInput!) {
|
||||
createHoliday(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Required documents
|
||||
if (values.requiredDocumentTypes.length > 0) {
|
||||
const docInputs = requiredDocInputsFromForm(values, clinicId);
|
||||
await Promise.all(
|
||||
docInputs.map((data) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateClinicRequiredDocument($data: ClinicRequiredDocumentCreateInput!) {
|
||||
createClinicRequiredDocument(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
notify.success('Clinic added', values.clinicName);
|
||||
await props.onComplete('clinics');
|
||||
await fetchClinics();
|
||||
// Mark complete on first successful create. Don't auto-advance —
|
||||
// admins typically add multiple clinics in one sitting; the
|
||||
// Continue button on the wizard nav handles forward motion.
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('clinics');
|
||||
}
|
||||
setValues(emptyClinicFormValues());
|
||||
props.onAdvance();
|
||||
} catch (err) {
|
||||
console.error('[wizard/clinics] save failed', err);
|
||||
} finally {
|
||||
@@ -45,24 +137,35 @@ export const WizardStepClinics = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Same trick as the Team step: once at least one clinic exists,
|
||||
// flip isCompleted=true so the WizardStep renders the "Continue"
|
||||
// button as the primary action — the form stays open below for
|
||||
// adding more clinics.
|
||||
const pretendCompleted = props.isCompleted || clinics.length > 0;
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="clinics"
|
||||
isCompleted={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<ClinicsRightPane clinics={clinics} />}
|
||||
>
|
||||
{props.isCompleted && (
|
||||
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
You've already added at least one clinic. Fill the form again to add another, or click{' '}
|
||||
<b>Next</b> to continue.
|
||||
</div>
|
||||
)}
|
||||
<ClinicForm value={values} onChange={setValues} />
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Adding…' : 'Add clinic'}
|
||||
</button>
|
||||
</div>
|
||||
</WizardStep>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { DoctorsRightPane, type DoctorSummary } from './wizard-right-panes';
|
||||
import {
|
||||
DoctorForm,
|
||||
doctorFormToGraphQLInput,
|
||||
doctorCoreToGraphQLInput,
|
||||
visitSlotInputsFromForm,
|
||||
emptyDoctorFormValues,
|
||||
type DoctorFormValues,
|
||||
} from '@/components/forms/doctor-form';
|
||||
@@ -20,21 +22,40 @@ type ClinicLite = { id: string; clinicName: string | null };
|
||||
export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
const [values, setValues] = useState<DoctorFormValues>(emptyDoctorFormValues);
|
||||
const [clinics, setClinics] = useState<ClinicLite[]>([]);
|
||||
const [doctors, setDoctors] = useState<DoctorSummary[]>([]);
|
||||
const [loadingClinics, setLoadingClinics] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.graphql<{ clinics: { edges: { node: ClinicLite }[] } }>(
|
||||
`{ clinics(first: 100) { edges { node { id clinicName } } } }`,
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{
|
||||
clinics: { edges: { node: ClinicLite }[] };
|
||||
doctors: { edges: { node: DoctorSummary }[] };
|
||||
}>(
|
||||
`{
|
||||
clinics(first: 100) { edges { node { id clinicName } } }
|
||||
doctors(first: 100, orderBy: { createdAt: DescNullsLast }) {
|
||||
edges { node { id fullName { firstName lastName } department specialty } }
|
||||
}
|
||||
}`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
)
|
||||
.then((data) => setClinics(data.clinics.edges.map((e) => e.node)))
|
||||
.catch(() => setClinics([]))
|
||||
.finally(() => setLoadingClinics(false));
|
||||
);
|
||||
setClinics(data.clinics.edges.map((e) => e.node));
|
||||
setDoctors(data.doctors.edges.map((e) => e.node));
|
||||
} catch (err) {
|
||||
console.error('[wizard/doctors] fetch failed', err);
|
||||
setClinics([]);
|
||||
setDoctors([]);
|
||||
} finally {
|
||||
setLoadingClinics(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const clinicOptions = useMemo(
|
||||
() => clinics.map((c) => ({ id: c.id, label: c.clinicName ?? 'Unnamed clinic' })),
|
||||
[clinics],
|
||||
@@ -47,16 +68,37 @@ export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
// 1. Core doctor record
|
||||
const res = await apiClient.graphql<{ createDoctor: { id: string } }>(
|
||||
`mutation CreateDoctor($data: DoctorCreateInput!) {
|
||||
createDoctor(data: $data) { id }
|
||||
}`,
|
||||
{ data: doctorFormToGraphQLInput(values) },
|
||||
{ data: doctorCoreToGraphQLInput(values) },
|
||||
);
|
||||
const doctorId = res.createDoctor.id;
|
||||
|
||||
// 2. Visit slots (doctor can be at multiple clinics on
|
||||
// multiple days with different times each).
|
||||
const slotInputs = visitSlotInputsFromForm(values, doctorId);
|
||||
if (slotInputs.length > 0) {
|
||||
await Promise.all(
|
||||
slotInputs.map((data) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateDoctorVisitSlot($data: DoctorVisitSlotCreateInput!) {
|
||||
createDoctorVisitSlot(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
notify.success('Doctor added', `Dr. ${values.firstName} ${values.lastName}`);
|
||||
await props.onComplete('doctors');
|
||||
await fetchData();
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('doctors');
|
||||
}
|
||||
setValues(emptyDoctorFormValues());
|
||||
props.onAdvance();
|
||||
} catch (err) {
|
||||
console.error('[wizard/doctors] save failed', err);
|
||||
} finally {
|
||||
@@ -64,16 +106,19 @@ export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pretendCompleted = props.isCompleted || doctors.length > 0;
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="doctors"
|
||||
isCompleted={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<DoctorsRightPane doctors={doctors} />}
|
||||
>
|
||||
{loadingClinics ? (
|
||||
<p className="text-sm text-tertiary">Loading clinics…</p>
|
||||
@@ -87,13 +132,17 @@ export const WizardStepDoctors = (props: WizardStepComponentProps) => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{props.isCompleted && (
|
||||
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
You've already added at least one doctor. Fill the form again to add another, or
|
||||
click <b>Next</b> to continue.
|
||||
</div>
|
||||
)}
|
||||
<DoctorForm value={values} onChange={setValues} clinics={clinicOptions} />
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Adding…' : 'Add doctor'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</WizardStep>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { IdentityRightPane } from './wizard-right-panes';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
@@ -74,6 +75,7 @@ export const WizardStepIdentity = (props: WizardStepComponentProps) => {
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<IdentityRightPane />}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading current branding…</p>
|
||||
|
||||
@@ -1,100 +1,441 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import { TeamRightPane, type TeamMemberSummary } from './wizard-right-panes';
|
||||
import {
|
||||
InviteMemberForm,
|
||||
type InviteMemberFormValues,
|
||||
EmployeeCreateForm,
|
||||
emptyEmployeeCreateFormValues,
|
||||
generateTempPassword,
|
||||
type EmployeeCreateFormValues,
|
||||
type RoleOption,
|
||||
} from '@/components/forms/invite-member-form';
|
||||
} from '@/components/forms/employee-create-form';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
// Team step — fetch roles from the platform and present the invite form.
|
||||
// The admin types one or more emails and picks a role. sendInvitations
|
||||
// fires, the backend emails them, and the wizard advances on success.
|
||||
// Team step (post-rework) — creates workspace members directly from
|
||||
// the portal via the sidecar's /api/team/members endpoint. The admin
|
||||
// enters name + email + temp password + role. SIP seat assignment is
|
||||
// NOT done here — it lives exclusively in the Telephony wizard step
|
||||
// so admins manage one thing in one place.
|
||||
//
|
||||
// Role assignment itself happens AFTER the invitee accepts (since we only
|
||||
// have a workspaceMemberId once they've joined the workspace). For now we
|
||||
// just send the invitations — the admin can finalise role assignments
|
||||
// from /settings/team once everyone has accepted.
|
||||
export const WizardStepTeam = (props: WizardStepComponentProps) => {
|
||||
const [values, setValues] = useState<InviteMemberFormValues>({ emails: [], roleId: '' });
|
||||
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Edit mode: clicking the pencil icon on an employee row in the right
|
||||
// pane loads that member back into the form (name + role only — email,
|
||||
// password and SIP seat are not editable here). Save in edit mode
|
||||
// fires PUT /api/team/members/:id instead of POST.
|
||||
//
|
||||
// Email invitations are NOT used anywhere in this flow. The admin is
|
||||
// expected to share the temp password with the employee directly.
|
||||
// Recently-created employees keep their plaintext password in
|
||||
// component state so the right pane's copy icon can paste a
|
||||
// shareable credentials block to the clipboard. Page reload clears
|
||||
// that state — only employees created in the current session show
|
||||
// the copy icon. Older members get only the edit icon.
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.graphql<{
|
||||
getRoles: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
canBeAssignedToUsers: boolean;
|
||||
}[];
|
||||
}>(`{ getRoles { id label description canBeAssignedToUsers } }`, undefined, { silent: true })
|
||||
.then((data) =>
|
||||
setRoles(
|
||||
data.getRoles
|
||||
.filter((r) => r.canBeAssignedToUsers)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
supportingText: r.description ?? undefined,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => setRoles([]));
|
||||
// In-memory record of an employee the admin just created in this
|
||||
// session. Holds the plaintext temp password so the copy-icon flow
|
||||
// works without ever sending the password back from the server.
|
||||
type CreatedMemberRecord = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roleId: string;
|
||||
tempPassword: string;
|
||||
};
|
||||
|
||||
type RoleRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
canBeAssignedToUsers: boolean;
|
||||
};
|
||||
|
||||
type AgentRow = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
sipExtension: string | null;
|
||||
ozonetelAgentId: string | null;
|
||||
workspaceMemberId: string | null;
|
||||
workspaceMember: {
|
||||
id: string;
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
userEmail: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type WorkspaceMemberRow = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
// Platform returns `null` (not an empty array) for members with no
|
||||
// role assigned — touching `.roles[0]` directly throws. Always
|
||||
// optional-chain reads.
|
||||
roles: { id: string; label: string }[] | null;
|
||||
};
|
||||
|
||||
const AI_EMAIL_SUFFIX = '@ai.fortytwo.local';
|
||||
|
||||
// Build the credentials block that gets copied to the clipboard. Two
|
||||
// lines (login url + email) plus the temp password — formatted so
|
||||
// the admin can paste it straight into WhatsApp / SMS. Login URL is
|
||||
// derived from the current browser origin since the wizard is always
|
||||
// loaded from the workspace's own URL (or Vite dev), so this matches
|
||||
// what the employee will use.
|
||||
const buildCredentialsBlock = (email: string, tempPassword: string): string => {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return `Login: ${origin}/login\nEmail: ${email}\nTemporary password: ${tempPassword}`;
|
||||
};
|
||||
|
||||
export const WizardStepTeam = (props: WizardStepComponentProps) => {
|
||||
const { user } = useAuth();
|
||||
const currentUserEmail = user?.email ?? null;
|
||||
|
||||
// Initialise the form with a fresh temp password so the admin
|
||||
// doesn't have to click "regenerate" before saving the very first
|
||||
// employee.
|
||||
const [values, setValues] = useState<EmployeeCreateFormValues>(() => ({
|
||||
...emptyEmployeeCreateFormValues,
|
||||
password: generateTempPassword(),
|
||||
}));
|
||||
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
|
||||
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||||
// Agents are still fetched (even though we don't show a SIP seat
|
||||
// picker here) because the right-pane summary needs each member's
|
||||
// current SIP extension to show the green badge.
|
||||
const [agents, setAgents] = useState<AgentRow[]>([]);
|
||||
const [members, setMembers] = useState<WorkspaceMemberRow[]>([]);
|
||||
const [createdMembers, setCreatedMembers] = useState<CreatedMemberRecord[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const isEditing = editingMemberId !== null;
|
||||
|
||||
const fetchRolesAndAgents = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{
|
||||
getRoles: RoleRow[];
|
||||
agents: { edges: { node: AgentRow }[] };
|
||||
workspaceMembers: { edges: { node: WorkspaceMemberRow }[] };
|
||||
}>(
|
||||
`{
|
||||
getRoles { id label description canBeAssignedToUsers }
|
||||
agents(first: 100) {
|
||||
edges { node {
|
||||
id name sipExtension ozonetelAgentId workspaceMemberId
|
||||
workspaceMember { id name { firstName lastName } userEmail }
|
||||
} }
|
||||
}
|
||||
workspaceMembers(first: 200) {
|
||||
edges { node {
|
||||
id userEmail name { firstName lastName }
|
||||
roles { id label }
|
||||
} }
|
||||
}
|
||||
}`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
);
|
||||
const assignable = data.getRoles.filter((r) => r.canBeAssignedToUsers);
|
||||
setRoles(
|
||||
assignable.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
supportingText: r.description ?? undefined,
|
||||
})),
|
||||
);
|
||||
setAgents(data.agents.edges.map((e) => e.node));
|
||||
setMembers(
|
||||
data.workspaceMembers.edges
|
||||
.map((e) => e.node)
|
||||
.filter((m) => !m.userEmail.endsWith(AI_EMAIL_SUFFIX)),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] fetch roles/agents failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (values.emails.length === 0) {
|
||||
notify.error('Add at least one email');
|
||||
useEffect(() => {
|
||||
fetchRolesAndAgents();
|
||||
}, [fetchRolesAndAgents]);
|
||||
|
||||
// Reset form back to a fresh "create" state with a new auto-gen
|
||||
// password. Used after both create-success and edit-cancel.
|
||||
const resetForm = () => {
|
||||
setEditingMemberId(null);
|
||||
setValues({
|
||||
...emptyEmployeeCreateFormValues,
|
||||
password: generateTempPassword(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveCreate = async () => {
|
||||
const firstName = values.firstName.trim();
|
||||
const email = values.email.trim();
|
||||
if (!firstName) {
|
||||
notify.error('First name is required');
|
||||
return;
|
||||
}
|
||||
if (!email) {
|
||||
notify.error('Email is required');
|
||||
return;
|
||||
}
|
||||
if (!values.password) {
|
||||
notify.error('Temporary password is required');
|
||||
return;
|
||||
}
|
||||
if (!values.roleId) {
|
||||
notify.error('Pick a role');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation SendInvitations($emails: [String!]!) {
|
||||
sendInvitations(emails: $emails) { success errors }
|
||||
}`,
|
||||
{ emails: values.emails },
|
||||
);
|
||||
const created = await apiClient.post<{
|
||||
id: string;
|
||||
userEmail: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roleId: string;
|
||||
}>('/api/team/members', {
|
||||
firstName,
|
||||
lastName: values.lastName.trim(),
|
||||
email,
|
||||
password: values.password,
|
||||
roleId: values.roleId,
|
||||
});
|
||||
|
||||
// Stash the plaintext temp password alongside the created
|
||||
// member so the copy-icon can build a credentials block
|
||||
// later. The password is NOT sent back from the server —
|
||||
// we hold the only copy in this component's memory.
|
||||
setCreatedMembers((prev) => [
|
||||
...prev,
|
||||
{ ...created, tempPassword: values.password },
|
||||
]);
|
||||
notify.success(
|
||||
'Invitations sent',
|
||||
`${values.emails.length} invitation${values.emails.length === 1 ? '' : 's'} sent.`,
|
||||
'Employee created',
|
||||
`${firstName} ${values.lastName.trim()}`.trim() || email,
|
||||
);
|
||||
await props.onComplete('team');
|
||||
setValues({ emails: [], roleId: '' });
|
||||
props.onAdvance();
|
||||
await fetchRolesAndAgents();
|
||||
resetForm();
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('team');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] invite failed', err);
|
||||
console.error('[wizard/team] create failed', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveUpdate = async () => {
|
||||
if (!editingMemberId) return;
|
||||
const firstName = values.firstName.trim();
|
||||
if (!firstName) {
|
||||
notify.error('First name is required');
|
||||
return;
|
||||
}
|
||||
if (!values.roleId) {
|
||||
notify.error('Pick a role');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.put(`/api/team/members/${editingMemberId}`, {
|
||||
firstName,
|
||||
lastName: values.lastName.trim(),
|
||||
roleId: values.roleId,
|
||||
});
|
||||
notify.success(
|
||||
'Employee updated',
|
||||
`${firstName} ${values.lastName.trim()}`.trim() || values.email,
|
||||
);
|
||||
await fetchRolesAndAgents();
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] update failed', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = isEditing ? handleSaveUpdate : handleSaveCreate;
|
||||
|
||||
// Right-pane edit handler — populate the form with the picked
|
||||
// member's data and switch into edit mode. Email is preserved as
|
||||
// the row's email (read-only in edit mode); password is cleared
|
||||
// since the form hides the field anyway.
|
||||
const handleEditMember = (memberId: string) => {
|
||||
const member = members.find((m) => m.id === memberId);
|
||||
if (!member) return;
|
||||
const firstRole = member.roles?.[0] ?? null;
|
||||
setEditingMemberId(memberId);
|
||||
setValues({
|
||||
firstName: member.name?.firstName ?? '',
|
||||
lastName: member.name?.lastName ?? '',
|
||||
email: member.userEmail,
|
||||
password: '',
|
||||
roleId: firstRole?.id ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
// Right-pane copy handler — build the shareable credentials block
|
||||
// and put it on the clipboard. Only fires for members in the
|
||||
// createdMembers in-memory map; rows without a known temp password
|
||||
// don't show the icon at all.
|
||||
const handleCopyCredentials = async (memberId: string) => {
|
||||
const member = members.find((m) => m.id === memberId);
|
||||
if (!member) return;
|
||||
|
||||
// Three-tier fallback:
|
||||
// 1. In-browser memory (createdMembers state) — populated when
|
||||
// the admin created this employee in the current session,
|
||||
// survives until refresh. Fastest path, no network call.
|
||||
// 2. Sidecar Redis cache via GET /api/team/members/:id/temp-password
|
||||
// — populated for any member created via this endpoint
|
||||
// within the last 24h, survives reloads.
|
||||
// 3. Cache miss → tell the admin the password is no longer
|
||||
// recoverable and direct them to the platform reset flow.
|
||||
const fromMemory =
|
||||
createdMembers.find(
|
||||
(c) => c.userEmail.toLowerCase() === member.userEmail.toLowerCase(),
|
||||
) ?? createdMembers.find((c) => c.id === memberId);
|
||||
let tempPassword = fromMemory?.tempPassword ?? null;
|
||||
|
||||
if (!tempPassword) {
|
||||
try {
|
||||
const res = await apiClient.get<{ password: string | null }>(
|
||||
`/api/team/members/${memberId}/temp-password`,
|
||||
{ silent: true },
|
||||
);
|
||||
tempPassword = res.password;
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] temp-password fetch failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tempPassword) {
|
||||
notify.error(
|
||||
'Password unavailable',
|
||||
'The temp password expired (>24h). Reset the password from settings to mint a new one.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const block = buildCredentialsBlock(member.userEmail, tempPassword);
|
||||
try {
|
||||
await navigator.clipboard.writeText(block);
|
||||
notify.success('Copied', 'Credentials copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('[wizard/team] clipboard write failed', err);
|
||||
notify.error('Copy failed', 'Could not write to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
// Trick: we lie to WizardStep about isCompleted so that once at
|
||||
// least one employee exists, the primary wizard button flips to
|
||||
// "Continue" and the create form stays available below for more
|
||||
// adds.
|
||||
const pretendCompleted = props.isCompleted || members.length > 0 || createdMembers.length > 0;
|
||||
|
||||
// Build the right pane summary. Every non-admin row gets the
|
||||
// copy icon — `canCopyCredentials: true` unconditionally — and
|
||||
// the click handler figures out at action time whether to read
|
||||
// from in-browser memory or the sidecar's Redis cache. If both
|
||||
// are empty (>24h old), the click toasts a "password expired"
|
||||
// message instead of silently failing.
|
||||
const teamSummaries = useMemo<TeamMemberSummary[]>(
|
||||
() =>
|
||||
members.map((m) => {
|
||||
const seat = agents.find((a) => a.workspaceMemberId === m.id);
|
||||
const firstRole = m.roles?.[0] ?? null;
|
||||
return {
|
||||
id: m.id,
|
||||
userEmail: m.userEmail,
|
||||
name: m.name,
|
||||
roleLabel: firstRole?.label ?? null,
|
||||
sipExtension: seat?.sipExtension ?? null,
|
||||
isCurrentUser: currentUserEmail !== null && m.userEmail === currentUserEmail,
|
||||
canCopyCredentials: true,
|
||||
};
|
||||
}),
|
||||
[members, agents, currentUserEmail],
|
||||
);
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="team"
|
||||
isCompleted={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={
|
||||
<TeamRightPane
|
||||
members={teamSummaries}
|
||||
onEdit={handleEditMember}
|
||||
onCopy={handleCopyCredentials}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.isCompleted && (
|
||||
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
Invitations already sent. Add more emails below to invite additional members, or click{' '}
|
||||
<b>Next</b> to continue.
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading team settings…</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
{isEditing ? (
|
||||
<p>
|
||||
Editing an existing employee. You can change their name and role.
|
||||
To change their SIP seat, go to the <b>Telephony</b> step.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Create employees in-place. Each person gets an auto-generated
|
||||
temporary password that you share directly — no email
|
||||
invitations are sent. Click the eye icon to reveal it before
|
||||
you save. After creating CC agents, head to the <b>Telephony</b>{' '}
|
||||
step to assign them SIP seats.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EmployeeCreateForm
|
||||
value={values}
|
||||
onChange={setValues}
|
||||
roles={roles}
|
||||
mode={isEditing ? 'edit' : 'create'}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{isEditing && (
|
||||
<Button size="md" color="secondary" isDisabled={saving} onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
|
||||
>
|
||||
{saving
|
||||
? isEditing
|
||||
? 'Updating…'
|
||||
: 'Creating…'
|
||||
: isEditing
|
||||
? 'Update employee'
|
||||
: 'Create employee'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<InviteMemberForm value={values} onChange={setValues} roles={roles} />
|
||||
<p className="mt-4 text-xs text-tertiary">
|
||||
Invited members receive an email with a link to set their password. Fine-tune role assignments
|
||||
from the Team page after they join.
|
||||
</p>
|
||||
</WizardStep>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,101 +1,321 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faHeadset, faTrash } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { WizardStep } from './wizard-step';
|
||||
import {
|
||||
TelephonyForm,
|
||||
emptyTelephonyFormValues,
|
||||
type TelephonyFormValues,
|
||||
} from '@/components/forms/telephony-form';
|
||||
import { TelephonyRightPane, type SipSeatSummary } from './wizard-right-panes';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { WizardStepComponentProps } from './wizard-step-types';
|
||||
|
||||
// Telephony step — loads the existing masked config from the sidecar and
|
||||
// lets the admin fill in the Ozonetel/SIP/Exotel credentials. On save, PUTs
|
||||
// the full form (the backend treats '***masked***' as "no change") and
|
||||
// marks the step complete.
|
||||
// Telephony step (post-3-pane rework). The middle pane is now an
|
||||
// assign/unassign editor: pick a SIP seat, pick a workspace member,
|
||||
// click Assign — or pick an already-mapped seat and click Unassign.
|
||||
// The right pane shows the live current state (read-only mapping
|
||||
// summary). Editing here calls updateAgent to set/clear
|
||||
// workspaceMemberId, then refetches.
|
||||
//
|
||||
// Unlike the entity steps, this is a single-doc config so we always load the
|
||||
// current state rather than treating the form as "add new".
|
||||
// SIP seats themselves are pre-provisioned by onboard-hospital.sh
|
||||
// (see step 5b) — admins can't add or delete seats from this UI,
|
||||
// only link them to people. To add a new seat, contact support.
|
||||
|
||||
type AgentRow = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
sipExtension: string | null;
|
||||
ozonetelAgentId: string | null;
|
||||
workspaceMemberId: string | null;
|
||||
workspaceMember: {
|
||||
id: string;
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
userEmail: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type WorkspaceMemberRow = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
};
|
||||
|
||||
const AI_EMAIL_SUFFIX = '@ai.fortytwo.local';
|
||||
|
||||
const memberDisplayName = (m: {
|
||||
name: { firstName: string | null; lastName: string | null } | null;
|
||||
userEmail: string;
|
||||
}): 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;
|
||||
};
|
||||
|
||||
export const WizardStepTelephony = (props: WizardStepComponentProps) => {
|
||||
const [values, setValues] = useState<TelephonyFormValues>(emptyTelephonyFormValues);
|
||||
const [agents, setAgents] = useState<AgentRow[]>([]);
|
||||
const [members, setMembers] = useState<WorkspaceMemberRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Editor state — which seat is selected, which member to assign.
|
||||
const [selectedSeatId, setSelectedSeatId] = useState<string>('');
|
||||
const [selectedMemberId, setSelectedMemberId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get<TelephonyFormValues>('/api/config/telephony', { silent: true })
|
||||
.then((data) => {
|
||||
setValues({
|
||||
ozonetel: {
|
||||
agentId: data.ozonetel?.agentId ?? '',
|
||||
agentPassword: data.ozonetel?.agentPassword ?? '',
|
||||
did: data.ozonetel?.did ?? '',
|
||||
sipId: data.ozonetel?.sipId ?? '',
|
||||
campaignName: data.ozonetel?.campaignName ?? '',
|
||||
},
|
||||
sip: {
|
||||
domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com',
|
||||
wsPort: data.sip?.wsPort ?? '444',
|
||||
},
|
||||
exotel: {
|
||||
apiKey: data.exotel?.apiKey ?? '',
|
||||
apiToken: data.exotel?.apiToken ?? '',
|
||||
accountSid: data.exotel?.accountSid ?? '',
|
||||
subdomain: data.exotel?.subdomain ?? 'api.exotel.com',
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// If the endpoint is unreachable, fall back to defaults so the
|
||||
// admin can at least fill out the form.
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{
|
||||
agents: { edges: { node: AgentRow }[] };
|
||||
workspaceMembers: { edges: { node: WorkspaceMemberRow }[] };
|
||||
}>(
|
||||
`{
|
||||
agents(first: 100) {
|
||||
edges { node {
|
||||
id name sipExtension ozonetelAgentId workspaceMemberId
|
||||
workspaceMember { id name { firstName lastName } userEmail }
|
||||
} }
|
||||
}
|
||||
workspaceMembers(first: 200) {
|
||||
edges { node {
|
||||
id userEmail name { firstName lastName }
|
||||
} }
|
||||
}
|
||||
}`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
);
|
||||
setAgents(data.agents.edges.map((e) => e.node));
|
||||
setMembers(
|
||||
data.workspaceMembers.edges
|
||||
.map((e) => e.node)
|
||||
.filter((m) => !m.userEmail.endsWith(AI_EMAIL_SUFFIX)),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[wizard/telephony] fetch failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Required fields for a working Ozonetel setup.
|
||||
if (
|
||||
!values.ozonetel.agentId.trim() ||
|
||||
!values.ozonetel.did.trim() ||
|
||||
!values.ozonetel.sipId.trim() ||
|
||||
!values.ozonetel.campaignName.trim()
|
||||
) {
|
||||
notify.error('Missing required fields', 'Agent ID, DID, SIP ID, and campaign name are all required.');
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Map every agent to a SipSeatSummary for the right pane. Single
|
||||
// source of truth — both panes read from `agents`.
|
||||
const seatSummaries = useMemo<SipSeatSummary[]>(
|
||||
() =>
|
||||
agents.map((a) => ({
|
||||
id: a.id,
|
||||
sipExtension: a.sipExtension,
|
||||
ozonetelAgentId: a.ozonetelAgentId,
|
||||
workspaceMember: a.workspaceMember,
|
||||
})),
|
||||
[agents],
|
||||
);
|
||||
|
||||
// Pre-compute lookups for the editor — which member already owns
|
||||
// each seat, and which members are already taken (so the dropdown
|
||||
// can hide them).
|
||||
const takenMemberIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
agents
|
||||
.filter((a) => a.workspaceMemberId !== null)
|
||||
.map((a) => a.workspaceMemberId!),
|
||||
),
|
||||
[agents],
|
||||
);
|
||||
|
||||
const seatItems = useMemo(
|
||||
() =>
|
||||
agents.map((a) => ({
|
||||
id: a.id,
|
||||
label: `Ext ${a.sipExtension ?? '—'}`,
|
||||
supportingText: a.workspaceMember
|
||||
? `Currently: ${memberDisplayName(a.workspaceMember)}`
|
||||
: 'Unassigned',
|
||||
})),
|
||||
[agents],
|
||||
);
|
||||
|
||||
// Members dropdown — when a seat is selected and the seat is
|
||||
// currently mapped, force the member field to show the current
|
||||
// owner so the admin can see who they're displacing. When seat
|
||||
// is unassigned, only show free members (the takenMemberIds
|
||||
// filter).
|
||||
const memberItems = useMemo(() => {
|
||||
const selectedSeat = agents.find((a) => a.id === selectedSeatId);
|
||||
const currentOwnerId = selectedSeat?.workspaceMemberId ?? null;
|
||||
return members
|
||||
.filter((m) => m.id === currentOwnerId || !takenMemberIds.has(m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
label: memberDisplayName(m),
|
||||
supportingText: m.userEmail,
|
||||
}));
|
||||
}, [members, agents, selectedSeatId, takenMemberIds]);
|
||||
|
||||
// When the admin picks a seat, default the member dropdown to
|
||||
// whoever currently owns it (if anyone) so Unassign just works.
|
||||
useEffect(() => {
|
||||
if (!selectedSeatId) {
|
||||
setSelectedMemberId('');
|
||||
return;
|
||||
}
|
||||
const seat = agents.find((a) => a.id === selectedSeatId);
|
||||
setSelectedMemberId(seat?.workspaceMemberId ?? '');
|
||||
}, [selectedSeatId, agents]);
|
||||
|
||||
const selectedSeat = agents.find((a) => a.id === selectedSeatId);
|
||||
const isCurrentlyMapped = selectedSeat?.workspaceMemberId !== null && selectedSeat?.workspaceMemberId !== undefined;
|
||||
|
||||
const updateSeat = async (seatId: string, workspaceMemberId: string | null) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.put('/api/config/telephony', {
|
||||
ozonetel: values.ozonetel,
|
||||
sip: values.sip,
|
||||
exotel: values.exotel,
|
||||
});
|
||||
notify.success('Telephony saved', 'Changes are live — no restart needed.');
|
||||
await props.onComplete('telephony');
|
||||
props.onAdvance();
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateAgent($id: UUID!, $data: AgentUpdateInput!) {
|
||||
updateAgent(id: $id, data: $data) { id workspaceMemberId }
|
||||
}`,
|
||||
{ id: seatId, data: { workspaceMemberId } },
|
||||
);
|
||||
await fetchData();
|
||||
// Mark the step complete on first successful action so
|
||||
// the wizard can advance. Subsequent edits don't re-mark.
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('telephony');
|
||||
}
|
||||
// Clear editor selection so the admin starts the next
|
||||
// assign from scratch.
|
||||
setSelectedSeatId('');
|
||||
setSelectedMemberId('');
|
||||
} catch (err) {
|
||||
console.error('[wizard/telephony] save failed', err);
|
||||
console.error('[wizard/telephony] updateAgent failed', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (!selectedSeatId || !selectedMemberId) {
|
||||
notify.error('Pick a seat and a member to assign');
|
||||
return;
|
||||
}
|
||||
updateSeat(selectedSeatId, selectedMemberId);
|
||||
};
|
||||
|
||||
const handleUnassign = () => {
|
||||
if (!selectedSeatId) return;
|
||||
updateSeat(selectedSeatId, null);
|
||||
};
|
||||
|
||||
const pretendCompleted = props.isCompleted || agents.some((a) => a.workspaceMemberId !== null);
|
||||
|
||||
return (
|
||||
<WizardStep
|
||||
step="telephony"
|
||||
isCompleted={props.isCompleted}
|
||||
isCompleted={pretendCompleted}
|
||||
isLast={props.isLast}
|
||||
onPrev={props.onPrev}
|
||||
onNext={props.onNext}
|
||||
onMarkComplete={handleSave}
|
||||
onMarkComplete={async () => {
|
||||
if (!props.isCompleted) {
|
||||
await props.onComplete('telephony');
|
||||
}
|
||||
props.onAdvance();
|
||||
}}
|
||||
onFinish={props.onFinish}
|
||||
saving={saving}
|
||||
rightPane={<TelephonyRightPane seats={seatSummaries} />}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-tertiary">Loading telephony settings…</p>
|
||||
<p className="text-sm text-tertiary">Loading SIP seats…</p>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="rounded-lg border border-secondary bg-secondary p-6 text-sm text-tertiary">
|
||||
<p className="font-medium text-primary">No SIP seats configured</p>
|
||||
<p className="mt-1">
|
||||
This hospital has no pre-provisioned agent profiles. Contact support to
|
||||
add SIP seats, then come back to finish setup.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<TelephonyForm value={values} onChange={setValues} />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
|
||||
<p>
|
||||
Pick a SIP seat and assign it to a workspace member. To free up a seat,
|
||||
select it and click <b>Unassign</b>. The right pane shows the live
|
||||
mapping — what you change here updates there immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="SIP seat"
|
||||
placeholder="Select a seat"
|
||||
items={seatItems}
|
||||
selectedKey={selectedSeatId || null}
|
||||
onSelectionChange={(key) => setSelectedSeatId((key as string) || '')}
|
||||
>
|
||||
{(item) => (
|
||||
<Select.Item
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportingText={item.supportingText}
|
||||
/>
|
||||
)}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
label="Workspace member"
|
||||
placeholder={
|
||||
!selectedSeatId
|
||||
? 'Pick a seat first'
|
||||
: memberItems.length === 0
|
||||
? 'No available members'
|
||||
: 'Select a member'
|
||||
}
|
||||
isDisabled={!selectedSeatId || memberItems.length === 0}
|
||||
items={memberItems}
|
||||
selectedKey={selectedMemberId || null}
|
||||
onSelectionChange={(key) => setSelectedMemberId((key as string) || '')}
|
||||
>
|
||||
{(item) => (
|
||||
<Select.Item
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportingText={item.supportingText}
|
||||
/>
|
||||
)}
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{isCurrentlyMapped && (
|
||||
<Button
|
||||
color="secondary-destructive"
|
||||
size="md"
|
||||
isDisabled={saving}
|
||||
onClick={handleUnassign}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faTrash} className={className} />
|
||||
)}
|
||||
>
|
||||
Unassign
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
isDisabled={saving || !selectedSeatId || !selectedMemberId}
|
||||
onClick={handleAssign}
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faHeadset} className={className} />
|
||||
)}
|
||||
>
|
||||
{selectedSeat?.workspaceMemberId === selectedMemberId
|
||||
? 'Already assigned'
|
||||
: isCurrentlyMapped
|
||||
? 'Reassign'
|
||||
: 'Assign'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WizardStep>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useContext, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowLeft, faArrowRight, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { SETUP_STEP_LABELS, type SetupStepName } from '@/lib/setup-state';
|
||||
import { WizardLayoutContext } from './wizard-layout-context';
|
||||
|
||||
type WizardStepProps = {
|
||||
step: SetupStepName;
|
||||
@@ -14,6 +16,11 @@ type WizardStepProps = {
|
||||
onFinish: () => void;
|
||||
saving?: boolean;
|
||||
children: ReactNode;
|
||||
// Optional content for the wizard shell's right preview pane.
|
||||
// Portaled into the shell's <aside> via WizardLayoutContext when
|
||||
// both are mounted. Each step component declares this inline so
|
||||
// the per-step data fetching stays in one place.
|
||||
rightPane?: ReactNode;
|
||||
};
|
||||
|
||||
// Single-step wrapper. The parent picks which step is active and supplies
|
||||
@@ -31,10 +38,14 @@ export const WizardStep = ({
|
||||
onFinish,
|
||||
saving = false,
|
||||
children,
|
||||
rightPane,
|
||||
}: WizardStepProps) => {
|
||||
const meta = SETUP_STEP_LABELS[step];
|
||||
const { rightPaneEl } = useContext(WizardLayoutContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
{rightPane && rightPaneEl && createPortal(rightPane, rightPaneEl)}
|
||||
<div className="rounded-xl border border-secondary bg-primary p-8 shadow-xs">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -64,30 +75,55 @@ export const WizardStep = ({
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{/* One primary action at the bottom — never two
|
||||
competing buttons. Previously the wizard showed
|
||||
Mark complete + Next side-by-side, and users
|
||||
naturally clicked Next (rightmost = "continue"),
|
||||
skipping the save+complete chain entirely. Result
|
||||
was every step staying at 0/6.
|
||||
|
||||
New behaviour: a single button whose label and
|
||||
handler depend on completion state.
|
||||
- !isCompleted, not last → "Save and continue"
|
||||
calls onMarkComplete (which does save +
|
||||
complete + advance via the step component's
|
||||
handleSave). Forces the agent through the
|
||||
completion path.
|
||||
- !isCompleted, last → "Save and finish"
|
||||
same chain, plus onFinish at the end.
|
||||
- isCompleted, not last → "Continue"
|
||||
calls onNext (pure navigation).
|
||||
- isCompleted, last → "Finish setup"
|
||||
calls onFinish.
|
||||
|
||||
Free-form navigation is still available via the
|
||||
left-side step nav, so users can revisit completed
|
||||
steps without re-saving. */}
|
||||
<div className="flex items-center gap-3">
|
||||
{!isCompleted && (
|
||||
{!isCompleted ? (
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
isLoading={saving}
|
||||
showTextWhileLoading
|
||||
onClick={onMarkComplete}
|
||||
iconTrailing={
|
||||
isLast
|
||||
? undefined
|
||||
: ({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faArrowRight} className={className} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Mark complete
|
||||
{isLast ? 'Save and finish' : 'Save and continue'}
|
||||
</Button>
|
||||
)}
|
||||
{isLast ? (
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
isDisabled={!isCompleted}
|
||||
onClick={onFinish}
|
||||
>
|
||||
) : isLast ? (
|
||||
<Button color="primary" size="md" onClick={onFinish}>
|
||||
Finish setup
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color={isCompleted ? 'primary' : 'secondary'}
|
||||
color="primary"
|
||||
size="md"
|
||||
isDisabled={!onNext}
|
||||
onClick={onNext ?? undefined}
|
||||
@@ -95,11 +131,12 @@ export const WizardStep = ({
|
||||
<FontAwesomeIcon icon={faArrowRight} className={className} />
|
||||
)}
|
||||
>
|
||||
Next
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,21 +8,37 @@ import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-m
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import {
|
||||
ClinicForm,
|
||||
clinicFormToGraphQLInput,
|
||||
clinicCoreToGraphQLInput,
|
||||
holidayInputsFromForm,
|
||||
requiredDocInputsFromForm,
|
||||
emptyClinicFormValues,
|
||||
type ClinicFormValues,
|
||||
type ClinicStatus,
|
||||
type DocumentType,
|
||||
type ClinicHolidayEntry,
|
||||
} from '@/components/forms/clinic-form';
|
||||
import { formatTimeLabel } from '@/components/application/date-picker/time-picker';
|
||||
import { formatDaySelection, type DaySelection } from '@/components/application/day-selector/day-selector';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { markSetupStepComplete } from '@/lib/setup-state';
|
||||
|
||||
// /settings/clinics — list + add/edit slideout for the clinic entity. Uses the
|
||||
// platform GraphQL API directly; there's no wrapping hook because this is the
|
||||
// only consumer of clinics CRUD (the call desk uses a read-only doctors
|
||||
// query and bypasses clinics entirely).
|
||||
// /settings/clinics — list + add/edit slideout. Schema aligns with the
|
||||
// reworked Clinic entity in helix-engage/src/objects/clinic.object.ts:
|
||||
// - openMonday..openSunday (7 BOOLEANs) for the weekly pattern
|
||||
// - opensAt/closesAt (TEXT, HH:MM) for the shared daily time range
|
||||
// - two child entities: Holiday (closures) and ClinicRequiredDocument
|
||||
// (required-doc selection per clinic)
|
||||
//
|
||||
// Save flow:
|
||||
// 1. createClinic / updateClinic (main record)
|
||||
// 2. Fire child mutations in parallel:
|
||||
// - For holidays: delete-all-recreate on edit (simple, idempotent)
|
||||
// - For required docs: diff old vs new, delete removed, create added
|
||||
|
||||
type Clinic = {
|
||||
// -- Fetched shapes from the platform ----------------------------------------
|
||||
|
||||
type ClinicNode = {
|
||||
id: string;
|
||||
clinicName: string | null;
|
||||
status: ClinicStatus | null;
|
||||
@@ -35,14 +51,28 @@ type Clinic = {
|
||||
} | null;
|
||||
phone: { primaryPhoneNumber: string | null } | null;
|
||||
email: { primaryEmail: string | null } | null;
|
||||
weekdayHours: string | null;
|
||||
saturdayHours: string | null;
|
||||
sundayHours: string | null;
|
||||
openMonday: boolean | null;
|
||||
openTuesday: boolean | null;
|
||||
openWednesday: boolean | null;
|
||||
openThursday: boolean | null;
|
||||
openFriday: boolean | null;
|
||||
openSaturday: boolean | null;
|
||||
openSunday: boolean | null;
|
||||
opensAt: string | null;
|
||||
closesAt: string | null;
|
||||
walkInAllowed: boolean | null;
|
||||
onlineBooking: boolean | null;
|
||||
cancellationWindowHours: number | null;
|
||||
arriveEarlyMin: number | null;
|
||||
requiredDocuments: string | null;
|
||||
// Reverse-side collections. Platform exposes them as Relay edges.
|
||||
holidays?: {
|
||||
edges: Array<{
|
||||
node: { id: string; date: string | null; reasonLabel: string | null };
|
||||
}>;
|
||||
};
|
||||
clinicRequiredDocuments?: {
|
||||
edges: Array<{ node: { id: string; documentType: DocumentType | null } }>;
|
||||
};
|
||||
};
|
||||
|
||||
const CLINICS_QUERY = `{
|
||||
@@ -57,16 +87,34 @@ const CLINICS_QUERY = `{
|
||||
}
|
||||
phone { primaryPhoneNumber }
|
||||
email { primaryEmail }
|
||||
weekdayHours saturdayHours sundayHours
|
||||
openMonday openTuesday openWednesday openThursday openFriday openSaturday openSunday
|
||||
opensAt closesAt
|
||||
walkInAllowed onlineBooking
|
||||
cancellationWindowHours arriveEarlyMin
|
||||
requiredDocuments
|
||||
holidays(first: 50) {
|
||||
edges { node { id date reasonLabel } }
|
||||
}
|
||||
clinicRequiredDocuments(first: 50) {
|
||||
edges { node { id documentType } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const toFormValues = (clinic: Clinic): ClinicFormValues => ({
|
||||
// -- Helpers -----------------------------------------------------------------
|
||||
|
||||
const toDaySelection = (c: ClinicNode): DaySelection => ({
|
||||
monday: !!c.openMonday,
|
||||
tuesday: !!c.openTuesday,
|
||||
wednesday: !!c.openWednesday,
|
||||
thursday: !!c.openThursday,
|
||||
friday: !!c.openFriday,
|
||||
saturday: !!c.openSaturday,
|
||||
sunday: !!c.openSunday,
|
||||
});
|
||||
|
||||
const toFormValues = (clinic: ClinicNode): ClinicFormValues => ({
|
||||
clinicName: clinic.clinicName ?? '',
|
||||
addressStreet1: clinic.addressCustom?.addressStreet1 ?? '',
|
||||
addressStreet2: clinic.addressCustom?.addressStreet2 ?? '',
|
||||
@@ -75,15 +123,29 @@ const toFormValues = (clinic: Clinic): ClinicFormValues => ({
|
||||
addressPostcode: clinic.addressCustom?.addressPostcode ?? '',
|
||||
phone: clinic.phone?.primaryPhoneNumber ?? '',
|
||||
email: clinic.email?.primaryEmail ?? '',
|
||||
weekdayHours: clinic.weekdayHours ?? '',
|
||||
saturdayHours: clinic.saturdayHours ?? '',
|
||||
sundayHours: clinic.sundayHours ?? '',
|
||||
openDays: toDaySelection(clinic),
|
||||
opensAt: clinic.opensAt ?? null,
|
||||
closesAt: clinic.closesAt ?? null,
|
||||
status: clinic.status ?? 'ACTIVE',
|
||||
walkInAllowed: clinic.walkInAllowed ?? true,
|
||||
onlineBooking: clinic.onlineBooking ?? true,
|
||||
cancellationWindowHours: clinic.cancellationWindowHours != null ? String(clinic.cancellationWindowHours) : '',
|
||||
cancellationWindowHours:
|
||||
clinic.cancellationWindowHours != null ? String(clinic.cancellationWindowHours) : '',
|
||||
arriveEarlyMin: clinic.arriveEarlyMin != null ? String(clinic.arriveEarlyMin) : '',
|
||||
requiredDocuments: clinic.requiredDocuments ?? '',
|
||||
requiredDocumentTypes:
|
||||
clinic.clinicRequiredDocuments?.edges
|
||||
.map((e) => e.node.documentType)
|
||||
.filter((t): t is DocumentType => t !== null) ?? [],
|
||||
holidays:
|
||||
clinic.holidays?.edges
|
||||
.filter((e) => e.node.date) // date is required on create but platform may have nulls from earlier
|
||||
.map(
|
||||
(e): ClinicHolidayEntry => ({
|
||||
id: e.node.id,
|
||||
date: e.node.date ?? '',
|
||||
label: e.node.reasonLabel ?? '',
|
||||
}),
|
||||
) ?? [],
|
||||
});
|
||||
|
||||
const statusLabel: Record<ClinicStatus, string> = {
|
||||
@@ -98,17 +160,69 @@ const statusColor: Record<ClinicStatus, 'success' | 'warning' | 'gray'> = {
|
||||
PERMANENTLY_CLOSED: 'gray',
|
||||
};
|
||||
|
||||
// Save-flow helpers — each mutation is a thin wrapper so handleSave
|
||||
// reads linearly.
|
||||
const createClinicMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql<{ createClinic: { id: string } }>(
|
||||
`mutation CreateClinic($data: ClinicCreateInput!) {
|
||||
createClinic(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const updateClinicMutation = (id: string, data: Record<string, unknown>) =>
|
||||
apiClient.graphql<{ updateClinic: { id: string } }>(
|
||||
`mutation UpdateClinic($id: UUID!, $data: ClinicUpdateInput!) {
|
||||
updateClinic(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{ id, data },
|
||||
);
|
||||
|
||||
const createHolidayMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateHoliday($data: HolidayCreateInput!) {
|
||||
createHoliday(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const deleteHolidayMutation = (id: string) =>
|
||||
apiClient.graphql(
|
||||
`mutation DeleteHoliday($id: UUID!) { deleteHoliday(id: $id) { id } }`,
|
||||
{ id },
|
||||
);
|
||||
|
||||
const createRequiredDocMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateClinicRequiredDocument($data: ClinicRequiredDocumentCreateInput!) {
|
||||
createClinicRequiredDocument(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const deleteRequiredDocMutation = (id: string) =>
|
||||
apiClient.graphql(
|
||||
`mutation DeleteClinicRequiredDocument($id: UUID!) {
|
||||
deleteClinicRequiredDocument(id: $id) { id }
|
||||
}`,
|
||||
{ id },
|
||||
);
|
||||
|
||||
// -- Page --------------------------------------------------------------------
|
||||
|
||||
export const ClinicsPage = () => {
|
||||
const [clinics, setClinics] = useState<Clinic[]>([]);
|
||||
const [clinics, setClinics] = useState<ClinicNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [slideoutOpen, setSlideoutOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Clinic | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<ClinicNode | null>(null);
|
||||
const [formValues, setFormValues] = useState<ClinicFormValues>(emptyClinicFormValues);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const fetchClinics = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{ clinics: { edges: { node: Clinic }[] } }>(CLINICS_QUERY);
|
||||
const data = await apiClient.graphql<{ clinics: { edges: { node: ClinicNode }[] } }>(
|
||||
CLINICS_QUERY,
|
||||
);
|
||||
setClinics(data.clinics.edges.map((e) => e.node));
|
||||
} catch {
|
||||
// toast already shown by apiClient
|
||||
@@ -127,7 +241,7 @@ export const ClinicsPage = () => {
|
||||
setSlideoutOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (clinic: Clinic) => {
|
||||
const handleEdit = (clinic: ClinicNode) => {
|
||||
setEditTarget(clinic);
|
||||
setFormValues(toFormValues(clinic));
|
||||
setSlideoutOpen(true);
|
||||
@@ -140,27 +254,47 @@ export const ClinicsPage = () => {
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const input = clinicFormToGraphQLInput(formValues);
|
||||
const coreInput = clinicCoreToGraphQLInput(formValues);
|
||||
|
||||
// 1. Upsert the clinic itself.
|
||||
let clinicId: string;
|
||||
if (editTarget) {
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateClinic($id: UUID!, $data: ClinicUpdateInput!) {
|
||||
updateClinic(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{ id: editTarget.id, data: input },
|
||||
);
|
||||
notify.success('Clinic updated', `${formValues.clinicName} has been updated.`);
|
||||
await updateClinicMutation(editTarget.id, coreInput);
|
||||
clinicId = editTarget.id;
|
||||
} else {
|
||||
await apiClient.graphql(
|
||||
`mutation CreateClinic($data: ClinicCreateInput!) {
|
||||
createClinic(data: $data) { id }
|
||||
}`,
|
||||
{ data: input },
|
||||
);
|
||||
const res = await createClinicMutation(coreInput);
|
||||
clinicId = res.createClinic.id;
|
||||
notify.success('Clinic added', `${formValues.clinicName} has been added.`);
|
||||
// First clinic added unblocks the wizard's clinics step. Failures
|
||||
// here are silent — the badge will just be stale until next load.
|
||||
markSetupStepComplete('clinics').catch(() => {});
|
||||
}
|
||||
|
||||
// 2. Holidays — delete-all-recreate. Simple, always correct.
|
||||
if (editTarget?.holidays?.edges?.length) {
|
||||
await Promise.all(
|
||||
editTarget.holidays.edges.map((e) => deleteHolidayMutation(e.node.id)),
|
||||
);
|
||||
}
|
||||
if (formValues.holidays.length > 0) {
|
||||
const holidayInputs = holidayInputsFromForm(formValues, clinicId);
|
||||
await Promise.all(holidayInputs.map((data) => createHolidayMutation(data)));
|
||||
}
|
||||
|
||||
// 3. Required docs — delete-all-recreate for symmetry.
|
||||
if (editTarget?.clinicRequiredDocuments?.edges?.length) {
|
||||
await Promise.all(
|
||||
editTarget.clinicRequiredDocuments.edges.map((e) =>
|
||||
deleteRequiredDocMutation(e.node.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (formValues.requiredDocumentTypes.length > 0) {
|
||||
const docInputs = requiredDocInputsFromForm(formValues, clinicId);
|
||||
await Promise.all(docInputs.map((data) => createRequiredDocMutation(data)));
|
||||
}
|
||||
|
||||
if (editTarget) {
|
||||
notify.success('Clinic updated', `${formValues.clinicName} has been updated.`);
|
||||
}
|
||||
await fetchClinics();
|
||||
close();
|
||||
} catch (err) {
|
||||
@@ -170,7 +304,10 @@ export const ClinicsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const activeCount = useMemo(() => clinics.filter((c) => c.status === 'ACTIVE').length, [clinics]);
|
||||
const activeCount = useMemo(
|
||||
() => clinics.filter((c) => c.status === 'ACTIVE').length,
|
||||
[clinics],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
@@ -231,6 +368,11 @@ export const ClinicsPage = () => {
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const status = clinic.status ?? 'ACTIVE';
|
||||
const dayLabel = formatDaySelection(toDaySelection(clinic));
|
||||
const hoursLabel =
|
||||
clinic.opensAt && clinic.closesAt
|
||||
? `${formatTimeLabel(clinic.opensAt)}–${formatTimeLabel(clinic.closesAt)}`
|
||||
: 'Not set';
|
||||
return (
|
||||
<Table.Row id={clinic.id}>
|
||||
<Table.Cell>
|
||||
@@ -252,9 +394,10 @@ export const ClinicsPage = () => {
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-xs text-tertiary">
|
||||
{clinic.weekdayHours ?? 'Not set'}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-primary">{dayLabel}</span>
|
||||
<span className="text-xs text-tertiary">{hoursLabel}</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={statusColor[status]} type="pill-color">
|
||||
|
||||
@@ -8,36 +8,54 @@ import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-m
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import {
|
||||
DoctorForm,
|
||||
doctorFormToGraphQLInput,
|
||||
doctorCoreToGraphQLInput,
|
||||
visitSlotInputsFromForm,
|
||||
emptyDoctorFormValues,
|
||||
type DoctorDepartment,
|
||||
type DoctorFormValues,
|
||||
type DayOfWeek,
|
||||
type DoctorVisitSlotEntry,
|
||||
} from '@/components/forms/doctor-form';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { markSetupStepComplete } from '@/lib/setup-state';
|
||||
|
||||
// /settings/doctors — list + add/edit slideout. Loads clinics in parallel to
|
||||
// populate the clinic dropdown in the form. If there are no clinics yet, the
|
||||
// form's clinic select is disabled and the CTA copy steers the admin back to
|
||||
// /settings/clinics first.
|
||||
// /settings/doctors — list + add/edit slideout. Doctors are hospital-
|
||||
// wide; their multi-clinic visiting schedule is modelled as a list of
|
||||
// DoctorVisitSlot child records fetched through the reverse relation.
|
||||
//
|
||||
// Save flow mirrors clinics.tsx: create/update the core doctor row,
|
||||
// then delete-all-recreate the visit slots. Slots are recreated in
|
||||
// parallel (Promise.all). Pre-existing slots from the fetched record
|
||||
// get their id propagated into form state so edit mode shows the
|
||||
// current schedule.
|
||||
|
||||
type Doctor = {
|
||||
type DoctorNode = {
|
||||
id: string;
|
||||
fullName: { firstName: string | null; lastName: string | null } | null;
|
||||
department: DoctorDepartment | null;
|
||||
specialty: string | null;
|
||||
qualifications: string | null;
|
||||
yearsOfExperience: number | null;
|
||||
visitingHours: string | null;
|
||||
consultationFeeNew: { amountMicros: number | null; currencyCode: string | null } | null;
|
||||
consultationFeeFollowUp: { amountMicros: number | null; currencyCode: string | null } | null;
|
||||
phone: { primaryPhoneNumber: string | null } | null;
|
||||
email: { primaryEmail: string | null } | null;
|
||||
registrationNumber: string | null;
|
||||
active: boolean | null;
|
||||
clinicId: string | null;
|
||||
clinic: { id: string; clinicName: string | null } | null;
|
||||
// Reverse-side relation to DoctorVisitSlot records.
|
||||
doctorVisitSlots?: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
id: string;
|
||||
clinicId: string | null;
|
||||
clinic: { id: string; clinicName: string | null } | null;
|
||||
dayOfWeek: DayOfWeek | null;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type ClinicLite = { id: string; clinicName: string | null };
|
||||
@@ -49,15 +67,24 @@ const DOCTORS_QUERY = `{
|
||||
id
|
||||
fullName { firstName lastName }
|
||||
department specialty qualifications yearsOfExperience
|
||||
visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
consultationFeeFollowUp { amountMicros currencyCode }
|
||||
phone { primaryPhoneNumber }
|
||||
email { primaryEmail }
|
||||
registrationNumber
|
||||
active
|
||||
clinicId
|
||||
clinic { id clinicName }
|
||||
doctorVisitSlots(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
clinicId
|
||||
clinic { id clinicName }
|
||||
dayOfWeek
|
||||
startTime
|
||||
endTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,15 +102,23 @@ const departmentLabel: Record<DoctorDepartment, string> = {
|
||||
ONCOLOGY: 'Oncology',
|
||||
};
|
||||
|
||||
const toFormValues = (doctor: Doctor): DoctorFormValues => ({
|
||||
const dayLabel: Record<DayOfWeek, string> = {
|
||||
MONDAY: 'Mon',
|
||||
TUESDAY: 'Tue',
|
||||
WEDNESDAY: 'Wed',
|
||||
THURSDAY: 'Thu',
|
||||
FRIDAY: 'Fri',
|
||||
SATURDAY: 'Sat',
|
||||
SUNDAY: 'Sun',
|
||||
};
|
||||
|
||||
const toFormValues = (doctor: DoctorNode): DoctorFormValues => ({
|
||||
firstName: doctor.fullName?.firstName ?? '',
|
||||
lastName: doctor.fullName?.lastName ?? '',
|
||||
department: doctor.department ?? '',
|
||||
specialty: doctor.specialty ?? '',
|
||||
qualifications: doctor.qualifications ?? '',
|
||||
yearsOfExperience: doctor.yearsOfExperience != null ? String(doctor.yearsOfExperience) : '',
|
||||
clinicId: doctor.clinicId ?? '',
|
||||
visitingHours: doctor.visitingHours ?? '',
|
||||
consultationFeeNew: doctor.consultationFeeNew?.amountMicros
|
||||
? String(Math.round(doctor.consultationFeeNew.amountMicros / 1_000_000))
|
||||
: '',
|
||||
@@ -94,6 +129,16 @@ const toFormValues = (doctor: Doctor): DoctorFormValues => ({
|
||||
email: doctor.email?.primaryEmail ?? '',
|
||||
registrationNumber: doctor.registrationNumber ?? '',
|
||||
active: doctor.active ?? true,
|
||||
visitSlots:
|
||||
doctor.doctorVisitSlots?.edges.map(
|
||||
(e): DoctorVisitSlotEntry => ({
|
||||
id: e.node.id,
|
||||
clinicId: e.node.clinicId ?? e.node.clinic?.id ?? '',
|
||||
dayOfWeek: e.node.dayOfWeek ?? '',
|
||||
startTime: e.node.startTime ?? null,
|
||||
endTime: e.node.endTime ?? null,
|
||||
}),
|
||||
) ?? [],
|
||||
});
|
||||
|
||||
const formatFee = (money: { amountMicros: number | null } | null): string => {
|
||||
@@ -101,19 +146,79 @@ const formatFee = (money: { amountMicros: number | null } | null): string => {
|
||||
return `₹${Math.round(money.amountMicros / 1_000_000).toLocaleString('en-IN')}`;
|
||||
};
|
||||
|
||||
// Compact "clinics + days" summary for the list row. Groups by clinic
|
||||
// so "Koramangala: Mon Wed / Whitefield: Tue Thu Fri" is one string.
|
||||
const summariseVisitSlots = (
|
||||
doctor: DoctorNode,
|
||||
clinicNameById: Map<string, string>,
|
||||
): string => {
|
||||
const edges = doctor.doctorVisitSlots?.edges ?? [];
|
||||
if (edges.length === 0) return 'No slots';
|
||||
const byClinic = new Map<string, DayOfWeek[]>();
|
||||
for (const e of edges) {
|
||||
const cid = e.node.clinicId ?? e.node.clinic?.id;
|
||||
if (!cid || !e.node.dayOfWeek) continue;
|
||||
if (!byClinic.has(cid)) byClinic.set(cid, []);
|
||||
byClinic.get(cid)!.push(e.node.dayOfWeek);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const [cid, days] of byClinic.entries()) {
|
||||
const name = clinicNameById.get(cid) ?? 'Unknown clinic';
|
||||
const dayStr = days.map((d) => dayLabel[d]).join(' ');
|
||||
parts.push(`${name}: ${dayStr}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
// -- Mutation helpers --------------------------------------------------------
|
||||
|
||||
const createDoctorMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql<{ createDoctor: { id: string } }>(
|
||||
`mutation CreateDoctor($data: DoctorCreateInput!) {
|
||||
createDoctor(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const updateDoctorMutation = (id: string, data: Record<string, unknown>) =>
|
||||
apiClient.graphql<{ updateDoctor: { id: string } }>(
|
||||
`mutation UpdateDoctor($id: UUID!, $data: DoctorUpdateInput!) {
|
||||
updateDoctor(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{ id, data },
|
||||
);
|
||||
|
||||
const createVisitSlotMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateDoctorVisitSlot($data: DoctorVisitSlotCreateInput!) {
|
||||
createDoctorVisitSlot(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const deleteVisitSlotMutation = (id: string) =>
|
||||
apiClient.graphql(
|
||||
`mutation DeleteDoctorVisitSlot($id: UUID!) {
|
||||
deleteDoctorVisitSlot(id: $id) { id }
|
||||
}`,
|
||||
{ id },
|
||||
);
|
||||
|
||||
// -- Page --------------------------------------------------------------------
|
||||
|
||||
export const DoctorsPage = () => {
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [doctors, setDoctors] = useState<DoctorNode[]>([]);
|
||||
const [clinics, setClinics] = useState<ClinicLite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [slideoutOpen, setSlideoutOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Doctor | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<DoctorNode | null>(null);
|
||||
const [formValues, setFormValues] = useState<DoctorFormValues>(emptyDoctorFormValues);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{
|
||||
doctors: { edges: { node: Doctor }[] };
|
||||
doctors: { edges: { node: DoctorNode }[] };
|
||||
clinics: { edges: { node: ClinicLite }[] };
|
||||
}>(DOCTORS_QUERY);
|
||||
setDoctors(data.doctors.edges.map((e) => e.node));
|
||||
@@ -152,7 +257,7 @@ export const DoctorsPage = () => {
|
||||
setSlideoutOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (doctor: Doctor) => {
|
||||
const handleEdit = (doctor: DoctorNode) => {
|
||||
setEditTarget(doctor);
|
||||
setFormValues(toFormValues(doctor));
|
||||
setSlideoutOpen(true);
|
||||
@@ -165,25 +270,36 @@ export const DoctorsPage = () => {
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const input = doctorFormToGraphQLInput(formValues);
|
||||
const coreInput = doctorCoreToGraphQLInput(formValues);
|
||||
|
||||
// 1. Upsert doctor
|
||||
let doctorId: string;
|
||||
if (editTarget) {
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateDoctor($id: UUID!, $data: DoctorUpdateInput!) {
|
||||
updateDoctor(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{ id: editTarget.id, data: input },
|
||||
);
|
||||
notify.success('Doctor updated', `Dr. ${formValues.firstName} ${formValues.lastName}`);
|
||||
await updateDoctorMutation(editTarget.id, coreInput);
|
||||
doctorId = editTarget.id;
|
||||
} else {
|
||||
await apiClient.graphql(
|
||||
`mutation CreateDoctor($data: DoctorCreateInput!) {
|
||||
createDoctor(data: $data) { id }
|
||||
}`,
|
||||
{ data: input },
|
||||
);
|
||||
const res = await createDoctorMutation(coreInput);
|
||||
doctorId = res.createDoctor.id;
|
||||
notify.success('Doctor added', `Dr. ${formValues.firstName} ${formValues.lastName}`);
|
||||
markSetupStepComplete('doctors').catch(() => {});
|
||||
}
|
||||
|
||||
// 2. Visit slots — delete-all-recreate, same pattern as
|
||||
// clinics.tsx holidays/requiredDocs. Simple, always correct,
|
||||
// acceptable overhead at human-edit frequency.
|
||||
if (editTarget?.doctorVisitSlots?.edges?.length) {
|
||||
await Promise.all(
|
||||
editTarget.doctorVisitSlots.edges.map((e) => deleteVisitSlotMutation(e.node.id)),
|
||||
);
|
||||
}
|
||||
const slotInputs = visitSlotInputsFromForm(formValues, doctorId);
|
||||
if (slotInputs.length > 0) {
|
||||
await Promise.all(slotInputs.map((data) => createVisitSlotMutation(data)));
|
||||
}
|
||||
|
||||
if (editTarget) {
|
||||
notify.success('Doctor updated', `Dr. ${formValues.firstName} ${formValues.lastName}`);
|
||||
}
|
||||
await fetchAll();
|
||||
close();
|
||||
} catch (err) {
|
||||
@@ -242,7 +358,7 @@ export const DoctorsPage = () => {
|
||||
<Table.Header>
|
||||
<Table.Head label="DOCTOR" isRowHeader />
|
||||
<Table.Head label="DEPARTMENT" />
|
||||
<Table.Head label="CLINIC" />
|
||||
<Table.Head label="VISITING SCHEDULE" />
|
||||
<Table.Head label="FEE (NEW)" />
|
||||
<Table.Head label="STATUS" />
|
||||
<Table.Head label="" />
|
||||
@@ -252,10 +368,6 @@ export const DoctorsPage = () => {
|
||||
const firstName = doctor.fullName?.firstName ?? '';
|
||||
const lastName = doctor.fullName?.lastName ?? '';
|
||||
const name = `Dr. ${firstName} ${lastName}`.trim();
|
||||
const clinicName =
|
||||
doctor.clinic?.clinicName ??
|
||||
(doctor.clinicId ? clinicNameById.get(doctor.clinicId) : null) ??
|
||||
'—';
|
||||
return (
|
||||
<Table.Row id={doctor.id}>
|
||||
<Table.Cell>
|
||||
@@ -272,7 +384,9 @@ export const DoctorsPage = () => {
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-tertiary">{clinicName}</span>
|
||||
<span className="text-xs text-tertiary">
|
||||
{summariseVisitSlots(doctor, clinicNameById)}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-tertiary">
|
||||
@@ -323,7 +437,7 @@ export const DoctorsPage = () => {
|
||||
</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
{editTarget
|
||||
? 'Update clinician details and clinic assignment'
|
||||
? 'Update clinician details and visiting schedule'
|
||||
: 'Add a new clinician to your hospital'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -80,14 +80,13 @@ export const SetupWizardPage = () => {
|
||||
const onNext = !isLastStep ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]) : null;
|
||||
|
||||
const handleComplete = async (step: SetupStepName) => {
|
||||
try {
|
||||
const updated = await markSetupStepComplete(step, user?.email);
|
||||
setState(updated);
|
||||
} catch (err) {
|
||||
console.error('Failed to mark step complete', err);
|
||||
// Non-fatal — the step's own save already succeeded. We just
|
||||
// couldn't persist the wizard-state badge.
|
||||
}
|
||||
// No try/catch here — if the setup-state PUT fails, we WANT
|
||||
// the error to propagate up to the step's handleSave so the
|
||||
// agent sees a toast AND the advance flow pauses until the
|
||||
// issue is resolved. Silent swallowing hid the real failure
|
||||
// mode during the Ramaiah local test.
|
||||
const updated = await markSetupStepComplete(step, user?.email);
|
||||
setState(updated);
|
||||
};
|
||||
|
||||
const handleAdvance = () => {
|
||||
|
||||
@@ -9,25 +9,21 @@ import { Table, TableCard } from '@/components/application/table/table';
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import {
|
||||
InviteMemberForm,
|
||||
type InviteMemberFormValues,
|
||||
EmployeeCreateForm,
|
||||
emptyEmployeeCreateFormValues,
|
||||
type EmployeeCreateFormValues,
|
||||
type RoleOption,
|
||||
} from '@/components/forms/invite-member-form';
|
||||
} from '@/components/forms/employee-create-form';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { getInitials } from '@/lib/format';
|
||||
import { markSetupStepComplete } from '@/lib/setup-state';
|
||||
|
||||
// /settings/team — Phase 3 rewrite. The Phase 2 version was read-only and
|
||||
// inferred roles from the email string; this version:
|
||||
// 1. Fetches real roles via getRoles (platform mutation).
|
||||
// 2. Fetches workspace members WITH their role assignments so the listing
|
||||
// shows actual roles.
|
||||
// 3. Lets admins invite via sendInvitations (multi-email).
|
||||
// 4. Lets admins change a member's role via updateWorkspaceMemberRole.
|
||||
//
|
||||
// Invitations mark the setup-state `team` step complete on first success so
|
||||
// the wizard can advance.
|
||||
// /settings/team — in-place employee management. Fetches real roles + SIP
|
||||
// seats from the platform and uses the sidecar's /api/team/members
|
||||
// endpoint to create workspace members directly with a temp password
|
||||
// that the admin hands out (no email invitations — see
|
||||
// feedback-no-invites in memory). Also lets admins change a member's
|
||||
// role via updateWorkspaceMemberRole.
|
||||
|
||||
type MemberRole = {
|
||||
id: string;
|
||||
@@ -39,10 +35,22 @@ type WorkspaceMember = {
|
||||
name: { firstName: string; lastName: string } | null;
|
||||
userEmail: string;
|
||||
avatarUrl: string | null;
|
||||
roles: MemberRole[];
|
||||
// Platform returns null (not []) for members with no role assigned —
|
||||
// optional-chain when reading.
|
||||
roles: MemberRole[] | null;
|
||||
};
|
||||
|
||||
const MEMBERS_QUERY = `{
|
||||
type CreatedMemberResponse = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
// Combined query — workspace members + assignable roles. Bundled to
|
||||
// save a round-trip and keep the table consistent across the join.
|
||||
const TEAM_QUERY = `{
|
||||
workspaceMembers(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
@@ -54,9 +62,6 @@ const MEMBERS_QUERY = `{
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const ROLES_QUERY = `{
|
||||
getRoles {
|
||||
id
|
||||
label
|
||||
@@ -69,31 +74,32 @@ export const TeamSettingsPage = () => {
|
||||
const [members, setMembers] = useState<WorkspaceMember[]>([]);
|
||||
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [inviteValues, setInviteValues] = useState<InviteMemberFormValues>({ emails: [], roleId: '' });
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createValues, setCreateValues] = useState<EmployeeCreateFormValues>(
|
||||
emptyEmployeeCreateFormValues,
|
||||
);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [memberData, roleData] = await Promise.all([
|
||||
apiClient.graphql<{ workspaceMembers: { edges: { node: WorkspaceMember }[] } }>(
|
||||
MEMBERS_QUERY,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
),
|
||||
apiClient.graphql<{
|
||||
getRoles: { id: string; label: string; description: string | null; canBeAssignedToUsers: boolean }[];
|
||||
}>(ROLES_QUERY, undefined, { silent: true }),
|
||||
]);
|
||||
setMembers(memberData.workspaceMembers.edges.map((e) => e.node));
|
||||
const data = await apiClient.graphql<{
|
||||
workspaceMembers: { edges: { node: WorkspaceMember }[] };
|
||||
getRoles: {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
canBeAssignedToUsers: boolean;
|
||||
}[];
|
||||
}>(TEAM_QUERY, undefined, { silent: true });
|
||||
|
||||
setMembers(data.workspaceMembers.edges.map((e) => e.node));
|
||||
const assignable = data.getRoles.filter((r) => r.canBeAssignedToUsers);
|
||||
setRoles(
|
||||
roleData.getRoles
|
||||
.filter((r) => r.canBeAssignedToUsers)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
supportingText: r.description ?? undefined,
|
||||
})),
|
||||
assignable.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
supportingText: r.description ?? undefined,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
// silently fail
|
||||
@@ -150,38 +156,46 @@ export const TeamSettingsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendInvites = async (close: () => void) => {
|
||||
if (inviteValues.emails.length === 0) {
|
||||
notify.error('Add at least one email');
|
||||
const handleCreateMember = async (close: () => void) => {
|
||||
const firstName = createValues.firstName.trim();
|
||||
const email = createValues.email.trim();
|
||||
if (!firstName) {
|
||||
notify.error('First name is required');
|
||||
return;
|
||||
}
|
||||
setIsSending(true);
|
||||
if (!email) {
|
||||
notify.error('Email is required');
|
||||
return;
|
||||
}
|
||||
if (!createValues.password) {
|
||||
notify.error('Temporary password is required');
|
||||
return;
|
||||
}
|
||||
if (!createValues.roleId) {
|
||||
notify.error('Pick a role');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation SendInvitations($emails: [String!]!) {
|
||||
sendInvitations(emails: $emails) {
|
||||
success
|
||||
errors
|
||||
result {
|
||||
email
|
||||
id
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ emails: inviteValues.emails },
|
||||
);
|
||||
await apiClient.post<CreatedMemberResponse>('/api/team/members', {
|
||||
firstName,
|
||||
lastName: createValues.lastName.trim(),
|
||||
email,
|
||||
password: createValues.password,
|
||||
roleId: createValues.roleId,
|
||||
});
|
||||
notify.success(
|
||||
'Invitations sent',
|
||||
`${inviteValues.emails.length} invitation${inviteValues.emails.length === 1 ? '' : 's'} sent.`,
|
||||
'Employee created',
|
||||
`${firstName} ${createValues.lastName.trim()}`.trim() || email,
|
||||
);
|
||||
markSetupStepComplete('team').catch(() => {});
|
||||
setInviteValues({ emails: [], roleId: '' });
|
||||
setCreateValues(emptyEmployeeCreateFormValues);
|
||||
await fetchData();
|
||||
close();
|
||||
} catch (err) {
|
||||
console.error('[team] invite failed', err);
|
||||
console.error('[team] create failed', err);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -215,11 +229,11 @@ export const TeamSettingsPage = () => {
|
||||
<FontAwesomeIcon icon={faUserPlus} className={className} />
|
||||
)}
|
||||
onClick={() => {
|
||||
setInviteValues({ emails: [], roleId: '' });
|
||||
setInviteOpen(true);
|
||||
setCreateValues(emptyEmployeeCreateFormValues);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
Invite members
|
||||
Add employee
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -244,7 +258,8 @@ export const TeamSettingsPage = () => {
|
||||
const lastName = member.name?.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unnamed';
|
||||
const initials = getInitials(firstName || '?', lastName || '?');
|
||||
const currentRoleId = member.roles[0]?.id ?? null;
|
||||
const memberRoles = member.roles ?? [];
|
||||
const currentRoleId = memberRoles[0]?.id ?? null;
|
||||
|
||||
return (
|
||||
<Table.Row id={member.id}>
|
||||
@@ -284,9 +299,9 @@ export const TeamSettingsPage = () => {
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
) : member.roles.length > 0 ? (
|
||||
) : memberRoles.length > 0 ? (
|
||||
<Badge size="sm" color="gray">
|
||||
{member.roles[0].label}
|
||||
{memberRoles[0].label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">No role</span>
|
||||
@@ -344,7 +359,7 @@ export const TeamSettingsPage = () => {
|
||||
</TableCard.Root>
|
||||
</div>
|
||||
|
||||
<SlideoutMenu isOpen={inviteOpen} onOpenChange={setInviteOpen} isDismissable>
|
||||
<SlideoutMenu isOpen={createOpen} onOpenChange={setCreateOpen} isDismissable>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<SlideoutMenu.Header onClose={close}>
|
||||
@@ -353,19 +368,23 @@ export const TeamSettingsPage = () => {
|
||||
<FontAwesomeIcon icon={faUserPlus} className="size-5 text-fg-brand-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary">Invite members</h2>
|
||||
<h2 className="text-lg font-semibold text-primary">Add employee</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
Send invitations to supervisors, agents, and doctors
|
||||
Create supervisors, CC agents and admins in place
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Header>
|
||||
|
||||
<SlideoutMenu.Content>
|
||||
<InviteMemberForm value={inviteValues} onChange={setInviteValues} roles={roles} />
|
||||
<p className="text-xs text-tertiary">
|
||||
Invitees receive an email with a link to set their password. Role assignment can be changed
|
||||
from the employees table after they accept.
|
||||
<EmployeeCreateForm
|
||||
value={createValues}
|
||||
onChange={setCreateValues}
|
||||
roles={roles}
|
||||
/>
|
||||
<p className="mt-4 text-xs text-tertiary">
|
||||
The employee logs in with this email and the temporary password
|
||||
you set. Share both with them directly — no email is sent.
|
||||
</p>
|
||||
</SlideoutMenu.Content>
|
||||
|
||||
@@ -377,16 +396,11 @@ export const TeamSettingsPage = () => {
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isLoading={isSending}
|
||||
isLoading={isCreating}
|
||||
showTextWhileLoading
|
||||
onClick={() => handleSendInvites(close)}
|
||||
isDisabled={inviteValues.emails.length === 0}
|
||||
onClick={() => handleCreateMember(close)}
|
||||
>
|
||||
{isSending
|
||||
? 'Sending...'
|
||||
: `Send ${inviteValues.emails.length || ''} invitation${
|
||||
inviteValues.emails.length === 1 ? '' : 's'
|
||||
}`.trim()}
|
||||
{isCreating ? 'Creating…' : 'Create employee'}
|
||||
</Button>
|
||||
</div>
|
||||
</SlideoutMenu.Footer>
|
||||
|
||||
Reference in New Issue
Block a user