From 877caa3291393b2b9b2a9d2fb68afb6e99840af9 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 3 Apr 2026 23:59:40 +1000 Subject: [PATCH] Added Imported Flights Table --- .../Controllers/FlightImportController.php | 192 ++++++++++++++++++ app/Http/Controllers/LogoController.php | 7 + app/Models/ImportedFlight.php | 35 ++++ app/Models/User.php | 7 + package-lock.json | 7 + package.json | 1 + resources/css/app.css | 5 +- .../FlightsGoneBy/AirlineSearchBox.vue | 69 +++++++ .../js/Components/FlightsGoneBy/GlassBox.vue | 7 +- resources/js/Pages/Fr24Import.vue | 92 ++++++++- resources/js/Pages/ReconcileFlight.vue | 165 +++++++++++++++ resources/js/app.ts | 1 + resources/js/plugins/vuetify.ts | 4 + resources/js/utils/helpers.ts | 3 + resources/views/app.blade.php | 2 +- routes/web.php | 46 ++++- 16 files changed, 622 insertions(+), 21 deletions(-) create mode 100644 app/Http/Controllers/FlightImportController.php create mode 100644 app/Models/ImportedFlight.php create mode 100644 resources/js/Components/FlightsGoneBy/AirlineSearchBox.vue create mode 100644 resources/js/Pages/ReconcileFlight.vue create mode 100644 resources/js/utils/helpers.ts diff --git a/app/Http/Controllers/FlightImportController.php b/app/Http/Controllers/FlightImportController.php new file mode 100644 index 0000000..d95812e --- /dev/null +++ b/app/Http/Controllers/FlightImportController.php @@ -0,0 +1,192 @@ + 'Please Select', + 1 => 'Economy', + 2 => 'Business', + 3 => 'First', + 4 => 'Premium Economy', + 5 => 'Private', + ]; + + const array SEAT_TYPES = [ + 0 => 'Please Select', + 1 => 'Window', + 2 => 'Middle', + 3 => 'Aisle', + ]; + + const array FLIGHT_REASONS = [ + 0 => 'Please Select', + 1 => 'Pleasure', + 2 => 'Business', + 3 => 'Crew', + 4 => 'Other', + ]; + + private function formatTime(?string $time): string + { + if (!$time) return '00:00'; + + [$hours, $minutes] = explode(':', $time); + return str_pad($hours, 2, '0', STR_PAD_LEFT) . ':' . $minutes; + } + + public function getPossibleAirlines(string $airlineQuery) { + preg_match('/\((\w{2,3})\/(\w{3,4})\)/', $airlineQuery, $matches); + $iata = $matches[1] ?? null; + $icao = $matches[2] ?? null; + + $airlines = Airline::when($iata || $icao, function ($query) use ($iata, $icao) { + $query->orderByRaw(" + CASE + WHEN \"IATA_code\" = ? AND \"ICAO_code\" = ? THEN 0 + WHEN \"IATA_code\" = ? THEN 1 + WHEN \"ICAO_code\" = ? THEN 2 + ELSE 3 + END + ", [$iata, $icao, $iata, $icao]) + ->where(function ($q) use ($iata, $icao) { + $q->where('IATA_code', $iata) + ->orWhere('ICAO_code', $icao); + }); + }) + ->orderByDesc('active') + ->limit(10) + ->get(['id', 'name', 'IATA_code', 'ICAO_code']) + ->map(fn($a) => [ + 'value' => $a->id, + 'title' => "{$a->name} ({$a->IATA_code}/{$a->ICAO_code})", + ]) + ->values() + ->toArray(); + + return [ + 'airline_options' => $airlines, + 'raw_airline' => $airlineQuery, + ]; + } + + public function reconcile(Request $request) + { + $user = Auth::user(); + + $flightToReconcile = ImportedFlight::where('user_id', $user->id)->orderBy('id')->first(); + + if (!$flightToReconcile) { + return null; + } + + $date = null; + if ($flightToReconcile->date) { + $date = Carbon::createFromFormat('m-d-y', $flightToReconcile->date)->format('Y-m-d'); + } + + + return [ + 'flight_classes' => collect(self::FLIGHT_CLASSES)->map(fn($title, $value) => [ + 'value' => $value, + 'title' => $title, + ])->values(), + 'flight_reasons' => collect(self::FLIGHT_REASONS)->map(fn($title, $value) => [ + 'value' => $value, + 'title' => $title, + ])->values(), + 'seat_types' => collect(self::SEAT_TYPES)->map(fn($title, $value) => [ + 'value' => $value, + 'title' => $title, + ])->values(), + + 'flight_number' => $flightToReconcile->flight_number ?? '', + 'date' => $date ?? '', + 'dep_time' => $this->formatTime($flightToReconcile->dep_time), + 'arr_time' => $this->formatTime($flightToReconcile->arr_time), + 'duration' => $this->formatTime($flightToReconcile->duration), + 'registration' => $flightToReconcile->registration ?? '', + 'note' => $flightToReconcile->note ?? '', + 'seat_number' => $flightToReconcile->seat_number ?? '', + 'flight_class' => $flightToReconcile->flight_class ?? '', + 'seat_type' => $flightToReconcile->seat_type ?? '', + 'flight_reason' => $flightToReconcile->flight_reason ?? '', + 'airline_options' => $this->getPossibleAirlines($flightToReconcile->airline ?? '')['airline_options'], + ]; + } + public function store(Request $request) + { + try { + $request->validate([ + 'csv' => ['required', 'file', 'mimes:csv,txt', 'max:10240'], + ]); + + $path = $request->file('csv')->getRealPath(); + $handle = fopen($path, 'r'); + + fgetcsv($handle); + + // Read and normalise header row + $headers = array_map( + fn($h) => strtolower(trim(str_replace(' ', '_', $h))), + fgetcsv($handle) + ); + + \Log::debug('CSV headers', $headers); + $firstRow = fgetcsv($handle); + \Log::debug('First row', $firstRow ?? ['empty']); + \Log::debug('Header count: ' . count($headers) . ', Row count: ' . count($firstRow ?? [])); + + $map = [ + 'date' => 'date', + 'flight_number' => 'flight_number', + 'from' => 'from', + 'to' => 'to', + 'dep_time' => 'dep_time', + 'arr_time' => 'arr_time', + 'duration' => 'duration', + 'airline' => 'airline', + 'aircraft' => 'aircraft', + 'registration' => 'registration', + 'seat_number' => 'seat_number', + 'seat_type' => 'seat_type', + 'flight_class' => 'flight_class', + 'flight_reason' => 'flight_reason', + 'note' => 'note', + ]; + + $userId = Auth::id(); + $imported = 0; + + while (($row = fgetcsv($handle)) !== false) { + if (count($row) !== count($headers)) { + continue; // skip malformed rows + } + + $raw = array_combine($headers, $row); + $data = ['user_id' => $userId]; + + foreach ($map as $csvKey => $column) { + $data[$column] = $raw[$csvKey] ?? null; + } + + ImportedFlight::create($data); + $imported++; + } + + fclose($handle); + + return response()->json(['imported' => $imported]); + } catch (\Exception $e) { + return response()->json(['message' => $e->getMessage()], 500); + } + } +} diff --git a/app/Http/Controllers/LogoController.php b/app/Http/Controllers/LogoController.php index 1a59cee..a04053a 100644 --- a/app/Http/Controllers/LogoController.php +++ b/app/Http/Controllers/LogoController.php @@ -21,6 +21,13 @@ class LogoController extends Controller ]); } + public function getLogoById(int $id){ + $airline = Airline::where('id', $id) + ->first(); + + return $this->getAirlineLogo($airline); + } + public function getLogoByCode(string $code){ $column = strlen($code) == 2 diff --git a/app/Models/ImportedFlight.php b/app/Models/ImportedFlight.php new file mode 100644 index 0000000..b3b0a7a --- /dev/null +++ b/app/Models/ImportedFlight.php @@ -0,0 +1,35 @@ +belongsTo(User::class); + } + +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..71870ae 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,8 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -29,4 +31,9 @@ class User extends Authenticatable 'password' => 'hashed', ]; } + + public function ImportedFlights(): HasMany + { + return $this->hasMany(ImportedFlight::class); + } } diff --git a/package-lock.json b/package-lock.json index 5035717..a09c094 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@mdi/font": "^7.4.47", "vuetify": "^4.0.5" }, "devDependencies": { @@ -174,6 +175,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mdi/font": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", + "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==", + "license": "Apache-2.0" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", diff --git a/package.json b/package.json index 1237ea5..2790603 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "vue": "^3.4.0" }, "dependencies": { + "@mdi/font": "^7.4.47", "vuetify": "^4.0.5" } } diff --git a/resources/css/app.css b/resources/css/app.css index efac4ef..e899ff5 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,6 +1,4 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; + :root { --bg: #0a0f1c; /* deep night sky */ @@ -12,6 +10,7 @@ --accent-soft: #0ea5e9; /* deeper blue */ --accent-glow: rgba(56, 189, 248, 0.15); --border: #1f2937; + } .glass { diff --git a/resources/js/Components/FlightsGoneBy/AirlineSearchBox.vue b/resources/js/Components/FlightsGoneBy/AirlineSearchBox.vue new file mode 100644 index 0000000..9dc0847 --- /dev/null +++ b/resources/js/Components/FlightsGoneBy/AirlineSearchBox.vue @@ -0,0 +1,69 @@ + + + diff --git a/resources/js/Components/FlightsGoneBy/GlassBox.vue b/resources/js/Components/FlightsGoneBy/GlassBox.vue index c5accaf..54facef 100644 --- a/resources/js/Components/FlightsGoneBy/GlassBox.vue +++ b/resources/js/Components/FlightsGoneBy/GlassBox.vue @@ -11,12 +11,9 @@ diff --git a/resources/js/Pages/Fr24Import.vue b/resources/js/Pages/Fr24Import.vue index 84a325d..2960255 100644 --- a/resources/js/Pages/Fr24Import.vue +++ b/resources/js/Pages/Fr24Import.vue @@ -1,24 +1,98 @@ - diff --git a/resources/js/app.ts b/resources/js/app.ts index 74d167a..6f7ddcc 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -6,6 +6,7 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { ZiggyVue } from '../../vendor/tightenco/ziggy'; import { createApp, h, DefineComponent } from 'vue'; import vuetify from './plugins/vuetify'; +import '@mdi/font/css/materialdesignicons.css' const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; diff --git a/resources/js/plugins/vuetify.ts b/resources/js/plugins/vuetify.ts index 7b52b3d..4fab586 100644 --- a/resources/js/plugins/vuetify.ts +++ b/resources/js/plugins/vuetify.ts @@ -1,8 +1,12 @@ import 'vuetify/styles' import { createVuetify } from 'vuetify' + export default createVuetify({ theme: { defaultTheme: 'dark', }, + icons: { + defaultSet: 'mdi', + }, }) diff --git a/resources/js/utils/helpers.ts b/resources/js/utils/helpers.ts new file mode 100644 index 0000000..c2ff395 --- /dev/null +++ b/resources/js/utils/helpers.ts @@ -0,0 +1,3 @@ +export function getCsrfToken(): string { + return (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? ''; +} diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 5ed39e2..9703d04 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -3,7 +3,7 @@ - + {{ config('app.name', 'Laravel') }} diff --git a/routes/web.php b/routes/web.php index 6e45116..0e3c7e7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,9 @@ group( return Inertia::render('Index'); }); - Route::get('/import/fr24', function () { - return Inertia::render('Fr24Import'); - }); Route::get('/dashboard', function () { return Inertia::render('Dashboard'); })->middleware(['auth', 'verified'])->name('dashboard'); Route::middleware('auth')->group(function () { + Route::get('/import/fr24', function () { + if (Auth::user()->importedFlights()->exists()) { + return redirect()->route('reconcile'); + } + return Inertia::render('Fr24Import'); + })->name('import.fr24'); + + Route::get('/reconcile', function () { + $flight = new FlightImportController()->reconcile(request()); + + if (!$flight) { + return redirect('/'); + } + + return Inertia::render('ReconcileFlight', [ + 'flight' => $flight, + ]); + })->name('reconcile'); + + + Route::get('/airlines/search', function () { + $q = request('q', ''); + + return Airline::orderByDesc('active') + ->where(function ($query) use ($q) { + $query->where('name', 'ilike', "%{$q}%") + ->orWhere('IATA_code', 'ilike', "%{$q}%") + ->orWhere('ICAO_code', 'ilike', "%{$q}%"); + }) + ->limit(15) + ->get(['id', 'name', 'IATA_code', 'ICAO_code', 'logo']) + ->map(fn($a) => [ + 'value' => $a->id, + 'title' => "{$a->name} ({$a->IATA_code}/{$a->ICAO_code})", + ]) + ->values(); + })->name('airlines.search'); + + Route::post('/flights/import', [FlightImportController::class, 'store'])->name('flights.import.store'); + Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); @@ -44,4 +83,5 @@ Route::domain(config('app.api_domain'))->group(function () { }); Route::get('airlines/logos/tail/{code}', [LogoController::class, 'getLogoByCode']) ->where('code', '[A-Za-z0-9]{2,3}'); + Route::get('airlines/logos/tail/id/{id}', [LogoController::class, 'getLogoById']); });