mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
44509723fd
The status hides itself for a stay linked to a trip place instead of each form branching around it, and a lookup takes a missing name.
416 lines
19 KiB
TypeScript
416 lines
19 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, travelersChanged, filesNeedConnection, uploadBookingFiles } from './bookingFormModel'
|
|
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
|
|
import { useBookingFileAttach } from './useBookingFileAttach'
|
|
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 }) => 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 setReservationTravelers = useTripStore(s => s.setReservationTravelers)
|
|
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, onFileUpload, setPendingFiles, toast, t })
|
|
|
|
const assignmentOptions = useMemo(
|
|
() => buildAssignmentOptions(days, assignments, t, locale),
|
|
[days, assignments, t, locale]
|
|
)
|
|
|
|
useEffect(() => {
|
|
// 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 })
|
|
// Persist the traveler assignment once we have the reservation id (create → save
|
|
// result, edit → existing reservation), and only when it actually changed (#1517).
|
|
const savedId = saved?.id ?? reservation?.id
|
|
if (savedId && tripId) {
|
|
const { changed, nextIds } = travelersChanged(reservation, travelerIds)
|
|
if (changed) {
|
|
try { await setReservationTravelers(tripId, savedId, nextIds) } catch { toast.error(t('common.unknownError')) }
|
|
}
|
|
}
|
|
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
|
|
await uploadBookingFiles(onFileUpload, saved.id, pendingFiles, 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
|
|
notes={form.notes}
|
|
onNotesChange={value => set('notes', value)}
|
|
url={form.url}
|
|
onUrlChange={value => set('url', value)}
|
|
reservationId={reservation?.id}
|
|
tripFiles={files}
|
|
pendingFiles={pendingFiles}
|
|
fileInputRef={fileInputRef}
|
|
attach={attach}
|
|
canAttach={!!onFileUpload}
|
|
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' })
|
|
}
|