mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
e8fbba236d
The booking and transport dialogs each spelled out the notes and the link as four value and change props, and with the saving flag the two calls became one copied block. The shared lower part now takes the form and its setter, as the dialogs hold them, and writes the two fields itself.
410 lines
18 KiB
TypeScript
410 lines
18 KiB
TypeScript
import { useEffect, useRef, useMemo, useId } from 'react'
|
|
import { useParams } from 'react-router'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { useAddonStore } from '../../store/addonStore'
|
|
import CustomSelect from '../shared/CustomSelect'
|
|
import { BookingCodeInput } from '../shared/BookingCode'
|
|
import { buildAssignmentOptions } from './assignmentOptions'
|
|
import AddressInput from './AddressInput'
|
|
import { Link2 } from 'lucide-react'
|
|
import { useToast } from '../shared/Toast'
|
|
import { useTranslation } from '../../i18n'
|
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
|
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
|
import { BookingNotesFilesCosts } from './BookingNotesFilesCosts'
|
|
import { importedPriceEntry } from './importedPrice'
|
|
import { TravelerPicker } from './TravelerPicker'
|
|
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
|
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
|
import type { BookingReviewDraft } from './parsedItemToDraft'
|
|
import { stayPlaces } from '../../utils/stayPlaces'
|
|
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
|
|
import { DialogShell, DialogFooter, FooterSpacer, DialogButton } from '../shared/DialogShell'
|
|
import { INPUT, GRID_2, GRID_3, EditorField, PillSelect } from '../shared/dialogParts'
|
|
import { typeInfo } from './bookings/bookingsModel'
|
|
import type { StatusTone } from './bookings/bookingParts'
|
|
import { RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill, selectedDayDate } from './reservationFormModel'
|
|
import { useReservationForm } from './useReservationForm'
|
|
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, filesNeedConnection, type BookingSaveSteps } from './bookingFormModel'
|
|
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
|
|
import { useBookingFileAttach } from './useBookingFileAttach'
|
|
import { useBookingFollowUp } from './useBookingFollowUp'
|
|
import StayPinStatus from './StayPinStatus'
|
|
|
|
const EMPTY_FORM = {
|
|
title: '', type: 'other', status: 'pending',
|
|
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
|
notes: '', url: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
|
place_id: '' as string | number,
|
|
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
|
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
|
hotel_address: '',
|
|
}
|
|
|
|
interface ReservationModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
onSave: (data: Record<string, string | number | null> & { title: string }, steps?: BookingSaveSteps) => Promise<Reservation | undefined>
|
|
reservation: Reservation | null
|
|
days: Day[]
|
|
places: Place[]
|
|
assignments: AssignmentsMap
|
|
selectedDayId: number | null
|
|
files?: TripFile[]
|
|
onFileUpload?: (fd: FormData) => Promise<unknown>
|
|
onFileDelete: (fileId: number) => Promise<void>
|
|
accommodations?: Accommodation[]
|
|
defaultAssignmentId?: number | null
|
|
onOpenExpense?: (req: BookingExpenseRequest) => void
|
|
// Pre-fill a brand-new booking from a parsed import item (review-before-save).
|
|
// Distinct from `reservation`: the form is populated but stays in create mode.
|
|
prefill?: BookingReviewDraft | null
|
|
/** Trip members + guests, for the traveler picker (#1517). */
|
|
tripMembers?: TripMember[]
|
|
}
|
|
|
|
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense, prefill = null, tripMembers = [] }: ReservationModalProps) {
|
|
const { id: tripId } = useParams<{ id: string }>()
|
|
const toast = useToast()
|
|
const { t, locale } = useTranslation()
|
|
const fileInputRef = useRef(null)
|
|
const titleId = useId()
|
|
|
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
|
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
|
// Set right before submit when the user clicked create/edit expense.
|
|
const expense = useBookingExpenseIntent(() => handleSubmit(), {
|
|
remove: item => deleteBudgetItem(Number(tripId), item.id),
|
|
onError: () => toast.error(t('common.unknownError')),
|
|
})
|
|
|
|
const {
|
|
form, setForm, set, isSaving, setIsSaving, pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler,
|
|
isEndBeforeStart, endTimeBeforeStartTime, dateBounds: tripDateRange, startDate, startTime, setStartDate, setStartTime, takeStopDay,
|
|
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, stayPin, saveData: buildSaveData,
|
|
} = useReservationForm(EMPTY_FORM, days, places)
|
|
const attach = useBookingFileAttach({ reservation, files, setPendingFiles })
|
|
const followUp = useBookingFollowUp({
|
|
session: isOpen ? (reservation ?? prefill ?? 'new') : null,
|
|
tripId, original: reservation, travelerIds, pendingFiles, setPendingFiles, upload: onFileUpload, t,
|
|
})
|
|
|
|
const assignmentOptions = useMemo(
|
|
() => buildAssignmentOptions(days, assignments, t, locale),
|
|
[days, assignments, t, locale]
|
|
)
|
|
|
|
useEffect(() => {
|
|
// A try that stored the booking and failed on its files keeps the form as the
|
|
// user left it; the places and stays that save refreshed must not reseed it.
|
|
if (followUp.holdsSaved()) return
|
|
// Match an existing place by name (exact, then loose contains) for hotels.
|
|
const matchPlaceId = (name: string | undefined): string | number => {
|
|
const n = (name || '').trim().toLowerCase()
|
|
if (!n) return ''
|
|
const exact = places.find(p => p.name?.trim().toLowerCase() === n)
|
|
if (exact) return exact.id
|
|
const loose = places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
|
|
return loose?.id ?? ''
|
|
}
|
|
|
|
setTravelerIds(travelerIdsOf(reservation))
|
|
stayPin.reset()
|
|
if (reservation) {
|
|
setForm({ ...reservationFieldsFrom(reservation, accommodations, places, true), assignment_id: reservation.assignment_id || '' })
|
|
} else if (prefill) {
|
|
// Review-before-save: populate from a parsed import item, stay in create mode.
|
|
const hotelPlaceId = matchPlaceId(prefill._venue?.name || prefill.title)
|
|
setForm({
|
|
...reservationFieldsFromPrefill(prefill, days, true),
|
|
assignment_id: defaultAssignmentId ?? '',
|
|
accommodation_id: '',
|
|
place_id: '',
|
|
hotel_place_id: hotelPlaceId,
|
|
})
|
|
// A stay the trip does not hold yet: find it on the map now, so the review
|
|
// says whether it will show there before it is saved.
|
|
if (prefill.type === 'hotel' && !hotelPlaceId) stayPin.lookUp(prefill._venue?.name || prefill.title, prefill._venue?.address)
|
|
// Seed the booking's Files with the document this item was parsed from.
|
|
setPendingFiles(prefill._sourceFiles ?? [])
|
|
} else {
|
|
setForm({ ...EMPTY_FORM, assignment_id: defaultAssignmentId ?? '' })
|
|
setPendingFiles([])
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId, days, places, accommodations])
|
|
|
|
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
|
|
e?.preventDefault?.()
|
|
if (!form.title.trim()) return
|
|
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
|
if (filesNeedConnection(pendingFiles, !!onFileUpload)) { toast.error(t('reservations.filesNeedConnection')); return }
|
|
setIsSaving(true)
|
|
try {
|
|
const saveData = buildSaveData({ assignmentId: form.assignment_id, isEdit: !!reservation?.id, withCheckInEnd: true })
|
|
// Imported booking → auto-create the linked cost from the parsed price (what the
|
|
// old direct import did). Only on create (not edit) and only when there's a price.
|
|
if (!reservation && prefill && isBudgetEnabled) {
|
|
const entry = importedPriceEntry(prefill.metadata, form.type)
|
|
if (entry) saveData.create_budget_entry = entry
|
|
}
|
|
const saved = await onSave(saveData as Record<string, string | number | null> & { title: string }, followUp.steps(form.title))
|
|
// Open the Costs editor for the saved booking when the user asked to
|
|
// create/edit its linked expense (gated on saved?.id).
|
|
const expenseRequest = expenseRequestAfterSave(expense.take(), saved, form, days)
|
|
if (expenseRequest && onOpenExpense) onOpenExpense(expenseRequest)
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
// On an import review (not yet saved), preview the parsed price as the cost that will be
|
|
// linked: the same entry the save sends, so the two cannot name different currencies.
|
|
const pendingExpense = pendingImportExpense(reservation, prefill, form.type)
|
|
|
|
const status = form.status === 'confirmed' ? 'confirmed' : 'pending'
|
|
const tone: StatusTone = form.type === 'transit' ? 'transit' : status
|
|
const shownType = typeInfo(form.type)
|
|
const saveDisabled = isSaving || !form.title.trim() || isEndBeforeStart
|
|
const dayOptions = days.map(d => {
|
|
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
|
|
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined
|
|
return {
|
|
value: d.id,
|
|
label: d.title || t('dayplan.dayN', { n: d.day_number }),
|
|
badge: dateBadge ?? dayBadge,
|
|
}
|
|
})
|
|
|
|
const header = (
|
|
<BookingDialogHeader
|
|
tone={tone}
|
|
type={form.type}
|
|
labelId={titleId}
|
|
onClose={onClose}
|
|
eyebrow={reservation ? t('reservations.editTitle') : t('reservations.newTitle')}
|
|
titleInput={{
|
|
value: form.title,
|
|
onChange: v => set('title', v),
|
|
label: t('reservations.titleLabel'),
|
|
placeholder: t('reservations.titlePlaceholder'),
|
|
required: true,
|
|
}}
|
|
pills={(
|
|
<>
|
|
<StatusPill status={status} onToggle={() => set('status', status === 'confirmed' ? 'pending' : 'confirmed')} />
|
|
<PillSelect
|
|
label={t('reservations.bookingType')}
|
|
value={form.type}
|
|
onChange={value => set('type', value)}
|
|
options={TYPE_OPTIONS.map(o => ({
|
|
value: o.value,
|
|
label: t(o.labelKey),
|
|
icon: <o.Icon size={14} style={{ color: typeInfo(o.value).color }} />,
|
|
}))}
|
|
fallback={{ label: t(shownType.chipKey), icon: <shownType.Icon size={14} style={{ color: shownType.color }} /> }}
|
|
/>
|
|
</>
|
|
)}
|
|
/>
|
|
)
|
|
|
|
const footer = (
|
|
<DialogFooter>
|
|
<FooterSpacer />
|
|
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
|
<DialogButton variant="primary" onClick={handleSubmit} disabled={saveDisabled}>
|
|
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
|
</DialogButton>
|
|
</DialogFooter>
|
|
)
|
|
|
|
return (
|
|
<DialogShell
|
|
open={isOpen}
|
|
onClose={onClose}
|
|
labelledBy={titleId}
|
|
width="editor"
|
|
align="top"
|
|
// A stray click beside the editor asks before the typing is lost (#2253).
|
|
discardGuard={{ form, files: pendingFiles.length, travelers: [...travelerIds] }}
|
|
onSubmit={handleSubmit}
|
|
header={header}
|
|
footer={footer}
|
|
>
|
|
{/* When: the plan stop it belongs to, then start and end (hidden for hotels) */}
|
|
{form.type !== 'hotel' && (
|
|
<div className="flex flex-col gap-3">
|
|
{assignmentOptions.length > 0 && (
|
|
<EditorField label={<><Link2 size={10} className="me-[3px] inline align-[-1px]" />{t('reservations.linkAssignment')}</>}>
|
|
<CustomSelect
|
|
value={form.assignment_id}
|
|
onChange={value => {
|
|
set('assignment_id', value)
|
|
const opt = assignmentOptions.find(o => o.value === value)
|
|
if (opt?.dayDate) takeStopDay(opt.dayDate)
|
|
}}
|
|
placeholder={t('reservations.pickAssignment')}
|
|
options={[
|
|
{ value: '', label: t('reservations.noAssignment') },
|
|
...assignmentOptions,
|
|
]}
|
|
searchable
|
|
size="sm"
|
|
/>
|
|
</EditorField>
|
|
)}
|
|
<div className={GRID_2}>
|
|
<EditorField label={t('reservations.date')}>
|
|
<CustomDatePicker
|
|
value={startDate}
|
|
onChange={setStartDate}
|
|
min={tripDateRange.min}
|
|
max={tripDateRange.max}
|
|
/>
|
|
</EditorField>
|
|
<EditorField label={t('reservations.startTime')}>
|
|
<CustomTimePicker
|
|
value={startTime}
|
|
onChange={tm => setStartTime(tm, selectedDayDate(days, selectedDayId))}
|
|
/>
|
|
</EditorField>
|
|
<EditorField label={t('reservations.endDate')} error={isEndBeforeStart ? t('reservations.validation.endBeforeStart') : undefined}>
|
|
<CustomDatePicker
|
|
value={form.end_date}
|
|
onChange={d => set('end_date', d || '')}
|
|
min={tripDateRange.min}
|
|
max={tripDateRange.max}
|
|
/>
|
|
</EditorField>
|
|
<EditorField
|
|
label={t('reservations.endTime')}
|
|
hint={endTimeBeforeStartTime ? <span className="text-warning">{t('reservations.validation.endTimeBeforeStartTime')}</span> : undefined}
|
|
>
|
|
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
|
</EditorField>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Where: a trip place/activity (#1353) and the address. Hotels pick their
|
|
place through the accommodation below. */}
|
|
{form.type !== 'hotel' && (
|
|
<div className={GRID_2}>
|
|
<EditorField label={t('reservations.meta.linkPlace')}>
|
|
<CustomSelect
|
|
value={form.place_id}
|
|
onChange={pickPlace}
|
|
placeholder={t('reservations.meta.pickPlace')}
|
|
options={[
|
|
{ value: '', label: '—' },
|
|
...places.map(p => ({ value: p.id, label: p.name })),
|
|
]}
|
|
searchable
|
|
size="sm"
|
|
/>
|
|
</EditorField>
|
|
<EditorField label={t('reservations.locationAddress')}>
|
|
<AddressInput value={form.location} onChange={v => set('location', v)}
|
|
placeholder={t('reservations.locationPlaceholder')} className={INPUT} />
|
|
</EditorField>
|
|
</div>
|
|
)}
|
|
|
|
{/* The stay: place and nights, then the check-in window */}
|
|
{form.type === 'hotel' && (
|
|
<>
|
|
<div className="flex flex-col gap-3">
|
|
<div className={GRID_3}>
|
|
<EditorField label={t('reservations.meta.hotelPlace')}>
|
|
<CustomSelect
|
|
value={form.hotel_place_id}
|
|
onChange={pickHotelPlace}
|
|
placeholder={t('reservations.meta.pickHotel')}
|
|
options={[
|
|
{ value: '', label: '—' },
|
|
...stayPlaces(places, form.hotel_place_id).map(p => ({ value: p.id, label: p.name })),
|
|
]}
|
|
searchable
|
|
size="sm"
|
|
/>
|
|
</EditorField>
|
|
<EditorField label={t('reservations.meta.fromDay')}>
|
|
<CustomSelect
|
|
value={form.hotel_start_day}
|
|
onChange={pickHotelStart}
|
|
placeholder={t('reservations.meta.selectDay')}
|
|
options={dayOptions}
|
|
size="sm"
|
|
/>
|
|
</EditorField>
|
|
<EditorField label={t('reservations.meta.toDay')}>
|
|
<CustomSelect
|
|
value={form.hotel_end_day}
|
|
onChange={pickHotelEnd}
|
|
placeholder={t('reservations.meta.selectDay')}
|
|
options={dayOptions}
|
|
size="sm"
|
|
/>
|
|
</EditorField>
|
|
</div>
|
|
<div className={GRID_3}>
|
|
<EditorField label={t('reservations.meta.checkIn')}>
|
|
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
|
|
</EditorField>
|
|
<EditorField label={t('reservations.meta.checkInUntil')}>
|
|
<CustomTimePicker value={form.meta_check_in_end_time} onChange={v => set('meta_check_in_end_time', v)} />
|
|
</EditorField>
|
|
<EditorField label={t('reservations.meta.checkOut')}>
|
|
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
|
|
</EditorField>
|
|
</div>
|
|
</div>
|
|
<EditorField label={t('reservations.locationAddress')}>
|
|
<AddressInput value={form.hotel_address} onChange={v => set('hotel_address', v)}
|
|
onPick={stayPin.pick}
|
|
placeholder={t('reservations.locationPlaceholder')} className={INPUT} />
|
|
</EditorField>
|
|
<StayPinStatus
|
|
pin={stayPin.pin}
|
|
hidden={!!form.hotel_place_id}
|
|
onRetry={() => stayPin.lookUp(form.title, form.hotel_address)}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* The booking code, and the trip members and guests on this booking (#1517) */}
|
|
<div className={GRID_2}>
|
|
<EditorField label={t('reservations.confirmationCode')}>
|
|
<BookingCodeInput value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
|
placeholder={t('reservations.confirmationPlaceholder')} className={INPUT} />
|
|
</EditorField>
|
|
<EditorField label={t('reservations.travelers.label')}>
|
|
<TravelerPicker tripMembers={tripMembers} selectedIds={travelerIds} onToggle={toggleTraveler} />
|
|
</EditorField>
|
|
</div>
|
|
|
|
<BookingNotesFilesCosts
|
|
form={form}
|
|
set={set}
|
|
reservationId={reservation?.id}
|
|
tripFiles={files}
|
|
pendingFiles={pendingFiles}
|
|
fileInputRef={fileInputRef}
|
|
attach={attach}
|
|
canAttach={!!onFileUpload}
|
|
saving={isSaving}
|
|
showCosts={isBudgetEnabled}
|
|
pendingExpense={pendingExpense}
|
|
expense={expense}
|
|
/>
|
|
</DialogShell>
|
|
)
|
|
}
|
|
|
|
function formatDate(dateStr, locale) {
|
|
const d = new Date(dateStr + 'T00:00:00Z')
|
|
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
|
}
|