import { type PolicyQuote } from '../booking-ui/usage'; import { Amounts } from '../booking-ui/receivables'; import { formatDateTime } from '../booking-ui/dateTime'; import * as React from 'react'; import { __, sprintf } from '@wordpress/i18n'; import { BookingApi, BookingError } from '../booking-ui/api'; import { useQuote, QuoteView } from '../booking-ui/pricing'; import { PaymentChoice, usePaymentChoice, paymentLabel } from '../booking-ui/credit'; import { Autocomplete } from '../booking-ui/Autocomplete'; import { BookingTerm, useBookingTimes } from '../booking-ui/BookingTimes'; import type { CalendarPrefill } from '../booking-ui/Calendar'; import type { Booking, MemberResult, Options } from '../booking-ui/types'; interface Customer { member_id?: number; customer_user_id?: number | null; display_name: string; member_number?: string; email?: string; } interface Contact { id: number; display_name: string; phone: string | null; email: string | null; } export function BookingEditor({ api, booking: b, prefill, initialDate = '', onSaved, onClose, onDirtyChange }: { api: BookingApi; booking?: Booking; prefill?: CalendarPrefill; initialDate?: string; onSaved: (b: Booking) => void; onClose: () => void; onDirtyChange?: (dirty: boolean) => void; }) { const [options, setOptions] = React.useState(null), [date, setDate] = React.useState(b?.starts_at_local.slice(0, 10) ?? prefill?.date ?? initialDate), [resource, setResource] = React.useState(String(b?.resource_id ?? prefill?.resource_id ?? '')); const [type, setType] = React.useState(b?.customer_type ?? (window.ABeCeBookingConfig?.membershipsEnabled ? 'member' : 'registered_non_member')), [customer, setCustomer] = React.useState(b && b.customer_type !== 'external' ? { member_id: b.customer_member_id ?? undefined, customer_user_id: b.customer_user_id, display_name: b.booker_name } : null), [query, setQuery] = React.useState(''); const originalSnapshot = React.useMemo(() => { try { return b?.external_contact_snapshot ? JSON.parse(b.external_contact_snapshot) : {}; } catch { return {}; } }, [b]); const [contact, setContact] = React.useState(b?.external_contact_id ? { id: b.external_contact_id, display_name: b.external_customer_label ?? '', phone: originalSnapshot.phone ?? null, email: originalSnapshot.email ?? null } : null); const [usedLabel, setUsedLabel] = React.useState(!!b && b.customer_type === 'external' && !b.external_contact_id), [dismissSearch, setDismissSearch] = React.useState(0); const [externalLabel, setExternalLabel] = React.useState(b?.external_customer_label ?? ''), [phone, setPhone] = React.useState(originalSnapshot.phone ?? ''), [email, setEmail] = React.useState(originalSnapshot.email ?? ''), [saveContact, setSaveContact] = React.useState(!b); const [count, setCount] = React.useState(b?.participant_count ?? 1), [booker, setBooker] = React.useState(b?.booker_participates ?? true), [members, setMembers] = React.useState(b?.participants.filter(p => p.participant_type === 'member' && p.member_id !== b.customer_member_id).map(p => ({ member_id: p.member_id!, display_name: p.name ?? '' })) ?? []); const [note, setNote] = React.useState(b?.customer_note ?? ''), [adminNote, setAdminNote] = React.useState(b?.admin_note ?? ''), [busy, setBusy] = React.useState(false), [error, setError] = React.useState(''); const locked = !!b && !b.can_edit_price; const [recurring, setRecurring] = React.useState(false), [following, setFollowing] = React.useState(false), [manual, setManual] = React.useState(false), [manualStart, setManualStart] = React.useState(b?.starts_at_local.slice(11, 16) ?? '18:00'), [manualDuration, setManualDuration] = React.useState(b?.duration_minutes ?? 120), [availabilityConfirmed, setAvailabilityConfirmed] = React.useState(false); const seriesMode = recurring || following; React.useEffect(() => { let alive = true; api.request('admin/booking-options', 'GET', undefined, date ? { date } : {}).then(value => { if (alive) { setOptions(value); setDate(current => current || value.today); } }).catch(e => { if (alive) setError(e.message); }); return () => { alive = false; }; }, [api, date]); const userId = type === 'external' ? null : customer?.customer_user_id ?? null, memberId = type === 'member' ? customer?.member_id ?? null : null; const selectedCustomer = type === 'external' || !!customer; const availability: Record = locked ? {} : { resource_id: resource, date, ...(b ? { booking_id: String(b.id) } : {}), ...(selectedCustomer ? { customer_type: type, ...(userId ? { customer_user_id: String(userId) } : {}), ...(memberId ? { customer_member_id: String(memberId) } : {}) } : {}) }; const times = useBookingTimes(api, selectedCustomer ? 'admin/booking-availability' : 'admin/booking-times', availability, b?.starts_at_local ?? prefill?.starts_at, b?.ends_at_local, prefill?.availability, !!b); const source = b && type === b.customer_type && userId === b.customer_user_id && memberId === b.customer_member_id ? b.booking_source : type === 'external' ? 'phone' : 'admin'; const identity = type === 'external' ? { external_contact_id: contact?.id ?? null, external_customer_label: externalLabel.trim() || null, external_phone: phone.trim() || null, external_email: email.trim() || null, save_external_contact: !contact && saveContact } : { external_contact_id: null, external_customer_label: null, external_phone: null, external_email: null, save_external_contact: false }; const payload: Record = { resource_id: Number(resource), date, start_time: times.start.slice(11, 16), start_offset: times.start.slice(-6), duration_minutes: times.duration || b?.duration_minutes, participant_count: count, booker_participates: type === 'member' ? booker : true, member_ids: type === 'member' ? members.map(m => m.member_id) : [], customer_type: type, customer_user_id: userId, customer_member_id: memberId, booking_source: source, customer_note: note || null, admin_note: adminNote || null, ...identity }; React.useEffect(() => setAvailabilityConfirmed(false), [JSON.stringify(payload), manual, manualStart, manualDuration]); const notesPayload = { customer_note: note || null, admin_note: adminNote || null, ...identity }; const effectivePayload = locked ? notesPayload : manual ? { ...payload, start_time: manualStart, start_offset: null, duration_minutes: manualDuration } : payload; const originalMembers = b?.participants.filter(p => p.participant_type === 'member' && p.member_id !== b.customer_member_id).map(p => p.member_id).sort().join(',') ?? ''; const unchanged = !!b && resource === String(b.resource_id) && date === b.starts_at_local.slice(0, 10) && times.start === b.starts_at_local && times.end === b.ends_at_local && type === b.customer_type && userId === b.customer_user_id && memberId === b.customer_member_id && count === b.participant_count && booker === b.booker_participates && members.map(m => m.member_id).sort().join(',') === originalMembers; const validCount = Number.isInteger(count) && count > 0 && count <= (options?.resources.find(r => String(r.id) === resource)?.capacity ?? 65535) && count >= members.length + (type === 'member' && booker ? 1 : 0); const manualResource = options?.resources.find(r => String(r.id) === resource), manualStep = manualResource?.booking_step_minutes ?? 1, manualMinimum = Math.ceil((manualResource?.min_booking_minutes ?? 1) / manualStep) * manualStep; const ready = !!date && !!resource && selectedCustomer && validCount && (type !== 'external' || !!externalLabel.trim()) && (locked || (manual ? !!manualStart && manualDuration > 0 : times.valid) || unchanged); const quote = useQuote(api, 'admin/pricing/quote', b ? { ...effectivePayload, booking_id: b.id } : effectivePayload, ready); const warning = (quote.price as unknown as { daily_limit_warning?: { count: number; limit: number; }; } | null)?.daily_limit_warning; const [dailyRaceWarning, setDailyRaceWarning] = React.useState(''); const [dailyConfirmed, setDailyConfirmed] = React.useState(false); React.useEffect(() => { setDailyConfirmed(false); setDailyRaceWarning(''); }, [date, resource, userId, memberId, warning?.count]); const debtWarning = (quote.price as unknown as { outstanding_balance_warning?: { amounts_by_currency: Record; }; } | null)?.outstanding_balance_warning; const [debtConfirmed, setDebtConfirmed] = React.useState(false), [debtRaceWarning, setDebtRaceWarning] = React.useState(''); React.useEffect(() => { setDebtConfirmed(false); setDebtRaceWarning(''); }, [userId, memberId, contact?.id, JSON.stringify(debtWarning?.amounts_by_currency)]); const debtNeedsConfirm = (!!debtWarning || !!debtRaceWarning); const policy = quote.price as (typeof quote.price & PolicyQuote); const [usageConfirmed, setUsageConfirmed] = React.useState(false), [restrictionConfirmed, setRestrictionConfirmed] = React.useState(false); const usageWarnings = policy?.usage_limit_warnings ?? [], restrictionWarnings = policy?.customer_restriction_warnings ?? []; const needsPolicyConfirm = (usageWarnings.length > 0 && !usageConfirmed) || (restrictionWarnings.length > 0 && !restrictionConfirmed); React.useEffect(() => { setUsageConfirmed(false); setRestrictionConfirmed(false); }, [JSON.stringify(effectivePayload), JSON.stringify(usageWarnings), JSON.stringify(restrictionWarnings)]); const payment = usePaymentChoice(api, b ? null : quote.price, true, userId, !!options?.current_user.can_manage_credit, 0, Number(resource) || null); function changeType(value: string) { setUsedLabel(false); setType(value); setCustomer(null); setMembers([]); setBooker(true); setQuery(''); setContact(null); setExternalLabel(''); setPhone(''); setEmail(''); setSaveContact(!b); setError(''); onDirtyChange?.(true); } const loadCustomers = React.useCallback((q: string) => api.request('booking-customers', 'GET', undefined, { q, date, type }), [api, date, type]); const loadContacts = React.useCallback((q: string) => api.request('admin/external-contacts/search', 'GET', undefined, { q }), [api]); async function save(e: React.FormEvent) { e.preventDefault(); if (seriesMode) return; if ((quote.price as unknown as { availability_warning?: string; })?.availability_warning && !availabilityConfirmed) return; if (needsPolicyConfirm || debtNeedsConfirm && !debtConfirmed) return; if (!ready || !quote.price || quote.pending || ((!!warning || !!dailyRaceWarning) && !dailyConfirmed) || (!b && !payment.ready)) return; setBusy(true); setError(''); try { onSaved(await api.request(b ? `bookings/${b.id}` : 'admin/bookings', b ? 'PATCH' : 'POST', b ? { ...effectivePayload, confirm_availability: availabilityConfirmed, confirm_usage_limit: usageConfirmed, confirm_customer_restriction: restrictionConfirmed, confirm_daily_limit: dailyConfirmed, confirm_outstanding_balance: debtConfirmed } : { ...effectivePayload, confirm_availability: availabilityConfirmed, confirm_usage_limit: usageConfirmed, confirm_customer_restriction: restrictionConfirmed, payment_method: quote.price.final_amount ? payment.method : null, confirm_customer_credit: payment.confirmed, confirm_daily_limit: dailyConfirmed, confirm_outstanding_balance: debtConfirmed })); } catch (e) { if (e instanceof BookingError && e.status === 409 && e.code === 'abece_booking_booking_conflict') { setError(__('This time was booked by another user or is no longer available. Please select another time.', 'abece-booking')); times.refresh(); } else if (e instanceof BookingError && e.code === 'abece_booking_daily_limit_confirmation_required') { setDailyRaceWarning(e.message); setDailyConfirmed(false); } else if (e instanceof BookingError && e.code === 'abece_booking_outstanding_balance_confirmation_required') { setDebtRaceWarning(e.message); setDebtConfirmed(false); } else { setError((e as Error).message); if (e instanceof BookingError && ['usage_limit_exceeded', 'customer_restricted'].includes(e.code)) { setUsageConfirmed(false); setRestrictionConfirmed(false); quote.refresh(); } } } finally { setBusy(false); } } const latest = b?.history.at(-1), created = b?.history.find(event => event.event_type === 'created'); return
{b && }{error &&

{error}

}
onDirtyChange?.(true)}>
{ setDate(value); times.clear(); setError(''); }} onResource={value => { setResource(value); setError(''); }} times={times} customTime={manual} disabled={locked || busy} focusEnd={!!b}/> {!locked &&
{manual &&
}
} {false} {false}

