mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
e6f66014a5
A transport added from the day-plan sidebar could not be deleted: the edit window (TransportModal) had no delete action, and the only transport delete affordances lived on the Transports-tab cards, which that flow never reaches. Add an onDelete prop and a Delete action in the edit footer, guarded by the shared ConfirmDialog. Wire it from TripPlannerPage, gated on reservation_edit (the permission the server enforces for reservation deletion).
1278 lines
73 KiB
TypeScript
1278 lines
73 KiB
TypeScript
import { useState, useEffect, useRef, useMemo } from 'react'
|
|
import { useParams } from 'react-router'
|
|
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
|
import Modal from '../shared/Modal'
|
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
|
import CustomSelect from '../shared/CustomSelect'
|
|
import { BookingCodeInput } from '../shared/BookingCode'
|
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
|
import AirportSelect, { type Airport } from './AirportSelect'
|
|
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
|
import { toLocationPicks } from './locationPicks'
|
|
import { useTranslation } from '../../i18n'
|
|
import { useToast } from '../shared/Toast'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { useAddonStore } from '../../store/addonStore'
|
|
import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters'
|
|
import { openFile } from '../../utils/fileDownload'
|
|
import apiClient from '../../api/client'
|
|
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types'
|
|
import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '../../utils/flightLegs'
|
|
import { BookingCostsSection } from './BookingCostsSection'
|
|
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 TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
|
|
import { typeToCostCategory } from '@trek/shared'
|
|
|
|
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] as const
|
|
type TransportType = typeof TRANSPORT_TYPES[number]
|
|
|
|
interface EndpointPick {
|
|
airport?: Airport
|
|
location?: LocationPoint
|
|
}
|
|
|
|
function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
|
return {
|
|
role, sequence,
|
|
name: a.city ? `${a.city} (${a.iata})` : a.name,
|
|
code: a.iata,
|
|
lat: a.lat, lng: a.lng,
|
|
timezone: a.tz,
|
|
local_date: date,
|
|
local_time: time,
|
|
}
|
|
}
|
|
|
|
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
|
return {
|
|
role, sequence,
|
|
name: l.name,
|
|
code: null,
|
|
lat: l.lat, lng: l.lng,
|
|
timezone: null,
|
|
local_date: date,
|
|
local_time: time,
|
|
}
|
|
}
|
|
|
|
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
|
|
if (!e || !e.code) return null
|
|
return {
|
|
iata: e.code, icao: null,
|
|
name: e.name, city: stripAirportCode(e.name),
|
|
country: '',
|
|
lat: e.lat, lng: e.lng,
|
|
tz: e.timezone || '',
|
|
}
|
|
}
|
|
|
|
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
|
|
if (!e) return null
|
|
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
|
}
|
|
|
|
// ── Multi-leg flight waypoints ─────────────────────────────────────────────
|
|
// A flight is an ordered list of airports. The origin has only a departure, the
|
|
// destination only an arrival, and each intermediate stop has both — plus the
|
|
// airline/flight number of the flight LEAVING it. N waypoints = N-1 legs. A
|
|
// single-leg flight is just two waypoints, so it persists exactly as before.
|
|
interface WaypointForm {
|
|
airport: Airport | null
|
|
arrDayId: string | number
|
|
arrTime: string
|
|
depDayId: string | number
|
|
depTime: string
|
|
airline: string
|
|
flight_number: string
|
|
seat: string
|
|
// Booking reference of the leg leaving this waypoint: airlines often issue one
|
|
// per flight rather than one per booking (#1943). Empty means the booking's own.
|
|
confirmation_number: string
|
|
}
|
|
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
|
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '', confirmation_number: '' }
|
|
}
|
|
|
|
// ── Multi-leg train stations ───────────────────────────────────────────────
|
|
// A train mirrors the flight route model, but its waypoints are STATIONS
|
|
// (location search, no timezone) and each leg carries a train number + platform
|
|
// instead of an airline + flight number. N stations = N-1 legs.
|
|
interface StationWaypointForm {
|
|
location: LocationPoint | null
|
|
arrDayId: string | number
|
|
arrTime: string
|
|
depDayId: string | number
|
|
depTime: string
|
|
train_number: string
|
|
platform: string
|
|
seat: string
|
|
confirmation_number: string
|
|
}
|
|
// ── Car stops along the drive ──────────────────────────────────────────────
|
|
//
|
|
// A rental is one booking with one pick-up and one return, so — unlike a flight or a
|
|
// train — the stops in between are not legs of their own: they are places the drive
|
|
// passes through, each with an optional time the driver plans to be there. They persist
|
|
// as the same `role: 'stop'` endpoints every other transport type already uses.
|
|
interface CarStopForm {
|
|
location: LocationPoint | null
|
|
time: string
|
|
}
|
|
|
|
const emptyCarStop = (): CarStopForm => ({ location: null, time: '' })
|
|
|
|
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
|
|
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
|
|
}
|
|
|
|
const TYPE_OPTIONS = [
|
|
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
|
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
|
{ value: 'bus', labelKey: 'reservations.type.bus', Icon: Bus },
|
|
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
|
{ value: 'taxi', labelKey: 'reservations.type.taxi', Icon: CarTaxiFront },
|
|
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike },
|
|
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
|
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat },
|
|
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route },
|
|
]
|
|
|
|
const defaultForm = {
|
|
title: '',
|
|
type: 'flight' as TransportType,
|
|
status: 'pending' as 'pending' | 'confirmed',
|
|
start_day_id: '' as string | number,
|
|
end_day_id: '' as string | number,
|
|
departure_time: '',
|
|
arrival_time: '',
|
|
confirmation_number: '',
|
|
notes: '',
|
|
meta_airline: '',
|
|
meta_flight_number: '',
|
|
meta_train_number: '',
|
|
meta_platform: '',
|
|
meta_seat: '',
|
|
}
|
|
|
|
interface TransportModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
onSave: (data: Record<string, any> & { title: string }) => Promise<Reservation | undefined>
|
|
reservation: Reservation | null
|
|
days: Day[]
|
|
selectedDayId: number | null
|
|
files?: TripFile[]
|
|
onFileUpload?: (fd: FormData) => Promise<unknown>
|
|
onFileDelete?: (fileId: number) => Promise<void>
|
|
/** Deletes the reservation being edited. Omit to hide the delete action (create mode, or no permission). */
|
|
onDelete?: () => void | Promise<void>
|
|
onOpenExpense?: (req: BookingExpenseRequest) => void
|
|
// Pre-fill a brand-new transport booking from a parsed import item (review-
|
|
// before-save); like `reservation` for the form but stays in create mode.
|
|
prefill?: BookingReviewDraft | null
|
|
/** Data for the Automated (public transit) mode's quick picks. */
|
|
places?: Place[]
|
|
/** Day→assignments map, used to scope the quick picks to the chosen day (#1460). */
|
|
assignments?: AssignmentsMap
|
|
accommodations?: Accommodation[]
|
|
/** Open directly in the Automated public-transit mode (day-header tram button, "change route"). */
|
|
initialAutomated?: boolean
|
|
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
|
|
tripHasDates?: boolean
|
|
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
|
|
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null; time?: string | null } | null
|
|
/** Trip members + guests, for the traveler picker (#1517). */
|
|
tripMembers?: TripMember[]
|
|
}
|
|
|
|
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) {
|
|
const { t, locale } = useTranslation()
|
|
const toast = useToast()
|
|
// The trip's places, offered by every location field of the manual tab (#2468).
|
|
const locationPicks = useMemo(() => toLocationPicks(places), [places])
|
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
|
const budgetItems = useTripStore(s => s.budgetItems)
|
|
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
|
const loadFiles = useTripStore(s => s.loadFiles)
|
|
const setReservationTravelers = useTripStore(s => s.setReservationTravelers)
|
|
const { id: tripId } = useParams<{ id: string }>()
|
|
// Set right before submitting when the user clicked "create/edit expense", so
|
|
// the post-save handler knows to open the Costs editor for the saved booking.
|
|
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
|
const [form, setForm] = useState({ ...defaultForm })
|
|
// Manual vs Automated (public transit search) creation mode (#1065).
|
|
const [automated, setAutomated] = useState(false)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
|
const [toPick, setToPick] = useState<EndpointPick>({})
|
|
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
|
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
|
// Train route as an ordered list of stations (origin .. stops .. destination).
|
|
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
|
|
// A car keeps its pick-up and return as the frame of the rental and gains the stops
|
|
// in between (#1797): one booking, one continuous drive, several places along it.
|
|
const [carStops, setCarStops] = useState<CarStopForm[]>([])
|
|
|
|
/** Swaps a stop with its neighbour. Purely local: `sequence` is derived on save. */
|
|
const moveCarStop = (index: number, delta: number): void => {
|
|
setCarStops(prev => {
|
|
const to = index + delta
|
|
if (to < 0 || to >= prev.length) return prev
|
|
const next = [...prev]
|
|
;[next[index], next[to]] = [next[to], next[index]]
|
|
return next
|
|
})
|
|
}
|
|
const [uploadingFile, setUploadingFile] = useState(false)
|
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
|
const [showFilePicker, setShowFilePicker] = useState(false)
|
|
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
|
// Travelers assigned to this booking (#1517) — seeded on open, persisted after save.
|
|
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const filePickerRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!showFilePicker) return
|
|
const closeOnOutsidePointer = (event: PointerEvent) => {
|
|
if (!filePickerRef.current?.contains(event.target as Node)) setShowFilePicker(false)
|
|
}
|
|
document.addEventListener('pointerdown', closeOnOutsidePointer)
|
|
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
|
|
}, [showFilePicker])
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) setShowFilePicker(false)
|
|
}, [isOpen])
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
setTravelerIds(new Set((reservation?.travelers || []).map(tv => tv.user_id)))
|
|
// Edit uses the saved `reservation`; a review-import populates from `prefill`.
|
|
// Either way the init reads the same fields — `reservation` still decides
|
|
// edit-vs-create at submit time.
|
|
const src = (reservation ?? prefill) as Reservation | null
|
|
if (src) setAutomated(initialAutomated)
|
|
// On a review-import, seed the booking's Files with the parsed source document.
|
|
setPendingFiles(!reservation && prefill?._sourceFiles ? prefill._sourceFiles : [])
|
|
if (src) {
|
|
const meta = typeof src.metadata === 'string'
|
|
? JSON.parse(src.metadata || '{}')
|
|
: (src.metadata || {})
|
|
const eps = src.endpoints || []
|
|
const from = eps.find(e => e.role === 'from')
|
|
const to = eps.find(e => e.role === 'to')
|
|
// 'transport_other', not 'flight': an import whose type could not be read has
|
|
// to arrive as something the user corrects, and a wrong flight looks right
|
|
// enough to be saved unnoticed (#2076).
|
|
const type = (TRANSPORT_TYPES as readonly string[]).includes(src.type)
|
|
? src.type as TransportType
|
|
: 'transport_other'
|
|
setForm({
|
|
title: src.title || '',
|
|
type,
|
|
status: src.status === 'confirmed' ? 'confirmed' : 'pending',
|
|
// For an edit, keep the saved day; for an imported prefill (no day_id), resolve it
|
|
// from the parsed pick-up/return date so the date isn't lost on save.
|
|
start_day_id: src.day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_time).date),
|
|
end_day_id: src.end_day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_end_time).date),
|
|
departure_time: splitReservationDateTime(src.reservation_time).time ?? '',
|
|
arrival_time: splitReservationDateTime(src.reservation_end_time).time ?? '',
|
|
confirmation_number: src.confirmation_number || '',
|
|
notes: src.notes || '',
|
|
meta_airline: meta.airline || '',
|
|
meta_flight_number: meta.flight_number || '',
|
|
meta_train_number: meta.train_number || '',
|
|
meta_platform: meta.platform || '',
|
|
meta_seat: meta.seat || '',
|
|
})
|
|
// Only an import prefill carries a per-endpoint local_date without a day_id. On an
|
|
// edit the saved day wins: local_date is denormalised and can lag behind after a
|
|
// day drag, insertDay or a trip-date shift, so resolving from it would silently
|
|
// move the booking to another day on a plain re-save.
|
|
const endpointDayId = (ep?: { local_date?: string | null } | null) =>
|
|
reservation ? '' : resolveDayId(days, ep?.local_date)
|
|
|
|
if (type === 'flight') {
|
|
const orderedEps = orderedEndpoints(src)
|
|
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
|
|
let wps: WaypointForm[]
|
|
if (orderedEps.length >= 2) {
|
|
wps = orderedEps.map((ep, i) => {
|
|
const legInto = metaLegs[i - 1] // leg arriving INTO waypoint i
|
|
const legOut = metaLegs[i] // leg departing FROM waypoint i
|
|
const isFirst = i === 0
|
|
const isLast = i === orderedEps.length - 1
|
|
return {
|
|
airport: airportFromEndpoint(ep),
|
|
// An import prefill gives each endpoint its own local_date but no day_id, so
|
|
// resolve the day from that date — otherwise the review's per-leg day
|
|
// selectors render empty even though the time, read from the same
|
|
// endpoint, is filled.
|
|
arrDayId: legInto?.arr_day_id ?? (endpointDayId(ep) || (isLast ? (src.end_day_id ?? '') : '')),
|
|
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
|
|
depDayId: legOut?.dep_day_id ?? (endpointDayId(ep) || (isFirst ? (src.day_id ?? '') : '')),
|
|
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
|
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
|
|
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
|
|
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
|
|
// No fallback to src.confirmation_number: that one belongs to the
|
|
// whole booking and stays in the form's own field, otherwise a
|
|
// plain re-save would copy it onto the first leg.
|
|
confirmation_number: legOut?.confirmation_number ?? '',
|
|
}
|
|
})
|
|
} else {
|
|
// Legacy flight with no (or partial) endpoints — seed two waypoints.
|
|
const dep = emptyWaypoint(endpointDayId(from) || (src.day_id ?? ''))
|
|
dep.airport = airportFromEndpoint(from)
|
|
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
|
|
dep.airline = meta.airline ?? ''
|
|
dep.flight_number = meta.flight_number ?? ''
|
|
dep.seat = meta.seat ?? ''
|
|
const arr = emptyWaypoint(endpointDayId(to) || (src.end_day_id ?? src.day_id ?? ''))
|
|
arr.airport = airportFromEndpoint(to)
|
|
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
|
|
wps = [dep, arr]
|
|
}
|
|
setWaypoints(wps)
|
|
} else if (type === 'train') {
|
|
// Mirror the flight seeding with stations + per-leg train fields. A
|
|
// current single-leg train (2 endpoints, no metadata.legs) round-trips
|
|
// through the >=2 branch: the flat train_number/platform/seat land on
|
|
// the first station, dep/arr day+time from src.day_id/end_day_id.
|
|
const orderedEps = orderedEndpoints(src)
|
|
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
|
|
let wps: StationWaypointForm[]
|
|
if (orderedEps.length >= 2) {
|
|
wps = orderedEps.map((ep, i) => {
|
|
const legInto = metaLegs[i - 1]
|
|
const legOut = metaLegs[i]
|
|
const isFirst = i === 0
|
|
const isLast = i === orderedEps.length - 1
|
|
return {
|
|
location: locationFromEndpoint(ep),
|
|
// See the flight branch: resolve each station's day from its endpoint local_date
|
|
// so an import prefill doesn't leave the per-leg day selectors empty.
|
|
arrDayId: legInto?.arr_day_id ?? (endpointDayId(ep) || (isLast ? (src.end_day_id ?? '') : '')),
|
|
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
|
|
depDayId: legOut?.dep_day_id ?? (endpointDayId(ep) || (isFirst ? (src.day_id ?? '') : '')),
|
|
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
|
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
|
|
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
|
|
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
|
|
// See the flight branch: the booking's own reference stays out of the legs.
|
|
confirmation_number: legOut?.confirmation_number ?? '',
|
|
}
|
|
})
|
|
} else {
|
|
const dep = emptyStationWaypoint(endpointDayId(from) || (src.day_id ?? ''))
|
|
dep.location = locationFromEndpoint(from)
|
|
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
|
|
dep.train_number = meta.train_number ?? ''
|
|
dep.platform = meta.platform ?? ''
|
|
dep.seat = meta.seat ?? ''
|
|
const arr = emptyStationWaypoint(endpointDayId(to) || (src.end_day_id ?? src.day_id ?? ''))
|
|
arr.location = locationFromEndpoint(to)
|
|
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
|
|
wps = [dep, arr]
|
|
}
|
|
setTrainWaypoints(wps)
|
|
setFromPick({})
|
|
setToPick({})
|
|
} else {
|
|
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
|
setToPick({ location: locationFromEndpoint(to) || undefined })
|
|
// Stops persist for every type; only a car offers an editor for them, so only a
|
|
// car reads them back into one. The others keep passing theirs through untouched.
|
|
setCarStops(
|
|
src.type === 'car'
|
|
? (src.endpoints ?? [])
|
|
.filter(e => e.role === 'stop')
|
|
.slice()
|
|
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
|
.map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
|
|
: [],
|
|
)
|
|
}
|
|
} else {
|
|
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
|
setAutomated(initialAutomated)
|
|
setFromPick({})
|
|
setToPick({})
|
|
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
|
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
|
|
setCarStops([])
|
|
}
|
|
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
|
|
|
|
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
|
|
|
const toggleTraveler = (id: number) => setTravelerIds(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id); else next.add(id)
|
|
return next
|
|
})
|
|
|
|
const handleSubmit = async (e?: React.FormEvent) => {
|
|
e?.preventDefault()
|
|
if (!form.title.trim()) return
|
|
setIsSaving(true)
|
|
try {
|
|
const startDay = days.find(d => d.id === Number(form.start_day_id))
|
|
const endDay = days.find(d => d.id === Number(form.end_day_id))
|
|
|
|
const buildTime = (day: Day | undefined, time: string): string | null => {
|
|
if (!time) return null
|
|
return day?.date ? `${day.date}T${time}` : time
|
|
}
|
|
|
|
const dayDate = (id: string | number): string | null => days.find(d => d.id === Number(id))?.date ?? null
|
|
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
|
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
|
|
const firstWp = flightWps[0]
|
|
const lastWp = flightWps[flightWps.length - 1]
|
|
// Train route: the first/last waypoint drive the span + flat metadata
|
|
// (day/time/train number are entered independently of geocoding, exactly
|
|
// like the old form fields, so a train with no map-picked station still
|
|
// saves its day/time/train number). Only geocoded stations become map
|
|
// endpoints + legs, mirroring the old "push only if the location is set".
|
|
const trainWps = form.type === 'train' ? trainWaypoints : []
|
|
const firstTrainWp = trainWps[0]
|
|
const lastTrainWp = trainWps[trainWps.length - 1]
|
|
const trainStations = form.type === 'train' ? trainWaypoints.filter(w => w.location) : []
|
|
// Per-leg day-plan positions are owned by the day planner, not this form — keep
|
|
// them when re-saving so editing a flight doesn't reset where its legs sit.
|
|
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
|
|
|
|
const metadata: Record<string, any> = {}
|
|
if (form.type === 'flight') {
|
|
// Top-level keys mirror the first/last leg so legacy readers keep working.
|
|
if (firstWp?.airline) metadata.airline = firstWp.airline
|
|
if (firstWp?.flight_number) metadata.flight_number = firstWp.flight_number
|
|
if (firstWp?.airport) {
|
|
metadata.departure_airport = firstWp.airport.iata
|
|
metadata.departure_timezone = firstWp.airport.tz
|
|
}
|
|
if (lastWp?.airport) {
|
|
metadata.arrival_airport = lastWp.airport.iata
|
|
metadata.arrival_timezone = lastWp.airport.tz
|
|
}
|
|
// Per-leg detail only for true multi-leg flights — a single-leg flight
|
|
// keeps the exact same (flat) metadata it had before this feature.
|
|
if (flightWps.length > 2) {
|
|
metadata.legs = flightWps.slice(0, -1).map((w, i) => {
|
|
const next = flightWps[i + 1]
|
|
return {
|
|
from: w.airport!.iata,
|
|
to: next.airport!.iata,
|
|
...(w.airline ? { airline: w.airline } : {}),
|
|
...(w.flight_number ? { flight_number: w.flight_number } : {}),
|
|
...(w.seat ? { seat: w.seat } : {}),
|
|
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
|
|
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
|
dep_time: w.depTime || null,
|
|
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
|
arr_time: next.arrTime || null,
|
|
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
|
|
}
|
|
})
|
|
}
|
|
if (firstWp?.seat) metadata.seat = firstWp.seat
|
|
} else if (form.type === 'train') {
|
|
// Flat keys mirror the first leg so legacy readers keep working; a
|
|
// 2-station train emits exactly {train_number?,platform?,seat?} — the
|
|
// same shape it saved before this feature.
|
|
if (firstTrainWp?.train_number) metadata.train_number = firstTrainWp.train_number
|
|
if (firstTrainWp?.platform) metadata.platform = firstTrainWp.platform
|
|
if (firstTrainWp?.seat) metadata.seat = firstTrainWp.seat
|
|
// Per-leg detail only for a true multi-leg train (>2 geocoded stations);
|
|
// a simple train keeps the same flat metadata it saved before.
|
|
if (trainStations.length > 2) {
|
|
metadata.legs = trainStations.slice(0, -1).map((w, i) => {
|
|
const next = trainStations[i + 1]
|
|
return {
|
|
from: w.location!.name,
|
|
to: next.location!.name,
|
|
...(w.train_number ? { train_number: w.train_number } : {}),
|
|
...(w.platform ? { platform: w.platform } : {}),
|
|
...(w.seat ? { seat: w.seat } : {}),
|
|
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
|
|
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
|
dep_time: w.depTime || null,
|
|
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
|
arr_time: next.arrTime || null,
|
|
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A transit itinerary (#1065) lives in metadata.transit + 'stop' endpoints,
|
|
// neither of which this form shows or edits — so re-saving must not wipe
|
|
// them. They're kept only while from/to are unchanged: picking a different
|
|
// origin or destination invalidates the stored connection.
|
|
const prevMeta = reservation ? parseReservationMetadata(reservation) : {}
|
|
const prevEndpointsAll = reservation?.endpoints || []
|
|
const prevFrom = prevEndpointsAll.find(ep => ep.role === 'from')
|
|
const prevTo = prevEndpointsAll.find(ep => ep.role === 'to')
|
|
const near = (a?: number | null, b?: number | null) => a != null && b != null && Math.abs(a - b) < 1e-6
|
|
const keepTransit = !!(prevMeta.transit && form.type !== 'flight' &&
|
|
prevFrom && prevTo && fromPick.location && toPick.location &&
|
|
near(prevFrom.lat, fromPick.location.lat) && near(prevFrom.lng, fromPick.location.lng) &&
|
|
near(prevTo.lat, toPick.location.lat) && near(prevTo.lng, toPick.location.lng))
|
|
if (keepTransit) metadata.transit = prevMeta.transit
|
|
// A joined AirTrail import (#1535) records every source flight id in
|
|
// metadata.airtrail_ids so the picker doesn't offer those legs again —
|
|
// an edit in this form must not drop that linkage.
|
|
if (Array.isArray(prevMeta.airtrail_ids)) metadata.airtrail_ids = prevMeta.airtrail_ids
|
|
|
|
const startDate = startDay?.date ?? null
|
|
const endDate = (endDay ?? startDay)?.date ?? null
|
|
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
|
if (form.type === 'flight') {
|
|
flightWps.forEach((w, i) => {
|
|
const isFirst = i === 0
|
|
const isLast = i === flightWps.length - 1
|
|
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
|
|
const dId = isLast ? w.arrDayId : w.depDayId
|
|
const time = isLast ? w.arrTime : w.depTime
|
|
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
|
|
})
|
|
} else if (form.type === 'train') {
|
|
trainStations.forEach((w, i) => {
|
|
const isFirst = i === 0
|
|
const isLast = i === trainStations.length - 1
|
|
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
|
|
const dId = isLast ? w.arrDayId : w.depDayId
|
|
const time = isLast ? w.arrTime : w.depTime
|
|
// The destination date falls back to the departure day (as the old flat
|
|
// path did via `endDay ?? startDay`) when the arrival day is left blank.
|
|
const date = dayDate(dId) ?? (isLast ? dayDate(firstTrainWp?.depDayId ?? '') : null)
|
|
endpoints.push(endpointFromLocation(w.location!, role, i, date, time || null))
|
|
})
|
|
} else {
|
|
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
|
// A car writes the stops the driver planned; every other type keeps passing the
|
|
// itinerary's transfer stops through while the route is unchanged (#1065).
|
|
const carEndpoints = form.type === 'car'
|
|
? carStops
|
|
.filter(s => s.location)
|
|
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
|
|
: []
|
|
const stops = keepTransit && form.type !== 'car'
|
|
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
|
|
: []
|
|
stops.forEach((s, i) => endpoints.push({
|
|
role: 'stop', sequence: i + 1, name: s.name, code: s.code ?? null,
|
|
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
|
|
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
|
|
}))
|
|
carEndpoints.forEach(e => endpoints.push(e))
|
|
const stopCount = stops.length + carEndpoints.length
|
|
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null))
|
|
}
|
|
|
|
// Flights and trains derive their span from the first/last waypoint; other
|
|
// transports keep using the single departure/arrival form fields unchanged.
|
|
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
|
|
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
|
|
const trainDepDay = firstTrainWp && firstTrainWp.depDayId ? Number(firstTrainWp.depDayId) : null
|
|
const trainArrDay = lastTrainWp && lastTrainWp.arrDayId ? Number(lastTrainWp.arrDayId) : null
|
|
const payload = {
|
|
title: form.title,
|
|
type: form.type,
|
|
status: form.status,
|
|
day_id: form.type === 'flight' ? flightDepDay : form.type === 'train' ? trainDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
|
end_day_id: form.type === 'flight' ? flightArrDay : form.type === 'train' ? trainArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
|
reservation_time: form.type === 'flight'
|
|
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
|
|
: form.type === 'train'
|
|
? buildTime(days.find(d => d.id === trainDepDay), firstTrainWp?.depTime || '')
|
|
: buildTime(startDay, form.departure_time),
|
|
reservation_end_time: form.type === 'flight'
|
|
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
|
|
: form.type === 'train'
|
|
// Fall back to the departure day so a same-day train (arrival day left
|
|
// blank) still gets its date, matching the non-flight `endDay ?? startDay`.
|
|
? buildTime(days.find(d => d.id === trainArrDay) ?? days.find(d => d.id === trainDepDay), lastTrainWp?.arrTime || '')
|
|
: buildTime(endDay ?? startDay, form.arrival_time),
|
|
location: null,
|
|
confirmation_number: form.confirmation_number || null,
|
|
notes: form.notes || null,
|
|
// An empty object, not null: null clears the column outright, and that
|
|
// took the mirrored booking price with it on every edit of a type that
|
|
// fills no metadata of its own — restaurant, event, tour, parking, other,
|
|
// a hotel without check-in times (#2233). An object still clears what the
|
|
// form dropped, and lets the server carry the price across.
|
|
metadata,
|
|
endpoints,
|
|
needs_review: false,
|
|
}
|
|
// 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) (payload as Record<string, unknown>).create_budget_entry = entry
|
|
}
|
|
const saved = await onSave(payload)
|
|
// 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 original = (reservation?.travelers || []).map(tv => tv.user_id)
|
|
const nextIds = [...travelerIds]
|
|
const changed = original.length !== nextIds.length || nextIds.some(id => !original.includes(id))
|
|
if (changed) {
|
|
try { await setReservationTravelers(tripId, savedId, nextIds) } catch { toast.error(t('common.unknownError')) }
|
|
}
|
|
}
|
|
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
|
for (const file of pendingFiles) {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
fd.append('reservation_id', String(saved.id))
|
|
fd.append('description', form.title)
|
|
await onFileUpload(fd)
|
|
}
|
|
}
|
|
// The user asked to create/edit the linked expense — open the Costs editor
|
|
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
|
|
const intent = expenseIntentRef.current
|
|
expenseIntentRef.current = null
|
|
if (intent && onOpenExpense && saved?.id) {
|
|
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
|
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
|
}
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
|
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
|
const handleRemoveExpense = async (item: BudgetItem) => {
|
|
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
|
}
|
|
|
|
// 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 importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null
|
|
const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
if (reservation?.id) {
|
|
setUploadingFile(true)
|
|
try {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
fd.append('reservation_id', String(reservation.id))
|
|
fd.append('description', reservation.title)
|
|
await onFileUpload!(fd)
|
|
toast.success(t('reservations.toast.fileUploaded'))
|
|
} catch {
|
|
toast.error(t('reservations.toast.uploadError'))
|
|
} finally {
|
|
setUploadingFile(false)
|
|
e.target.value = ''
|
|
}
|
|
} else {
|
|
setPendingFiles(prev => [...prev, file])
|
|
e.target.value = ''
|
|
}
|
|
}
|
|
|
|
const attachedFiles = reservation?.id
|
|
? files.filter(f =>
|
|
f.reservation_id === reservation.id ||
|
|
linkedFileIds.includes(f.id) ||
|
|
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
|
|
)
|
|
: []
|
|
|
|
const inputClass = 'w-full border border-edge rounded-[10px] px-[12px] py-[8px] text-[13px] font-[inherit] outline-none box-border text-content bg-surface-input'
|
|
const labelClass = 'block text-[11px] font-semibold text-content-faint mb-[5px] uppercase tracking-[0.03em]'
|
|
|
|
const dayOptions = [
|
|
{ value: '', label: '—' },
|
|
...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,
|
|
}
|
|
}),
|
|
]
|
|
|
|
// The per-leg booking code is only offered where handleSubmit actually writes
|
|
// metadata.legs: the SAME condition, over the waypoints that become endpoints,
|
|
// not over the raw rows. Anywhere else the value would vanish on save (#1943).
|
|
const writesFlightLegs = waypoints.filter(w => w.airport).length > 2
|
|
const writesTrainLegs = trainWaypoints.filter(w => w.location).length > 2
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
title={automated ? t('transit.title') : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
|
size="2xl"
|
|
footer={
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, width: '100%' }}>
|
|
<div>
|
|
{!automated && reservation?.id && onDelete && (
|
|
<button type="button" onClick={() => setShowDeleteConfirm(true)} className="text-danger" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '8px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
<Trash2 size={13} /> {t('common.delete')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button type="button" onClick={onClose} className="text-content-muted" style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
{t('common.cancel')}
|
|
</button>
|
|
{!automated && (
|
|
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
|
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
{/* Manual vs Automated creation switch (#1065) — creating only; editing a
|
|
journey re-enters via "change route" with the switch hidden. Without
|
|
trip dates there is nothing to plan a departure against, so Automated
|
|
is not offered and only the manual form shows. */}
|
|
{!reservation && tripHasDates && (
|
|
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2, marginBottom: 14 }}>
|
|
{([['manual', t('transport.modeManual')], ['automated', t('transport.modeAutomated')]] as const).map(([m, label]) => {
|
|
const active = (m === 'automated') === automated
|
|
return (
|
|
<button key={m} type="button" onClick={() => setAutomated(m === 'automated')}
|
|
className={active ? 'bg-surface-card text-content' : 'text-content-muted'}
|
|
style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap', background: active ? undefined : 'transparent', boxShadow: active ? '0 1px 4px rgba(0,0,0,0.08)' : 'none' }}>
|
|
{label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{automated ? (
|
|
/* ── Automated: public transit search (#1065) ── */
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
|
{/* search header: what this is + the day it plans for */}
|
|
<div className="bg-surface-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px', borderRadius: 14, flexWrap: 'wrap' }}>
|
|
<div style={{ width: 42, height: 42, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 12, background: '#7c3aed18' }}>
|
|
<TramFront size={20} strokeWidth={1.8} color="#7c3aed" />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 180 }}>
|
|
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em' }}>{t('transit.title')}</div>
|
|
<div className="text-content-faint" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('transit.searchHint')}</div>
|
|
</div>
|
|
<div style={{ width: typeof window !== 'undefined' && window.innerWidth < 768 ? '100%' : 210, flexShrink: 0 }}>
|
|
<CustomSelect value={form.start_day_id} onChange={v => set('start_day_id', v)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
|
</div>
|
|
</div>
|
|
{(() => {
|
|
const transitDay = days.find(d => d.id === Number(form.start_day_id))
|
|
if (!transitDay) return <div className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 2px 12px' }}>{t('transit.pickDay')}</div>
|
|
// Quick picks offer the chosen day's itinerary, not the whole trip (#1460).
|
|
const dayPlaces = (assignments[String(transitDay.id)] || [])
|
|
.slice().sort((a, b) => a.order_index - b.order_index)
|
|
.map(a => places.find(p => p.id === a.place_id))
|
|
.filter((p): p is Place => p != null)
|
|
return (
|
|
<TransitSearchPanel
|
|
day={transitDay}
|
|
days={days}
|
|
places={dayPlaces}
|
|
accommodations={accommodations}
|
|
onAdd={(payload) => onSave(payload as Record<string, any> & { title: string })}
|
|
initialFrom={transitPrefill?.from ?? null}
|
|
initialTo={transitPrefill?.to ?? null}
|
|
initialTime={transitPrefill?.time ?? null}
|
|
/>
|
|
)
|
|
})()}
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
|
|
{/* Type selector */}
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.bookingType')}</label>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
|
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
|
|
<button key={value} type="button" onClick={() => set('type', value)} className={form.type === value ? 'bg-[var(--text-primary)] text-[var(--bg-primary)]' : 'bg-surface-card text-content-muted'} style={{
|
|
display: 'flex', alignItems: 'center', gap: 4,
|
|
padding: '5px 10px', borderRadius: 99, border: '1px solid',
|
|
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
|
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
|
}}>
|
|
<Icon size={11} /> {t(labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.titleLabel')} *</label>
|
|
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
|
|
placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
|
|
</div>
|
|
|
|
{form.type === 'flight' ? (
|
|
/* ── Flight route: ordered airports (origin · stops · destination) ── */
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<label className={labelClass}>{t('reservations.layover.route')}</label>
|
|
{waypoints.map((wp, i) => {
|
|
const isFirst = i === 0
|
|
const isLast = i === waypoints.length - 1
|
|
const updateWp = (patch: Partial<WaypointForm>) => setWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
|
|
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
|
|
return (
|
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<AirportSelect value={wp.airport} onChange={a => updateWp({ airport: a || null })} />
|
|
</div>
|
|
{!isFirst && !isLast && (
|
|
<button type="button" onClick={() => setWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{!isFirst && (
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
|
|
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
|
|
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
|
|
</div>
|
|
{wp.airport && (
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{t('reservations.meta.arrivalTimezone')}</label>
|
|
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'var(--bg-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={wp.airport.tz}>{wp.airport.tz}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!isLast && (
|
|
<>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{t('reservations.departureDate')}</label>
|
|
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{t('reservations.departureTime')}</label>
|
|
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
|
|
</div>
|
|
{wp.airport && (
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{t('reservations.meta.departureTimezone')}</label>
|
|
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'var(--bg-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={wp.airport.tz}>{wp.airport.tz}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={`grid grid-cols-1 ${writesFlightLegs ? 'sm:grid-cols-4' : 'sm:grid-cols-3'} gap-3`}>
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
|
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
|
|
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
|
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
|
|
</div>
|
|
{writesFlightLegs && (
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
|
|
<BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
|
|
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{!isLast && (
|
|
<button type="button" onClick={() => setWaypoints(prev => [...prev.slice(0, i + 1), emptyWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
|
|
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
<Plus size={12} /> {t('reservations.layover.addStop')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : form.type === 'train' ? (
|
|
/* ── Train route: ordered stations (origin · stops · destination) ── */
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<label className={labelClass}>{t('reservations.layover.route')}</label>
|
|
{trainWaypoints.map((wp, i) => {
|
|
const isFirst = i === 0
|
|
const isLast = i === trainWaypoints.length - 1
|
|
const updateWp = (patch: Partial<StationWaypointForm>) => setTrainWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
|
|
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
|
|
return (
|
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} places={locationPicks} />
|
|
</div>
|
|
{!isFirst && !isLast && (
|
|
<button type="button" onClick={() => setTrainWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{!isFirst && (
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
|
|
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
|
|
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!isLast && (
|
|
<>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{t('reservations.departureDate')}</label>
|
|
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{t('reservations.departureTime')}</label>
|
|
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
|
|
</div>
|
|
</div>
|
|
<div className={`grid grid-cols-1 ${writesTrainLegs ? 'sm:grid-cols-4' : 'sm:grid-cols-3'} gap-3`}>
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
|
|
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.meta.platform')}</label>
|
|
<input type="text" value={wp.platform} onChange={e => updateWp({ platform: e.target.value })} placeholder="12" className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
|
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
|
|
</div>
|
|
{writesTrainLegs && (
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
|
|
<BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
|
|
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{!isLast && (
|
|
<button type="button" onClick={() => setTrainWaypoints(prev => [...prev.slice(0, i + 1), emptyStationWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
|
|
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
<Plus size={12} /> {t('reservations.layover.addStop')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* From / To endpoints (non-flight) */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.meta.from')}</label>
|
|
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} places={locationPicks} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.meta.to')}</label>
|
|
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} places={locationPicks} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stops along the drive — cars only (#1797). The rental frame above stays the
|
|
pick-up and return; these are the places in between, in order. */}
|
|
{form.type === 'car' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<label className={labelClass}>{t('roadtrip.stops.label')}</label>
|
|
{carStops.map((stop, i) => (
|
|
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
|
{/* The order of these stops IS the route: `sequence` is the array index
|
|
at save time, so the only way to change which one is driven first
|
|
was to delete both and re-enter them. Buttons rather than dragging,
|
|
because the row already carries a location picker and a time picker
|
|
and there is nothing left to grab. */}
|
|
{carStops.length > 1 && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => moveCarStop(i, -1)}
|
|
disabled={i === 0}
|
|
aria-label={t('dayplan.moveUp')}
|
|
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
|
|
style={{ background: 'none', border: 'none', cursor: i === 0 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
|
|
>
|
|
<ChevronUp size={13} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => moveCarStop(i, 1)}
|
|
disabled={i === carStops.length - 1}
|
|
aria-label={t('dayplan.moveDown')}
|
|
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
|
|
style={{ background: 'none', border: 'none', cursor: i === carStops.length - 1 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
|
|
>
|
|
<ChevronDown size={13} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<LocationSelect
|
|
value={stop.location}
|
|
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
|
|
places={locationPicks}
|
|
/>
|
|
</div>
|
|
<div style={{ width: 110, flexShrink: 0 }}>
|
|
<CustomTimePicker
|
|
value={stop.time}
|
|
onChange={v => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCarStops(prev => prev.filter((_, j) => j !== i))}
|
|
aria-label={t('roadtrip.stops.remove')}
|
|
className="text-content-faint hover:text-danger"
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 8, display: 'flex', alignItems: 'center' }}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => setCarStops(prev => [...prev, emptyCarStop()])}
|
|
className="text-content-faint hover:text-content-secondary"
|
|
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}
|
|
>
|
|
<Plus size={12} /> {t('reservations.layover.addStop')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Departure row */}
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
|
|
<CustomSelect value={form.start_day_id} onChange={value => set('start_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
|
|
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Arrival row */}
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
|
|
<CustomSelect value={form.end_day_id} onChange={value => set('end_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
|
|
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Train-specific fields */}
|
|
{/* Train number / platform / seat are per-leg now (in the route above). */}
|
|
|
|
{/* Booking Code + Status */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
|
|
<BookingCodeInput value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
|
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.status')}</label>
|
|
<CustomSelect
|
|
value={form.status}
|
|
onChange={value => set('status', value)}
|
|
options={[
|
|
{ value: 'pending', label: t('reservations.pending') },
|
|
{ value: 'confirmed', label: t('reservations.confirmed') },
|
|
]}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.notes')}</label>
|
|
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
|
|
placeholder={t('reservations.notesPlaceholder')}
|
|
className={inputClass} style={{ resize: 'none', lineHeight: 1.5 }} />
|
|
</div>
|
|
|
|
{/* Travelers — assign trip members & guests to this transport (#1517) */}
|
|
<div>
|
|
<label className={labelClass}>{t('reservations.travelers.label')}</label>
|
|
<TravelerPicker tripMembers={tripMembers} selectedIds={travelerIds} onToggle={toggleTraveler} />
|
|
</div>
|
|
|
|
{/* Files */}
|
|
<div>
|
|
<label className={labelClass}>{t('files.title')}</label>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{attachedFiles.map(f => (
|
|
<div key={f.id} className="bg-surface-secondary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8 }}>
|
|
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
|
|
<span className="text-content-secondary" style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
|
<button type="button" onClick={() => { openFile(f.url).catch(() => {}) }} aria-label={t('common.open')} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 0, display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></button>
|
|
<button type="button" onClick={async () => {
|
|
if (f.reservation_id === reservation?.id) {
|
|
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch { toast.error(t('reservations.toast.updateError')) }
|
|
}
|
|
try {
|
|
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
|
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
|
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
|
} catch { toast.error(t('reservations.toast.updateError')) }
|
|
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
|
if (tripId) loadFiles(tripId)
|
|
}} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 0, flexShrink: 0 }}>
|
|
<X size={11} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{pendingFiles.map((f, i) => (
|
|
<div key={i} className="bg-surface-secondary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8 }}>
|
|
<FileText size={12} className="text-content-muted" style={{ flexShrink: 0 }} />
|
|
<span className="text-content-secondary" style={{ flex: 1, fontSize: 'calc(12px * var(--fs-scale-body, 1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
|
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
|
className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 0, flexShrink: 0 }}>
|
|
<X size={11} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={handleFileChange} />
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
|
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{
|
|
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
|
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
|
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
|
}}>
|
|
<Paperclip size={11} />
|
|
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
|
</button>}
|
|
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
|
<div ref={filePickerRef} style={{ position: 'relative' }}>
|
|
<button type="button" onClick={() => setShowFilePicker(v => !v)} className="text-content-faint" style={{
|
|
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
|
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
|
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit',
|
|
}}>
|
|
<Link2 size={11} /> {t('reservations.linkExisting')}
|
|
</button>
|
|
{showFilePicker && (
|
|
<div className="bg-surface-card" style={{
|
|
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
|
border: '1px solid var(--border-primary)', borderRadius: 10,
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
|
|
}}>
|
|
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
|
<button key={f.id} type="button" onClick={async () => {
|
|
try {
|
|
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
|
setLinkedFileIds(prev => [...prev, f.id])
|
|
setShowFilePicker(false)
|
|
if (tripId) loadFiles(tripId)
|
|
} catch { toast.error(t('reservations.toast.updateError')) }
|
|
}}
|
|
className="text-content-secondary"
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
|
background: 'none', border: 'none', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: 'inherit',
|
|
borderRadius: 7, textAlign: 'left',
|
|
}}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
|
<FileText size={12} className="text-content-faint" style={{ flexShrink: 0 }} />
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Costs — create / view the expense linked to this booking */}
|
|
{isBudgetEnabled && (
|
|
<BookingCostsSection
|
|
reservationId={reservation?.id ?? null}
|
|
pendingExpense={pendingExpense}
|
|
onCreate={handleCreateExpense}
|
|
onEdit={handleEditExpense}
|
|
onRemove={handleRemoveExpense}
|
|
/>
|
|
)}
|
|
|
|
</form>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
isOpen={showDeleteConfirm}
|
|
onClose={() => setShowDeleteConfirm(false)}
|
|
title={t('reservations.confirm.deleteTitle')}
|
|
message={t('reservations.confirm.deleteBody', { name: reservation?.title ?? '' })}
|
|
confirmLabel={t('common.delete')}
|
|
cancelLabel={t('common.cancel')}
|
|
onConfirm={async () => {
|
|
setShowDeleteConfirm(false)
|
|
await onDelete?.()
|
|
onClose()
|
|
}}
|
|
/>
|
|
</Modal>
|
|
)
|
|
}
|