mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Bug 556 triggered a broader audit of every date input in the app: - appointment-form DatePicker now has minValue=today(getLocalTimeZone()) — can't book or reschedule into the past (tightens bug 555 at the input layer too; the past-slot filter in masterdata service still handles the hour-granularity) - clinic-form holiday date picker gets the same — can't observe a holiday that already passed Audit complete: - enquiry-form follow-up date: already had min=today (bug 556 fix) - appointment-form: fixed here - clinic-form holidays: fixed here - my-performance date filter: past valid (reports over history) - campaign-edit start/end: past valid (historical campaigns)
518 lines
20 KiB
TypeScript
518 lines
20 KiB
TypeScript
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 { 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 /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).
|
|
//
|
|
// 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;
|
|
addressCity: string;
|
|
addressState: string;
|
|
addressPostcode: string;
|
|
phone: string;
|
|
email: 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;
|
|
// Children (persisted via separate mutations)
|
|
requiredDocumentTypes: DocumentType[];
|
|
holidays: ClinicHolidayEntry[];
|
|
};
|
|
|
|
export const emptyClinicFormValues = (): ClinicFormValues => ({
|
|
clinicName: '',
|
|
addressStreet1: '',
|
|
addressStreet2: '',
|
|
addressCity: '',
|
|
addressState: '',
|
|
addressPostcode: '',
|
|
phone: '',
|
|
email: '',
|
|
openDays: defaultDaySelection(),
|
|
opensAt: '09:00',
|
|
closesAt: '18:00',
|
|
status: 'ACTIVE',
|
|
walkInAllowed: true,
|
|
onlineBooking: true,
|
|
cancellationWindowHours: '24',
|
|
arriveEarlyMin: '15',
|
|
requiredDocumentTypes: [],
|
|
holidays: [],
|
|
});
|
|
|
|
const STATUS_ITEMS = [
|
|
{ id: 'ACTIVE', label: 'Active' },
|
|
{ id: 'TEMPORARILY_CLOSED', label: 'Temporarily closed' },
|
|
{ id: 'PERMANENTLY_CLOSED', label: 'Permanently closed' },
|
|
];
|
|
|
|
// 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,
|
|
};
|
|
|
|
// 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,
|
|
addressStreet2: v.addressStreet2 || null,
|
|
addressCity: v.addressCity || null,
|
|
addressState: v.addressState || null,
|
|
addressPostcode: v.addressPostcode || null,
|
|
addressCountry: 'India',
|
|
};
|
|
}
|
|
|
|
if (v.phone.trim()) {
|
|
input.phone = {
|
|
primaryPhoneNumber: v.phone.trim(),
|
|
primaryPhoneCountryCode: 'IN',
|
|
primaryPhoneCallingCode: '+91',
|
|
additionalPhones: null,
|
|
};
|
|
}
|
|
|
|
if (v.email.trim()) {
|
|
input.email = {
|
|
primaryEmail: v.email.trim(),
|
|
additionalEmails: null,
|
|
};
|
|
}
|
|
|
|
if (v.cancellationWindowHours.trim()) {
|
|
const n = Number(v.cancellationWindowHours);
|
|
if (!Number.isNaN(n)) input.cancellationWindowHours = n;
|
|
}
|
|
if (v.arriveEarlyMin.trim()) {
|
|
const n = Number(v.arriveEarlyMin);
|
|
if (!Number.isNaN(n)) input.arriveEarlyMin = n;
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
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
|
|
label="Clinic name"
|
|
isRequired
|
|
placeholder="e.g. Main Hospital Campus"
|
|
value={value.clinicName}
|
|
onChange={(v) => patch({ clinicName: v })}
|
|
/>
|
|
|
|
<Select
|
|
label="Status"
|
|
placeholder="Select status"
|
|
items={STATUS_ITEMS}
|
|
selectedKey={value.status}
|
|
onSelectionChange={(key) => patch({ status: key as ClinicStatus })}
|
|
>
|
|
{(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>
|
|
<Input
|
|
label="Street address"
|
|
placeholder="Street / building / landmark"
|
|
value={value.addressStreet1}
|
|
onChange={(v) => patch({ addressStreet1: v })}
|
|
/>
|
|
<Input
|
|
label="Area / locality (optional)"
|
|
placeholder="Area, neighbourhood"
|
|
value={value.addressStreet2}
|
|
onChange={(v) => patch({ addressStreet2: v })}
|
|
/>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Input
|
|
label="City"
|
|
placeholder="Bengaluru"
|
|
value={value.addressCity}
|
|
onChange={(v) => patch({ addressCity: v })}
|
|
/>
|
|
<Input
|
|
label="State"
|
|
placeholder="Karnataka"
|
|
value={value.addressState}
|
|
onChange={(v) => patch({ addressState: v })}
|
|
/>
|
|
</div>
|
|
<Input
|
|
label="Postcode"
|
|
placeholder="560034"
|
|
value={value.addressPostcode}
|
|
onChange={(v) => patch({ addressPostcode: v })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Contact */}
|
|
<div className="flex flex-col gap-3">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
|
Contact
|
|
</p>
|
|
<Input
|
|
label="Phone"
|
|
type="tel"
|
|
placeholder="9876543210"
|
|
value={value.phone}
|
|
onChange={(v) => patch({ phone: v })}
|
|
/>
|
|
<Input
|
|
label="Email"
|
|
type="email"
|
|
placeholder="branch@hospital.com"
|
|
value={value.email}
|
|
onChange={(v) => patch({ email: v })}
|
|
/>
|
|
</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>
|
|
<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">
|
|
<TimePicker
|
|
label="Opens at"
|
|
value={value.opensAt}
|
|
onChange={(opensAt) => patch({ opensAt })}
|
|
/>
|
|
<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">
|
|
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() : '' })
|
|
}
|
|
// Holidays must be today or in the future — you
|
|
// can't observe a holiday that already passed.
|
|
minValue={today(getLocalTimeZone())}
|
|
/>
|
|
</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"
|
|
isSelected={value.walkInAllowed}
|
|
onChange={(checked) => patch({ walkInAllowed: checked })}
|
|
/>
|
|
<Toggle
|
|
label="Accept online bookings"
|
|
isSelected={value.onlineBooking}
|
|
onChange={(checked) => patch({ onlineBooking: checked })}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Input
|
|
label="Cancel window (hours)"
|
|
type="number"
|
|
value={value.cancellationWindowHours}
|
|
onChange={(v) => patch({ cancellationWindowHours: v })}
|
|
/>
|
|
<Input
|
|
label="Arrive early (min)"
|
|
type="number"
|
|
value={value.arriveEarlyMin}
|
|
onChange={(v) => patch({ arriveEarlyMin: v })}
|
|
/>
|
|
</div>
|
|
</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>
|
|
);
|
|
};
|