Added Crew and General Aviation Filters
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Airline;
|
use App\Models\Airline;
|
||||||
use App\Models\Airport;
|
use App\Models\Airport;
|
||||||
|
use App\Models\CrewType;
|
||||||
use App\Models\FlightClass;
|
use App\Models\FlightClass;
|
||||||
use App\Models\FlightReason;
|
use App\Models\FlightReason;
|
||||||
use App\Models\SeatType;
|
use App\Models\SeatType;
|
||||||
@@ -33,6 +34,7 @@ class FlightController extends Controller
|
|||||||
'flight_reason_id' => ['integer', 'exists:flight_reasons,id'],
|
'flight_reason_id' => ['integer', 'exists:flight_reasons,id'],
|
||||||
'note' => ['nullable', 'string', 'max:5000'],
|
'note' => ['nullable', 'string', 'max:5000'],
|
||||||
'auto_update' => ['boolean'],
|
'auto_update' => ['boolean'],
|
||||||
|
'crew_type_id' => ['nullable', 'exists:crew_types,id'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +90,7 @@ class FlightController extends Controller
|
|||||||
'flight_reason_id' => $validated['flight_reason_id'],
|
'flight_reason_id' => $validated['flight_reason_id'],
|
||||||
'note' => $validated['note'],
|
'note' => $validated['note'],
|
||||||
'auto_update' => $validated['auto_update'],
|
'auto_update' => $validated['auto_update'],
|
||||||
|
'crew_type_id' => $validated['crew_type_id'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +116,17 @@ class FlightController extends Controller
|
|||||||
return redirect()->route('profile.departure-board', [Auth::user()->name, $flight->id]);
|
return redirect()->route('profile.departure-board', [Auth::user()->name, $flight->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function add(){
|
public function staticData() : array {
|
||||||
return Inertia::render('AddFlight', [
|
return [
|
||||||
'seat_types' => SeatType::orderBy('id')->get()->toArray(),
|
'seat_types' => SeatType::orderBy('id')->get()->toArray(),
|
||||||
'flight_reasons' => FlightReason::orderBy('id')->get()->toArray(),
|
'flight_reasons' => FlightReason::orderBy('id')->get()->toArray(),
|
||||||
'flight_classes' => FlightClass::orderBy('id')->get()->toArray(),
|
'flight_classes' => FlightClass::orderBy('id')->get()->toArray(),
|
||||||
]);
|
'crew_types' => CrewType::orderBy('id')->get()->toArray(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(){
|
||||||
|
return Inertia::render('AddFlight', $this->staticData());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(UserFlight $flight)
|
public function edit(UserFlight $flight)
|
||||||
@@ -136,6 +144,7 @@ class FlightController extends Controller
|
|||||||
'auto_update' => $flight->auto_update,
|
'auto_update' => $flight->auto_update,
|
||||||
'seat_type' => $flight->seatType->toArray(),
|
'seat_type' => $flight->seatType->toArray(),
|
||||||
'flight_class' => $flight->flightClass->toArray(),
|
'flight_class' => $flight->flightClass->toArray(),
|
||||||
|
'crew_type' => $flight->crewType?->toArray() ?? [],
|
||||||
'flight_reason' => $flight->flightReason->toArray(),
|
'flight_reason' => $flight->flightReason->toArray(),
|
||||||
'airline_options' => $flight->airline
|
'airline_options' => $flight->airline
|
||||||
? [['value' => $flight->airline->id, 'title' => $flight->airline->display_name]]
|
? [['value' => $flight->airline->id, 'title' => $flight->airline->display_name]]
|
||||||
@@ -148,9 +157,7 @@ class FlightController extends Controller
|
|||||||
];
|
];
|
||||||
return Inertia::render('AddFlight', [
|
return Inertia::render('AddFlight', [
|
||||||
'flight' => $flightData,
|
'flight' => $flightData,
|
||||||
'seat_types' => SeatType::orderBy('id')->get()->toArray(),
|
...$this->staticData(),
|
||||||
'flight_reasons' => FlightReason::orderBy('id')->get()->toArray(),
|
|
||||||
'flight_classes' => FlightClass::orderBy('id')->get()->toArray(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class FlightProfileController extends Controller
|
|||||||
'seatType',
|
'seatType',
|
||||||
'flightReason',
|
'flightReason',
|
||||||
'flightClass',
|
'flightClass',
|
||||||
|
'crewType'
|
||||||
])
|
])
|
||||||
->orderBy('departure_date', 'desc')
|
->orderBy('departure_date', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class CrewType extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'crew_types';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'internal_name',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -6,5 +6,8 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
|
|
||||||
class FlightClass extends Model
|
class FlightClass extends Model
|
||||||
{
|
{
|
||||||
//
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'internal_name',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class UserFlight extends Model
|
|||||||
'seat_type_id',
|
'seat_type_id',
|
||||||
'flight_class_id',
|
'flight_class_id',
|
||||||
'flight_reason_id',
|
'flight_reason_id',
|
||||||
|
'crew_type_id',
|
||||||
'note',
|
'note',
|
||||||
'auto_update',
|
'auto_update',
|
||||||
];
|
];
|
||||||
@@ -66,11 +67,17 @@ class UserFlight extends Model
|
|||||||
return $this->belongsTo(Airport::class, 'arrival_airport_id');
|
return $this->belongsTo(Airport::class, 'arrival_airport_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function crewType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CrewType::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function airline(): BelongsTo
|
public function airline(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Airline::class);
|
return $this->belongsTo(Airline::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function aircraft(): BelongsTo
|
public function aircraft(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Aircraft::class);
|
return $this->belongsTo(Aircraft::class);
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\FlightClass;
|
||||||
|
use App\Models\FlightReason;
|
||||||
|
use App\Models\UserFlight;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Crew Types
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
Schema::create('crew_types', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('internal_name')->unique();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('crew_types')->insert([
|
||||||
|
['name' => 'Unspecified', 'internal_name' => 'unspecified'],
|
||||||
|
['name' => 'Cabin Crew', 'internal_name' => 'cabin_crew'],
|
||||||
|
['name' => 'Purser / CSM', 'internal_name' => 'purser'],
|
||||||
|
['name' => 'Captain', 'internal_name' => 'captain'],
|
||||||
|
['name' => 'First Officer', 'internal_name' => 'first_officer'],
|
||||||
|
['name' => 'Second Officer', 'internal_name' => 'second_officer'],
|
||||||
|
['name' => 'Deadhead', 'internal_name' => 'deadhead'],
|
||||||
|
['name' => 'Marshal / Security', 'internal_name' => 'marshal'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Followees
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
Schema::create('followees', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->foreignId('followee_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'followee_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// User Actions
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
Schema::create('user_actions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->unsignedBigInteger('user_flight_id');
|
||||||
|
$table->text('message');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Achievements
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
Schema::create('achievements', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('internal_name')->unique();
|
||||||
|
$table->text('description');
|
||||||
|
$table->string('icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// User Achievements
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
Schema::create('user_achievements', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->foreignId('achievement_id')->constrained('achievements')->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'achievement_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::statement('ALTER TABLE flight_classes ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY');
|
||||||
|
DB::statement('ALTER TABLE flight_reasons ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY');
|
||||||
|
|
||||||
|
DB::statement("
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('flight_classes', 'id'),
|
||||||
|
(SELECT MAX(id) FROM flight_classes)
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
DB::statement("
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('flight_reasons', 'id'),
|
||||||
|
(SELECT MAX(id) FROM flight_reasons)
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
DB::table('flight_classes')->insert([
|
||||||
|
['name' => 'General Aviation'],
|
||||||
|
['name' => 'Crew'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('flight_reasons')->insert([
|
||||||
|
['name' => 'Visiting Friends / Relatives'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::table('flight_classes', function (Blueprint $table) {
|
||||||
|
$table->string('internal_name')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('flight_classes')->get()->each(function ($row) {
|
||||||
|
DB::table('flight_classes')
|
||||||
|
->where('id', $row->id)
|
||||||
|
->update(['internal_name' => Str::slug($row->name, '_')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('flight_classes', function (Blueprint $table) {
|
||||||
|
$table->string('internal_name')->nullable(false)->unique()->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('user_flights', function (Blueprint $table) {
|
||||||
|
$table->foreignId('crew_type_id')->nullable()->constrained('crew_types')->nullOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
$economy = FlightClass::where('internal_name', 'economy')->first();
|
||||||
|
$unspecified = FlightClass::where('internal_name', 'unspecified')->first();
|
||||||
|
UserFlight::where('flight_class_id', $unspecified->id)->update(['flight_class_id' => $economy->id]);
|
||||||
|
|
||||||
|
FlightReason::where('name', 'Other')->update(['id' => 999]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_achievements');
|
||||||
|
Schema::dropIfExists('achievements');
|
||||||
|
Schema::dropIfExists('user_actions');
|
||||||
|
Schema::dropIfExists('followees');
|
||||||
|
Schema::dropIfExists('crew_types');
|
||||||
|
DB::table('flight_classes')->whereIn('name', ['General Aviation', 'Crew'])->delete();
|
||||||
|
DB::table('flight_reasons')->where('name', 'Crew')->delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -119,6 +119,22 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.class-general_aviation-global {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
color: #22c55e;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-crew-global {
|
||||||
|
background: rgba(198, 120, 110, 0.15);
|
||||||
|
border: 1px solid rgba(198, 120, 110, 0.3);
|
||||||
|
color: #c6786e;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── First: Platinum ── */
|
/* ── First: Platinum ── */
|
||||||
.class-first-global {
|
.class-first-global {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
|
|||||||
@@ -8,19 +8,12 @@ defineProps<{
|
|||||||
flight: Flight
|
flight: Flight
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function classKey(flight: Flight): string {
|
|
||||||
const n = flight.flight_class?.name?.toLowerCase() ?? ''
|
|
||||||
if (n.includes('private')) return 'private'
|
|
||||||
if (n.includes('first')) return 'first'
|
|
||||||
if (n.includes('business')) return 'business'
|
|
||||||
if (n.includes('premium')) return 'premium'
|
|
||||||
return 'economy'
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="boarding-pass">
|
<div class="boarding-pass">
|
||||||
<div class="pass-header" :class="`class-${classKey(flight)}-global`">
|
<div class="pass-header" :class="`class-${flight.flight_class?.internal_name}-global`">
|
||||||
<span v-if="flight.flight_class?.name !== 'Unspecified'" class="pass-header-class">
|
<span v-if="flight.flight_class?.name !== 'Unspecified'" class="pass-header-class">
|
||||||
{{ flight.flight_class?.name }}
|
{{ flight.flight_class?.name }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const chartOptions = computed(() => ({
|
|||||||
},
|
},
|
||||||
theme: { mode: 'dark' },
|
theme: { mode: 'dark' },
|
||||||
labels: props.labels,
|
labels: props.labels,
|
||||||
colors: ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4'],
|
colors: ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4', 'pink'],
|
||||||
dataLabels: {
|
dataLabels: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
formatter: (val: number) => `${Math.round(val)}%`,
|
formatter: (val: number) => `${Math.round(val)}%`,
|
||||||
|
|||||||
+43
-15
@@ -1,12 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue"
|
|
||||||
import VueApexCharts from "vue3-apexcharts"
|
import VueApexCharts from "vue3-apexcharts"
|
||||||
|
|
||||||
interface TooltipItem {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string
|
title: string
|
||||||
series: { name: string; data: number[] }[]
|
series: { name: string; data: number[] }[]
|
||||||
@@ -17,7 +12,6 @@ const props = defineProps<{
|
|||||||
barHeight?: number
|
barHeight?: number
|
||||||
colors?: string[]
|
colors?: string[]
|
||||||
options?: object
|
options?: object
|
||||||
events?: object
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const BAR_HEIGHT = computed(() => props.barHeight ?? 32)
|
const BAR_HEIGHT = computed(() => props.barHeight ?? 32)
|
||||||
@@ -29,6 +23,25 @@ const scrollHeight = computed(() => {
|
|||||||
return `${visible * BAR_HEIGHT.value + 40}px`
|
return `${visible * BAR_HEIGHT.value + 40}px`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Tooltip state (exposed to parent via scoped slot) ─────────────────────────
|
||||||
|
|
||||||
|
const tooltipVisible = ref(false)
|
||||||
|
const tooltipX = ref(0)
|
||||||
|
const tooltipY = ref(0)
|
||||||
|
const hoveredIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
tooltipX.value = e.clientX + 14
|
||||||
|
tooltipY.value = e.clientY + 14
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseLeave() {
|
||||||
|
tooltipVisible.value = false
|
||||||
|
hoveredIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chart options ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
const chartOptions = computed(() => ({
|
||||||
chart: {
|
chart: {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
@@ -38,6 +51,16 @@ const chartOptions = computed(() => ({
|
|||||||
animations: { enabled: false },
|
animations: { enabled: false },
|
||||||
stacked: true,
|
stacked: true,
|
||||||
animation: { enabled: false },
|
animation: { enabled: false },
|
||||||
|
events: {
|
||||||
|
dataPointMouseEnter: (_e: unknown, _ctx: unknown, config: { dataPointIndex: number }) => {
|
||||||
|
tooltipVisible.value = true
|
||||||
|
hoveredIndex.value = config.dataPointIndex
|
||||||
|
},
|
||||||
|
dataPointMouseLeave: () => {
|
||||||
|
tooltipVisible.value = false
|
||||||
|
hoveredIndex.value = null
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
theme: { mode: 'dark' },
|
theme: { mode: 'dark' },
|
||||||
plotOptions: {
|
plotOptions: {
|
||||||
@@ -68,12 +91,11 @@ const chartOptions = computed(() => ({
|
|||||||
markers: { width: 8, height: 8, radius: 2 },
|
markers: { width: 8, height: 8, radius: 2 },
|
||||||
itemMargin: { horizontal: 8 },
|
itemMargin: { horizontal: 8 },
|
||||||
},
|
},
|
||||||
tooltip: { theme: 'dark', shared: true, intersect: false },
|
tooltip: { enabled: false },
|
||||||
states: {
|
states: {
|
||||||
hover: { filter: { type: 'lighten', value: 0.1 } },
|
hover: { filter: { type: 'lighten', value: 0.1 } },
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -81,7 +103,12 @@ const chartOptions = computed(() => ({
|
|||||||
<div class="chart-title">{{ title }}</div>
|
<div class="chart-title">{{ title }}</div>
|
||||||
|
|
||||||
<div v-if="categories.length" class="chart-outer">
|
<div v-if="categories.length" class="chart-outer">
|
||||||
<div class="chart-scroll" :style="{ height: scrollHeight }">
|
<div
|
||||||
|
class="chart-scroll"
|
||||||
|
:style="{ height: scrollHeight }"
|
||||||
|
@mousemove="onMouseMove"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
|
>
|
||||||
<VueApexCharts
|
<VueApexCharts
|
||||||
type="bar"
|
type="bar"
|
||||||
:height="chartHeight"
|
:height="chartHeight"
|
||||||
@@ -90,8 +117,13 @@ const chartOptions = computed(() => ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Optional slot for custom tooltip -->
|
<slot
|
||||||
<slot name="tooltip" />
|
name="tooltip"
|
||||||
|
:visible="tooltipVisible"
|
||||||
|
:x="tooltipX"
|
||||||
|
:y="tooltipY"
|
||||||
|
:index="hoveredIndex"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="footerValue !== undefined" class="chart-footer">
|
<div v-if="footerValue !== undefined" class="chart-footer">
|
||||||
<span class="total-count">{{ footerValue }}</span>
|
<span class="total-count">{{ footerValue }}</span>
|
||||||
@@ -166,8 +198,4 @@ const chartOptions = computed(() => ({
|
|||||||
:deep(svg) {
|
:deep(svg) {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-hidden {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -42,19 +42,19 @@ const chartEvents = computed(() => ({
|
|||||||
@mousemove="onMouseMove"
|
@mousemove="onMouseMove"
|
||||||
@mouseleave="onMouseLeave"
|
@mouseleave="onMouseLeave"
|
||||||
>
|
>
|
||||||
<template #tooltip>
|
<template #tooltip="{ visible, x, y, index }">
|
||||||
<ChartTooltip :visible="!!tooltipItem" :x="tooltipX" :y="tooltipY">
|
<ChartTooltip :visible="visible" :x="x" :y="y">
|
||||||
<div class="ct-name">
|
<div class="ct-name">
|
||||||
<span :class="`fi fi-${tooltipItem?.code.toLowerCase()}`" />
|
<span v-if="index !== null" :class="`fi fi-${countries[index].code.toLowerCase()}`" />
|
||||||
{{ tooltipItem?.name }}
|
{{ index !== null ? countries[index].name : '' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="ct-row">
|
<div class="ct-row">
|
||||||
<span class="ct-label">Flights</span>
|
<span class="ct-label">Flights</span>
|
||||||
<span class="ct-val">{{ tooltipItem?.past }}</span>
|
<span class="ct-val">{{ index !== null ? countries[index].past : '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ct-row" v-if="tooltipItem?.upcoming">
|
<div v-if="index !== null && countries[index].upcoming" class="ct-row">
|
||||||
<span class="ct-label">Upcoming</span>
|
<span class="ct-label">Upcoming</span>
|
||||||
<span class="ct-val">{{ tooltipItem?.upcoming }}</span>
|
<span class="ct-val">{{ countries[index].upcoming }}</span>
|
||||||
</div>
|
</div>
|
||||||
</ChartTooltip>
|
</ChartTooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -43,19 +43,19 @@ const chartEvents = computed(() => ({
|
|||||||
@mousemove="onMouseMove"
|
@mousemove="onMouseMove"
|
||||||
@mouseleave="onMouseLeave"
|
@mouseleave="onMouseLeave"
|
||||||
>
|
>
|
||||||
<template #tooltip>
|
<template #tooltip="{ visible, x, y, index }">
|
||||||
<ChartTooltip :visible="!!tooltipItem" :x="tooltipX" :y="tooltipY">
|
<ChartTooltip :visible="visible" :x="x" :y="y">
|
||||||
<div class="ct-name">
|
<div class="ct-name">
|
||||||
<img :src="`${page.logo_api_url}/airlines/logos/tail/id/${tooltipItem?.id}`" width="24" height="24"/>
|
<img :src="`${page.logo_api_url}/airlines/logos/tail/id/${index !== null ? airlines[index].id : ''}`" width="24" height="24"/>
|
||||||
{{ tooltipItem?.name }}
|
{{ index !== null ? airlines[index].name : '' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="ct-row">
|
<div class="ct-row">
|
||||||
<span class="ct-label">Flights</span>
|
<span class="ct-label">Flights</span>
|
||||||
<span class="ct-val">{{ tooltipItem?.past }}</span>
|
<span class="ct-val">{{ index !== null ? airlines[index].past : '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ct-row" v-if="tooltipItem?.upcoming">
|
<div v-if="index !== null && airlines[index].upcoming" class="ct-row">
|
||||||
<span class="ct-label">Upcoming</span>
|
<span class="ct-label">Upcoming</span>
|
||||||
<span class="ct-val">{{ tooltipItem?.upcoming }}</span>
|
<span class="ct-val">{{ airlines[index].upcoming }}</span>
|
||||||
</div>
|
</div>
|
||||||
</ChartTooltip>
|
</ChartTooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FlightStats } from "@/Composables/useFlightStats"
|
|||||||
import { useChartTooltip } from '@/Composables/useChartTooltip'
|
import { useChartTooltip } from '@/Composables/useChartTooltip'
|
||||||
import ScrollingHorizontalBarChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/ScrollingHorizontalBarChart.vue"
|
import ScrollingHorizontalBarChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/ScrollingHorizontalBarChart.vue"
|
||||||
import ChartTooltip from "@/Components/FlightsGoneBy/Charts/ChartTooltip.vue"
|
import ChartTooltip from "@/Components/FlightsGoneBy/Charts/ChartTooltip.vue"
|
||||||
|
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
||||||
|
|
||||||
interface AirportItem {
|
interface AirportItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -42,21 +43,21 @@ const chartEvents = computed(() => ({
|
|||||||
@mousemove="onMouseMove"
|
@mousemove="onMouseMove"
|
||||||
@mouseleave="onMouseLeave"
|
@mouseleave="onMouseLeave"
|
||||||
>
|
>
|
||||||
<template #tooltip>
|
<template #tooltip="{ visible, x, y, index }">
|
||||||
<ChartTooltip :visible="!!tooltipItem" :x="tooltipX" :y="tooltipY">
|
<ChartTooltip :visible="visible" :x="x" :y="y">
|
||||||
<div class="ct-name">{{ tooltipItem?.fullName }}</div>
|
<div class="ct-name">{{ index !== null ? airports[index].fullName : '' }}</div>
|
||||||
<div class="ct-sub">{{ tooltipItem?.label }}</div>
|
<div class="ct-sub"><InlineBadge variant="generic">{{ index !== null ? airports[index].label : '' }}</InlineBadge></div>
|
||||||
<div class="ct-row">
|
<div class="ct-row">
|
||||||
<span class="ct-label">Departures</span>
|
<span class="ct-label">Departures</span>
|
||||||
<span class="ct-val">{{ tooltipItem?.departures }}</span>
|
<span class="ct-val">{{ index !== null ? airports[index].departures : '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ct-row">
|
<div class="ct-row">
|
||||||
<span class="ct-label">Arrivals</span>
|
<span class="ct-label">Arrivals</span>
|
||||||
<span class="ct-val">{{ tooltipItem?.arrivals }}</span>
|
<span class="ct-val">{{ index !== null ? airports[index].arrivals : '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tooltipItem?.upcoming" class="ct-row">
|
<div v-if="index !== null && airports[index].upcoming" class="ct-row">
|
||||||
<span class="ct-label">Upcoming</span>
|
<span class="ct-label">Upcoming</span>
|
||||||
<span class="ct-val">{{ tooltipItem?.upcoming }}</span>
|
<span class="ct-val">{{ airports[index].upcoming }}</span>
|
||||||
</div>
|
</div>
|
||||||
</ChartTooltip>
|
</ChartTooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,11 +1,49 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { FlightStats } from "@/Composables/useFlightStats"
|
||||||
|
import ScrollingHorizontalBarChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/ScrollingHorizontalBarChart.vue"
|
||||||
|
import ChartTooltip from "@/Components/FlightsGoneBy/Charts/ChartTooltip.vue"
|
||||||
|
import { useChartTooltip } from "@/Composables/useChartTooltip"
|
||||||
|
|
||||||
|
interface RouteItem {
|
||||||
|
label: string
|
||||||
|
depLabel: string
|
||||||
|
arrLabel: string
|
||||||
|
past: number
|
||||||
|
upcoming: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
flightStats: FlightStats
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { tooltipItem, tooltipX, tooltipY, onMouseMove, onMouseLeave } = useChartTooltip<RouteItem>()
|
||||||
|
|
||||||
|
const routes = computed(() => props.flightStats.topRoutes.value.routes)
|
||||||
|
const series = computed(() => props.flightStats.topRoutes.value.series)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<ScrollingHorizontalBarChart
|
||||||
|
title="Top Routes"
|
||||||
|
:series="series"
|
||||||
|
:categories="routes.map(r => r.label)"
|
||||||
|
:footer-value="routes.length"
|
||||||
|
footer-label="total routes"
|
||||||
|
:max-visible="18"
|
||||||
|
>
|
||||||
|
<template #tooltip="{ visible, x, y, index }">
|
||||||
|
<ChartTooltip :visible="visible" :x="x" :y="y">
|
||||||
|
<div class="ct-name">{{ index !== null ? routes[index].label : '' }}</div>
|
||||||
|
<div class="ct-row">
|
||||||
|
<span class="ct-label">Flights</span>
|
||||||
|
<span class="ct-val">{{ index !== null ? routes[index].past : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="index !== null && routes[index].upcoming" class="ct-row">
|
||||||
|
<span class="ct-label">Upcoming</span>
|
||||||
|
<span class="ct-val">{{ routes[index].upcoming }}</span>
|
||||||
|
</div>
|
||||||
|
</ChartTooltip>
|
||||||
|
</template>
|
||||||
|
</ScrollingHorizontalBarChart>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CrewType } from "@/Types/types";
|
||||||
|
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
crewType: CrewType
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<GlassTooltip>
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<div v-bind="tooltipProps" style="cursor:pointer">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="tooltip-rows">
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-label">Position</span>
|
||||||
|
<span class="tooltip-value">{{ crewType.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassTooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tooltip-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-label {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--muted, #445566);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-value {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text, #c8cdd8);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,6 +9,8 @@ import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
|||||||
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
|
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
|
||||||
import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
|
import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
|
||||||
import {FlightStats} from "@/Composables/useFlightStats";
|
import {FlightStats} from "@/Composables/useFlightStats";
|
||||||
|
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
||||||
|
import CrewTooltip from "@/Components/FlightsGoneBy/CrewTooltip.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
flightStats: FlightStats
|
flightStats: FlightStats
|
||||||
@@ -225,7 +227,10 @@ watch(
|
|||||||
|
|
||||||
<td class="v-data-table__td ">
|
<td class="v-data-table__td ">
|
||||||
<span class="class-cell">
|
<span class="class-cell">
|
||||||
|
<CrewTooltip v-if="(item as Flight).flight_reason?.name == 'Crew'" :crew-type="(item as Flight).crew_type!">
|
||||||
<FlightClassBadge :flight="(item as Flight)" />
|
<FlightClassBadge :flight="(item as Flight)" />
|
||||||
|
</CrewTooltip>
|
||||||
|
<FlightClassBadge v-else :flight="(item as Flight)" />
|
||||||
<InlineBadge v-if="(item as Flight).seat_number" variant="economy">{{(item as Flight).seat_number}}</InlineBadge>
|
<InlineBadge v-if="(item as Flight).seat_number" variant="economy">{{(item as Flight).seat_number}}</InlineBadge>
|
||||||
<InlineBadge v-if="(item as Flight).seat_type?.name && (item as Flight).seat_type?.name !== 'Unassigned'" variant="economy">{{(item as Flight).seat_type?.name}}</InlineBadge>
|
<InlineBadge v-if="(item as Flight).seat_type?.name && (item as Flight).seat_type?.name !== 'Unassigned'" variant="economy">{{(item as Flight).seat_type?.name}}</InlineBadge>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -60,9 +60,7 @@ defineProps<{
|
|||||||
|
|
||||||
<!-- Routes + International vs Domestic -->
|
<!-- Routes + International vs Domestic -->
|
||||||
<div class="flight-charts glass charts-row">
|
<div class="flight-charts glass charts-row">
|
||||||
<!--
|
<TopRoutesChart :flight-stats="flightStats" />
|
||||||
<TopRoutesChart :flights="[...flights, ...upcomingFlights]" :upcoming-flights="upcomingFlights" />
|
|
||||||
-->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {Flight} from "@/Types/types";
|
import {BadgeVariant, Flight} from "@/Types/types";
|
||||||
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
flight: Flight
|
flight: Flight
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
type BadgeVariant = 'first' | 'business' | 'premium' | 'economy' | 'private' | 'unspecified';
|
|
||||||
|
|
||||||
function classVariant(name?: string | null): BadgeVariant {
|
|
||||||
const n = name?.toLowerCase() ?? '';
|
|
||||||
if (n.includes('private')) return 'private';
|
|
||||||
if (n.includes('first')) return 'first';
|
|
||||||
if (n.includes('business')) return 'business';
|
|
||||||
if (n.includes('premium')) return 'premium';
|
|
||||||
if (n.includes('economy')) return 'economy';
|
|
||||||
return 'unspecified';
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<InlineBadge :variant="classVariant(flight.flight_class?.name)">
|
<InlineBadge :variant="flight.flight_class?.internal_name ?? 'generic'">
|
||||||
{{ flight.flight_class?.name }}
|
{{ flight.flight_class?.name }}
|
||||||
</InlineBadge>
|
</InlineBadge>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -14,12 +14,11 @@ const emit = defineEmits<{
|
|||||||
countries: string[]
|
countries: string[]
|
||||||
continents: string[]
|
continents: string[]
|
||||||
flightClasses: number[]
|
flightClasses: number[]
|
||||||
|
crewTypes: number[]
|
||||||
}]
|
}]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ── Available options ─────────────────────────────────────────────────────────
|
// ── Available options ─────────────────────────────────────────────────────────
|
||||||
// flights is server-rendered and never mutated client-side, so we build options
|
|
||||||
// once as a plain value rather than a reactive computed.
|
|
||||||
|
|
||||||
function buildOptions(flights: Flight[]) {
|
function buildOptions(flights: Flight[]) {
|
||||||
const years = new Set<number>()
|
const years = new Set<number>()
|
||||||
@@ -27,6 +26,7 @@ function buildOptions(flights: Flight[]) {
|
|||||||
const countries = new Map<string, { code: string; name: string }>()
|
const countries = new Map<string, { code: string; name: string }>()
|
||||||
const continents = new Map<string, { code: string; name: string }>()
|
const continents = new Map<string, { code: string; name: string }>()
|
||||||
const classes = new Map<number, { id: number; name: string }>()
|
const classes = new Map<number, { id: number; name: string }>()
|
||||||
|
const crewTypes = new Map<number, { id: number; name: string }>()
|
||||||
|
|
||||||
flights.forEach(f => {
|
flights.forEach(f => {
|
||||||
years.add(new Date(f.departure_date).getFullYear())
|
years.add(new Date(f.departure_date).getFullYear())
|
||||||
@@ -44,6 +44,9 @@ function buildOptions(flights: Flight[]) {
|
|||||||
|
|
||||||
if (f.flight_class?.id && f.flight_class?.name)
|
if (f.flight_class?.id && f.flight_class?.name)
|
||||||
classes.set(f.flight_class.id, { id: f.flight_class.id, name: f.flight_class.name })
|
classes.set(f.flight_class.id, { id: f.flight_class.id, name: f.flight_class.name })
|
||||||
|
|
||||||
|
if (f.crew_type?.id && f.crew_type?.name)
|
||||||
|
crewTypes.set(f.crew_type.id, { id: f.crew_type.id, name: f.crew_type.name })
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -52,6 +55,7 @@ function buildOptions(flights: Flight[]) {
|
|||||||
countries: [...countries.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
countries: [...countries.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
continents: [...continents.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
continents: [...continents.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
classes: [...classes.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
classes: [...classes.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
crewTypes: [...crewTypes.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +67,7 @@ const selectedAirlines = ref<number[]>([])
|
|||||||
const selectedCountries = ref<string[]>([])
|
const selectedCountries = ref<string[]>([])
|
||||||
const selectedContinents = ref<string[]>([])
|
const selectedContinents = ref<string[]>([])
|
||||||
const selectedFlightClasses = ref<number[]>([])
|
const selectedFlightClasses = ref<number[]>([])
|
||||||
|
const selectedCrewTypes = ref<number[]>([])
|
||||||
|
|
||||||
function emitFilters() {
|
function emitFilters() {
|
||||||
emit('change', {
|
emit('change', {
|
||||||
@@ -71,6 +76,7 @@ function emitFilters() {
|
|||||||
countries: selectedCountries.value,
|
countries: selectedCountries.value,
|
||||||
continents: selectedContinents.value,
|
continents: selectedContinents.value,
|
||||||
flightClasses: selectedFlightClasses.value,
|
flightClasses: selectedFlightClasses.value,
|
||||||
|
crewTypes: selectedCrewTypes.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +202,24 @@ const countryFlagClass = (code: string) =>
|
|||||||
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightClasses.length - 2 }}</span>
|
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightClasses.length - 2 }}</span>
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-if="availableOptions.crewTypes.length > 0"
|
||||||
|
v-model="selectedCrewTypes"
|
||||||
|
:items="availableOptions.crewTypes"
|
||||||
|
item-title="name" item-value="id"
|
||||||
|
label="Crew Type"
|
||||||
|
multiple clearable hide-details
|
||||||
|
density="compact" variant="outlined"
|
||||||
|
@update:model-value="emitFilters"
|
||||||
|
>
|
||||||
|
<template #selection="{ item, index }">
|
||||||
|
<span v-if="index < 2" class="v-select__selection-text">
|
||||||
|
{{ (item as any).name }}<span v-if="index < Math.min(selectedCrewTypes.length, 2) - 1">, </span>
|
||||||
|
</span>
|
||||||
|
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCrewTypes.length - 2 }}</span>
|
||||||
|
</template>
|
||||||
|
</v-select>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -114,10 +114,10 @@ function routePopupHTML(historical: Flight[], future: Flight[]): string {
|
|||||||
const key = `${f.departure_airport.id}-${f.arrival_airport.id}`
|
const key = `${f.departure_airport.id}-${f.arrival_airport.id}`
|
||||||
const label = `${f.departure_airport.municipality} to ${f.arrival_airport.municipality}`
|
const label = `${f.departure_airport.municipality} to ${f.arrival_airport.municipality}`
|
||||||
if (!dirs.has(key)) dirs.set(key, { label, airlines: [] })
|
if (!dirs.has(key)) dirs.set(key, { label, airlines: [] })
|
||||||
const airline = `<span style="display:inline-flex;align-items:center;gap:6px;">
|
const airline = f.airline ? `<span style="display:inline-flex;align-items:center;gap:6px;">
|
||||||
<img src="${logoApiUrl}/airlines/logos/tail/id/${f.airline?.id}" width="24" height="24" alt="${f.airline?.IATA_code}" style="flex-shrink:0;" />
|
<img src="${logoApiUrl}/airlines/logos/tail/id/${f.airline?.id}" width="24" height="24" alt="${f.airline?.IATA_code}" style="flex-shrink:0;" />
|
||||||
${f.airline?.name}
|
${f.airline?.name}
|
||||||
</span>`
|
</span>` : ''
|
||||||
if (airline && !dirs.get(key)!.airlines.includes(airline)) {
|
if (airline && !dirs.get(key)!.airlines.includes(airline)) {
|
||||||
dirs.get(key)!.airlines.push(airline)
|
dirs.get(key)!.airlines.push(airline)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const emit = defineEmits<{
|
|||||||
countries: string[]
|
countries: string[]
|
||||||
continents: string[]
|
continents: string[]
|
||||||
flightClasses: number[]
|
flightClasses: number[]
|
||||||
|
crewTypes: number[]
|
||||||
}]
|
}]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {BadgeVariant} from "@/Types/types";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
variant?: 'first' | 'business' | 'premium' | 'economy' | 'private' | 'unspecified' | 'generic';
|
variant?: BadgeVariant
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturda
|
|||||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||||
|
|
||||||
// ── Date part cache ───────────────────────────────────────────────────────────
|
// ── Date part cache ───────────────────────────────────────────────────────────
|
||||||
// Intl.DateTimeFormat is expensive. We compute each flight's date parts once
|
|
||||||
// and cache them for the lifetime of the page.
|
|
||||||
|
|
||||||
const dateCache = new Map<number, { year: number; month: number; day: number }>()
|
const dateCache = new Map<number, { year: number; month: number; day: number }>()
|
||||||
|
|
||||||
@@ -318,6 +316,107 @@ export function getTopAirports(flights: Flight[], upcomingFlights: Flight[]) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Aircraft ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AircraftItem {
|
||||||
|
label: string
|
||||||
|
id: number
|
||||||
|
past: number
|
||||||
|
upcoming: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTopAircraft(flights: Flight[], upcomingFlights: Flight[]) {
|
||||||
|
console.time('getTopAircraft')
|
||||||
|
|
||||||
|
function countAircraft(list: Flight[]) {
|
||||||
|
const counts = new Map<string, { count: number; id: number }>()
|
||||||
|
list.forEach(f => {
|
||||||
|
const aircraft = f.aircraft
|
||||||
|
if (!aircraft?.designator) return
|
||||||
|
const existing = counts.get(aircraft.designator) ?? { count: 0, id: aircraft.id }
|
||||||
|
existing.count++
|
||||||
|
counts.set(aircraft.designator, existing)
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
const past = countAircraft(flights)
|
||||||
|
const upcoming = countAircraft(upcomingFlights)
|
||||||
|
const allNames = new Set([...past.keys(), ...upcoming.keys()])
|
||||||
|
|
||||||
|
const sorted: AircraftItem[] = [...allNames]
|
||||||
|
.map(name => ({
|
||||||
|
label: name,
|
||||||
|
id: (past.get(name) ?? upcoming.get(name))!.id,
|
||||||
|
past: past.get(name)?.count ?? 0,
|
||||||
|
upcoming: upcoming.get(name)?.count ?? 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
aircraft: sorted,
|
||||||
|
series: [
|
||||||
|
{ name: 'Flights', data: sorted.map(s => s.past) },
|
||||||
|
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
console.timeEnd('getTopAircraft')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RouteItem {
|
||||||
|
label: string
|
||||||
|
depLabel: string
|
||||||
|
arrLabel: string
|
||||||
|
past: number
|
||||||
|
upcoming: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
|
||||||
|
|
||||||
|
function countRoutes(list: Flight[]) {
|
||||||
|
const counts = new Map<string, { count: number; depLabel: string; arrLabel: string }>()
|
||||||
|
list.forEach(f => {
|
||||||
|
const dep = airportLabel(f.departure_airport)
|
||||||
|
const arr = airportLabel(f.arrival_airport)
|
||||||
|
if (!dep || !arr) return
|
||||||
|
const key = `${dep}-${arr}`
|
||||||
|
const existing = counts.get(key) ?? { count: 0, depLabel: dep, arrLabel: arr }
|
||||||
|
existing.count++
|
||||||
|
counts.set(key, existing)
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
const past = countRoutes(flights)
|
||||||
|
const upcoming = countRoutes(upcomingFlights)
|
||||||
|
const allKeys = new Set([...past.keys(), ...upcoming.keys()])
|
||||||
|
|
||||||
|
const sorted: RouteItem[] = [...allKeys]
|
||||||
|
.map(key => {
|
||||||
|
const meta = (past.get(key) ?? upcoming.get(key))!
|
||||||
|
return {
|
||||||
|
label: key,
|
||||||
|
depLabel: meta.depLabel,
|
||||||
|
arrLabel: meta.arrLabel,
|
||||||
|
past: past.get(key)?.count ?? 0,
|
||||||
|
upcoming: upcoming.get(key)?.count ?? 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
routes: sorted,
|
||||||
|
series: [
|
||||||
|
{ name: 'Flights', data: sorted.map(s => s.past) },
|
||||||
|
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// ── Flight types ──────────────────────────────────────────────────────────────
|
// ── Flight types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getFlightTypes(flights: Flight[]) {
|
export function getFlightTypes(flights: Flight[]) {
|
||||||
@@ -346,8 +445,6 @@ export type FlightStats = ReturnType<typeof useFlightStats>
|
|||||||
export function useFlightStats(flights: Ref<Flight[]>) {
|
export function useFlightStats(flights: Ref<Flight[]>) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
// Pre-warm the date cache as soon as flights are available so the first
|
|
||||||
// filter interaction doesn't pay the Intl.DateTimeFormat cost.
|
|
||||||
watch(flights, (list) => {
|
watch(flights, (list) => {
|
||||||
console.time('dateCache warm')
|
console.time('dateCache warm')
|
||||||
list.forEach(f => getDateParts(f))
|
list.forEach(f => getDateParts(f))
|
||||||
@@ -380,6 +477,8 @@ export function useFlightStats(flights: Ref<Flight[]>) {
|
|||||||
const continents = computed(() => getContinents(allFlights.value))
|
const continents = computed(() => getContinents(allFlights.value))
|
||||||
const topAirlines = computed(() => getTopAirlines(pastFlights.value, upcomingFlights.value))
|
const topAirlines = computed(() => getTopAirlines(pastFlights.value, upcomingFlights.value))
|
||||||
const topAirports = computed(() => getTopAirports(pastFlights.value, upcomingFlights.value))
|
const topAirports = computed(() => getTopAirports(pastFlights.value, upcomingFlights.value))
|
||||||
|
const topAircraft = computed(() => getTopAircraft(pastFlights.value, upcomingFlights.value))
|
||||||
|
const topRoutes = computed(() => getTopRoutes(pastFlights.value, upcomingFlights.value))
|
||||||
const flightTypes = computed(() => getFlightTypes(allFlights.value))
|
const flightTypes = computed(() => getFlightTypes(allFlights.value))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -396,5 +495,7 @@ export function useFlightStats(flights: Ref<Flight[]>) {
|
|||||||
flightTypes,
|
flightTypes,
|
||||||
topAirlines,
|
topAirlines,
|
||||||
topAirports,
|
topAirports,
|
||||||
|
topAircraft,
|
||||||
|
topRoutes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Head, useForm } from '@inertiajs/vue3'
|
|||||||
import AirlineSearchBox from '@/Components/FlightsGoneBy/AirlineSearchBox.vue'
|
import AirlineSearchBox from '@/Components/FlightsGoneBy/AirlineSearchBox.vue'
|
||||||
import AircraftSearchBox from '@/Components/FlightsGoneBy/AircraftSearchBox.vue'
|
import AircraftSearchBox from '@/Components/FlightsGoneBy/AircraftSearchBox.vue'
|
||||||
import AirportSearchBox from '@/Components/FlightsGoneBy/AirportSearchBox.vue'
|
import AirportSearchBox from '@/Components/FlightsGoneBy/AirportSearchBox.vue'
|
||||||
import type { SeatType, FlightReason, FlightClass} from '@/Types/types'
|
import type {SeatType, FlightReason, FlightClass, CrewType} from '@/Types/types'
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
|
|
||||||
defineOptions({ layout: MainLayout })
|
defineOptions({ layout: MainLayout })
|
||||||
@@ -24,6 +24,7 @@ const props = defineProps<{
|
|||||||
seat_type: SeatType | null
|
seat_type: SeatType | null
|
||||||
flight_class: FlightClass | null
|
flight_class: FlightClass | null
|
||||||
flight_reason: FlightReason | null
|
flight_reason: FlightReason | null
|
||||||
|
crew_type: CrewType | null
|
||||||
airline_options: { value: number; title: string }[]
|
airline_options: { value: number; title: string }[]
|
||||||
from_options: { value: number; title: string; country_code: string }[]
|
from_options: { value: number; title: string; country_code: string }[]
|
||||||
to_options: { value: number; title: string; country_code: string }[]
|
to_options: { value: number; title: string; country_code: string }[]
|
||||||
@@ -32,6 +33,7 @@ const props = defineProps<{
|
|||||||
seat_types: SeatType[]
|
seat_types: SeatType[]
|
||||||
flight_classes: FlightClass[]
|
flight_classes: FlightClass[]
|
||||||
flight_reasons: FlightReason[]
|
flight_reasons: FlightReason[]
|
||||||
|
crew_types: CrewType[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isEdit = !!props.flight
|
const isEdit = !!props.flight
|
||||||
@@ -111,10 +113,17 @@ const form = useForm({
|
|||||||
seat_type: props.flight?.seat_type ?? props.seat_types[0] ?? null as SeatType | null,
|
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_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,
|
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 ?? '',
|
note: props.flight?.note ?? '',
|
||||||
auto_update: props.flight?.auto_update ?? false,
|
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) ───────────────────────────
|
// ── Submit form (ID-based, what actually gets sent) ───────────────────────────
|
||||||
|
|
||||||
const submitForm = useForm({
|
const submitForm = useForm({
|
||||||
@@ -130,6 +139,7 @@ const submitForm = useForm({
|
|||||||
seat_type_id: null as number | null,
|
seat_type_id: null as number | null,
|
||||||
flight_class_id: null as number | null,
|
flight_class_id: null as number | null,
|
||||||
flight_reason_id: null as number | null,
|
flight_reason_id: null as number | null,
|
||||||
|
crew_type_id: null as number | null,
|
||||||
note: '' as string | null,
|
note: '' as string | null,
|
||||||
auto_update: false,
|
auto_update: false,
|
||||||
})
|
})
|
||||||
@@ -147,6 +157,7 @@ function submit() {
|
|||||||
submitForm.seat_type_id = form.seat_type?.id
|
submitForm.seat_type_id = form.seat_type?.id
|
||||||
submitForm.flight_class_id = form.flight_class?.id
|
submitForm.flight_class_id = form.flight_class?.id
|
||||||
submitForm.flight_reason_id = form.flight_reason?.id
|
submitForm.flight_reason_id = form.flight_reason?.id
|
||||||
|
submitForm.crew_type_id = form.crew_type?.id ?? null
|
||||||
submitForm.note = form.note
|
submitForm.note = form.note
|
||||||
submitForm.auto_update = form.auto_update
|
submitForm.auto_update = form.auto_update
|
||||||
|
|
||||||
@@ -306,6 +317,7 @@ const arrivalMax = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<!-- ── Registration + Flight class ────────────────────────── -->
|
<!-- ── Registration + Flight class ────────────────────────── -->
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
@@ -356,7 +368,7 @@ const arrivalMax = computed(() => {
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<!-- ── Flight reason ──────────────────────────────────────── -->
|
<!-- ── Flight reason + Crew type ──────────────────────────── -->
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
<v-select
|
<v-select
|
||||||
@@ -370,6 +382,18 @@ const arrivalMax = computed(() => {
|
|||||||
:error-messages="submitForm.errors.flight_reason_id"
|
:error-messages="submitForm.errors.flight_reason_id"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</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"
|
||||||
|
:disabled="!lookupComplete"
|
||||||
|
:error-messages="submitForm.errors.crew_type_id"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<!-- ── Note ──────────────────────────────────────────────── -->
|
<!-- ── Note ──────────────────────────────────────────────── -->
|
||||||
@@ -399,6 +423,7 @@ const arrivalMax = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<!-- ── Submit ─────────────────────────────────────────────── -->
|
<!-- ── Submit ─────────────────────────────────────────────── -->
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12">
|
<v-col cols="12">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MainLayout from "@/Layouts/MainLayout.vue";
|
import MainLayout from "@/Layouts/MainLayout.vue";
|
||||||
import { Head } from '@inertiajs/vue3'
|
import { Head } from '@inertiajs/vue3'
|
||||||
import {ref, computed, defineAsyncComponent, watchEffect, onMounted} from 'vue'
|
import {ref, computed, watchEffect} from 'vue'
|
||||||
import { Flight, ProfileView, User } from "@/Types/types"
|
import { Flight, ProfileView, User } from "@/Types/types"
|
||||||
import { router } from '@inertiajs/vue3'
|
import { router } from '@inertiajs/vue3'
|
||||||
import { useFlightStats, FlightStats } from "@/Composables/useFlightStats"
|
import { useFlightStats } from "@/Composables/useFlightStats"
|
||||||
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue"
|
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue"
|
||||||
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue"
|
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue"
|
||||||
import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue"
|
import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue"
|
||||||
@@ -29,6 +29,7 @@ const selectedAirlines = ref<number[]>([])
|
|||||||
const selectedCountries = ref<string[]>([])
|
const selectedCountries = ref<string[]>([])
|
||||||
const selectedContinents = ref<string[]>([])
|
const selectedContinents = ref<string[]>([])
|
||||||
const selectedFlightClasses = ref<number[]>([])
|
const selectedFlightClasses = ref<number[]>([])
|
||||||
|
const selectedCrewTypes = ref<number[]>([])
|
||||||
|
|
||||||
function onFiltersChange(filters: {
|
function onFiltersChange(filters: {
|
||||||
years: number[]
|
years: number[]
|
||||||
@@ -36,18 +37,17 @@ function onFiltersChange(filters: {
|
|||||||
countries: string[]
|
countries: string[]
|
||||||
continents: string[]
|
continents: string[]
|
||||||
flightClasses: number[]
|
flightClasses: number[]
|
||||||
|
crewTypes: number[]
|
||||||
}) {
|
}) {
|
||||||
localSelectedFlightId.value = null
|
localSelectedFlightId.value = null
|
||||||
console.time('filter+stats')
|
|
||||||
selectedYears.value = filters.years
|
selectedYears.value = filters.years
|
||||||
selectedAirlines.value = filters.airlines
|
selectedAirlines.value = filters.airlines
|
||||||
selectedCountries.value = filters.countries
|
selectedCountries.value = filters.countries
|
||||||
selectedContinents.value = filters.continents
|
selectedContinents.value = filters.continents
|
||||||
selectedFlightClasses.value = filters.flightClasses
|
selectedFlightClasses.value = filters.flightClasses
|
||||||
console.timeEnd('filter+stats')
|
selectedCrewTypes.value = filters.crewTypes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function matchesFilters(f: Flight): boolean {
|
function matchesFilters(f: Flight): boolean {
|
||||||
const date = new Date(f.departure_date)
|
const date = new Date(f.departure_date)
|
||||||
if (selectedYears.value.length && !selectedYears.value.includes(date.getFullYear())) return false
|
if (selectedYears.value.length && !selectedYears.value.includes(date.getFullYear())) return false
|
||||||
@@ -62,8 +62,10 @@ function matchesFilters(f: Flight): boolean {
|
|||||||
const arrCode = f.arrival_airport.region?.continent?.code
|
const arrCode = f.arrival_airport.region?.continent?.code
|
||||||
if (!selectedContinents.value.includes(depCode ?? '') && !selectedContinents.value.includes(arrCode ?? '')) return false
|
if (!selectedContinents.value.includes(depCode ?? '') && !selectedContinents.value.includes(arrCode ?? '')) return false
|
||||||
}
|
}
|
||||||
return !(selectedFlightClasses.value.length && !selectedFlightClasses.value.includes(f.flight_class?.id ?? -1));
|
if (selectedFlightClasses.value.length && !selectedFlightClasses.value.includes(f.flight_class?.id ?? -1)) return false
|
||||||
|
if (selectedCrewTypes.value.length && !selectedCrewTypes.value.includes(f.crew_type?.id ?? -1)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredFlights = computed(() => {
|
const filteredFlights = computed(() => {
|
||||||
@@ -77,7 +79,6 @@ const stats = useFlightStats(filteredFlights)
|
|||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
console.time('all.charts.input')
|
console.time('all.charts.input')
|
||||||
// just access each stat to trigger the log
|
|
||||||
const _ = [
|
const _ = [
|
||||||
stats.perYear.value,
|
stats.perYear.value,
|
||||||
stats.perMonth.value,
|
stats.perMonth.value,
|
||||||
|
|||||||
Vendored
+9
@@ -10,6 +10,7 @@ declare module '@vue/runtime-core' {
|
|||||||
|
|
||||||
export type ProfileView = 'map' | 'board' | 'passes';
|
export type ProfileView = 'map' | 'board' | 'passes';
|
||||||
export type ChartType = "line" | "area" | "bar" | "pie" | "donut" | "radialBar" | "scatter" | "bubble" | "heatmap" | "candlestick" | "boxPlot" | "radar" | "polarArea" | "rangeBar" | "rangeArea" | "treemap" | undefined
|
export type ChartType = "line" | "area" | "bar" | "pie" | "donut" | "radialBar" | "scatter" | "bubble" | "heatmap" | "candlestick" | "boxPlot" | "radar" | "polarArea" | "rangeBar" | "rangeArea" | "treemap" | undefined
|
||||||
|
export type BadgeVariant = 'first' | 'business' | 'premium' | 'economy' | 'private' | 'unspecified' | 'generic' | 'general_aviation' | 'crew'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number
|
id: number
|
||||||
@@ -118,6 +119,13 @@ export interface FlightReason {
|
|||||||
export interface FlightClass {
|
export interface FlightClass {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
internal_name: BadgeVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrewType {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
internal_name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Flight {
|
export interface Flight {
|
||||||
@@ -138,6 +146,7 @@ export interface Flight {
|
|||||||
airline: Airline | null
|
airline: Airline | null
|
||||||
aircraft: Aircraft | null
|
aircraft: Aircraft | null
|
||||||
seat_type: SeatType | null
|
seat_type: SeatType | null
|
||||||
|
crew_type: CrewType | null
|
||||||
flight_reason: FlightReason | null
|
flight_reason: FlightReason | null
|
||||||
flight_class: FlightClass | null
|
flight_class: FlightClass | null
|
||||||
duration_display: string
|
duration_display: string
|
||||||
|
|||||||
Reference in New Issue
Block a user