[8.x] feat: Filament Import Export (#2136)

* add migrations

* add setting to choose which importers to use

* bump filament

* enable database notifications

* add AirportImporter and AirportExporter

* move the untrash code to beforeUpdate

* fix validation rules

* add AircraftImporter and AircraftExporter

* fix phpstan

* add ExpenseImporter and ExpenseExporter

* add FareImporter and FareExporter

* add FlightImporter and FlightExporter

* add SubfleetImporter and SubfleetExporter

* change how relationships are processed in FlightImporter

* restore records if they are trashed after import

* phpstan and types

* fix packages

* remove foreign keys

* apply some of the fixes suggested by CodeRabbit

* fix docstring

* re add the fk

* bump filament
This commit is contained in:
Arthur Parienté 2026-01-09 23:10:53 +01:00 committed by GitHub
parent 8fdd34d2ea
commit acd38c9fa8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 2751 additions and 929 deletions

View File

@ -29,6 +29,8 @@ class ExportAction extends Action
$this->label('Export to CSV');
$this->visible(!config('phpvms.use_queued_filament_imports'));
$this->action(function (array $arguments): ?BinaryFileResponse {
if (!isset($arguments['resourceTitle']) || !$arguments['exportType']) {
$this->failure();

View File

@ -27,6 +27,8 @@ class ImportAction extends Action
$this->label('Import from CSV');
$this->visible(!config('phpvms.use_queued_filament_imports'));
$this->schema(function (array $arguments): array {
$schema = [
FileUpload::make('importFile')->acceptedFileTypes(['text/csv'])->disk('local')->directory('import'),

View File

@ -0,0 +1,51 @@
<?php
namespace App\Filament\Exports;
use App\Models\Aircraft;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Support\Number;
class AircraftExporter extends Exporter
{
protected static ?string $model = Aircraft::class;
public static function getColumns(): array
{
return [
ExportColumn::make('subfleet.type'),
ExportColumn::make('icao'),
ExportColumn::make('iata'),
ExportColumn::make('airport.id'),
ExportColumn::make('hub.id'),
ExportColumn::make('landing_time'),
ExportColumn::make('name'),
ExportColumn::make('registration'),
ExportColumn::make('fin'),
ExportColumn::make('hex_code'),
ExportColumn::make('selcal'),
ExportColumn::make('dow'),
ExportColumn::make('mtow'),
ExportColumn::make('mlw'),
ExportColumn::make('zfw'),
ExportColumn::make('simbrief_type'),
ExportColumn::make('fuel_onboard'),
ExportColumn::make('flight_time'),
ExportColumn::make('status'),
ExportColumn::make('state'),
];
}
public static function getCompletedNotificationBody(Export $export): string
{
$body = 'Your aircraft export has completed and '.Number::format($export->successful_rows).' '.str('row')->plural($export->successful_rows).' exported.';
if (($failedRowsCount = $export->getFailedRowsCount()) !== 0) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to export.';
}
return $body;
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Filament\Exports;
use App\Models\Airport;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Support\Number;
class AirportExporter extends Exporter
{
protected static ?string $model = Airport::class;
public static function getColumns(): array
{
return [
ExportColumn::make('iata'),
ExportColumn::make('icao'),
ExportColumn::make('name'),
ExportColumn::make('location'),
ExportColumn::make('region'),
ExportColumn::make('country'),
ExportColumn::make('timezone'),
ExportColumn::make('hub'),
ExportColumn::make('notes'),
ExportColumn::make('lat'),
ExportColumn::make('lon'),
ExportColumn::make('elevation'),
ExportColumn::make('ground_handling_cost'),
ExportColumn::make('fuel_100ll_cost'),
ExportColumn::make('fuel_jeta_cost'),
ExportColumn::make('fuel_mogas_cost'),
];
}
public static function getCompletedNotificationBody(Export $export): string
{
$body = 'Your airport export has completed and '.Number::format($export->successful_rows).' '.str('row')->plural($export->successful_rows).' exported.';
if (($failedRowsCount = $export->getFailedRowsCount()) !== 0) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to export.';
}
return $body;
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Filament\Exports;
use App\Models\Aircraft;
use App\Models\Airport;
use App\Models\Expense;
use App\Models\Subfleet;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Support\Number;
class ExpenseExporter extends Exporter
{
protected static ?string $model = Expense::class;
public static function getColumns(): array
{
return [
ExportColumn::make('airline.icao'),
ExportColumn::make('name'),
ExportColumn::make('amount'),
ExportColumn::make('type'),
ExportColumn::make('flight_type'),
ExportColumn::make('charge_to_user'),
ExportColumn::make('multiplier'),
ExportColumn::make('active'),
ExportColumn::make('ref_model_type')
->formatStateUsing(function (Expense $record): string {
return $record->ref_model ? $record->ref_model_type : '';
}),
ExportColumn::make('ref_model_id')
->formatStateUsing(function (Expense $record): string {
if ($record->ref_model instanceof Aircraft) {
return $record->ref_model->registration;
}
if ($record->ref_model instanceof Airport) {
return $record->ref_model->icao;
}
if ($record->ref_model instanceof Subfleet) {
return $record->ref_model->type;
}
return '';
}),
];
}
public static function getCompletedNotificationBody(Export $export): string
{
$body = 'Your expense export has completed and '.Number::format($export->successful_rows).' '.str('row')->plural($export->successful_rows).' exported.';
if (($failedRowsCount = $export->getFailedRowsCount()) !== 0) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to export.';
}
return $body;
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Filament\Exports;
use App\Models\Fare;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Support\Number;
class FareExporter extends Exporter
{
protected static ?string $model = Fare::class;
public static function getColumns(): array
{
return [
ExportColumn::make('code'),
ExportColumn::make('name'),
ExportColumn::make('price'),
ExportColumn::make('cost'),
ExportColumn::make('capacity'),
ExportColumn::make('type'),
ExportColumn::make('notes'),
ExportColumn::make('active'),
];
}
public static function getCompletedNotificationBody(Export $export): string
{
$body = 'Your fare export has completed and '.Number::format($export->successful_rows).' '.str('row')->plural($export->successful_rows).' exported.';
if (($failedRowsCount = $export->getFailedRowsCount()) !== 0) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to export.';
}
return $body;
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Filament\Exports;
use App\Models\Enums\Days;
use App\Models\Flight;
use App\Support\Utils;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Support\Number;
class FlightExporter extends Exporter
{
protected static ?string $model = Flight::class;
public static function getColumns(): array
{
return [
ExportColumn::make('airline.icao'),
ExportColumn::make('flight_number'),
ExportColumn::make('callsign'),
ExportColumn::make('route_code'),
ExportColumn::make('route_leg'),
ExportColumn::make('dpt_airport.icao'),
ExportColumn::make('arr_airport.icao'),
ExportColumn::make('alt_airport.icao'),
ExportColumn::make('dpt_time'),
ExportColumn::make('arr_time'),
ExportColumn::make('level'),
ExportColumn::make('distance'),
ExportColumn::make('flight_time'),
ExportColumn::make('flight_type'),
ExportColumn::make('load_factor'),
ExportColumn::make('load_factor_variance'),
ExportColumn::make('route'),
ExportColumn::make('pilot_pay'),
ExportColumn::make('notes'),
ExportColumn::make('days')
->formatStateUsing(fn (Flight $record): string => self::getDays($record)),
ExportColumn::make('start_date'),
ExportColumn::make('end_date'),
ExportColumn::make('active'),
ExportColumn::make('event.id'),
ExportColumn::make('user.id'),
ExportColumn::make('owner_type'),
ExportColumn::make('owner_id'),
ExportColumn::make('subfleets')
->formatStateUsing(fn (Flight $record): string => self::getSubfleets($record)),
ExportColumn::make('fares')
->formatStateUsing(fn (Flight $record): string => self::getFares($record)),
ExportColumn::make('fields')
->formatStateUsing(fn (Flight $record): string => self::getFields($record)),
];
}
public static function getCompletedNotificationBody(Export $export): string
{
$body = 'Your flight export has completed and '.Number::format($export->successful_rows).' '.str('row')->plural($export->successful_rows).' exported.';
if (($failedRowsCount = $export->getFailedRowsCount()) !== 0) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to export.';
}
return $body;
}
/**
* Return the days string
*
*
* @return string
*/
private static function getDays(Flight $flight)
{
$days_str = '';
if ($flight->on_day(Days::MONDAY)) {
$days_str .= '1';
}
if ($flight->on_day(Days::TUESDAY)) {
$days_str .= '2';
}
if ($flight->on_day(Days::WEDNESDAY)) {
$days_str .= '3';
}
if ($flight->on_day(Days::THURSDAY)) {
$days_str .= '4';
}
if ($flight->on_day(Days::FRIDAY)) {
$days_str .= '5';
}
if ($flight->on_day(Days::SATURDAY)) {
$days_str .= '6';
}
if ($flight->on_day(Days::SUNDAY)) {
$days_str .= '7';
}
return $days_str;
}
/**
* Return any custom fares that have been made to this flight
*/
private static function getFares(Flight $flight): string
{
$fares = [];
foreach ($flight->fares as $fare) {
$fare_export = [];
if ($fare->pivot->price) {
$fare_export['price'] = $fare->pivot->price;
}
if ($fare->pivot->cost) {
$fare_export['cost'] = $fare->pivot->cost;
}
if ($fare->pivot->capacity) {
$fare_export['capacity'] = $fare->pivot->capacity;
}
$fares[$fare->code] = $fare_export;
}
return Utils::objectToMultiString($fares);
}
/**
* Parse all of the subfields
*/
private static function getFields(Flight $flight): string
{
$ret = [];
foreach ($flight->field_values as $field) {
$ret[$field->name] = $field->value;
}
return Utils::objectToMultiString($ret);
}
/**
* Create the list of subfleets that are associated here
*/
private static function getSubfleets(Flight $flight): string
{
$subfleets = [];
foreach ($flight->subfleets as $subfleet) {
$subfleets[] = $subfleet->type;
}
return Utils::objectToMultiString($subfleets);
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Filament\Exports;
use App\Models\Subfleet;
use App\Support\Utils;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Support\Number;
class SubfleetExporter extends Exporter
{
protected static ?string $model = Subfleet::class;
public static function getColumns(): array
{
return [
ExportColumn::make('airline.icao'),
ExportColumn::make('hub.name'),
ExportColumn::make('type'),
ExportColumn::make('simbrief_type'),
ExportColumn::make('name'),
ExportColumn::make('cost_block_hour'),
ExportColumn::make('cost_delay_minute'),
ExportColumn::make('fuel_type'),
ExportColumn::make('ground_handling_multiplier'),
ExportColumn::make('cargo_capacity'),
ExportColumn::make('fuel_capacity'),
ExportColumn::make('gross_weight'),
ExportColumn::make('fares')->formatStateUsing(fn (Subfleet $record): string => self::getFares($record)),
ExportColumn::make('ranks')->formatStateUsing(fn (Subfleet $record): string => self::getRanks($record)),
];
}
public static function getCompletedNotificationBody(Export $export): string
{
$body = 'Your subfleet export has completed and '.Number::format($export->successful_rows).' '.str('row')->plural($export->successful_rows).' exported.';
if (($failedRowsCount = $export->getFailedRowsCount()) !== 0) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to export.';
}
return $body;
}
/**
* Return any custom fares that have been made to this subfleet
*/
private static function getFares(Subfleet $subfleet): string
{
$fares = [];
foreach ($subfleet->fares as $fare) {
$fare_export = [];
if ($fare->pivot->price !== null) {
$fare_export['price'] = $fare->pivot->price;
}
if ($fare->pivot->cost !== null) {
$fare_export['cost'] = $fare->pivot->cost;
}
if ($fare->pivot->capacity !== null) {
$fare_export['capacity'] = $fare->pivot->capacity;
}
$fares[$fare->code] = $fare_export;
}
return Utils::objectToMultiString($fares);
}
/**
* Return any ranks that have been linked to this subfleet
*/
private static function getRanks(Subfleet $subfleet): string
{
$ranks = [];
foreach ($subfleet->ranks as $rank) {
$rank_export = [];
if ($rank->pivot->acars_pay !== null) {
$rank_export['acars_pay'] = $rank->pivot->acars_pay;
}
if ($rank->pivot->manual_pay !== null) {
$rank_export['manual_pay'] = $rank->pivot->manual_pay;
}
$ranks[$rank->id] = $rank_export;
}
return Utils::objectToMultiString($ranks);
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace App\Filament\Imports;
use App\Models\Aircraft;
use App\Models\Enums\AircraftState;
use App\Support\ICAO;
use App\Support\Units\Mass;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
use Illuminate\Support\Number;
/**
* @property Aircraft $record
*/
class AircraftImporter extends Importer
{
protected static ?string $model = Aircraft::class;
public static function getColumns(): array
{
return [
ImportColumn::make('subfleet')
->requiredMapping()
->relationship(resolveUsing: 'type')
->rules(['required']),
ImportColumn::make('icao')
->rules(['max:4']),
ImportColumn::make('iata')
->rules(['max:4']),
ImportColumn::make('airport')
->guess(['airport_id'])
->relationship(),
ImportColumn::make('hub')
->guess(['hub_id'])
->relationship(),
ImportColumn::make('landing_time')
->rules(['nullable', 'datetime']),
ImportColumn::make('name')
->requiredMapping()
->rules(['required']),
ImportColumn::make('registration'),
ImportColumn::make('fin'),
ImportColumn::make('hex_code'),
ImportColumn::make('selcal'),
ImportColumn::make('dow')
->fillRecordUsing(function (Aircraft $record, ?int $state): void {
$record->dow = $state > 0 ? Mass::make((float) $state, setting('units.weight')) : null;
})
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('mtow')
->fillRecordUsing(function (Aircraft $record, ?int $state): void {
$record->mtow = $state > 0 ? Mass::make((float) $state, setting('units.weight')) : null;
})
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('mlw')
->fillRecordUsing(function (Aircraft $record, ?int $state): void {
$record->mlw = $state > 0 ? Mass::make((float) $state, setting('units.weight')) : null;
})
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('zfw')
->fillRecordUsing(function (Aircraft $record, ?int $state): void {
$record->zfw = $state > 0 ? Mass::make((float) $state, setting('units.weight')) : null;
})
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('simbrief_type'),
ImportColumn::make('fuel_onboard')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('flight_time')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('status')
->requiredMapping()
->rules(['required']),
ImportColumn::make('state')
->numeric()
->rules(['required', 'integer']),
];
}
public function resolveRecord(): Aircraft
{
return Aircraft::withTrashed()->firstOrNew([
'registration' => $this->data['registration'],
]);
}
protected function beforeSave(): void
{
if (!$this->record->hex_code) {
$this->record->hex_code = ICAO::createHexCode();
}
if (!array_key_exists('state', $this->data) || $this->data['state'] === null) {
$this->record->state = AircraftState::PARKED;
}
}
protected function beforeUpdate(): void
{
if ($this->record->trashed()) {
$this->record->restore();
}
}
public static function getCompletedNotificationBody(Import $import): string
{
$body = 'Your aircraft import has completed and '.Number::format($import->successful_rows).' '.str('row')->plural($import->successful_rows).' imported.';
if (($failedRowsCount = $import->getFailedRowsCount()) !== 0) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to import.';
}
return $body;
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Filament\Imports;
use App\Models\Airport;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
use Illuminate\Support\Number;
use Illuminate\Validation\Rule;
use League\ISO3166\ISO3166;
/**
* @property Airport $record
*/
class AirportImporter extends Importer
{
protected static ?string $model = Airport::class;
public static function getColumns(): array
{
return [
ImportColumn::make('iata'),
ImportColumn::make('icao')
->fillRecordUsing(function (Airport $record, string $state): void {
$record->id = $state;
$record->icao = $state;
})
->requiredMapping()
->rules(['required', 'max:4']),
ImportColumn::make('name')
->requiredMapping()
->rules(['required']),
ImportColumn::make('location'),
ImportColumn::make('region'),
ImportColumn::make('country')
->fillRecordUsing(function (Airport $record, string $state): void {
$record->country = strtolower($state);
})
->rules([Rule::in(array_column((new ISO3166())->all(), 'alpha2'))]),
ImportColumn::make('timezone'),
ImportColumn::make('hub')
->requiredMapping()
->boolean()
->rules(['required', 'boolean']),
ImportColumn::make('notes'),
ImportColumn::make('lat')
->numeric(decimalPlaces: 5)
->rules(['numeric']),
ImportColumn::make('lon')
->numeric(decimalPlaces: 5)
->rules(['numeric']),
ImportColumn::make('elevation')
->integer()
->rules(['integer', 'nullable']),
ImportColumn::make('ground_handling_cost')
->numeric()
->rules(['numeric', 'nullable']),
ImportColumn::make('fuel_100ll_cost')
->numeric()
->rules(['numeric', 'nullable']),
ImportColumn::make('fuel_jeta_cost')
->numeric()
->rules(['numeric', 'nullable']),
ImportColumn::make('fuel_mogas_cost')
->numeric()
->rules(['numeric', 'nullable']),
];
}
public function resolveRecord(): Airport
{
return Airport::withTrashed()->firstOrNew([
'id' => $this->data['icao'],
]);
}
protected function beforeUpdate(): void
{
if ($this->record->trashed()) {
$this->record->restore();
}
}
public static function getCompletedNotificationBody(Import $import): string
{
$body = 'Your airport import has completed and '.Number::format($import->successful_rows).' '.str('row')->plural($import->successful_rows).' imported.';
if (($failedRowsCount = $import->getFailedRowsCount()) !== 0) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to import.';
}
return $body;
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Filament\Imports;
use App\Models\Aircraft;
use App\Models\Airport;
use App\Models\Expense;
use App\Models\Subfleet;
use Filament\Actions\Imports\Exceptions\RowImportFailedException;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Number;
class ExpenseImporter extends Importer
{
protected static ?string $model = Expense::class;
public static function getColumns(): array
{
return [
ImportColumn::make('airline')
->relationship(resolveUsing: 'icao'),
ImportColumn::make('name')
->requiredMapping()
->rules(['required']),
ImportColumn::make('amount')
->requiredMapping()
->numeric()
->rules(['required', 'integer']),
ImportColumn::make('type')
->requiredMapping()
->rules(['required']),
ImportColumn::make('flight_type'),
ImportColumn::make('charge_to_user')
->boolean()
->rules(['boolean']),
ImportColumn::make('multiplier')
->boolean()
->rules(['boolean']),
ImportColumn::make('active')
->boolean()
->rules(['boolean']),
ImportColumn::make('ref_model_type')
->guess(['ref_model']),
ImportColumn::make('ref_model_id'),
];
}
protected function beforeFill(): void
{
if (!array_key_exists('ref_model_type', $this->data) || $this->data['ref_model_type'] == '') {
$this->data['ref_model_type'] = Expense::class;
return;
}
if (!str_contains($this->data['ref_model_type'], 'App\Models\\')) {
$this->data['ref_model_type'] = 'App\Models\\'.$this->data['ref_model_type'];
}
$class = $this->data['ref_model_type'];
$id = $this->data['ref_model_id'];
if ($class === Aircraft::class) {
Log::info('Trying to import expense on aircraft, registration: '.$id);
if (is_numeric($id)) {
$obj = Aircraft::where('id', $id)->first();
} else {
$obj = Aircraft::where('registration', $id)->first();
}
} elseif ($class === Airport::class) {
Log::info('Trying to import expense on airport, icao: '.$id);
$obj = Airport::where('icao', $id)->first();
} elseif ($class === Subfleet::class) {
Log::info('Trying to import expense on subfleet, type: '.$id);
if (is_numeric($id)) {
$obj = Subfleet::where('id', $id)->first();
} else {
$obj = Subfleet::where('type', $id)->first();
}
} else {
throw new RowImportFailedException('Unknown ref_model_type: '.$this->data['ref_model_type']);
}
if (!$obj) {
throw new RowImportFailedException('Could not find '.$this->data['ref_model_type'].' with id '.$id);
}
$this->data['ref_model_id'] = $obj->id;
}
public function resolveRecord(): Expense
{
return Expense::firstOrNew([
'name' => $this->data['name'],
]);
}
public static function getCompletedNotificationBody(Import $import): string
{
$body = 'Your expense import has completed and '.Number::format($import->successful_rows).' '.str('row')->plural($import->successful_rows).' imported.';
if ($failedRowsCount = $import->getFailedRowsCount()) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to import.';
}
return $body;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Filament\Imports;
use App\Models\Fare;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
use Illuminate\Support\Number;
/**
* @property Fare $record
*/
class FareImporter extends Importer
{
protected static ?string $model = Fare::class;
public static function getColumns(): array
{
return [
ImportColumn::make('code')
->requiredMapping()
->rules(['required']),
ImportColumn::make('name')
->requiredMapping()
->rules(['required']),
ImportColumn::make('price')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('cost')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('capacity')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('type')
->requiredMapping()
->numeric()
->rules(['required', 'integer']),
ImportColumn::make('notes'),
ImportColumn::make('active')
->requiredMapping()
->boolean()
->rules(['required', 'boolean']),
];
}
public function resolveRecord(): Fare
{
return Fare::withTrashed()->firstOrNew([
'code' => $this->data['code'],
]);
}
protected function beforeUpdate(): void
{
if ($this->record->trashed()) {
$this->record->restore();
}
}
public static function getCompletedNotificationBody(Import $import): string
{
$body = 'Your fare import has completed and '.Number::format($import->successful_rows).' '.str('row')->plural($import->successful_rows).' imported.';
if (($failedRowsCount = $import->getFailedRowsCount()) !== 0) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to import.';
}
return $body;
}
}

View File

@ -0,0 +1,310 @@
<?php
namespace App\Filament\Imports;
use App\Models\Airport;
use App\Models\Enums\Days;
use App\Models\Fare;
use App\Models\Flight;
use App\Models\Subfleet;
use App\Services\AirportService;
use App\Services\FareService;
use App\Services\FlightService;
use App\Support\Utils;
use Filament\Actions\Imports\Exceptions\RowImportFailedException;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Number;
/**
* @property Flight $record
*/
class FlightImporter extends Importer
{
protected static ?string $model = Flight::class;
public static function getColumns(): array
{
return [
ImportColumn::make('airline')
->requiredMapping()
->relationship(resolveUsing: 'icao')
->rules(['required']),
ImportColumn::make('flight_number')
->requiredMapping()
->numeric()
->rules(['required', 'integer']),
ImportColumn::make('callsign')
->rules(['max:4']),
ImportColumn::make('route_code')
->rules(['max:5']),
ImportColumn::make('route_leg')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('dpt_airport')
->requiredMapping()
->fillRecordUsing(function (Flight $record, string $state): void {
$record->dpt_airport_id = self::processAirport($state)->id;
})
->rules(['required']),
ImportColumn::make('arr_airport')
->requiredMapping()
->fillRecordUsing(function (Flight $record, string $state): void {
$record->arr_airport_id = self::processAirport($state)->id;
})
->rules(['required']),
ImportColumn::make('alt_airport')
->fillRecordUsing(function (Flight $record, ?string $state): void {
if ($state) {
$record->alt_airport_id = self::processAirport($state)->id;
}
}),
ImportColumn::make('dpt_time')
->rules(['max:10']),
ImportColumn::make('arr_time')
->rules(['max:10']),
ImportColumn::make('level')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('distance')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('flight_time')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('flight_type')
->requiredMapping()
->rules(['required', 'max:1']),
ImportColumn::make('load_factor')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('load_factor_variance')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('route')
->fillRecordUsing(function (Flight $record, ?string $state): void {
$record->route = strtoupper($state);
}),
ImportColumn::make('pilot_pay')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('notes'),
ImportColumn::make('days')
->fillRecordUsing(function (Flight $record, ?string $state) {
if ($state) {
$record->days = self::setDays($state);
}
}),
ImportColumn::make('start_date')
->rules(['nullable', 'date']),
ImportColumn::make('end_date')
->rules(['nullable', 'date']),
ImportColumn::make('active')
->requiredMapping()
->boolean()
->rules(['required', 'boolean']),
ImportColumn::make('event')
->relationship(),
ImportColumn::make('user')
->relationship(),
ImportColumn::make('owner_type')
->rules(['max:191']),
ImportColumn::make('owner_id')
->rules(['max:36']),
ImportColumn::make('subfleets')
->fillRecordUsing(function (): void {}),
ImportColumn::make('fares')
->fillRecordUsing(function (): void {}),
ImportColumn::make('fields')
->fillRecordUsing(function (): void {}),
];
}
public function resolveRecord(): Flight
{
return Flight::withTrashed()->firstOrNew([
'flight_number' => $this->data['flight_number'],
'dpt_airport_id' => $this->data['dpt_airport'],
'arr_airport_id' => $this->data['arr_airport'],
'route_code' => $this->data['route_code'],
'route_leg' => $this->data['route_leg'],
'days' => self::setDays($this->data['days']),
], [
'id' => Utils::generateNewId(),
]);
}
public static function getCompletedNotificationBody(Import $import): string
{
$body = 'Your flight import has completed and '.Number::format($import->successful_rows).' '.str('row')->plural($import->successful_rows).' imported.';
if ($failedRowsCount = $import->getFailedRowsCount()) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to import.';
}
return $body;
}
protected function beforeUpdate(): void
{
if ($this->record->trashed()) {
$this->record->restore();
}
}
protected function afterSave(): void
{
if (array_key_exists('subfleets', $this->data) && $this->data['subfleets'] !== '') {
$this->processSubfleets($this->record, $this->data['subfleets']);
}
if (array_key_exists('fares', $this->data) && $this->data['fares'] !== '') {
$this->processFares($this->record, $this->data['fares']);
}
if (array_key_exists('fields', $this->data) && $this->data['fields'] !== '') {
$this->processFields($this->record, $this->data['fields']);
}
}
private static function setDays(string $state): int
{
if (!$state) {
return 0;
}
$days = [];
if (str_contains($state, '1')) {
$days[] = Days::MONDAY;
}
if (str_contains($state, '2')) {
$days[] = Days::TUESDAY;
}
if (str_contains($state, '3')) {
$days[] = Days::WEDNESDAY;
}
if (str_contains($state, '4')) {
$days[] = Days::THURSDAY;
}
if (str_contains($state, '5')) {
$days[] = Days::FRIDAY;
}
if (str_contains($state, '6')) {
$days[] = Days::SATURDAY;
}
if (str_contains($state, '7')) {
$days[] = Days::SUNDAY;
}
return Days::getDaysMask($days);
}
private function processSubfleets(Flight $flight, $col): void
{
$count = 0;
$subfleets = Utils::parseMultiColumnValues($col);
foreach ($subfleets as $subfleet_type) {
$subfleet_type = trim($subfleet_type);
if ($subfleet_type === '' || $subfleet_type === '0') {
continue;
}
$subfleet = Subfleet::firstOrCreate(
['type' => $subfleet_type],
[
'name' => $subfleet_type,
'airline_id' => $flight->airline_id,
]
);
$subfleet->save();
// sync
$flight->subfleets()->syncWithoutDetaching([$subfleet->id]);
$count++;
}
Log::info('Subfleets added/processed: '.$count);
}
private function processFares(Flight $flight, ?string $state): void
{
$fares = Utils::parseMultiColumnValues($state);
foreach ($fares as $fare_code => $fare_attributes) {
if (\is_int($fare_code)) {
$fare_code = $fare_attributes;
$fare_attributes = [];
}
$fare = Fare::firstOrCreate(['code' => $fare_code], ['name' => $fare_code]);
app(FareService::class)->setForFlight($flight, $fare, $fare_attributes);
$fare->save();
}
}
/**
* Parse all of the subfields
*/
private function processFields(Flight $flight, ?string $col): void
{
$pass_fields = [];
$fields = Utils::parseMultiColumnValues($col);
foreach ($fields as $field_name => $field_value) {
$pass_fields[] = [
'name' => $field_name,
'value' => $field_value,
];
}
app(FlightService::class)->updateCustomFields($flight, $pass_fields);
}
private static function processAirport(string $id): Airport
{
$airport = app(AirportService::class)->lookupAirportIfNotFound($id);
if (!$airport) {
throw new RowImportFailedException('Could not find airport '.$id);
}
return $airport;
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace App\Filament\Imports;
use App\Models\Fare;
use App\Models\Rank;
use App\Models\Subfleet;
use App\Services\FareService;
use App\Services\FleetService;
use App\Support\Utils;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
use Illuminate\Support\Number;
/**
* @property Subfleet $record
*/
class SubfleetImporter extends Importer
{
protected static ?string $model = Subfleet::class;
public static function getColumns(): array
{
return [
ImportColumn::make('airline')
->relationship(resolveUsing: 'icao'),
ImportColumn::make('hub')
->relationship(),
ImportColumn::make('type')
->requiredMapping()
->rules(['required']),
ImportColumn::make('simbrief_type'),
ImportColumn::make('name')
->requiredMapping()
->rules(['required']),
ImportColumn::make('cost_block_hour')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('cost_delay_minute')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('fuel_type')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('ground_handling_multiplier')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('cargo_capacity')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('fuel_capacity')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('gross_weight')
->numeric()
->rules(['nullable', 'integer']),
ImportColumn::make('fares')
->fillRecordUsing(function (): void {}),
ImportColumn::make('ranks')
->fillRecordUsing(function (): void {}),
];
}
protected function beforeUpdate(): void
{
if ($this->record->trashed()) {
$this->record->restore();
}
}
protected function afterSave(): void
{
if (array_key_exists('fares', $this->data) && $this->data['fares'] !== '') {
$this->processFares($this->record, $this->data['fares']);
}
if (array_key_exists('ranks', $this->data) && $this->data['ranks'] !== '') {
$this->processRanks($this->record, $this->data['ranks']);
}
}
public function resolveRecord(): Subfleet
{
return Subfleet::withTrashed()->firstOrNew([
'type' => $this->data['type'],
]);
}
public static function getCompletedNotificationBody(Import $import): string
{
$body = 'Your subfleet import has completed and '.Number::format($import->successful_rows).' '.str('row')->plural($import->successful_rows).' imported.';
if (($failedRowsCount = $import->getFailedRowsCount()) !== 0) {
$body .= ' '.Number::format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to import.';
}
return $body;
}
/**
* Parse all of the fares in the multi-format
*/
private function processFares(Subfleet $subfleet, $col): void
{
$fares = Utils::parseMultiColumnValues($col);
foreach ($fares as $fare_code => $fare_attributes) {
if (\is_int($fare_code)) {
$fare_code = $fare_attributes;
$fare_attributes = [];
}
$fare = Fare::firstOrCreate(['code' => $fare_code], ['name' => $fare_code]);
app(FareService::class)->setForSubfleet($subfleet, $fare, $fare_attributes);
$fare->save();
}
}
/**
* Parse all of the rakns in the multi-format
*/
private function processRanks(Subfleet $subfleet, $col): void
{
$ranks = Utils::parseMultiColumnValues($col);
foreach ($ranks as $rank_id => $rank_attributes) {
if (!\is_array($rank_attributes)) {
$rank_id = $rank_attributes;
$rank_attributes = [];
}
$rank = Rank::firstOrCreate(['id' => $rank_id], ['name' => 'Imported rank '.$rank_id]);
app(FleetService::class)->addSubfleetToRank($subfleet, $rank, $rank_attributes);
$rank->save();
}
}
}

View File

@ -2,11 +2,15 @@
namespace App\Filament\Resources\Airports\Pages;
use App\Filament\Actions\ExportAction;
use App\Filament\Actions\ImportAction;
use App\Filament\Actions\ExportAction as OldExportAction;
use App\Filament\Actions\ImportAction as OldImportAction;
use App\Filament\Exports\AirportExporter;
use App\Filament\Imports\AirportImporter;
use App\Filament\Resources\Airports\AirportResource;
use App\Models\Enums\ImportExportType;
use Filament\Actions\CreateAction;
use Filament\Actions\ExportAction;
use Filament\Actions\ImportAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Icons\Heroicon;
@ -17,11 +21,20 @@ class ListAirports extends ListRecords
protected function getHeaderActions(): array
{
return [
ExportAction::make('export')
OldExportAction::make('export')
->arguments(['resourceTitle' => 'airports', 'exportType' => ImportExportType::AIRPORT]),
ImportAction::make('import')
OldImportAction::make('import')
->arguments(['resourceTitle' => 'airports', 'importType' => ImportExportType::AIRPORT]),
ImportAction::make('import')
->visible(config('phpvms.use_queued_filament_imports'))
->importer(AirportImporter::class),
ExportAction::make('export')
->visible(config('phpvms.use_queued_filament_imports'))
->exporter(AirportExporter::class),
CreateAction::make()
->icon(Heroicon::OutlinedPlusCircle),
];

View File

@ -2,11 +2,15 @@
namespace App\Filament\Resources\Expenses\Pages;
use App\Filament\Actions\ExportAction;
use App\Filament\Actions\ImportAction;
use App\Filament\Actions\ExportAction as OldExportAction;
use App\Filament\Actions\ImportAction as OldImportAction;
use App\Filament\Exports\ExpenseExporter;
use App\Filament\Imports\ExpenseImporter;
use App\Filament\Resources\Expenses\ExpenseResource;
use App\Models\Enums\ImportExportType;
use Filament\Actions\CreateAction;
use Filament\Actions\ExportAction;
use Filament\Actions\ImportAction;
use Filament\Resources\Pages\ManageRecords;
use Filament\Support\Icons\Heroicon;
@ -17,12 +21,20 @@ class ManageExpenses extends ManageRecords
protected function getHeaderActions(): array
{
return [
ExportAction::make('export')
OldExportAction::make('export')
->arguments(['resourceTitle' => 'expenses', 'exportType' => ImportExportType::EXPENSES]),
ImportAction::make('import')
OldImportAction::make('import')
->arguments(['resourceTitle' => 'expenses', 'importType' => ImportExportType::EXPENSES]),
ImportAction::make('import')
->visible(config('phpvms.use_queued_filament_imports'))
->importer(ExpenseImporter::class),
ExportAction::make('export')
->visible(config('phpvms.use_queued_filament_imports'))
->exporter(ExpenseExporter::class),
CreateAction::make()
->icon(Heroicon::OutlinedPlusCircle),
];

View File

@ -2,11 +2,15 @@
namespace App\Filament\Resources\Fares\Pages;
use App\Filament\Actions\ExportAction;
use App\Filament\Actions\ImportAction;
use App\Filament\Actions\ExportAction as OldExportAction;
use App\Filament\Actions\ImportAction as OldImportAction;
use App\Filament\Exports\FareExporter;
use App\Filament\Imports\FareImporter;
use App\Filament\Resources\Fares\FareResource;
use App\Models\Enums\ImportExportType;
use Filament\Actions\CreateAction;
use Filament\Actions\ExportAction;
use Filament\Actions\ImportAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Icons\Heroicon;
@ -17,12 +21,20 @@ class ListFares extends ListRecords
protected function getHeaderActions(): array
{
return [
ExportAction::make('export')
OldExportAction::make('export')
->arguments(['resourceTitle' => 'fares', 'exportType' => ImportExportType::FARES]),
ImportAction::make('import')
OldImportAction::make('import')
->arguments(['resourceTitle' => 'fares', 'importType' => ImportExportType::FARES]),
ImportAction::make('import')
->visible(config('phpvms.use_queued_filament_imports'))
->importer(FareImporter::class),
ExportAction::make('export')
->visible(config('phpvms.use_queued_filament_imports'))
->exporter(FareExporter::class),
CreateAction::make()
->icon(Heroicon::OutlinedPlusCircle),
];

View File

@ -2,11 +2,15 @@
namespace App\Filament\Resources\Flights\Pages;
use App\Filament\Actions\ExportAction;
use App\Filament\Actions\ImportAction;
use App\Filament\Actions\ExportAction as OldExportAction;
use App\Filament\Actions\ImportAction as OldImportAction;
use App\Filament\Exports\FlightExporter;
use App\Filament\Imports\FlightImporter;
use App\Filament\Resources\Flights\FlightResource;
use App\Models\Enums\ImportExportType;
use Filament\Actions\CreateAction;
use Filament\Actions\ExportAction;
use Filament\Actions\ImportAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Icons\Heroicon;
@ -17,12 +21,20 @@ class ListFlights extends ListRecords
protected function getHeaderActions(): array
{
return [
ExportAction::make('export')
OldExportAction::make('export')
->arguments(['resourceTitle' => 'flights', 'exportType' => ImportExportType::FLIGHTS]),
ImportAction::make('import')
OldImportAction::make('import')
->arguments(['resourceTitle' => 'flights', 'importType' => ImportExportType::FLIGHTS]),
ImportAction::make('import')
->visible(config('phpvms.use_queued_filament_imports'))
->importer(FlightImporter::class),
ExportAction::make('export')
->visible(config('phpvms.use_queued_filament_imports'))
->exporter(FlightExporter::class),
CreateAction::make()
->icon(Heroicon::OutlinedPlusCircle),
];

View File

@ -2,11 +2,15 @@
namespace App\Filament\Resources\Subfleets\Pages;
use App\Filament\Actions\ExportAction;
use App\Filament\Actions\ImportAction;
use App\Filament\Actions\ExportAction as OldExportAction;
use App\Filament\Actions\ImportAction as OldImportAction;
use App\Filament\Exports\SubfleetExporter;
use App\Filament\Imports\SubfleetImporter;
use App\Filament\Resources\Subfleets\SubfleetResource;
use App\Models\Enums\ImportExportType;
use Filament\Actions\CreateAction;
use Filament\Actions\ExportAction;
use Filament\Actions\ImportAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Icons\Heroicon;
@ -17,12 +21,20 @@ class ListSubfleets extends ListRecords
protected function getHeaderActions(): array
{
return [
ExportAction::make('export')
OldExportAction::make('export')
->arguments(['resourceTitle' => 'subfleets', 'exportType' => ImportExportType::SUBFLEETS]),
ImportAction::make('import')
OldImportAction::make('import')
->arguments(['resourceTitle' => 'subfleets', 'importType' => ImportExportType::SUBFLEETS]),
ImportAction::make('import')
->visible(config('phpvms.use_queued_filament_imports'))
->importer(SubfleetImporter::class),
ExportAction::make('export')
->visible(config('phpvms.use_queued_filament_imports'))
->exporter(SubfleetExporter::class),
CreateAction::make()
->icon(Heroicon::OutlinedPlusCircle),
];

View File

@ -2,12 +2,16 @@
namespace App\Filament\Resources\Subfleets\RelationManagers;
use App\Filament\Actions\ExportAction;
use App\Filament\Actions\ImportAction;
use App\Filament\Actions\ExportAction as OldExportAction;
use App\Filament\Actions\ImportAction as OldImportAction;
use App\Filament\Exports\AircraftExporter;
use App\Filament\Imports\AircraftImporter;
use App\Filament\Resources\Subfleets\Resources\Aircraft\AircraftResource;
use App\Filament\Resources\Subfleets\Resources\Aircraft\Tables\AircraftTable;
use App\Models\Enums\ImportExportType;
use Filament\Actions\CreateAction;
use Filament\Actions\ExportAction;
use Filament\Actions\ImportAction;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
@ -22,18 +26,26 @@ class AircraftRelationManager extends RelationManager
{
return AircraftTable::configure($table)
->headerActions([
ExportAction::make('export')
OldExportAction::make('export')
->arguments([
'resourceTitle' => 'aircraft',
'exportType' => ImportExportType::AIRCRAFT,
]),
ImportAction::make('import')
OldImportAction::make('import')
->arguments([
'resourceTitle' => 'aircraft',
'importType' => ImportExportType::AIRCRAFT,
]),
ImportAction::make('import')
->visible(config('phpvms.use_queued_filament_imports'))
->importer(AircraftImporter::class),
ExportAction::make('export')
->visible(config('phpvms.use_queued_filament_imports'))
->exporter(AircraftExporter::class),
CreateAction::make()
->icon(Heroicon::OutlinedPlusCircle),
]);

View File

@ -358,12 +358,12 @@ class Flight extends Model
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'id', 'user_id');
return $this->belongsTo(User::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class, 'id', 'event_id');
return $this->belongsTo(Event::class);
}
public function owner(): MorphTo

View File

@ -103,6 +103,7 @@ class AdminPanelProvider extends PanelProvider
->unsavedChangesAlerts()
->spa(hasPrefetching: config('phpvms.use_prefetching_in_admin', false))
->errorNotifications()
->databaseNotifications()
->viteTheme('resources/css/filament/admin/theme.css');
}

View File

@ -10,7 +10,6 @@ use App\Models\Airport;
use App\Repositories\AirportRepository;
use App\Support\Metar;
use App\Support\Units\Distance;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use League\Geotools\Coordinate\Coordinate;
use League\Geotools\Geotools;
@ -96,10 +95,9 @@ class AirportService extends Service
/**
* Lookup an airport and save it if it hasn't been found
*
* @param string $icao
* @return Model|null
* @param string $icao
*/
public function lookupAirportIfNotFound($icao)
public function lookupAirportIfNotFound($icao): ?Airport
{
$icao = strtoupper($icao);
$airport = $this->airportRepo->findWithoutFail($icao);

View File

@ -123,4 +123,135 @@ class Utils
return '';
}
/**
* Parse a multi column values field. E.g:
* Y?price=200&cost=100; F?price=1200
* or
* gate=B32;cost index=100
*
* Converted into a multi-dimensional array
*/
public static function parseMultiColumnValues(string $field): array|string
{
$ret = [];
$split_values = explode(';', $field);
// No multiple values in here, just a straight value
if (\count($split_values) === 1) {
if (trim($split_values[0]) === '') {
return [];
}
if (str_contains($split_values[0], '?')) {
// This contains the query string, which turns it into a multi-level array
$query_str = explode('?', $split_values[0]);
$parent = trim($query_str[0]);
$children = [];
$kvp = explode('&', trim($query_str[1]));
foreach ($kvp as $items) {
if ($items === '' || $items === '0') {
continue;
}
self::kvpToArray($items, $children);
}
$ret[$parent] = $children;
return $ret;
}
if (str_contains($split_values[0], '=')) {
$ret = [];
self::kvpToArray($split_values[0], $ret);
return $ret;
}
// This is not a query string, return it back untouched
return [$split_values[0]];
}
foreach ($split_values as $value) {
$value = trim($value);
if ($value === '') {
continue;
}
// This isn't in the query string format, so it's
// just a straight key-value pair set
if (!str_contains($value, '?')) {
self::kvpToArray($value, $ret);
continue;
}
// This contains the query string, which turns it
// into the multi-level array
$query_str = explode('?', $value);
$parent = trim($query_str[0]);
$children = [];
$kvp = explode('&', trim($query_str[1]));
foreach ($kvp as $items) {
if ($items === '' || $items === '0') {
continue;
}
self::kvpToArray($items, $children);
}
$ret[$parent] = $children;
}
return $ret;
}
public static function kvpToArray($kvp_str, array &$arr): void
{
$item = explode('=', $kvp_str);
if (\count($item) === 1) { // just a list?
$arr[] = trim($item[0]);
} else { // actually a key-value pair
$k = trim($item[0]);
$v = trim($item[1]);
$arr[$k] = $v;
}
}
public static function objectToMultiString(object|array $obj): object|string
{
if (!\is_array($obj)) {
return $obj;
}
$ret_list = [];
foreach ($obj as $key => $val) {
if (is_numeric($key) && !\is_array($val)) {
$ret_list[] = $val;
continue;
}
$key = trim($key);
if (!\is_array($val)) {
$val = trim($val);
$ret_list[] = "{$key}={$val}";
} else {
$q = [];
foreach ($val as $subkey => $subval) {
$q[] = is_numeric($subkey) ? $subval : "{$subkey}={$subval}";
}
$q = implode('&', $q);
$ret_list[] = $q === '' || $q === '0' ? $key : "{$key}?{$q}";
}
}
return implode(';', $ret_list);
}
}

View File

@ -36,7 +36,7 @@
"symfony/http-client": "^7.2",
"symfony/yaml": "^7.2",
"psr/container": "1.1.1",
"composer/composer": "^2.7.7",
"composer/composer": "^2.9.3",
"composer/installers": "^2.3.0",
"laravel/framework": "^v12.19",
"arrilot/laravel-widgets": "^3.14.0",
@ -75,7 +75,7 @@
"spatie/laravel-ignition": "^2.9.1",
"kyslik/column-sortable": "^7.0",
"jlorente/laravel-data-migrations": "^2.0",
"filament/filament": "^4.1",
"filament/filament": "^4.5.2",
"flowframe/laravel-trend": "^0.4.0",
"bezhansalleh/filament-shield": "^4.0.2",
"spatie/laravel-backup": "^9.2.9",

1730
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -147,4 +147,9 @@ return [
* Whether to use prefetching in the admin panel (can use a lot of bandwidth)
*/
'use_prefetching_in_admin' => env('USE_PREFETCHING_IN_ADMIN', false),
/**
* Whether to use the built-in filament import system (relies on laravel queue worker)
*/
'use_queued_filament_imports' => env('USE_QUEUED_FILAMENT_IMPORTS', false),
];

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('job_batches');
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('imports', function (Blueprint $table): void {
$table->id();
$table->timestamp('completed_at')->nullable();
$table->string('file_name');
$table->string('file_path');
$table->string('importer');
$table->unsignedInteger('processed_rows')->default(0);
$table->unsignedInteger('total_rows');
$table->unsignedInteger('successful_rows')->default(0);
$table->unsignedInteger('user_id');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('imports');
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('exports', function (Blueprint $table): void {
$table->id();
$table->timestamp('completed_at')->nullable();
$table->string('file_disk');
$table->string('file_name')->nullable();
$table->string('exporter');
$table->unsignedInteger('processed_rows')->default(0);
$table->unsignedInteger('total_rows');
$table->unsignedInteger('successful_rows')->default(0);
$table->unsignedInteger('user_id');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('exports');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('failed_import_rows', function (Blueprint $table): void {
$table->id();
$table->json('data');
$table->foreignId('import_id')->constrained()->cascadeOnDelete();
$table->text('validation_error')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('failed_import_rows');
}
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))}}}export{c as default};
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))}}}export{c as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
function r({initialHeight:t,shouldAutosize:i,state:s}){return{state:s,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=t+"rem")},resize(){if(this.setInitialHeight(),this.$el.scrollHeight<=0)return;let e=this.$el.scrollHeight+"px";this.wrapperEl.style.height!==e&&(this.wrapperEl.style.height=e)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default};
function n({initialHeight:e,shouldAutosize:i,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=e+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let t=this.$el.style.height;this.$el.style.height="0px";let r=this.$el.scrollHeight;this.$el.style.height=t;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),s=Math.max(r,l)+"px";this.wrapperEl.style.height!==s&&(this.wrapperEl.style.height=s)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{n as default};

View File

@ -1 +1 @@
var i=()=>({isSticky:!1,enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1}});export{i as default};
var i=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let e=this.$el.parentElement;e&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(e),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let e=this.$el.parentElement;if(!e)return;let t=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=e.offsetWidth+parseInt(t.marginInlineStart,10)*-1+parseInt(t.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});export{i as default};

View File

@ -1 +1 @@
function u({activeTab:a,isTabPersistedInQueryString:e,livewireId:h,tab:o,tabQueryStringKey:s}){return{tab:o,init(){let t=this.getTabs(),i=new URLSearchParams(window.location.search);e&&i.has(s)&&t.includes(i.get(s))&&(this.tab=i.get(s)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[a-1]),Livewire.hook("commit",({component:r,commit:f,succeed:c,fail:l,respond:b})=>{c(({snapshot:d,effect:m})=>{this.$nextTick(()=>{if(r.id!==h)return;let n=this.getTabs();n.includes(this.tab)||(this.tab=n[a-1]??this.tab)})})})},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!e)return;let t=new URL(window.location.href);t.searchParams.set(s,this.tab),history.replaceState(null,document.title,t.toString())}}}export{u as default};
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),Livewire.hook("commit",({component:n,commit:d,succeed:r,fail:h,respond:u})=>{r(({snapshot:p,effect:i})=>{this.$nextTick(()=>{if(n.id!==g)return;let s=this.getTabs();s.includes(this.tab)||(this.tab=s[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,d,r,h){let u=t.map(i=>Math.ceil(i.clientWidth)),p=t.map(i=>{let s=i.querySelector(".fi-tabs-item-label"),a=i.querySelector(".fi-badge"),o=Math.ceil(s.clientWidth),l=a?Math.ceil(a.clientWidth):0;return{label:o,badge:l,total:o+(l>0?d+l:0)}});for(let i=0;i<t.length;i++){let s=u.slice(0,i+1).reduce((b,y)=>b+y,0),a=i*n,o=p.slice(i+1),l=o.length>0,W=l?Math.max(...o.map(b=>b.total)):0,D=l?r+W+d+h+n:0;if(s+a+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(c,this.tab),history.replaceState(null,document.title,t.toString())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),d=n.map(a=>a.style.display);n.forEach(a=>a.style.display=""),t.offsetHeight;let r=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),p=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),s=this.findOverflowIndex(n,r,h,p,i,u);n.forEach((a,o)=>a.style.display=d[o]),s!==-1&&(this.withinDropdownIndex=s),this.withinDropdownMounted=!0},destroy(){this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};

View File

@ -1 +1 @@
(()=>{var d=()=>({isSticky:!1,enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1}});var m=function(n,e,i){let t=n;if(e.startsWith("/")&&(i=!0,e=e.slice(1)),i)return e;for(;e.startsWith("../");)t=t.includes(".")?t.slice(0,t.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(t)?e:["",null,void 0].includes(e)?t:`${t}.${e}`},u=n=>{let e=Alpine.findClosest(n,i=>i.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:n})=>({handleFormValidationError(e){e.detail.livewireId===n&&this.$nextTick(()=>{let i=this.$el.querySelector("[data-validation-error]");if(!i)return;let t=i;for(;t;)t.dispatchEvent(new CustomEvent("expand")),t=t.parentNode;setTimeout(()=>i.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})}})),window.Alpine.data("filamentSchemaComponent",({path:n,containerPath:e,isLive:i,$wire:t})=>({$statePath:n,$get:(r,l)=>t.$get(m(e,r,l)),$set:(r,l,a,o=null)=>(o??(o=i),t.$set(m(e,r,a),l,o)),get $state(){return t.$get(n)}})),window.Alpine.data("filamentActionsSchemaComponent",d),Livewire.hook("commit",({component:n,commit:e,respond:i,succeed:t,fail:r})=>{t(({snapshot:l,effects:a})=>{a.dispatches?.forEach(o=>{if(!o.params?.awaitSchemaComponent)return;let s=Array.from(n.el.querySelectorAll(`[wire\\:partial="schema-component::${o.params.awaitSchemaComponent}"]`)).filter(c=>u(c)===n);if(s.length!==1){if(s.length>1)throw`Multiple schema components found with key [${o.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${n.id}-${o.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(o.name,{detail:o.params}))},{once:!0})}})})})});})();
(()=>{var d=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let t=this.$el.parentElement;t&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(t),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let t=this.$el.parentElement;if(!t)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=t.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var u=function(t,e,n){let i=t;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)i=i.includes(".")?i.slice(0,i.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(i)?e:["",null,void 0].includes(e)?i:`${i}.${e}`},c=t=>{let e=Alpine.findClosest(t,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:t})=>({handleFormValidationError(e){e.detail.livewireId===t&&this.$nextTick(()=>{let n=this.$el.querySelector("[data-validation-error]");if(!n)return;let i=n;for(;i;)i.dispatchEvent(new CustomEvent("expand")),i=i.parentNode;setTimeout(()=>n.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},isStateChanged(e,n){if(e===void 0)return!1;try{return JSON.stringify(e)!==JSON.stringify(n)}catch{return e!==n}}})),window.Alpine.data("filamentSchemaComponent",({path:t,containerPath:e,$wire:n})=>({$statePath:t,$get:(i,s)=>n.$get(u(e,i,s)),$set:(i,s,a,o=!1)=>n.$set(u(e,i,a),s,o),get $state(){return n.$get(t)}})),window.Alpine.data("filamentActionsSchemaComponent",d),Livewire.hook("commit",({component:t,commit:e,respond:n,succeed:i,fail:s})=>{i(({snapshot:a,effects:o})=>{o.dispatches?.forEach(r=>{if(!r.params?.awaitSchemaComponent)return;let l=Array.from(t.el.querySelectorAll(`[wire\\:partial="schema-component::${r.params.awaitSchemaComponent}"]`)).filter(h=>c(h)===t);if(l.length!==1){if(l.length>1)throw`Multiple schema components found with key [${r.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${t.id}-${r.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(r.name,{detail:r.params}))},{once:!0})}})})})});})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long