{__('Customer', 'abece-booking')}

{__('Customer type', 'abece-booking')}
{[['member', __('Club member', 'abece-booking')], ['registered_non_member', __('Registered non-member', 'abece-booking')], ['external', __('External / phone booking', 'abece-booking')]].filter(([value]) => value !== 'member' || options?.memberships_enabled === true).map(([value, label]) => )}
{type === 'external' ? <> { setUsedLabel(false); setExternalLabel(value); onDirtyChange?.(true); }} load={loadContacts} item={c => ({ key: c.id, name: c.display_name, description: [c.phone, c.email].filter(Boolean).join(' · ') })} focus onSelect={c => { setUsedLabel(false); setContact(c); setExternalLabel(c.display_name); setPhone(c.phone ?? ''); setEmail(c.email ?? ''); setSaveContact(false); onDirtyChange?.(true); }}/>{(contact || usedLabel) && { setUsedLabel(false); setContact(null); setExternalLabel(''); setPhone(''); setEmail(''); setSaveContact(!b); onDirtyChange?.(true); }}/>}{!contact && !usedLabel && externalLabel.trim() &&

}
{!contact && } : customer ? { if (type === 'member' && options?.memberships_enabled === false) { changeType('registered_non_member'); return; } setCustomer(null); setMembers([]); setQuery(''); onDirtyChange?.(true); }}/> : ({ key: c.member_id ?? c.customer_user_id ?? '', name: c.display_name, description: c.member_number ? sprintf(__('Member number: %s', 'abece-booking'), c.member_number) : c.email })} disabled={locked || !date} focus onSelect={c => { setCustomer(c); setMembers([]); setQuery(''); onDirtyChange?.(true); }}/>}

{__('Players', 'abece-booking')}

{(quote.price?.participant_share_applies || quote.reasons?.participant_share_applies) &&

{__('The total number of players affects member playing time consumption.', 'abece-booking')}

}{false}

{__('Notes', 'abece-booking')}