From a9aa65f0d2e6eae44e274a70291c7bbcc5b8d13b Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 12 Apr 2026 20:34:22 +1000 Subject: [PATCH] User can add/edit flights --- app/Http/Controllers/FlightController.php | 133 +++++++++++++++++- .../Controllers/FlightImportController.php | 11 ++ .../Controllers/FlightProfileController.php | 1 + app/Http/Controllers/SearchController.php | 1 + app/Models/User.php | 4 + app/Policies/UserFlightPolicy.php | 66 +++++++++ .../FlightsGoneBy/DepartureBoard.vue | 39 +++++ .../Components/FlightsGoneBy/MainHeader.vue | 4 +- resources/js/Pages/AddFlight.vue | 2 +- resources/js/Pages/Dashboard.vue | 9 +- resources/js/Pages/FlightProfile.vue | 3 +- routes/web.php | 13 +- 12 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 app/Policies/UserFlightPolicy.php diff --git a/app/Http/Controllers/FlightController.php b/app/Http/Controllers/FlightController.php index 9c972ff..942bb96 100644 --- a/app/Http/Controllers/FlightController.php +++ b/app/Http/Controllers/FlightController.php @@ -3,10 +3,39 @@ namespace App\Http\Controllers; use App\Models\Airline; +use App\Models\Airport; +use App\Models\FlightClass; +use App\Models\FlightReason; +use App\Models\SeatType; +use App\Models\UserFlight; +use Carbon\Carbon; use Illuminate\Http\Request; - +use Illuminate\Support\Facades\Auth; +use Inertia\Inertia; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; class FlightController extends Controller { + use AuthorizesRequests; + public function rules(): array + { + return [ + 'flight_number' => ['nullable', 'string', 'max:10'], + 'departure_date' => ['required', 'date'], + 'arrival_date' => ['required', 'date', 'after:departure_date'], + 'from_id' => ['required', 'integer', 'exists:airports,id'], + 'to_id' => ['required', 'integer', 'exists:airports,id'], + 'airline_id' => ['nullable', 'integer', 'exists:airlines,id'], + 'aircraft_id' => ['nullable', 'integer', 'exists:aircraft,id'], + 'aircraft_registration' => ['nullable', 'string', 'max:10'], + 'seat_number' => ['nullable', 'string', 'max:10'], + 'seat_type_id' => ['nullable', 'integer', 'exists:seat_types,id'], + 'flight_class_id' => ['nullable', 'integer', 'exists:flight_classes,id'], + 'flight_reason_id' => ['nullable', 'integer', 'exists:flight_reasons,id'], + 'note' => ['nullable', 'string', 'max:5000'], + 'auto_update' => ['boolean'], + ]; + } + public function lookup(Request $request) { $number = strtoupper(trim($request->query('number', ''))); @@ -28,4 +57,106 @@ class FlightController extends Controller 'aircraft_options' => [], ]); } + + private function convertedDates(array $validated): array + { + $departureAirport = Airport::find($validated['from_id']); + $arrivalAirport = Airport::find($validated['to_id']); + + return [ + Carbon::createFromFormat('Y-m-d\TH:i', $validated['departure_date'], $departureAirport->timezone)->utc(), + Carbon::createFromFormat('Y-m-d\TH:i', $validated['arrival_date'], $arrivalAirport->timezone)->utc(), + ]; + } + + private function flightPayload(array $validated): array + { + [$departureUtc, $arrivalUtc] = $this->convertedDates($validated); + + return [ + 'departure_date' => $departureUtc, + 'arrival_date' => $arrivalUtc, + 'flight_number' => $validated['flight_number'], + 'departure_airport_id' => $validated['from_id'], + 'arrival_airport_id' => $validated['to_id'], + 'airline_id' => $validated['airline_id'], + 'aircraft_id' => $validated['aircraft_id'], + 'aircraft_registration' => $validated['aircraft_registration'], + 'seat_number' => $validated['seat_number'], + 'seat_type_id' => $validated['seat_type_id'], + 'flight_class_id' => $validated['flight_class_id'], + 'flight_reason_id' => $validated['flight_reason_id'], + 'note' => $validated['note'], + 'auto_update' => $validated['auto_update'], + ]; + } + + + + public function store(Request $request) + { + $validated = $request->validate($this->rules()); + + auth()->user()->flights()->create($this->flightPayload($validated)); + + return redirect()->route('dashboard', Auth::user()->name); + } + + public function update(Request $request, UserFlight $flight) + { + $this->authorize('update', $flight); + + $validated = $request->validate($this->rules()); + + $flight->update($this->flightPayload($validated)); + + return redirect()->route('dashboard', Auth::user()->name); + } + + public function add(){ + return Inertia::render('AddFlight', [ + 'seat_types' => SeatType::all()->map(fn ($s) => ['value' => $s->id, 'title' => $s->name]), + 'flight_reasons' => FlightReason::all()->map(fn ($f) => ['value' => $f->id, 'title' => $f->name]), + 'flight_classes' => FlightClass::all()->map(fn ($f) => ['value' => $f->id, 'title' => $f->name]), + ]); + } + + public function edit(UserFlight $flight) + { + $this->authorize('update', $flight); + $flight->load('airline', 'aircraft', 'departureAirport.region.country', 'arrivalAirport.region.country', 'seatType', 'flightClass', 'flightReason'); + $flightData = [ + 'id' => $flight->id, + 'flight_number' => $flight->flight_number, + 'departure_date' => $flight->departure_date->setTimezone($flight->departureAirport->timezone)->format('Y-m-d\TH:i'), + 'arrival_date' => $flight->arrival_date->setTimezone($flight->arrivalAirport->timezone)->format('Y-m-d\TH:i'), + 'aircraft_registration' => $flight->aircraft_registration, + 'seat_number' => $flight->seat_number, + 'note' => $flight->note, + 'auto_update' => $flight->auto_update, + 'seat_type' => $flight->seatType + ? ['value' => $flight->seatType->id, 'title' => $flight->seatType->name] + : null, + 'flight_class' => $flight->flightClass + ? ['value' => $flight->flightClass->id, 'title' => $flight->flightClass->name] + : null, + 'flight_reason' => $flight->flightReason + ? ['value' => $flight->flightReason->id, 'title' => $flight->flightReason->name] + : null, + 'airline_options' => $flight->airline + ? [['value' => $flight->airline->id, 'title' => $flight->airline->name]] + : [], + 'from_options' => [['value' => $flight->departureAirport->id, 'title' => $flight->departureAirport->name, 'country_code' => strtolower($flight->departureAirport->region->country->code)]], + 'to_options' => [['value' => $flight->arrivalAirport->id, 'title' => $flight->arrivalAirport->name, 'country_code' => strtolower($flight->arrivalAirport->region->country->code)]], + 'aircraft_options' => $flight->aircraft + ? [['value' => $flight->aircraft->id, 'title' => $flight->aircraft->name]] + : [], + ]; + return Inertia::render('AddFlight', [ + 'flight' => $flightData, + 'seat_types' => SeatType::all()->map(fn($t) => ['value' => $t->id, 'title' => $t->name]), + 'flight_classes' => FlightClass::all()->map(fn($t) => ['value' => $t->id, 'title' => $t->name]), + 'flight_reasons' => FlightReason::all()->map(fn($t) => ['value' => $t->id, 'title' => $t->name]), + ]); + } } diff --git a/app/Http/Controllers/FlightImportController.php b/app/Http/Controllers/FlightImportController.php index ca3d5a1..a79f10f 100644 --- a/app/Http/Controllers/FlightImportController.php +++ b/app/Http/Controllers/FlightImportController.php @@ -38,11 +38,20 @@ class FlightImportController extends Controller preg_match('/\((\w+)\)/', $aircraftQuery, $matches); $designator = $matches[1] ?? null; + $sortOverrides = [ + 'B788' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END", + 'B789' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END", + ]; + if(!$designator){ $aircraft = []; } else { $aircraft = Aircraft::when($designator, fn($query) => $query->where('designator', 'ilike', $designator)) + ->when( + isset($sortOverrides[$designator]), + fn($q) => $q->orderByRaw($sortOverrides[$designator]) + ) ->orderBy('model_full_name') ->limit(10) ->get(['id', 'manufacturer_code', 'model_full_name', 'designator']) @@ -52,6 +61,7 @@ class FlightImportController extends Controller ]) ->values() ->toArray(); + } return $aircraft; @@ -80,6 +90,7 @@ class FlightImportController extends Controller END ", [$iata, $icao, $iata, $icao]) ->limit(10) + ->orderBy('id') ->get(['id', 'name', 'municipality', 'iata_code', 'icao_code', 'region_id']) ->map(fn($a) => [ 'value' => $a->id, diff --git a/app/Http/Controllers/FlightProfileController.php b/app/Http/Controllers/FlightProfileController.php index 01e82b1..2685db8 100644 --- a/app/Http/Controllers/FlightProfileController.php +++ b/app/Http/Controllers/FlightProfileController.php @@ -31,6 +31,7 @@ class FlightProfileController extends Controller return Inertia::render('FlightProfile', [ 'user' => $user, + 'canEdit' => auth()->check() && auth()->id() === $user->id, 'flights' => UserFlightResource::collection($flights)->resolve(), ]); } diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 64b0f5f..07ee160 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -34,6 +34,7 @@ class SearchController extends Controller return Aircraft::where('designator', 'ilike', "%{$q}%") ->orWhereRaw("CONCAT(manufacturer_code, ' ', model_full_name) ilike ?", ["%{$q}%"]) ->limit(200) + ->orderBy('id', 'asc') ->get(['id', 'manufacturer_code', 'model_full_name', 'designator']) ->map(fn($a) => [ 'value' => $a->id, diff --git a/app/Models/User.php b/app/Models/User.php index 71870ae..308c0ee 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -32,6 +32,10 @@ class User extends Authenticatable ]; } + public function flights(): HasMany { + return $this->hasMany(UserFlight::class); + } + public function ImportedFlights(): HasMany { return $this->hasMany(ImportedFlight::class); diff --git a/app/Policies/UserFlightPolicy.php b/app/Policies/UserFlightPolicy.php new file mode 100644 index 0000000..91eacb4 --- /dev/null +++ b/app/Policies/UserFlightPolicy.php @@ -0,0 +1,66 @@ +id === $userFlight->user_id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, UserFlight $userFlight): bool + { + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, UserFlight $userFlight): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, UserFlight $userFlight): bool + { + return false; + } +} diff --git a/resources/js/Components/FlightsGoneBy/DepartureBoard.vue b/resources/js/Components/FlightsGoneBy/DepartureBoard.vue index b86c0b2..35dc89a 100644 --- a/resources/js/Components/FlightsGoneBy/DepartureBoard.vue +++ b/resources/js/Components/FlightsGoneBy/DepartureBoard.vue @@ -11,6 +11,7 @@ import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue"; const props = defineProps<{ flights: Flight[] + canEdit: boolean }>() const headers = [ @@ -25,6 +26,7 @@ const headers = [ { title: 'DISTANCE', key: 'distance', sortable: true }, { title: 'AIRCRAFT', key: 'aircraft.designator', sortable: true }, { title: 'CLASS', key: 'flight_class', sortable: true }, + { title: '', key: 'actions', sortable: false }, ] const CLASS_ORDER: Record = { @@ -206,6 +208,30 @@ const tableItems = computed(() => + + + + + @@ -462,4 +488,17 @@ const tableItems = computed(() => letter-spacing: 0.2em; color: #445; } + +.actions-cell { + width: 40px; + padding: 0 0.5rem !important; + text-align: right; +} + +:deep(.v-list-item-title) { + font-family: 'Share Tech Mono', monospace !important; + font-size: 0.8rem !important; + letter-spacing: 0.08em !important; + color: #c8cdd8 !important; +} diff --git a/resources/js/Components/FlightsGoneBy/MainHeader.vue b/resources/js/Components/FlightsGoneBy/MainHeader.vue index dbc15e4..301cf59 100644 --- a/resources/js/Components/FlightsGoneBy/MainHeader.vue +++ b/resources/js/Components/FlightsGoneBy/MainHeader.vue @@ -18,7 +18,7 @@ const menuOpen = ref(false) Log In Register - Welcome, {{ props.auth.user.name }} + Welcome, {{ props.auth.user.name }} @@ -33,7 +33,7 @@ const menuOpen = ref(false) Log In Register - Welcome, {{ props.auth.user.name }} + Welcome, {{ props.auth.user.name }} diff --git a/resources/js/Pages/AddFlight.vue b/resources/js/Pages/AddFlight.vue index 0862c2b..78ad3b7 100644 --- a/resources/js/Pages/AddFlight.vue +++ b/resources/js/Pages/AddFlight.vue @@ -39,7 +39,7 @@ const isEdit = !!props.flight const flightNumber = ref(props.flight?.flight_number ?? '') const lookupLoading = ref(false) const lookupError = ref(null) -const lookupComplete = ref(isEdit) +const lookupComplete = ref(true) interface LookupResult { airline_options: { value: number; title: string }[] diff --git a/resources/js/Pages/Dashboard.vue b/resources/js/Pages/Dashboard.vue index c71339f..6a85bc4 100644 --- a/resources/js/Pages/Dashboard.vue +++ b/resources/js/Pages/Dashboard.vue @@ -19,15 +19,10 @@ const name = computed(() => page?.props?.auth?.user?.name || 'there'); - + Add a Flight - - - Edit Flights - - Import from FR24 @@ -38,7 +33,7 @@ const name = computed(() => page?.props?.auth?.user?.name || 'there'); View Profile - + Log Out diff --git a/resources/js/Pages/FlightProfile.vue b/resources/js/Pages/FlightProfile.vue index dc24e1c..e0bb2f5 100644 --- a/resources/js/Pages/FlightProfile.vue +++ b/resources/js/Pages/FlightProfile.vue @@ -18,6 +18,7 @@ defineProps<{ email: string } flights: Flight[] + canEdit: boolean }>() type View = 'board' | 'passes' | 'map' @@ -67,7 +68,7 @@ const activeView = ref('map') - + diff --git a/routes/web.php b/routes/web.php index 0da4694..4d13ae6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -37,13 +37,10 @@ Route::domain(config('app.domain'))->group( return Inertia::render('Fr24Import'); })->name('import.fr24'); - Route::get('/add-flight', function () { - return Inertia::render('AddFlight', [ - 'seat_types' => SeatType::all()->map(fn ($s) => ['value' => $s->id, 'title' => $s->name]), - 'flight_reasons' => FlightReason::all()->map(fn ($f) => ['value' => $f->id, 'title' => $f->name]), - 'flight_classes' => FlightClass::all()->map(fn ($f) => ['value' => $f->id, 'title' => $f->name]), - ]); - }); + Route::post('/flights', [FlightController::class, 'store'])->name('flights.store'); + Route::get('/flights/add', [FlightController::class, 'add'])->name('flights.add'); + Route::get('/flights/{flight}/edit', [FlightController::class, 'edit'])->name('flights.edit'); + Route::put('/flights/{flight}', [FlightController::class, 'update'])->name('flights.update'); Route::get('/reconcile', function () { $flight = new FlightImportController()->reconcile(request()); @@ -74,7 +71,7 @@ Route::domain(config('app.domain'))->group( Route::get('/search/airports', [SearchController::class, 'airports'])->name('search.airports'); - Route::get('/u/{username}', [FlightProfileController::class, 'view']); + Route::get('/u/{username}', [FlightProfileController::class, 'view'])->name('profile.view'); require __DIR__.'/auth.php';