Files
2026-08-21 22:53:29 +10:00

1067 lines
46 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import MainLayout from '@/Layouts/MainLayout.vue'
import GlassBox from '@/Components/FlightsGoneBy/GlassBox.vue'
import {Head, useForm, usePage} from '@inertiajs/vue3'
import AirlineSearchBox from '@/Components/FlightsGoneBy/AirlineSearchBox.vue'
import AircraftSearchBox from '@/Components/FlightsGoneBy/AircraftSearchBox.vue'
import AirportSearchBox from '@/Components/FlightsGoneBy/AirportSearchBox.vue'
import type {SeatType, FlightReason, FlightClass, CrewType, SharedProps} from '@/Types/types'
import { ref, watch, computed, onMounted, nextTick } from 'vue'
import { VTimePicker, VDatePicker } from 'vuetify/components'
import MissingEntityLink from "@/Components/FlightsGoneBy/Tooltips/MissingEntityLink.vue";
defineOptions({ layout: MainLayout })
const page = usePage<SharedProps>().props
const props = defineProps<{
flight?: {
id: number
flight_number: string
departure_date: string
arrival_date: string
aircraft_registration: string
seat_number: string
note: string
auto_update: boolean
seat_type: SeatType | null
flight_class: FlightClass | null
flight_reason: FlightReason | null
crew_type: CrewType | null
airline_options: { value: number; title: string; logo_url: string}[]
from_options: { value: number; title: string; country_code: string }[]
to_options: { value: number; title: string; country_code: string }[]
aircraft_options: { value: number; title: string }[]
}
seat_types: SeatType[]
flight_classes: FlightClass[]
flight_reasons: FlightReason[]
crew_types: CrewType[]
}>()
const isEdit = !!props.flight
// ── User format preferences ─────────────────────────────────────────────────
const timeFormat = computed(() => page.auth?.user?.resolved_settings?.time_format ?? 'ampm')
const pad = (n: number) => String(n).padStart(2, '0')
// ── Flight number lookup ──────────────────────────────────────────────────────
const flightNumber = ref(props.flight?.flight_number ?? '')
const lookupLoading = ref(false)
const lookupError = ref<string | null>(null)
const lookupKey = ref(0)
async function lookupFlight() {
if (!flightNumber.value.trim()) return
lookupLoading.value = true
lookupError.value = null
try {
const response = await fetch(`${route('flights.lookup')}?number=${encodeURIComponent(flightNumber.value.trim())}`, {
headers: { Accept: 'application/json' },
})
const data = await response.json()
if (!response.ok) {
lookupError.value = data.message ?? 'Lookup failed.'
return
}
if (data.airline_options?.length) {
airlineOptionsData.value = data.airline_options
if (!form.airline) form.airline = data.airline_options[0]
}
if (data.from_options?.length) {
fromOptionsData.value = data.from_options
if (data.from_options.length === 1 && !form.from) form.from = data.from_options[0]
}
if (data.to_options?.length) {
toOptionsData.value = data.to_options
if (data.to_options.length === 1 && !form.to) form.to = data.to_options[0]
}
if (data.aircraft_options?.length) {
aircraftOptionsData.value = data.aircraft_options
if (!form.aircraft) form.aircraft = data.aircraft_options[0]
}
// Populate scheduled times if the API returned them and the user
// hasn't already entered a time themselves. Suppress the +1hr
// auto-fill guess while doing this, since we have real data.
suppressArrivalAutoFill = true
const scheduledDep = normalizeTimeString(data.scheduled_departure_time)
if (scheduledDep && !departureTimePart.value) {
departureTimePart.value = scheduledDep
}
const scheduledArr = normalizeTimeString(data.scheduled_arrival_time)
if (scheduledArr && !arrivalTimePart.value) {
arrivalTimePart.value = scheduledArr
}
suppressArrivalAutoFill = false
// NOTE: if the lookup API is ever extended to return a day
// difference for the arrival (e.g. `data.arrival_day_offset`),
// wire it in here: `arrivalOffset.value = data.arrival_day_offset`
// (clamped/mapped to 'custom' if outside -1..2). Left out for now
// since the API doesn't provide it yet.
lookupKey.value++
} catch (e) {
lookupError.value = String(e)
} finally {
lookupLoading.value = false
}
}
// ── Display form (drives the template) ───────────────────────────────────────
const form = useForm({
departure_date: props.flight?.departure_date ?? '',
arrival_date: props.flight?.arrival_date ?? '',
from: props.flight?.from_options[0] ?? null as { value: number; title: string; country_code: string } | null,
to: props.flight?.to_options[0] ?? null as { value: number; title: string; country_code: string } | null,
airline: props.flight?.airline_options[0] ?? null as { value: number; title: string; logo_url: string } | null,
aircraft: props.flight?.aircraft_options[0] ?? null as { value: number; title: string } | null,
aircraft_registration: props.flight?.aircraft_registration ?? '',
seat_number: props.flight?.seat_number ?? '',
seat_type: props.flight?.seat_type ?? props.seat_types[0] ?? null as SeatType | null,
flight_class: props.flight?.flight_class ?? props.flight_classes[0] ?? null as FlightClass | null,
flight_reason: props.flight?.flight_reason ?? props.flight_reasons[0] ?? null as FlightReason | null,
crew_type: props.flight?.crew_type ?? null as CrewType | null,
note: props.flight?.note ?? '',
auto_update: props.flight?.auto_update ?? false,
})
const isCrew = computed(() => form.flight_reason?.name === 'Crew')
watch(isCrew, (val) => {
if (!val) form.crew_type = null
})
// ── Submit form (ID-based, what actually gets sent) ───────────────────────────
const submitForm = useForm({
flight_number: '' as string | null,
departure_date: '' as string | null,
arrival_date: '' as string | null,
from_id: null as number | null,
to_id: null as number | null,
airline_id: null as number | null,
aircraft_id: null as number | null,
aircraft_registration: '' as string | null,
seat_number: '' as string | null,
seat_type_id: null as number | null,
flight_class_id: null as number | null,
flight_reason_id: null as number | null,
crew_type_id: null as number | null,
note: '' as string | null,
auto_update: false,
add_more: false,
})
const departureIsFuture = computed(() => {
if (!form.departure_date) return false
return new Date(form.departure_date) > new Date()
})
watch(departureIsFuture, (isFuture) => {
form.auto_update = isFuture
}, { immediate: true })
function submit(addMore = false) {
submitForm.flight_number = flightNumber.value
submitForm.departure_date = form.departure_date
submitForm.arrival_date = form.arrival_date
submitForm.from_id = form.from?.value ?? null
submitForm.to_id = form.to?.value ?? null
submitForm.airline_id = form.airline?.value ?? null
submitForm.aircraft_id = form.aircraft?.value ?? null
submitForm.aircraft_registration = form.aircraft_registration
submitForm.seat_number = form.seat_number
submitForm.seat_type_id = form.seat_type?.id
submitForm.flight_class_id = form.flight_class?.id
submitForm.flight_reason_id = form.flight_reason?.id
submitForm.crew_type_id = form.crew_type?.id ?? null
submitForm.note = form.note
submitForm.auto_update = form.auto_update
submitForm.add_more = addMore
if (isEdit) {
submitForm.put(route('flights.update', { flight: props.flight!.id }))
} else {
submitForm.post(route('flights.store'))
}
}
// ── Prefilled options ─────────────────────────────────────────────────────────
const airlineOptionsData = ref<{ value: number; title: string; logo_url: string}[]>(props.flight?.airline_options ?? [])
const fromOptionsData = ref<{ value: number; title: string; country_code: string }[]>(props.flight?.from_options ?? [])
const toOptionsData = ref<{ value: number; title: string; country_code: string }[]>(props.flight?.to_options ?? [])
const aircraftOptionsData = ref<{ value: number; title: string }[]>(props.flight?.aircraft_options ?? [])
// ── Split date/time pickers ───────────────────────────────────────────────────
// form.departure_date / form.arrival_date stay as the single source of truth
// (YYYY-MM-DDTHH:mm strings) that the rest of the component and submit() rely
// on. These refs + watchers just give the template two separate pickers while
// keeping those combined strings in sync — no server-side changes needed.
function parseDateTime(dt: string | null | undefined) {
if (!dt) return { date: null as Date | null, time: null as string | null }
const d = new Date(dt)
if (isNaN(d.getTime())) return { date: null as Date | null, time: null as string | null }
return {
date: new Date(d.getFullYear(), d.getMonth(), d.getDate()),
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
}
}
function combineDateTime(date: Date | null, time: string | null): string {
if (!date) return ''
const [hh, mm] = (time ?? '00:00').split(':')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${(hh ?? '00').padStart(2, '0')}:${(mm ?? '00').padStart(2, '0')}`
}
// Whole-day difference between two Date objects (time-of-day ignored).
function diffInDays(from: Date | null, to: Date | null): number | null {
if (!from || !to) return null
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate())
const b = new Date(to.getFullYear(), to.getMonth(), to.getDate())
return Math.round((b.getTime() - a.getTime()) / 86_400_000)
}
function clearDepartureTime() { departureTimePart.value = null }
function clearArrivalTime() { arrivalTimePart.value = null }
// Normalizes whatever shape the API sends a scheduled time in ("14:30",
// "14:30:00", or a full datetime string) down to the internal "HH:mm" format.
function normalizeTimeString(t: string | null | undefined): string | null {
if (!t) return null
const match = t.match(/(\d{1,2}):(\d{2})/)
if (match) {
return `${match[1].padStart(2, '0')}:${match[2]}`
}
const d = new Date(t)
if (!isNaN(d.getTime())) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
}
return null
}
const depInit = parseDateTime(form.departure_date)
const arrInit = parseDateTime(form.arrival_date)
const departureDatePart = ref<Date | null>(depInit.date)
const departureTimePart = ref<string | null>(depInit.time)
const arrivalDatePart = ref<Date | null>(arrInit.date)
const arrivalTimePart = ref<string | null>(arrInit.time)
const depDateMenu = ref(false)
const depTimeMenu = ref(false)
const arrDateMenu = ref(false)
const arrTimeMenu = ref(false)
// A short guard so programmatic updates (e.g. auto-fill of arrival) don't
// fight with the "only auto-fill once" behaviour below.
let suppressArrivalAutoFill = false
// Keep form.departure_date in sync whenever either split piece changes
watch([departureDatePart, departureTimePart], () => {
form.departure_date = combineDateTime(departureDatePart.value, departureTimePart.value)
})
// Keep form.arrival_date in sync whenever either split piece changes
watch([arrivalDatePart, arrivalTimePart], () => {
if (suppressArrivalAutoFill) return
form.arrival_date = combineDateTime(arrivalDatePart.value, arrivalTimePart.value)
})
// ── Arrival day offset ───────────────────────────────────────────────────────
// Rather than making the user pick an independent arrival date, arrival date
// is normally *derived* from departure date + a small relative offset
// (-1 / Same day / +1 / +2). "Custom" drops back to a real date picker for
// the rare case that falls outside that range. This avoids needing airport
// time zones to work out the arrival date, which a duration-based input
// would require.
type ArrivalOffset = -1 | 0 | 1 | 2
const arrivalOffsetOptions: { value: ArrivalOffset; label: string }[] = [
{ value: -1, label: '1 Day' },
{ value: 0, label: 'Same Day' },
{ value: 1, label: '+1 Day' },
{ value: 2, label: '+2 Days' },
]
const initialDiff = diffInDays(depInit.date, arrInit.date)
const initialDiffIsQuickOption = initialDiff !== null && ([-1, 0, 1, 2] as number[]).includes(initialDiff)
// 'offset' (the default): arrival date is derived from departure date + the
// selected quick offset. 'custom': the user has dropped into a real date
// picker, e.g. because the actual gap is more than +2 days.
const arrivalDateMode = ref<'offset' | 'custom'>(
initialDiff !== null && !initialDiffIsQuickOption ? 'custom' : 'offset'
)
const arrivalOffset = ref<ArrivalOffset>(
initialDiffIsQuickOption ? (initialDiff as ArrivalOffset) : 0
)
function applyArrivalOffset() {
if (suppressArrivalAutoFill) return
if (arrivalDateMode.value === 'custom') return
if (!departureDatePart.value) {
arrivalDatePart.value = null
return
}
const d = new Date(departureDatePart.value)
d.setDate(d.getDate() + arrivalOffset.value)
arrivalDatePart.value = d
}
// Re-derive arrival date whenever departure date moves, or the offset is
// changed by hand.
watch(departureDatePart, applyArrivalOffset)
watch(arrivalOffset, applyArrivalOffset)
function useCustomArrivalDate() {
arrivalDateMode.value = 'custom'
}
function useQuickArrivalOffset() {
arrivalDateMode.value = 'offset'
applyArrivalOffset()
}
function formatFullDate(d: Date | null): string {
if (!d) return ''
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })
}
// Auto-fill arrival TIME (+1hr) the first time a departure time is chosen
// (and a departure date is already set) and arrival time is still empty.
// If adding the hour rolls over midnight, bump the offset selector to match
// (e.g. departs 23:30 -> offset flips to "+1") so the resolved arrival date
// stays correct without the user having to notice and fix it themselves.
watch(departureTimePart, (newTime) => {
if (suppressArrivalAutoFill) return
if (arrivalDateMode.value === 'custom') return
if (!newTime || !departureDatePart.value || arrivalTimePart.value) return
const dep = new Date(departureDatePart.value)
const [h, m] = newTime.split(':').map(Number)
dep.setHours(h, m)
const arr = new Date(dep.getTime() + 60 * 60 * 1000)
const rawDiff = diffInDays(departureDatePart.value, arr) ?? 0
const clampedDiff = Math.min(Math.max(rawDiff, -1), 2) as -1 | 0 | 1 | 2
suppressArrivalAutoFill = true
arrivalOffset.value = clampedDiff
arrivalDatePart.value = new Date(arr.getFullYear(), arr.getMonth(), arr.getDate())
arrivalTimePart.value = `${pad(arr.getHours())}:${pad(arr.getMinutes())}`
suppressArrivalAutoFill = false
form.arrival_date = combineDateTime(arrivalDatePart.value, arrivalTimePart.value)
}, { flush: 'sync' })
const getArrivalBound = (form: any, offsetDays: number) => {
if (!form.departure_date) return undefined
const d = new Date(form.departure_date)
d.setDate(d.getDate() + offsetDays)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
const arrivalMin = computed(() => getArrivalBound(form, -2))
const arrivalMax = computed(() => getArrivalBound(form, 3))
const arrivalMinDate = computed(() => arrivalMin.value ? new Date(arrivalMin.value) : undefined)
const arrivalMaxDate = computed(() => arrivalMax.value ? new Date(arrivalMax.value) : undefined)
// ── Native date inputs ───────────────────────────────────────────────────────
// departureDatePart / arrivalDatePart stay the source of truth as Date
// objects. The <input type="date"> elements below are deliberately left
// UNCONTROLLED from Vue's perspective — no v-model, no :model-value, no
// reactive :value binding of any kind. That's on purpose: any reactive
// binding through Vuetify's v-text-field — even one that only commits on
// blur/change, not every keystroke — still runs through v-text-field's own
// internal echo of the field, which re-renders the wrapped <input> on every
// keystroke regardless of what we bind from outside. For type="date"
// specifically, that internal re-render resets the browser's own
// per-segment editing state mid-type (day/month/year), which is what was
// scrambling what you typed. Using a genuinely uncontrolled native <input>
// (not wrapped in v-text-field at all) sidesteps that entirely.
//
// We grab a direct DOM ref to each native input and manage its .value
// imperatively instead:
// - once on mount, to show the initial value
// - whenever departureDatePart/arrivalDatePart changes from elsewhere
// (the calendar picker, a flight lookup, auto-fill) — but only if the
// field isn't currently focused, so we never fight with active typing
// - the native `change` event (fires once a full date has been entered
// and committed, or cleared) is what flows the typed value back into
// departureDatePart/arrivalDatePart
// This keeps the real native, locale-aware date input and picker UI intact.
//
// The arrival native date input only exists in the DOM while arrivalOffset
// is 'custom' (see template); its value is (re)synced whenever it mounts.
function toIsoDateString(d: Date | null): string {
if (!d) return ''
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
function fromIsoDateString(value: string): Date | null {
if (!value) return null
const [y, m, d] = value.split('-').map(Number)
if (!y || !m || !d) return null
return new Date(y, m - 1, d)
}
const departureDateEl = ref<HTMLInputElement | null>(null)
const arrivalDateEl = ref<HTMLInputElement | null>(null)
onMounted(() => {
if (departureDateEl.value) departureDateEl.value.value = toIsoDateString(departureDatePart.value)
if (arrivalDateEl.value) arrivalDateEl.value.value = toIsoDateString(arrivalDatePart.value)
})
watch(departureDatePart, (d) => {
const el = departureDateEl.value
if (el && document.activeElement !== el) el.value = toIsoDateString(d)
})
watch(arrivalDatePart, (d) => {
const el = arrivalDateEl.value
if (el && document.activeElement !== el) el.value = toIsoDateString(d)
})
// The arrival native input only exists while arrivalDateMode === 'custom',
// so re-sync its value whenever it (re)mounts into the DOM.
watch(arrivalDateMode, async (mode) => {
if (mode !== 'custom') return
await nextTick()
if (arrivalDateEl.value) arrivalDateEl.value.value = toIsoDateString(arrivalDatePart.value)
})
function commitDepartureDateInput(e: Event) {
const value = (e.target as HTMLInputElement).value
departureDatePart.value = value ? fromIsoDateString(value) : null
}
function commitArrivalDateInput(e: Event) {
const value = (e.target as HTMLInputElement).value
arrivalDatePart.value = value ? fromIsoDateString(value) : null
}
// ── Editable, format-aware time text fields ────────────────────────────────
// departureTimePart / arrivalTimePart stay the source of truth as internal
// "HH:mm" (24hr) strings. These input refs are what the text-fields actually
// bind to, so the user can type freely; parsing only commits on blur/enter.
function formatTimeForFormat(t: string | null, format: string): string {
if (!t) return ''
const [hStr, mStr] = t.split(':')
let h = parseInt(hStr, 10)
if (isNaN(h)) return ''
if (format === '24hr') {
return `${pad(h)}:${mStr}`
}
const ampm = h >= 12 ? 'PM' : 'AM'
let h12 = h % 12
if (h12 === 0) h12 = 12
return `${h12}:${mStr} ${ampm}`
}
function parseTimeInput(input: string): string | null {
const trimmed = input.trim()
if (!trimmed) return null
// ampm form: "2:30 PM", "2:30PM", "2 PM", "2PM"
let match = trimmed.match(/^(\d{1,2}):?(\d{2})?\s*([AaPp][Mm])$/)
if (match) {
let h = parseInt(match[1], 10)
const m = match[2] ? parseInt(match[2], 10) : 0
const ampm = match[3].toUpperCase()
if (h >= 1 && h <= 12 && m >= 0 && m <= 59) {
if (ampm === 'PM' && h !== 12) h += 12
if (ampm === 'AM' && h === 12) h = 0
return `${pad(h)}:${pad(m)}`
}
return null
}
// 24hr form: "14:30" or "1430"
match = trimmed.match(/^(\d{1,2}):?(\d{2})$/)
if (match) {
const h = parseInt(match[1], 10)
const m = parseInt(match[2], 10)
if (h >= 0 && h <= 23 && m >= 0 && m <= 59) {
return `${pad(h)}:${pad(m)}`
}
}
return null
}
const departureTimeInput = ref(formatTimeForFormat(departureTimePart.value, timeFormat.value))
const arrivalTimeInput = ref(formatTimeForFormat(arrivalTimePart.value, timeFormat.value))
watch([departureTimePart, timeFormat], () => {
departureTimeInput.value = formatTimeForFormat(departureTimePart.value, timeFormat.value)
})
watch([arrivalTimePart, timeFormat], () => {
arrivalTimeInput.value = formatTimeForFormat(arrivalTimePart.value, timeFormat.value)
})
function commitDepartureTimeInput() {
if (!departureTimeInput.value.trim()) {
departureTimePart.value = null
return
}
const parsed = parseTimeInput(departureTimeInput.value)
if (parsed !== null) {
departureTimePart.value = parsed
} else {
departureTimeInput.value = formatTimeForFormat(departureTimePart.value, timeFormat.value)
}
}
function commitArrivalTimeInput() {
if (!arrivalTimeInput.value.trim()) {
arrivalTimePart.value = null
return
}
const parsed = parseTimeInput(arrivalTimeInput.value)
if (parsed !== null) {
arrivalTimePart.value = parsed
} else {
arrivalTimeInput.value = formatTimeForFormat(arrivalTimePart.value, timeFormat.value)
}
}
</script>
<template>
<Head :title="isEdit ? 'Edit Flight' : 'Add Flight'" />
<GlassBox
:title="isEdit ? 'Edit Flight' : 'Add Flight'"
:blurb="isEdit
? 'Update the details for this flight.'
: 'Enter a flight number then press Look Up to continue.'"
>
<v-form style="width: 100%">
<v-container>
<!-- ── Flight number + lookup ────────────────────────────── -->
<v-row>
<v-col cols="12">
<div class="d-flex ga-3 align-start">
<v-text-field
v-model="flightNumber"
label="Flight Number"
placeholder="e.g. QF1"
hide-details
@keydown.enter="lookupFlight"
/>
<v-btn
:loading="lookupLoading"
:disabled="!flightNumber.trim()"
size="large"
style="height: 56px"
@click="lookupFlight"
>
Look Up
</v-btn>
</div>
<div v-if="lookupError" class="text-error text-caption mt-1">
{{ lookupError }}
</div>
</v-col>
</v-row>
<!-- ── Departure date + time ──────────────────────────────── -->
<v-row>
<v-col cols="12" sm="6">
<div class="native-date-field">
<v-icon icon="mdi-calendar" size="20" class="native-date-field__icon" />
<label class="native-date-field__label">Departure Date</label>
<input
ref="departureDateEl"
type="date"
class="native-date-field__input"
@change="commitDepartureDateInput"
>
<v-menu v-model="depDateMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }">
<v-icon
v-bind="menuProps"
icon="mdi-calendar-month"
size="20"
style="cursor: pointer"
/>
</template>
<v-date-picker
v-model="departureDatePart"
@update:model-value="depDateMenu = false"
/>
</v-menu>
</div>
<div v-if="submitForm.errors.departure_date" class="native-date-field__error text-error">
{{ submitForm.errors.departure_date }}
</div>
</v-col>
<v-col cols="12" sm="6">
<v-menu v-model="depTimeMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }">
<v-text-field
v-bind="menuProps"
v-model="departureTimeInput"
label="Departure Time"
clearable
prepend-inner-icon="mdi-clock-outline"
:placeholder="timeFormat === '24hr' ? 'e.g. 1430' : 'e.g. 2:30 PM'"
@click:clear="clearDepartureTime"
@blur="commitDepartureTimeInput"
@keydown.enter="commitDepartureTimeInput"
/>
</template>
<v-card>
<v-time-picker
v-model="departureTimePart"
:format="timeFormat"
scrollable
/>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="depTimeMenu = false">OK</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</v-col>
</v-row>
<!-- ── Arrival ───────────────────────────────────────────── -->
<v-row>
<v-col cols="12" sm="6">
<label class="arrival-offset__label">Arrival Date</label>
<template v-if="arrivalDateMode === 'offset'">
<v-btn-toggle
v-model="arrivalOffset"
mandatory
variant="outlined"
color="primary"
divided
class="arrival-offset__toggle"
>
<v-btn
v-for="opt in arrivalOffsetOptions"
:key="opt.value"
:value="opt.value"
class="arrival-offset__btn"
>
{{ opt.label }}
</v-btn>
</v-btn-toggle>
<div class="arrival-offset__footer">
<span v-if="arrivalDatePart" class="arrival-offset__hint">
Arrives {{ formatFullDate(arrivalDatePart) }}
</span>
<button
type="button"
class="arrival-offset__link"
@click="useCustomArrivalDate"
>
Custom date
</button>
</div>
</template>
<template v-else>
<div class="native-date-field">
<v-icon icon="mdi-calendar" size="20" class="native-date-field__icon" />
<input
ref="arrivalDateEl"
type="date"
class="native-date-field__input"
@change="commitArrivalDateInput"
>
<v-menu v-model="arrDateMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }">
<v-icon
v-bind="menuProps"
icon="mdi-calendar-month"
size="20"
style="cursor: pointer"
/>
</template>
<v-date-picker
v-model="arrivalDatePart"
:min="arrivalMinDate"
:max="arrivalMaxDate"
@update:model-value="arrDateMenu = false"
/>
</v-menu>
</div>
<button
type="button"
class="arrival-offset__link mt-1"
@click="useQuickArrivalOffset"
>
Use quick select instead
</button>
</template>
<div v-if="submitForm.errors.arrival_date" class="native-date-field__error text-error">
{{ submitForm.errors.arrival_date }}
</div>
</v-col>
<v-col cols="12" sm="6">
<v-menu v-model="arrTimeMenu" :close-on-content-click="false">
<template #activator="{ props: menuProps }">
<v-text-field
v-bind="menuProps"
v-model="arrivalTimeInput"
label="Arrival Time"
clearable
prepend-inner-icon="mdi-clock-outline"
:placeholder="timeFormat === '24hr' ? 'e.g. 1430' : 'e.g. 2:30 PM'"
@click:clear="clearArrivalTime"
@blur="commitArrivalTimeInput"
@keydown.enter="commitArrivalTimeInput"
/>
</template>
<v-card>
<v-time-picker
v-model="arrivalTimePart"
:format="timeFormat"
scrollable
/>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="arrTimeMenu = false">OK</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</v-col>
</v-row>
<!-- ── From ──────────────────────────────────────────────── -->
<v-row>
<v-col cols="12">
<AirportSearchBox
v-model="form.from"
label="From"
:prefilled-options="fromOptionsData"
:error-messages="submitForm.errors.from_id"
/>
<MissingEntityLink label="Missing airport?">
<p>If you can't find an airport we will be happy to add it, but you'll have to contact support with details of the airport.</p>
<p>Unfortunately you won't be able to proceed with adding this flight until the airport has been added.</p>
</MissingEntityLink>
</v-col>
</v-row>
<!-- ── To ────────────────────────────────────────────────── -->
<v-row>
<v-col cols="12">
<AirportSearchBox
v-model="form.to"
label="To"
:prefilled-options="toOptionsData"
:error-messages="submitForm.errors.to_id"
/>
</v-col>
</v-row>
<!-- ── Airline ────────────────────────────────────────────── -->
<v-row>
<v-col cols="12">
<AirlineSearchBox
:key="`airline-${lookupKey}`"
v-model="form.airline"
:prefilled-options="airlineOptionsData"
:error-messages="submitForm.errors.airline_id"
/>
<MissingEntityLink label="Missing airline? No problem">
<p>If you can't find the airline you flew with, just leave it blank but make sure to add a valid flight number. We will be notified of flights with missing airlines and add the airline shortly.</p>
<p>If you don't know your flight number or a missing airline hasn't been added within 48 hours, please contact support.</p>
</MissingEntityLink>
</v-col>
</v-row>
<!-- ── Aircraft ───────────────────────────────────────────── -->
<v-row>
<v-col cols="12">
<AircraftSearchBox
:key="`aircraft-${lookupKey}`"
v-model="form.aircraft"
:prefilled-options="aircraftOptionsData"
:error-messages="submitForm.errors.aircraft_id"
/>
</v-col>
</v-row>
<!-- ── Registration + Flight class ────────────────────────── -->
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="form.aircraft_registration"
label="Aircraft Registration"
placeholder="e.g. VH-OQA"
:error-messages="submitForm.errors.aircraft_registration"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="form.flight_class"
label="Flight Class"
:items="flight_classes"
item-title="name"
item-value="id"
:return-object="true"
:error-messages="submitForm.errors.flight_class_id"
/>
</v-col>
</v-row>
<!-- ── Seat number + Seat type ────────────────────────────── -->
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="form.seat_number"
label="Seat Number"
placeholder="e.g. 12A"
:error-messages="submitForm.errors.seat_number"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="form.seat_type"
label="Seat Type"
:items="seat_types"
item-title="name"
item-value="id"
:return-object="true"
:error-messages="submitForm.errors.seat_type_id"
/>
</v-col>
</v-row>
<!-- ── Flight reason + Crew type ──────────────────────────── -->
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="form.flight_reason"
label="Flight Reason"
:items="flight_reasons"
item-title="name"
item-value="id"
:return-object="true"
:error-messages="submitForm.errors.flight_reason_id"
/>
</v-col>
<v-col v-if="isCrew" cols="12" md="6">
<v-select
v-model="form.crew_type"
label="Crew Type"
:items="crew_types"
item-title="name"
item-value="id"
:return-object="true"
:error-messages="submitForm.errors.crew_type_id"
/>
</v-col>
</v-row>
<!-- ── Note ──────────────────────────────────────────────── -->
<v-row>
<v-col cols="12">
<v-textarea
v-model="form.note"
label="Note"
placeholder="Any additional notes…"
:error-messages="submitForm.errors.note"
rows="3"
auto-grow
/>
</v-col>
</v-row>
<!-- ── Auto update ────────────────────────────────────────── -->
<v-row v-if="departureIsFuture">
<v-col cols="12">
<v-checkbox
v-model="form.auto_update"
label="Automatically update flight details within 24 hours of flight departure"
hide-details
density="compact"
/>
</v-col>
</v-row>
<!-- ── Submit ─────────────────────────────────────────────── -->
<v-row>
<v-col v-if="!isEdit" cols="12" md="6">
<v-btn
block
size="large"
variant="outlined"
:loading="submitForm.processing && submitForm.add_more"
:disabled="submitForm.processing"
@click="submit(true)"
>
Save Flight and Add Another
</v-btn>
</v-col>
<v-col cols="12" :md="isEdit ? 12 : 6">
<v-btn
block
size="large"
:loading="submitForm.processing && !submitForm.add_more"
:disabled="submitForm.processing"
@click="submit(false)"
>
{{ isEdit ? 'Save Changes' : 'Save Flight' }}
</v-btn>
</v-col>
</v-row>
</v-container>
</v-form>
</GlassBox>
</template>
<style scoped>
/* Hand-rolled field chrome for the native <input type="date"> fields.
These deliberately bypass v-text-field (see the "Native date inputs"
comment in the script block for why), so we approximate the surrounding
Vuetify outlined-field look here instead of getting it for free. */
.native-date-field {
position: relative;
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 8px 12px;
border: 1px none rgba(255, 255, 255, 0.24);
border-bottom-style: solid;
background: transparent;
transition: border-color 0.15s ease;
}
.native-date-field:focus-within {
border-color: rgb(var(--surface));
border-width: 2px;
padding: 7px 11px;
}
.native-date-field__icon {
opacity: 0.7;
flex-shrink: 0;
}
.native-date-field__label {
position: absolute;
top: -8px;
left: 38px;
padding: 0 4px;
font-size: 12px;
line-height: 1;
color: rgba(255, 255, 255, 0.6);
pointer-events: none;
}
.native-date-field__input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: inherit;
font-size: 16px;
color-scheme: dark;
}
.native-date-field__input::-webkit-calendar-picker-indicator {
display: none;
-webkit-appearance: none;
}
/* Firefox doesn't support ::-webkit-calendar-picker-indicator; this trims
its built-in indicator/spinner button area instead. */
.native-date-field__input::-moz-calendar-picker-indicator {
display: none;
}
.native-date-field__error {
margin-top: 4px;
padding-left: 12px;
font-size: 12px;
}
/* Arrival day-offset control */
.arrival-offset__label {
display: block;
font-size: 12px;
line-height: 1;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 6px;
padding-left: 2px;
}
.arrival-offset__toggle {
display: grid;
grid-template-columns: repeat(4, 1fr);
width: 100%;
height: 48px;
border-radius: 4px;
}
.arrival-offset__btn {
min-width: 0;
flex: none;
width: 100%;
height: 100%;
font-size: 11px;
letter-spacing: 0;
text-transform: none;
padding: 0 4px;
}
.arrival-offset__footer {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-top: 6px;
padding: 0 2px;
}
.arrival-offset__hint {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
}
.arrival-offset__link {
flex-shrink: 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.arrival-offset__link:hover {
color: rgb(255, 255, 255);
}
</style>