diff --git a/CLAUDE.md b/CLAUDE.md index ac695cc..112942b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,13 @@ Sie arbeitet auf der eigenen Datenbank `test` und baut sie selbst auf: PHP und Composer sind auf dem Host nicht installiert. Also `ddev artisan …`, `ddev composer …`, `ddev exec vendor/bin/pest`. Meldet `ddev` „unknown command", ist das Arbeitsverzeichnis falsch. +**`ddev exec` schickt den Befehl ein zweites Mal durch bash.** Anführungszeichen sind vorher schon +weg, und `$…` wird expandiert, bevor der Befehl im Container ankommt. `ddev exec php -r 'echo +$argv[1];' wert` läuft deshalb nicht wie gedacht — im Container kommt `php -r echo ; wert` an. +Alles, was Anführungszeichen, `$`, `(` oder `;` enthält, gehört in eine Datei, die dann mit +`ddev exec php datei.php` läuft. Werte über vorangestellte Zuweisungen übergeben +(`ddev exec MEINE_VAR=wert php datei.php`), nicht über Argumente. + ## Sprache Code, Klassennamen, Spaltennamen und Commit-Messages englisch. Oberflächentexte deutsch — @@ -67,6 +74,22 @@ Feldnamen ab. (`storage/app/private`) und dürfen ausschließlich über den autorisierten `VehicleDocumentDownloadController` ausgeliefert werden — kein `Storage::url()`, kein `public/storage`-Symlink, keine signierte URL. +- **Löschen ist an den Zustand gebunden.** Ein aktiver Datensatz bietet die Löschaktion gar nicht + erst an; sichtbar wird sie erst bei einem inaktiven (bzw. beim Fahrzeug: ausgemusterten). Die + Regel steckt in `GuardedDeleteAction::visible()` und beim Fahrzeug zusätzlich in + `EditVehicle::deleteAction()`. Wer eine neue Löschaktion einbaut, muss die Bedingung mitnehmen — + sonst ist die Regel an dieser Stelle aufgehoben. +- **Auswahlfelder auf Stammdaten filtern auf `active`**, ergänzt um den bereits gesetzten Wert + (`->orWhere('id', $record?->…)`). Ohne diese Ausnahme leert das nächste Speichern still eine + Zuordnung, wenn der Bezug inzwischen inaktiv geworden ist. Vorbilder: die vier `Select`s in + `VehicleForm` und der Anbieter-Select in `FuelCardForm`. +- **Der Bruttolistenpreis gehört wie die übrigen Kostenfelder in die Section „Kosten"** — + außerhalb wäre er ungeschützt (siehe erster Punkt in dieser Liste). +- **2FA-Pflicht liegt in eigener Middleware.** Filaments `multiFactorAuthentication(..., + isRequired:)` wertet den Wert beim Registrieren der Routen aus, wo noch kein Benutzer bekannt + ist. `EnsureTwoFactorWhenRequired` in der `authMiddleware` des Panels leitet auf die + Profilseite; die muss samt Livewire-Requests und Logout durchgelassen werden, sonst leitet die + Middleware auf eine Seite um, die sie erneut umleitet — eine Schleife. ## Dokumentation diff --git a/app/Enums/AppointmentType.php b/app/Enums/AppointmentType.php index 1437773..1f3c250 100644 --- a/app/Enums/AppointmentType.php +++ b/app/Enums/AppointmentType.php @@ -10,6 +10,8 @@ enum AppointmentType: string implements HasColor, HasLabel case Service = 'service'; case Workshop = 'workshop'; case Inspection = 'inspection'; + case Accident = 'accident'; + case Damage = 'damage'; public function getLabel(): string { @@ -17,14 +19,24 @@ public function getLabel(): string self::Service => 'Service', self::Workshop => 'Werkstatt', self::Inspection => 'Hauptuntersuchung', + self::Accident => 'Unfall', + self::Damage => 'Schaden', }; } /** - * 'danger' ist in diesem Panel auf Rose verschoben und 'primary' ist das - * Markenrot (siehe AdminPanelProvider) - beide daher hier bewusst - * gemieden, damit Termin-Badges nicht mit Loeschen/Marke verwechselt - * werden. + * 'danger' war bewusst gemieden, solange die Historie nur planbare + * Termine kannte - das Panel verschiebt 'danger' auf Rose und 'primary' + * ist das Markenrot (siehe AdminPanelProvider). Loeschen und Marke sollen + * nicht mit gewoehnlichen Eintraegen verwechselt werden. + * + * Fuer den Unfall ist genau dieses Hervorstechen richtig: der Eintrag + * soll ins Auge fallen, nicht gleich neben Service und Werkstatt + * verschwinden. + * + * 'orange' fuer den Schaden musste neu registriert werden, weil 'warning' + * bereits fuer die Hauptuntersuchung belegt ist und jede Farbe hier eine + * einzelne Bedeutung tragen soll (siehe Farbtest). */ public function getColor(): string { @@ -32,6 +44,8 @@ public function getColor(): string self::Service => 'info', self::Workshop => 'gray', self::Inspection => 'warning', + self::Accident => 'danger', + self::Damage => 'orange', }; } } diff --git a/app/Filament/Actions/ActivateAction.php b/app/Filament/Actions/ActivateAction.php new file mode 100644 index 0000000..eb1793d --- /dev/null +++ b/app/Filament/Actions/ActivateAction.php @@ -0,0 +1,42 @@ +label('Aktivieren'); + $this->icon('heroicon-m-arrow-uturn-left'); + $this->color('success'); + + $this->visible(fn (Model $record): bool => ! $this->isRecordActive($record)); + + $this->action(function (Model $record): void { + $this->setRecordState($record, $this->activeValue); + + Notification::make() + ->success() + ->title('Eintrag aktiviert') + ->send(); + }); + } +} diff --git a/app/Filament/Actions/Concerns/TracksActiveState.php b/app/Filament/Actions/Concerns/TracksActiveState.php new file mode 100644 index 0000000..2527cfd --- /dev/null +++ b/app/Filament/Actions/Concerns/TracksActiveState.php @@ -0,0 +1,45 @@ +stateColumn('status', VehicleStatus::Retired, VehicleStatus::Active) + */ + public function stateColumn(string $column, mixed $inactiveValue, mixed $activeValue): static + { + $this->stateColumn = $column; + $this->inactiveValue = $inactiveValue; + $this->activeValue = $activeValue; + + return $this; + } + + protected function isRecordActive(Model $record): bool + { + return $record->{$this->stateColumn} === $this->activeValue; + } + + protected function setRecordState(Model $record, mixed $value): void + { + $record->{$this->stateColumn} = $value; + $record->save(); + } +} diff --git a/app/Filament/Actions/DeactivateAction.php b/app/Filament/Actions/DeactivateAction.php new file mode 100644 index 0000000..cc72f76 --- /dev/null +++ b/app/Filament/Actions/DeactivateAction.php @@ -0,0 +1,56 @@ +label('Deaktivieren'); + $this->icon('heroicon-m-archive-box'); + $this->color('gray'); + $this->requiresConfirmation(); + $this->modalHeading('Eintrag deaktivieren'); + $this->modalDescription( + 'Der Eintrag bleibt vollständig erhalten und behält seine Zuordnungen. ' + .'Er taucht nur nicht mehr in Auswahllisten und in der Standardansicht auf. ' + .'Löschen ist danach möglich.' + ); + $this->modalSubmitActionLabel('Deaktivieren'); + + $this->visible(fn (Model $record): bool => $this->isRecordActive($record)); + + $this->action(function (Model $record): void { + $this->setRecordState($record, $this->inactiveValue); + + Notification::make() + ->success() + ->title('Eintrag deaktiviert') + ->send(); + }); + } +} diff --git a/app/Filament/Actions/GuardedDeleteAction.php b/app/Filament/Actions/GuardedDeleteAction.php index 8e7789c..489e694 100644 --- a/app/Filament/Actions/GuardedDeleteAction.php +++ b/app/Filament/Actions/GuardedDeleteAction.php @@ -2,6 +2,7 @@ namespace App\Filament\Actions; +use App\Filament\Actions\Concerns\TracksActiveState; use Closure; use Filament\Actions\DeleteAction; use Filament\Notifications\Notification; @@ -19,9 +20,17 @@ * vehicles.*) - dafuer lassen sich Relation, Beschriftung und Meldungstext * ueber guardedBy() umstellen, ohne dass sich am Standardverhalten der * uebrigen Aufrufer etwas aendert. + * + * Seit der Umstellung auf "Deaktivieren statt Loeschen" ist das der zweite + * Schritt: Ein aktiver Datensatz wird deaktiviert (DeactivateAction), und erst + * ein inaktiver laesst sich entfernen. Die beiden Bedingungen greifen + * unabhaengig voneinander - der Zustand entscheidet, ob geloescht werden darf, + * der Fremdschluessel, ob es technisch geht. */ class GuardedDeleteAction extends DeleteAction { + use TracksActiveState; + protected string $guardedRelationshipName = 'vehicles'; /** @@ -38,6 +47,13 @@ protected function setUp(): void { parent::setUp(); + $this->modalHeading('Endgültig löschen'); + + // Nur inaktive Datensaetze duerfen ueberhaupt geloescht werden. Wer + // hier eine weitere visible()-Bedingung ergaenzt, muss diese + // mitnehmen - sonst ist die Regel an dieser Stelle aufgehoben. + $this->visible(fn (Model $record): bool => ! $this->isRecordActive($record)); + $this->before(function (GuardedDeleteAction $action, Model $record): void { $relationship = $record->{$this->guardedRelationshipName}(); $relatedRecord = $relationship->first(); @@ -59,7 +75,7 @@ protected function setUp(): void Notification::make() ->danger() - ->title('Löschen nicht möglich') + ->title('Endgültig löschen nicht möglich') ->body($message) ->persistent() ->send(); diff --git a/app/Filament/Exports/VehicleCostExporter.php b/app/Filament/Exports/VehicleCostExporter.php index 65e5dd1..bf8d5be 100644 --- a/app/Filament/Exports/VehicleCostExporter.php +++ b/app/Filament/Exports/VehicleCostExporter.php @@ -20,6 +20,9 @@ public static function getColumns(): array ExportColumn::make('monthly_lease_rate')->label('Leasingrate monatlich'), ExportColumn::make('annual_vehicle_tax')->label('Kfz-Steuer jaehrlich'), ExportColumn::make('annual_insurance')->label('Versicherung jaehrlich'), + // Bewusst nach den laufenden Kosten und vor den Jahreskosten - er + // fliesst nicht ein, aber gehoert zur Kostenuebersicht. + ExportColumn::make('gross_list_price')->label('Bruttolistenpreis'), ExportColumn::make('annual_total_cost') ->label('Jahreskosten') // annual_total_cost ist ein reiner float-Accessor (siehe diff --git a/app/Filament/Resources/Dealerships/DealershipResource.php b/app/Filament/Resources/Dealerships/DealershipResource.php index 05296d1..abf4727 100644 --- a/app/Filament/Resources/Dealerships/DealershipResource.php +++ b/app/Filament/Resources/Dealerships/DealershipResource.php @@ -15,6 +15,7 @@ use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; use UnitEnum; class DealershipResource extends Resource @@ -62,4 +63,14 @@ public static function getPages(): array 'edit' => EditDealership::route('/{record}/edit'), ]; } + + /** + * Inaktive Stammdaten sollen im Alltag nicht auftauchen - auch nicht ueber + * die globale Suche, die sonst der einzige Weg waere, sie unbeabsichtigt + * wieder in Umlauf zu bringen. + */ + public static function getGlobalSearchEloquentQuery(): Builder + { + return parent::getGlobalSearchEloquentQuery()->where('active', true); + } } diff --git a/app/Filament/Resources/Dealerships/Schemas/DealershipForm.php b/app/Filament/Resources/Dealerships/Schemas/DealershipForm.php index 1373af6..5299053 100644 --- a/app/Filament/Resources/Dealerships/Schemas/DealershipForm.php +++ b/app/Filament/Resources/Dealerships/Schemas/DealershipForm.php @@ -4,6 +4,7 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Schemas\Schema; class DealershipForm @@ -36,6 +37,10 @@ public static function configure(Schema $schema): Schema TextInput::make('city') ->label('Ort') ->maxLength(255), + Toggle::make('active') + ->label('Aktiv') + ->default(true) + ->helperText('Inaktive Einträge tauchen nicht mehr in Auswahllisten auf. Gelöscht werden kann nur, was inaktiv ist.'), Textarea::make('notes') ->label('Notizen') ->rows(3) diff --git a/app/Filament/Resources/Dealerships/Schemas/DealershipInfolist.php b/app/Filament/Resources/Dealerships/Schemas/DealershipInfolist.php index b924695..b3dcef4 100644 --- a/app/Filament/Resources/Dealerships/Schemas/DealershipInfolist.php +++ b/app/Filament/Resources/Dealerships/Schemas/DealershipInfolist.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\Dealerships\Schemas; use App\Models\Dealership; +use Filament\Infolists\Components\IconEntry; use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; @@ -58,6 +59,9 @@ public static function configure(Schema $schema): Schema Section::make('Notizen') ->schema([ + IconEntry::make('active') + ->label('Aktiv') + ->boolean(), TextEntry::make('notes') ->label('Notizen') ->placeholder('—') diff --git a/app/Filament/Resources/Dealerships/Tables/DealershipsTable.php b/app/Filament/Resources/Dealerships/Tables/DealershipsTable.php index d2e0c2b..433441a 100644 --- a/app/Filament/Resources/Dealerships/Tables/DealershipsTable.php +++ b/app/Filament/Resources/Dealerships/Tables/DealershipsTable.php @@ -2,10 +2,13 @@ namespace App\Filament\Resources\Dealerships\Tables; +use App\Filament\Actions\ActivateAction; +use App\Filament\Actions\DeactivateAction; use App\Filament\Actions\GuardedDeleteAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; class DealershipsTable @@ -29,11 +32,28 @@ public static function configure(Table $table): Table TextColumn::make('vehicles_count') ->label('Fahrzeuge') ->counts('vehicles'), + TextColumn::make('active') + ->label('Status') + ->badge() + ->formatStateUsing(fn (bool $state): string => $state ? 'Aktiv' : 'Inaktiv') + ->color(fn (bool $state): string => $state ? 'success' : 'gray'), ]) ->defaultSort('name') + ->filters([ + // Gleiches Muster wie in der Fahrzeugliste: Inaktive sind + // zuschaltbar, stehen im Alltag aber nicht im Weg. + TernaryFilter::make('active') + ->label('Status') + ->placeholder('Alle') + ->trueLabel('Nur aktive') + ->falseLabel('Nur inaktive') + ->default(true), + ]) ->recordActions([ ViewAction::make(), EditAction::make(), + DeactivateAction::make(), + ActivateAction::make(), GuardedDeleteAction::make(), ]); } diff --git a/app/Filament/Resources/Employees/EmployeeResource.php b/app/Filament/Resources/Employees/EmployeeResource.php index debd656..b44be1b 100644 --- a/app/Filament/Resources/Employees/EmployeeResource.php +++ b/app/Filament/Resources/Employees/EmployeeResource.php @@ -16,6 +16,7 @@ use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use UnitEnum; @@ -87,4 +88,14 @@ public static function getPages(): array 'edit' => EditEmployee::route('/{record}/edit'), ]; } + + /** + * Inaktive Stammdaten sollen im Alltag nicht auftauchen - auch nicht ueber + * die globale Suche, die sonst der einzige Weg waere, sie unbeabsichtigt + * wieder in Umlauf zu bringen. + */ + public static function getGlobalSearchEloquentQuery(): Builder + { + return parent::getGlobalSearchEloquentQuery()->where('active', true); + } } diff --git a/app/Filament/Resources/Employees/Tables/EmployeesTable.php b/app/Filament/Resources/Employees/Tables/EmployeesTable.php index 1ba4acc..2aa883e 100644 --- a/app/Filament/Resources/Employees/Tables/EmployeesTable.php +++ b/app/Filament/Resources/Employees/Tables/EmployeesTable.php @@ -2,11 +2,13 @@ namespace App\Filament\Resources\Employees\Tables; +use App\Filament\Actions\ActivateAction; +use App\Filament\Actions\DeactivateAction; use App\Filament\Actions\GuardedDeleteAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; -use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; class EmployeesTable @@ -29,14 +31,28 @@ public static function configure(Table $table): Table TextColumn::make('vehicles_count') ->label('Fahrzeuge') ->counts('vehicles'), - IconColumn::make('active') - ->label('Aktiv') - ->boolean(), + TextColumn::make('active') + ->label('Status') + ->badge() + ->formatStateUsing(fn (bool $state): string => $state ? 'Aktiv' : 'Inaktiv') + ->color(fn (bool $state): string => $state ? 'success' : 'gray'), ]) ->defaultSort('last_name') + ->filters([ + // Gleiches Muster wie in der Fahrzeugliste: Inaktive sind + // zuschaltbar, stehen im Alltag aber nicht im Weg. + TernaryFilter::make('active') + ->label('Status') + ->placeholder('Alle') + ->trueLabel('Nur aktive') + ->falseLabel('Nur inaktive') + ->default(true), + ]) ->recordActions([ ViewAction::make(), EditAction::make(), + DeactivateAction::make(), + ActivateAction::make(), GuardedDeleteAction::make(), ]); } diff --git a/app/Filament/Resources/FuelCardProviders/FuelCardProviderResource.php b/app/Filament/Resources/FuelCardProviders/FuelCardProviderResource.php index 6bc1ce6..984099e 100644 --- a/app/Filament/Resources/FuelCardProviders/FuelCardProviderResource.php +++ b/app/Filament/Resources/FuelCardProviders/FuelCardProviderResource.php @@ -15,6 +15,7 @@ use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; use UnitEnum; class FuelCardProviderResource extends Resource @@ -62,4 +63,14 @@ public static function getPages(): array 'edit' => EditFuelCardProvider::route('/{record}/edit'), ]; } + + /** + * Inaktive Stammdaten sollen im Alltag nicht auftauchen - auch nicht ueber + * die globale Suche, die sonst der einzige Weg waere, sie unbeabsichtigt + * wieder in Umlauf zu bringen. + */ + public static function getGlobalSearchEloquentQuery(): Builder + { + return parent::getGlobalSearchEloquentQuery()->where('active', true); + } } diff --git a/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderForm.php b/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderForm.php index fef1db5..c2272f3 100644 --- a/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderForm.php +++ b/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderForm.php @@ -4,6 +4,7 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Schemas\Schema; class FuelCardProviderForm @@ -16,7 +17,18 @@ public static function configure(Schema $schema): Schema ->label('Name') ->required() ->unique(ignoreRecord: true) - ->maxLength(255), + ->maxLength(255) + // Ein inaktiver Anbieter belegt seinen Namen weiterhin. + // Ohne diesen Hinweis sucht man in der Liste nach einem + // Eintrag, der dort standardmaessig nicht sichtbar ist. + ->validationMessages([ + 'unique' => 'Dieser Name ist bereits vergeben — möglicherweise von einem ' + .'inaktiven Anbieter. Über den Statusfilter in der Liste sind diese sichtbar.', + ]), + Toggle::make('active') + ->label('Aktiv') + ->default(true) + ->helperText('Inaktive Einträge tauchen nicht mehr in Auswahllisten auf. Gelöscht werden kann nur, was inaktiv ist.'), Textarea::make('notes') ->label('Notizen') ->rows(3) diff --git a/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderInfolist.php b/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderInfolist.php index d232f2d..80d80db 100644 --- a/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderInfolist.php +++ b/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderInfolist.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\FuelCardProviders\Schemas; use App\Models\FuelCardProvider; +use Filament\Infolists\Components\IconEntry; use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; @@ -25,6 +26,9 @@ public static function configure(Schema $schema): Schema Section::make('Notizen') ->schema([ + IconEntry::make('active') + ->label('Aktiv') + ->boolean(), TextEntry::make('notes') ->label('Notizen') ->placeholder('—') diff --git a/app/Filament/Resources/FuelCardProviders/Tables/FuelCardProvidersTable.php b/app/Filament/Resources/FuelCardProviders/Tables/FuelCardProvidersTable.php index 0a22ecb..70c09d7 100644 --- a/app/Filament/Resources/FuelCardProviders/Tables/FuelCardProvidersTable.php +++ b/app/Filament/Resources/FuelCardProviders/Tables/FuelCardProvidersTable.php @@ -2,11 +2,14 @@ namespace App\Filament\Resources\FuelCardProviders\Tables; +use App\Filament\Actions\ActivateAction; +use App\Filament\Actions\DeactivateAction; use App\Filament\Actions\GuardedDeleteAction; use App\Models\FuelCard; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; class FuelCardProvidersTable @@ -22,14 +25,31 @@ public static function configure(Table $table): Table TextColumn::make('fuel_cards_count') ->label('Tankkarten') ->counts('fuelCards'), + TextColumn::make('active') + ->label('Status') + ->badge() + ->formatStateUsing(fn (bool $state): string => $state ? 'Aktiv' : 'Inaktiv') + ->color(fn (bool $state): string => $state ? 'success' : 'gray'), ]) ->defaultSort('name') + ->filters([ + // Gleiches Muster wie in der Fahrzeugliste: Inaktive sind + // zuschaltbar, stehen im Alltag aber nicht im Weg. + TernaryFilter::make('active') + ->label('Status') + ->placeholder('Alle') + ->trueLabel('Nur aktive') + ->falseLabel('Nur inaktive') + ->default(true), + ]) ->recordActions([ ViewAction::make(), EditAction::make(), // Beim Tankkartenanbieter ist die naechste Stufe eine // Tankkarte, kein Fahrzeug - siehe Kommentar an // GuardedDeleteAction. + DeactivateAction::make(), + ActivateAction::make(), GuardedDeleteAction::make() ->guardedBy( 'fuelCards', diff --git a/app/Filament/Resources/FuelCards/FuelCardResource.php b/app/Filament/Resources/FuelCards/FuelCardResource.php index 618212c..6caafbf 100644 --- a/app/Filament/Resources/FuelCards/FuelCardResource.php +++ b/app/Filament/Resources/FuelCards/FuelCardResource.php @@ -15,6 +15,7 @@ use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; use UnitEnum; class FuelCardResource extends Resource @@ -60,4 +61,14 @@ public static function getPages(): array 'edit' => EditFuelCard::route('/{record}/edit'), ]; } + + /** + * Inaktive Stammdaten sollen im Alltag nicht auftauchen - auch nicht ueber + * die globale Suche, die sonst der einzige Weg waere, sie unbeabsichtigt + * wieder in Umlauf zu bringen. + */ + public static function getGlobalSearchEloquentQuery(): Builder + { + return parent::getGlobalSearchEloquentQuery()->where('active', true); + } } diff --git a/app/Filament/Resources/FuelCards/Schemas/FuelCardForm.php b/app/Filament/Resources/FuelCards/Schemas/FuelCardForm.php index 81003ea..40248d4 100644 --- a/app/Filament/Resources/FuelCards/Schemas/FuelCardForm.php +++ b/app/Filament/Resources/FuelCards/Schemas/FuelCardForm.php @@ -2,10 +2,12 @@ namespace App\Filament\Resources\FuelCards\Schemas; +use App\Models\FuelCard; use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Schemas\Schema; class FuelCardForm @@ -16,7 +18,17 @@ public static function configure(Schema $schema): Schema ->components([ Select::make('fuel_card_provider_id') ->label('Anbieter') - ->relationship('fuelCardProvider', 'name') + // Wie im Fahrzeugformular: inaktive Anbieter aus der Liste, + // der bereits gesetzte bleibt drin. + ->relationship( + name: 'fuelCardProvider', + titleAttribute: 'name', + modifyQueryUsing: fn ($query, ?FuelCard $record) => $query + ->where(fn ($q) => $q->where('active', true) + ->orWhere('id', $record?->fuel_card_provider_id)), + ) + ->getOptionLabelFromRecordUsing(fn ($record): string => $record->name + .($record->active ? '' : ' (inaktiv)')) ->native(false) ->searchable() ->preload() @@ -31,6 +43,10 @@ public static function configure(Schema $schema): Schema ->label('Gültig bis') ->native(false) ->displayFormat('d.m.Y'), + Toggle::make('active') + ->label('Aktiv') + ->default(true) + ->helperText('Inaktive Einträge tauchen nicht mehr in Auswahllisten auf. Gelöscht werden kann nur, was inaktiv ist.'), Textarea::make('notes') ->label('Notizen') ->rows(3) diff --git a/app/Filament/Resources/FuelCards/Schemas/FuelCardInfolist.php b/app/Filament/Resources/FuelCards/Schemas/FuelCardInfolist.php index ee65b6c..927c8fb 100644 --- a/app/Filament/Resources/FuelCards/Schemas/FuelCardInfolist.php +++ b/app/Filament/Resources/FuelCards/Schemas/FuelCardInfolist.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\FuelCards\Schemas; use App\Models\FuelCard; +use Filament\Infolists\Components\IconEntry; use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; @@ -42,6 +43,9 @@ public static function configure(Schema $schema): Schema Section::make('Notizen') ->schema([ + IconEntry::make('active') + ->label('Aktiv') + ->boolean(), TextEntry::make('notes') ->label('Notizen') ->placeholder('—') diff --git a/app/Filament/Resources/FuelCards/Tables/FuelCardsTable.php b/app/Filament/Resources/FuelCards/Tables/FuelCardsTable.php index bda8b23..e6838af 100644 --- a/app/Filament/Resources/FuelCards/Tables/FuelCardsTable.php +++ b/app/Filament/Resources/FuelCards/Tables/FuelCardsTable.php @@ -2,11 +2,14 @@ namespace App\Filament\Resources\FuelCards\Tables; +use App\Filament\Actions\ActivateAction; +use App\Filament\Actions\DeactivateAction; use App\Filament\Actions\GuardedDeleteAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; +use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; class FuelCardsTable @@ -38,10 +41,20 @@ public static function configure(Table $table): Table SelectFilter::make('fuel_card_provider_id') ->label('Anbieter') ->relationship('fuelCardProvider', 'name'), + // Gleiches Muster wie in der Fahrzeugliste: Inaktive sind + // zuschaltbar, stehen im Alltag aber nicht im Weg. + TernaryFilter::make('active') + ->label('Status') + ->placeholder('Alle') + ->trueLabel('Nur aktive') + ->falseLabel('Nur inaktive') + ->default(true), ]) ->recordActions([ ViewAction::make(), EditAction::make(), + DeactivateAction::make(), + ActivateAction::make(), GuardedDeleteAction::make(), ]); } diff --git a/app/Filament/Resources/LeasingCompanies/LeasingCompanyResource.php b/app/Filament/Resources/LeasingCompanies/LeasingCompanyResource.php index b5f239b..da043f1 100644 --- a/app/Filament/Resources/LeasingCompanies/LeasingCompanyResource.php +++ b/app/Filament/Resources/LeasingCompanies/LeasingCompanyResource.php @@ -15,6 +15,7 @@ use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; use UnitEnum; class LeasingCompanyResource extends Resource @@ -62,4 +63,14 @@ public static function getPages(): array 'edit' => EditLeasingCompany::route('/{record}/edit'), ]; } + + /** + * Inaktive Stammdaten sollen im Alltag nicht auftauchen - auch nicht ueber + * die globale Suche, die sonst der einzige Weg waere, sie unbeabsichtigt + * wieder in Umlauf zu bringen. + */ + public static function getGlobalSearchEloquentQuery(): Builder + { + return parent::getGlobalSearchEloquentQuery()->where('active', true); + } } diff --git a/app/Filament/Resources/LeasingCompanies/Schemas/LeasingCompanyForm.php b/app/Filament/Resources/LeasingCompanies/Schemas/LeasingCompanyForm.php index efb0d0f..4676cf8 100644 --- a/app/Filament/Resources/LeasingCompanies/Schemas/LeasingCompanyForm.php +++ b/app/Filament/Resources/LeasingCompanies/Schemas/LeasingCompanyForm.php @@ -4,6 +4,7 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Schemas\Schema; class LeasingCompanyForm @@ -27,6 +28,10 @@ public static function configure(Schema $schema): Schema ->label('E-Mail') ->email() ->maxLength(255), + Toggle::make('active') + ->label('Aktiv') + ->default(true) + ->helperText('Inaktive Einträge tauchen nicht mehr in Auswahllisten auf. Gelöscht werden kann nur, was inaktiv ist.'), Textarea::make('notes') ->label('Notizen') ->rows(3) diff --git a/app/Filament/Resources/LeasingCompanies/Schemas/LeasingCompanyInfolist.php b/app/Filament/Resources/LeasingCompanies/Schemas/LeasingCompanyInfolist.php index 88e79ff..03b53c9 100644 --- a/app/Filament/Resources/LeasingCompanies/Schemas/LeasingCompanyInfolist.php +++ b/app/Filament/Resources/LeasingCompanies/Schemas/LeasingCompanyInfolist.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\LeasingCompanies\Schemas; use App\Models\LeasingCompany; +use Filament\Infolists\Components\IconEntry; use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; @@ -38,6 +39,9 @@ public static function configure(Schema $schema): Schema Section::make('Notizen') ->schema([ + IconEntry::make('active') + ->label('Aktiv') + ->boolean(), TextEntry::make('notes') ->label('Notizen') ->placeholder('—') diff --git a/app/Filament/Resources/LeasingCompanies/Tables/LeasingCompaniesTable.php b/app/Filament/Resources/LeasingCompanies/Tables/LeasingCompaniesTable.php index 5b59d37..cc2a00b 100644 --- a/app/Filament/Resources/LeasingCompanies/Tables/LeasingCompaniesTable.php +++ b/app/Filament/Resources/LeasingCompanies/Tables/LeasingCompaniesTable.php @@ -2,10 +2,13 @@ namespace App\Filament\Resources\LeasingCompanies\Tables; +use App\Filament\Actions\ActivateAction; +use App\Filament\Actions\DeactivateAction; use App\Filament\Actions\GuardedDeleteAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; class LeasingCompaniesTable @@ -26,11 +29,28 @@ public static function configure(Table $table): Table TextColumn::make('vehicles_count') ->label('Fahrzeuge') ->counts('vehicles'), + TextColumn::make('active') + ->label('Status') + ->badge() + ->formatStateUsing(fn (bool $state): string => $state ? 'Aktiv' : 'Inaktiv') + ->color(fn (bool $state): string => $state ? 'success' : 'gray'), ]) ->defaultSort('name') + ->filters([ + // Gleiches Muster wie in der Fahrzeugliste: Inaktive sind + // zuschaltbar, stehen im Alltag aber nicht im Weg. + TernaryFilter::make('active') + ->label('Status') + ->placeholder('Alle') + ->trueLabel('Nur aktive') + ->falseLabel('Nur inaktive') + ->default(true), + ]) ->recordActions([ ViewAction::make(), EditAction::make(), + DeactivateAction::make(), + ActivateAction::make(), GuardedDeleteAction::make(), ]); } diff --git a/app/Filament/Resources/Users/Schemas/UserForm.php b/app/Filament/Resources/Users/Schemas/UserForm.php index 5a4b4fc..98c8972 100644 --- a/app/Filament/Resources/Users/Schemas/UserForm.php +++ b/app/Filament/Resources/Users/Schemas/UserForm.php @@ -45,6 +45,10 @@ public static function configure(Schema $schema): Schema Toggle::make('can_view_costs') ->label('Darf Kosten sehen') ->helperText('Unabhängig von der Rolle. Ohne dieses Recht bleiben alle Beträge verborgen.'), + Toggle::make('requires_two_factor') + ->label('2FA verpflichtend') + ->helperText('Der Benutzer wird beim nächsten Aufruf des Panels auf die Profilseite geleitet und ' + .'kann die Anwendung erst nach Einrichtung von 2FA benutzen.'), ]); } } diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/app/Filament/Resources/Users/Tables/UsersTable.php index f64fcd5..fd6d693 100644 --- a/app/Filament/Resources/Users/Tables/UsersTable.php +++ b/app/Filament/Resources/Users/Tables/UsersTable.php @@ -3,8 +3,11 @@ namespace App\Filament\Resources\Users\Tables; use App\Enums\UserRole; +use App\Models\User; +use Filament\Actions\Action; use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; +use Filament\Notifications\Notification; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; @@ -33,6 +36,9 @@ public static function configure(Table $table): Table ->label('2FA aktiv') ->boolean() ->state(fn ($record): bool => filled($record->app_authentication_secret)), + IconColumn::make('requires_two_factor') + ->label('2FA Pflicht') + ->boolean(), ]) ->defaultSort('name') ->filters([ @@ -42,6 +48,34 @@ public static function configure(Table $table): Table ]) ->recordActions([ EditAction::make(), + // Ein Admin kann fremdes 2FA nur ENTFERNEN, nie einrichten - + // das Geheimnis darf nur in der Authenticator-App des + // Benutzers entstehen (siehe UserPolicy::resetTwoFactor). + // Sichtbar nur, wenn ueberhaupt etwas zurueckzusetzen ist. + Action::make('resetTwoFactor') + ->label('2FA zurücksetzen') + ->icon('heroicon-m-shield-exclamation') + ->color('warning') + ->requiresConfirmation() + ->modalHeading('Zwei-Faktor-Authentifizierung zurücksetzen') + ->modalDescription( + 'Der Benutzer muss die Authentifizierung danach neu einrichten. ' + .'Bisherige Wiederherstellungscodes werden ungültig.' + ) + ->modalSubmitActionLabel('Zurücksetzen') + ->visible(fn (User $record): bool => filled($record->app_authentication_secret) + && auth()->user()->can('resetTwoFactor', $record)) + ->action(function (User $record): void { + $record->forceFill([ + 'app_authentication_secret' => null, + 'app_authentication_recovery_codes' => null, + ])->save(); + + Notification::make() + ->success() + ->title('2FA zurückgesetzt') + ->send(); + }), DeleteAction::make(), ]); } diff --git a/app/Filament/Resources/VehicleCosts/Tables/VehicleCostsTable.php b/app/Filament/Resources/VehicleCosts/Tables/VehicleCostsTable.php index 0927c42..9703d43 100644 --- a/app/Filament/Resources/VehicleCosts/Tables/VehicleCostsTable.php +++ b/app/Filament/Resources/VehicleCosts/Tables/VehicleCostsTable.php @@ -41,6 +41,14 @@ public static function configure(Table $table): Table ->money('EUR') ->sortable() ->summarize(Sum::make('insurance_sum')->label('Summe')->money('EUR')), + // Bewusst OHNE Summarizer und OHNE Anteil an annual_total_cost: + // Ein Listenpreis ist keine laufende Position, die man ueber + // einen Fuhrpark aufsummiert oder in Jahreskosten einrechnet. + TextColumn::make('gross_list_price') + ->label('Bruttolistenpreis') + ->money('EUR') + ->sortable() + ->placeholder('—'), // Abgeleiteter Wert: nicht sortierbar, da keine Datenbankspalte. TextColumn::make('annual_total_cost') ->label('Jahreskosten') diff --git a/app/Filament/Resources/Vehicles/Pages/EditVehicle.php b/app/Filament/Resources/Vehicles/Pages/EditVehicle.php index 7867424..27f94fe 100644 --- a/app/Filament/Resources/Vehicles/Pages/EditVehicle.php +++ b/app/Filament/Resources/Vehicles/Pages/EditVehicle.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\Vehicles\Pages; +use App\Enums\VehicleStatus; use App\Filament\Resources\Vehicles\Schemas\VehicleForm; use App\Filament\Resources\Vehicles\VehicleResource; use Filament\Actions\Action; @@ -212,11 +213,20 @@ protected function getRedirectUrl(): ?string * Bestaetigung und damit den vollen mountAction()-Ablauf; ohne diesen * Methodennamen waere der Knopf weder testbar noch im echten Browser * klickbar. + * + * Sichtbar nur bei einem ausgemusterten Fahrzeug: Loeschen ist der zweite + * Schritt, davor steht das Ausmustern (DeactivateAction in der + * Fahrzeugliste). Diese Bedingung gehoert genau hierher und nirgends sonst + * - die Fahrzeugliste bietet gar kein Loeschen an, es haengt + * ausschliesslich an diesem Knopf. Ohne sie waere die Regel an der + * einzigen Stelle umgehbar, an der man ein Fahrzeug tatsaechlich loescht. */ public function deleteAction(): Action { return DeleteAction::make() ->outlined() - ->color('danger'); + ->color('danger') + ->modalHeading('Fahrzeug endgültig löschen') + ->visible(fn (): bool => $this->getRecord()->status === VehicleStatus::Retired); } } diff --git a/app/Filament/Resources/Vehicles/RelationManagers/VehicleAppointmentsRelationManager.php b/app/Filament/Resources/Vehicles/RelationManagers/VehicleAppointmentsRelationManager.php index e8bb21a..2b11b37 100644 --- a/app/Filament/Resources/Vehicles/RelationManagers/VehicleAppointmentsRelationManager.php +++ b/app/Filament/Resources/Vehicles/RelationManagers/VehicleAppointmentsRelationManager.php @@ -25,7 +25,13 @@ class VehicleAppointmentsRelationManager extends RelationManager { protected static string $relationship = 'appointments'; - protected static ?string $title = 'Termine'; + // "Historie" statt "Termine", weil hier nicht nur planbare Ereignisse + // (Service, Werkstatt, Hauptuntersuchung) landen, sondern auch nicht + // planbare wie Unfall und Schaden. Klassen- und Tabellenname bleiben + // absichtlich unveraendert (VehicleAppointment, vehicle_appointments): + // eine Umbenennung waere eine Migration und aendert fuer Nutzer nichts + // Sichtbares. + protected static ?string $title = 'Historie'; /** * Siehe Kommentar an VehicleDocumentsRelationManager::$isLazy - dasselbe diff --git a/app/Filament/Resources/Vehicles/Schemas/VehicleForm.php b/app/Filament/Resources/Vehicles/Schemas/VehicleForm.php index 9ffe040..316348b 100644 --- a/app/Filament/Resources/Vehicles/Schemas/VehicleForm.php +++ b/app/Filament/Resources/Vehicles/Schemas/VehicleForm.php @@ -83,8 +83,21 @@ public static function mainSections(): array ->schema([ Select::make('employee_id') ->label('Mitarbeiter') - ->relationship('employee', 'last_name') - ->getOptionLabelFromRecordUsing(fn ($record): string => $record->full_name) + // Inaktive gehoeren nicht in eine Neuzuordnung, der + // bereits gesetzte Wert MUSS aber in der Liste bleiben: + // sonst faende das Formular den gespeicherten Wert + // nicht wieder und wuerde die Zuordnung beim naechsten + // Speichern still leeren. Dasselbe Muster in allen vier + // Selects dieser Section und der Vertrag-Section. + ->relationship( + name: 'employee', + titleAttribute: 'last_name', + modifyQueryUsing: fn ($query, ?Vehicle $record) => $query + ->where(fn ($q) => $q->where('active', true) + ->orWhere('id', $record?->employee_id)), + ) + ->getOptionLabelFromRecordUsing(fn ($record): string => $record->full_name + .($record->active ? '' : ' (inaktiv)')) ->searchable(['first_name', 'last_name']) ->placeholder('Auswählen') ->preload(), @@ -93,9 +106,17 @@ public static function mainSections(): array // Anbieter + volle Nummer (Auftraggeber-Entscheidung), // damit man bei mehreren Anbietern nicht raten muss. // Der encrypted-Cast schuetzt weiterhin die Datenbank. - ->relationship('fuelCard', 'card_number', fn ($query) => $query->with('fuelCardProvider')) + ->relationship( + name: 'fuelCard', + titleAttribute: 'card_number', + modifyQueryUsing: fn ($query, ?Vehicle $record) => $query + ->with('fuelCardProvider') + ->where(fn ($q) => $q->where('active', true) + ->orWhere('id', $record?->fuel_card_id)), + ) ->getOptionLabelFromRecordUsing( fn ($record): string => "{$record->fuelCardProvider->name} {$record->card_number}" + .($record->active ? '' : ' (inaktiv)') ) ->placeholder('Auswählen') ->preload(), @@ -106,13 +127,29 @@ public static function mainSections(): array ->schema([ Select::make('leasing_company_id') ->label('Leasinggeber') - ->relationship('leasingCompany', 'name') + ->relationship( + name: 'leasingCompany', + titleAttribute: 'name', + modifyQueryUsing: fn ($query, ?Vehicle $record) => $query + ->where(fn ($q) => $q->where('active', true) + ->orWhere('id', $record?->leasing_company_id)), + ) + ->getOptionLabelFromRecordUsing(fn ($record): string => $record->name + .($record->active ? '' : ' (inaktiv)')) ->searchable() ->placeholder('Auswählen') ->preload(), Select::make('dealership_id') ->label('Autohaus') - ->relationship('dealership', 'name') + ->relationship( + name: 'dealership', + titleAttribute: 'name', + modifyQueryUsing: fn ($query, ?Vehicle $record) => $query + ->where(fn ($q) => $q->where('active', true) + ->orWhere('id', $record?->dealership_id)), + ) + ->getOptionLabelFromRecordUsing(fn ($record): string => $record->name + .($record->active ? '' : ' (inaktiv)')) ->searchable() ->placeholder('Auswählen') ->preload(), @@ -170,6 +207,13 @@ public static function mainSections(): array ->minValue(0) ->prefix('€') ->dehydrated(fn (): bool => auth()->user()?->canViewCosts() ?? false), + TextInput::make('gross_list_price') + ->label('Bruttolistenpreis') + ->numeric() + ->minValue(0) + ->prefix('€') + ->helperText('Listenpreis bei Erstzulassung. Fließt nicht in die Jahreskosten ein.') + ->dehydrated(fn (): bool => auth()->user()?->canViewCosts() ?? false), ]), ]; } @@ -299,7 +343,17 @@ private static function vehicleSection(): Component ->label('Kennzeichen') ->required() ->maxLength(255) - ->unique(ignoreRecord: true), + ->unique(ignoreRecord: true) + // Ein ausgemustertes Fahrzeug belegt sein Kennzeichen + // weiterhin (so gewollt - sonst stuende dasselbe + // Kennzeichen zweimal in der Tabelle). Ohne diesen + // Hinweis sucht man in der Liste nach einem Eintrag, + // der dort standardmaessig nicht sichtbar ist. + ->validationMessages([ + 'unique' => 'Dieses Kennzeichen ist bereits vergeben — ' + .'möglicherweise von einem ausgemusterten Fahrzeug. ' + .'Über den Statusfilter in der Fahrzeugliste sind diese sichtbar.', + ]), // Nicht Pflicht (Auftraggeber-Entscheidung): bei Altbestand // ist die Fahrgestellnummer nicht immer erfasst. Die // Umwandlung in Grossbuchstaben passiert VOR der diff --git a/app/Filament/Resources/Vehicles/Schemas/VehicleInfolist.php b/app/Filament/Resources/Vehicles/Schemas/VehicleInfolist.php index 9217202..d6ae9e6 100644 --- a/app/Filament/Resources/Vehicles/Schemas/VehicleInfolist.php +++ b/app/Filament/Resources/Vehicles/Schemas/VehicleInfolist.php @@ -114,6 +114,10 @@ public static function mainSections(): array ->label('Versicherung (jährlich)') ->money('EUR') ->placeholder('—'), + TextEntry::make('gross_list_price') + ->label('Bruttolistenpreis') + ->money('EUR') + ->placeholder('—'), // Abgeleiteter Wert (siehe Vehicle::annualTotalCost()) - // die Spezifikation verlangt ausgewiesene Jahreskosten // direkt am Fahrzeug, nicht nur auf der Kostenseite. diff --git a/app/Filament/Resources/Vehicles/Tables/VehiclesTable.php b/app/Filament/Resources/Vehicles/Tables/VehiclesTable.php index 1293c6a..92208b3 100644 --- a/app/Filament/Resources/Vehicles/Tables/VehiclesTable.php +++ b/app/Filament/Resources/Vehicles/Tables/VehiclesTable.php @@ -4,6 +4,8 @@ use App\Enums\FuelType; use App\Enums\VehicleStatus; +use App\Filament\Actions\ActivateAction; +use App\Filament\Actions\DeactivateAction; use App\Filament\Exports\VehicleExporter; use App\Models\Vehicle; use Filament\Actions\EditAction; @@ -49,7 +51,16 @@ public static function configure(Table $table): Table ->searchable(), TextColumn::make('employee.last_name') ->label('Fahrer') - ->formatStateUsing(fn ($state, Vehicle $record): string => $record->employee?->full_name ?? '—') + // Ein Fahrzeug, dessen Fahrer inzwischen inaktiv ist, + // behaelt seine Zuordnung (siehe DeactivateAction). Der + // Hinweis "(inaktiv)" verhindert, dass die Anzeige das + // verschweigt und der Datensatz wirkt, als sei niemand + // eingetragen. + ->formatStateUsing(fn ($state, Vehicle $record): string => match (true) { + $record->employee === null => '—', + ! $record->employee->active => "{$record->employee->full_name} (inaktiv)", + default => $record->employee->full_name, + }) ->searchable(['first_name', 'last_name']), TextColumn::make('fuel_type') ->label('Antriebsart') @@ -108,6 +119,23 @@ public static function configure(Table $table): Table ->recordActions([ ViewAction::make(), EditAction::make(), + // Das Fahrzeug fuehrt seinen Zustand als Enum in `status`, nicht + // als boolesches `active` wie die Stammdaten - daher + // stateColumn(). "Ausmustern" statt "Deaktivieren", weil das + // im Fuhrpark das gelaeufige Wort ist. + DeactivateAction::make() + ->label('Ausmustern') + ->modalHeading('Fahrzeug ausmustern') + ->modalDescription( + 'Das Fahrzeug bleibt mit seiner gesamten Historie, seinen Dokumenten ' + .'und Bildern erhalten, zählt aber nicht mehr zum aktiven Bestand. ' + .'Löschen ist danach möglich.' + ) + ->modalSubmitActionLabel('Ausmustern') + ->stateColumn('status', VehicleStatus::Retired, VehicleStatus::Active), + ActivateAction::make() + ->label('Wieder aktivieren') + ->stateColumn('status', VehicleStatus::Retired, VehicleStatus::Active), ]); } } diff --git a/app/Filament/Resources/Vehicles/VehicleResource.php b/app/Filament/Resources/Vehicles/VehicleResource.php index 143dfb6..e3e521f 100644 --- a/app/Filament/Resources/Vehicles/VehicleResource.php +++ b/app/Filament/Resources/Vehicles/VehicleResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\Vehicles; +use App\Enums\VehicleStatus; use App\Filament\Resources\Vehicles\Pages\CreateVehicle; use App\Filament\Resources\Vehicles\Pages\EditVehicle; use App\Filament\Resources\Vehicles\Pages\ListVehicles; @@ -18,6 +19,7 @@ use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use UnitEnum; @@ -110,7 +112,7 @@ public static function getGlobalSearchResultDetails(Model $record): array public static function getRelations(): array { return [ - RelationGroup::make('Dokumente & Termine', [ + RelationGroup::make('Dokumente & Historie', [ VehicleDocumentsRelationManager::class, VehicleAppointmentsRelationManager::class, ]), @@ -126,4 +128,14 @@ public static function getPages(): array 'edit' => EditVehicle::route('/{record}/edit'), ]; } + + /** + * Ausgemusterte Fahrzeuge tauchen im Alltag nicht mehr auf - auch nicht + * in der globalen Suche, die sonst der einzige Weg waere, ein + * ausgemustertes Fahrzeug versehentlich wieder heranzuziehen. + */ + public static function getGlobalSearchEloquentQuery(): Builder + { + return parent::getGlobalSearchEloquentQuery()->where('status', VehicleStatus::Active); + } } diff --git a/app/Http/Middleware/EnsureTwoFactorWhenRequired.php b/app/Http/Middleware/EnsureTwoFactorWhenRequired.php new file mode 100644 index 0000000..daf0f25 --- /dev/null +++ b/app/Http/Middleware/EnsureTwoFactorWhenRequired.php @@ -0,0 +1,45 @@ +user(); + + if (! $user instanceof User) { + return $next($request); + } + + if (! $user->requires_two_factor || filled($user->app_authentication_secret)) { + return $next($request); + } + + // Zielseite und Abmeldeweg muessen erreichbar bleiben, sonst leitete + // die Middleware auf eine Seite um, die sie erneut umleitet - eine + // Schleife, aus der sich der Benutzer nicht einmal abmelden koennte. + // Livewire-Requests tragen das Formular der Profilseite: wuerden sie + // umgeleitet, koennte 2FA dort nicht abgesendet werden. + if ($request->routeIs('filament.admin.auth.profile', 'filament.admin.auth.logout') + || $request->hasHeader('X-Livewire')) { + return $next($request); + } + + return redirect()->route('filament.admin.auth.profile'); + } +} diff --git a/app/Models/Dealership.php b/app/Models/Dealership.php index e3b282d..109f3bb 100644 --- a/app/Models/Dealership.php +++ b/app/Models/Dealership.php @@ -19,8 +19,25 @@ class Dealership extends Model 'postcode', 'city', 'notes', + 'active', ]; + /** + * Spiegelt den Migrations-Standard, damit ein frisch erzeugtes Modell + * denselben Zustand hat wie eine frisch geschriebene Zeile - auch ohne + * erneutes Laden aus der Datenbank. Gleiches Muster wie in User. + */ + protected $attributes = [ + 'active' => true, + ]; + + protected function casts(): array + { + return [ + 'active' => 'boolean', + ]; + } + public function vehicles(): HasMany { return $this->hasMany(Vehicle::class); diff --git a/app/Models/FuelCard.php b/app/Models/FuelCard.php index 58f4087..b79f638 100644 --- a/app/Models/FuelCard.php +++ b/app/Models/FuelCard.php @@ -16,6 +16,16 @@ class FuelCard extends Model 'card_number', 'valid_until', 'notes', + 'active', + ]; + + /** + * Spiegelt den Migrations-Standard, damit ein frisch erzeugtes Modell + * denselben Zustand hat wie eine frisch geschriebene Zeile - auch ohne + * erneutes Laden aus der Datenbank. Gleiches Muster wie in User. + */ + protected $attributes = [ + 'active' => true, ]; protected $hidden = [ @@ -27,6 +37,7 @@ protected function casts(): array return [ 'card_number' => 'encrypted', 'valid_until' => 'date', + 'active' => 'boolean', ]; } diff --git a/app/Models/FuelCardProvider.php b/app/Models/FuelCardProvider.php index dccaa36..4b13990 100644 --- a/app/Models/FuelCardProvider.php +++ b/app/Models/FuelCardProvider.php @@ -13,8 +13,25 @@ class FuelCardProvider extends Model protected $fillable = [ 'name', 'notes', + 'active', ]; + /** + * Spiegelt den Migrations-Standard, damit ein frisch erzeugtes Modell + * denselben Zustand hat wie eine frisch geschriebene Zeile - auch ohne + * erneutes Laden aus der Datenbank. Gleiches Muster wie in User. + */ + protected $attributes = [ + 'active' => true, + ]; + + protected function casts(): array + { + return [ + 'active' => 'boolean', + ]; + } + public function fuelCards(): HasMany { return $this->hasMany(FuelCard::class); diff --git a/app/Models/LeasingCompany.php b/app/Models/LeasingCompany.php index ff40e05..bee6280 100644 --- a/app/Models/LeasingCompany.php +++ b/app/Models/LeasingCompany.php @@ -16,8 +16,25 @@ class LeasingCompany extends Model 'phone', 'email', 'notes', + 'active', ]; + /** + * Spiegelt den Migrations-Standard, damit ein frisch erzeugtes Modell + * denselben Zustand hat wie eine frisch geschriebene Zeile - auch ohne + * erneutes Laden aus der Datenbank. Gleiches Muster wie in User. + */ + protected $attributes = [ + 'active' => true, + ]; + + protected function casts(): array + { + return [ + 'active' => 'boolean', + ]; + } + public function vehicles(): HasMany { return $this->hasMany(Vehicle::class); diff --git a/app/Models/User.php b/app/Models/User.php index d8ff39d..e452fb2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -26,6 +26,7 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication 'password', 'role', 'can_view_costs', + 'requires_two_factor', ]; protected $hidden = [ @@ -41,6 +42,7 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication protected $attributes = [ 'role' => 'viewer', 'can_view_costs' => false, + 'requires_two_factor' => false, ]; /** @@ -53,6 +55,7 @@ protected function casts(): array 'password' => 'hashed', 'role' => UserRole::class, 'can_view_costs' => 'boolean', + 'requires_two_factor' => 'boolean', ]; } diff --git a/app/Models/Vehicle.php b/app/Models/Vehicle.php index 0195ec3..2518af0 100644 --- a/app/Models/Vehicle.php +++ b/app/Models/Vehicle.php @@ -63,6 +63,7 @@ class Vehicle extends Model 'monthly_lease_rate', 'annual_vehicle_tax', 'annual_insurance', + 'gross_list_price', 'status', 'notes', ]; @@ -78,6 +79,7 @@ protected function casts(): array 'monthly_lease_rate' => 'decimal:2', 'annual_vehicle_tax' => 'decimal:2', 'annual_insurance' => 'decimal:2', + 'gross_list_price' => 'decimal:2', ]; } diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index bb7e359..e9b80b6 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -36,4 +36,14 @@ public function deleteAny(User $user): bool { return $user->isAdmin(); } + + /** + * Ein Admin kann fremdes 2FA zuruecksetzen, nie neu einrichten - das + * Geheimnis darf ausschliesslich in der Authenticator-App des Benutzers + * entstehen, sonst waere der zweite Faktor keiner mehr. + */ + public function resetTwoFactor(User $user, User $model): bool + { + return $user->isAdmin(); + } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 2d88bfa..20b70ac 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -3,6 +3,7 @@ namespace App\Providers\Filament; use App\Filament\Pages\Auth\Login; +use App\Http\Middleware\EnsureTwoFactorWhenRequired; use App\Models\AppSetting; use Filament\Auth\MultiFactor\App\AppAuthentication; use Filament\Http\Middleware\Authenticate; @@ -60,6 +61,10 @@ public function panel(Panel $panel): Panel ->colors([ 'primary' => AppSetting::palette(), 'danger' => Color::Rose, + // Zusaetzlich registriert fuer AppointmentType::Damage - + // 'warning' ist bereits fuer die Hauptuntersuchung belegt, + // und jeder Historien-Typ soll seine eigene Farbe haben. + 'orange' => Color::Orange, ]) ->login(Login::class) ->passwordReset() @@ -94,6 +99,7 @@ public function panel(Panel $panel): Panel ]) ->authMiddleware([ Authenticate::class, + EnsureTwoFactorWhenRequired::class, ]); } } diff --git a/database/migrations/2026_08_05_054516_add_active_to_master_data_tables.php b/database/migrations/2026_08_05_054516_add_active_to_master_data_tables.php new file mode 100644 index 0000000..92229a3 --- /dev/null +++ b/database/migrations/2026_08_05_054516_add_active_to_master_data_tables.php @@ -0,0 +1,48 @@ +boolean('active')->default(true); + }); + } + } + + public function down(): void + { + foreach (self::TABLES as $tableName) { + Schema::table($tableName, function (Blueprint $table): void { + $table->dropColumn('active'); + }); + } + } +}; diff --git a/database/migrations/2026_08_05_071234_add_gross_list_price_to_vehicles_table.php b/database/migrations/2026_08_05_071234_add_gross_list_price_to_vehicles_table.php new file mode 100644 index 0000000..6e49c3e --- /dev/null +++ b/database/migrations/2026_08_05_071234_add_gross_list_price_to_vehicles_table.php @@ -0,0 +1,29 @@ +decimal('gross_list_price', 10, 2)->nullable()->after('annual_insurance'); + }); + } + + public function down(): void + { + Schema::table('vehicles', function (Blueprint $table): void { + $table->dropColumn('gross_list_price'); + }); + } +}; diff --git a/database/migrations/2026_08_05_072303_add_requires_two_factor_to_users_table.php b/database/migrations/2026_08_05_072303_add_requires_two_factor_to_users_table.php new file mode 100644 index 0000000..c99266b --- /dev/null +++ b/database/migrations/2026_08_05_072303_add_requires_two_factor_to_users_table.php @@ -0,0 +1,32 @@ +boolean('requires_two_factor')->default(false)->after('can_view_costs'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('requires_two_factor'); + }); + } +}; diff --git a/docs/superpowers/plans/2026-08-05-korrekturen-nach-erstbenutzung.md b/docs/superpowers/plans/2026-08-05-korrekturen-nach-erstbenutzung.md new file mode 100644 index 0000000..9abb955 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-korrekturen-nach-erstbenutzung.md @@ -0,0 +1,1429 @@ +# Korrekturen nach der ersten Benutzung — Umsetzungsplan + +> **Für agentische Bearbeiter:** Dieser Plan wird Aufgabe für Aufgabe abgearbeitet. Die Schritte +> nutzen Checkbox-Syntax (`- [ ]`). Grundlage ist +> `docs/superpowers/specs/2026-08-05-korrekturen-nach-erstbenutzung-design.md`. + +**Ziel:** Fünf Rückmeldungen aus der ersten echten Benutzung umsetzen — Deaktivieren statt +Löschen, 2FA-Steuerung für Admins, Bruttolistenpreis, „Historie" statt „Termine", und der +Bildupload-Fehler bei kleinen Bildern. + +**Architektur:** Kein neues Konzept. Der vorhandene Zustand (`vehicles.status`, +`employees.active`) bekommt eine Wirkung und wird auf die übrigen Stammdaten ausgeweitet. Löschen +wird an diesen Zustand gebunden. Alles andere sind punktuelle Ergänzungen an bestehenden Klassen. + +**Tech-Stack:** Laravel 13, Filament 5, Livewire 4, MariaDB 11.8, Pest 4, PHP 8.4, DDEV. + +## Globale Vorgaben + +Diese gelten für **jede** Aufgabe: + +- Alle Befehle laufen über DDEV: `ddev artisan …`, `ddev composer …`, `ddev exec vendor/bin/pest`. +- **Niemals** `migrate:fresh`, `migrate:refresh`, `migrate:reset`, `db:wipe`, `db:seed` — die + Entwicklungsdatenbank enthält echte Arbeitsdaten. Nur `ddev artisan migrate` (vorwärts). +- **Keine schreibenden `tinker`-Befehle** gegen die Entwicklungsdatenbank. Lesende sind in Ordnung. +- Code, Klassennamen, Spaltennamen und Commit-Messages englisch. Oberflächentexte deutsch, Labels + immer explizit (`->label('Kennzeichen')`). +- Die Testsuite läuft gegen MariaDB (Datenbank `test`, siehe `phpunit.xml`), nicht gegen SQLite — + nur so werden die Fremdschlüssel mit `restrictOnDelete` echt geprüft. +- Vor jedem Commit: `ddev exec vendor/bin/pint` und `ddev exec vendor/bin/pest`. +- TDD: erst der Test, dann ihn scheitern sehen, dann die Umsetzung, dann bestehen sehen, dann + committen. Ein RED-Beweis ist die **kopierte Ausgabe** des Testlaufs, keine Behauptung. + +## Dateiübersicht + +**Aufgabe 1** (eigener Branch `fix/vehicle-image-upload`): +- Ändern: `app/Filament/Resources/Vehicles/Schemas/VehicleForm.php` (Bildfeld) +- Test: `tests/Feature/Filament/VehicleImageUploadTest.php` (neu) + +**Aufgaben 2–7** (Branch `feature/archiving-and-admin-controls`): +- Neu: `database/migrations/2026_08_05_100000_add_active_to_master_data_tables.php` +- Neu: `database/migrations/2026_08_05_100001_add_gross_list_price_to_vehicles_table.php` +- Neu: `database/migrations/2026_08_05_100002_add_requires_two_factor_to_users_table.php` +- Neu: `app/Filament/Actions/DeactivateAction.php`, `app/Filament/Actions/ActivateAction.php` +- Neu: `app/Http/Middleware/EnsureTwoFactorWhenRequired.php` +- Ändern: die Modelle `Dealership`, `LeasingCompany`, `FuelCard`, `FuelCardProvider` (`$fillable`, + `casts`), `Vehicle` (`$fillable`, Relationen), `User` (`$fillable`, `casts`) +- Ändern: je Resource die Klassen unter `Schemas/` (Formular, Infolist) und `Tables/` +- Ändern: `app/Enums/AppointmentType.php`, `app/Providers/Filament/AdminPanelProvider.php` +- Ändern: `app/Filament/Actions/GuardedDeleteAction.php` (nur noch sichtbar bei inaktiv) + +--- + +### Aufgabe 1: Bildupload nimmt beliebige Bildgrößen an + +**Branch:** `fix/vehicle-image-upload`, abzweigen von `main`. + +**Dateien:** +- Ändern: `app/Filament/Resources/Vehicles/Schemas/VehicleForm.php:205-259` +- Test: `tests/Feature/Filament/VehicleImageUploadTest.php` (neu) + +**Schnittstellen:** +- Verbraucht: nichts aus anderen Aufgaben. +- Erzeugt: nichts, was andere Aufgaben brauchen. + +- [ ] **Schritt 1: Branch anlegen** + +```bash +git switch main && git pull --ff-only origin main +git switch -c fix/vehicle-image-upload +``` + +- [ ] **Schritt 2: Den scheiternden Test schreiben** + +Datei `tests/Feature/Filament/VehicleImageUploadTest.php`: + +```php +admin()->withTwoFactor()->create()); + Storage::fake('local'); +}); + +// Der Kern des Fehlers: imageAspectRatio('4:3') erzeugt serverseitig +// Rule::dimensions()->ratio(). Ein Bild in einem anderen Verhaeltnis wurde +// dadurch abgelehnt, obwohl die Groesse laut Anforderung egal sein soll. +it('accepts an image that is not in a 4:3 ratio', function () { + $vehicle = Vehicle::factory()->create(['image_path' => null]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->fillForm(['image_path' => [UploadedFile::fake()->image('panorama.jpg', 1600, 400)]]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($vehicle->fresh()->image_path)->not->toBeNull(); +}); + +it('accepts an image far below the resize width of 2000 pixels', function () { + $vehicle = Vehicle::factory()->create(['image_path' => null]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->fillForm(['image_path' => [UploadedFile::fake()->image('klein.jpg', 800, 600)]]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($vehicle->fresh()->image_path)->not->toBeNull(); +}); + +it('still rejects an image above the ten megabyte limit', function () { + $vehicle = Vehicle::factory()->create(['image_path' => null]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->fillForm([ + 'image_path' => [UploadedFile::fake()->image('gross.jpg', 4000, 3000)->size(11 * 1024)], + ]) + ->call('save') + ->assertHasFormErrors(['image_path']); +}); +``` + +- [ ] **Schritt 3: Test laufen lassen und scheitern sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/Filament/VehicleImageUploadTest.php +``` + +Erwartet: Der erste Test scheitert mit einem Formularfehler auf `image_path` (die +`dimensions`-Regel). Die Ausgabe in den Commit-Verlauf übernehmen, nicht paraphrasieren. + +- [ ] **Schritt 4: Das Bildfeld ändern** + +In `VehicleForm.php` im Feld `image_path`: + +1. Die Zeilen `->imageAspectRatio('4:3')` und `->automaticallyOpenImageEditorForAspectRatio()` + **entfernen**. +2. `->automaticallyResizeImagesMode('cover')` zu `->automaticallyResizeImagesMode('contain')` + ändern. Begründung: Ohne festes Seitenverhältnis würde `cover` ein Panorama auf ein Quadrat + beschneiden, weil keine Zielhöhe gesetzt ist. `contain` skaliert nur herunter und schneidet nie. +3. `->automaticallyUpscaleImagesWhenResizing(false)` **ergänzen**. +4. Aus `->validationMessages([...])` den Eintrag `'dimensions' => …` **entfernen** — die Regel + gibt es nicht mehr, die Meldung wäre tote Konfiguration. +5. Den Kommentarblock über den entfernten Zeilen anpassen: Er beschreibt heute den erzwungenen + 4:3-Zuschnitt. Neu soll dort stehen, dass der Editor manuell verfügbar bleibt, dass die + Bildgröße bewusst nicht mehr eingeschränkt wird, und dass `automaticallyUpscaleImagesWhenResizing(false)` + verhindert, dass kleine Bilder auf 2000 Pixel hochgerechnet und dadurch unscharf werden. + +Ergebnis (der Teil der Kette, der sich ändert): + +```php +->imageEditor() +->automaticallyResizeImagesMode('contain') +->automaticallyResizeImagesToWidth('2000') +->automaticallyUpscaleImagesWhenResizing(false) +``` + +- [ ] **Schritt 5: Test laufen lassen und bestehen sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/Filament/VehicleImageUploadTest.php +``` + +Erwartet: alle drei Tests grün. + +- [ ] **Schritt 6: Darstellung auf beliebige Seitenverhältnisse einstellen** + +Bilder haben ab jetzt beliebige Formen. Überall dort, wo ein Fahrzeugbild angezeigt wird +(`VehiclesTable` als `ImageColumn`, die Detailseite als `ImageEntry`, die Vorschau im Formular), +prüfen, dass ein fester Rahmen mit `object-fit: cover` gesetzt ist — sonst werden Zeilen und +Kacheln unterschiedlich hoch. Filaments `ImageColumn` bietet dafür `->imageSize()` und +`->extraImgAttributes(['class' => 'object-cover'])`. + +Die Stellen finden mit: + +```bash +grep -rn "image_path" app/Filament/ resources/views/ +``` + +- [ ] **Schritt 7: Volle Suite, Pint, Commit** + +```bash +ddev exec vendor/bin/pint +ddev exec vendor/bin/pest +git add -A +git commit -m "fix: accept vehicle images of any size and aspect ratio" +``` + +- [ ] **Schritt 8: Im Browser gegenprüfen** + +Ein Bild mit 800×600 hochladen und danach die gespeicherte Datei ansehen: Sie muss **800×600** +sein, nicht auf 2000 Pixel hochgerechnet. Ein Bild mit 4000 Pixel Breite hochladen: Es muss auf +2000 Pixel Breite herunterskaliert ankommen, mit unverändertem Seitenverhältnis. Diese beiden +Werte tatsächlich ablesen — das Verhalten des FilePond-Plugins ist die eine Sache an dieser +Aufgabe, die sich nicht durch einen serverseitigen Test belegen lässt, weil die Verkleinerung im +Browser passiert. + +- [ ] **Schritt 9: Branch pushen und PR öffnen** + +```bash +git push -u origin fix/vehicle-image-upload +gh pr create --base main --title "fix: accept vehicle images of any size and aspect ratio" \ + --body "Beschreibung: Ursache (Upscaling auf 2000 Pixel und die serverseitige +4:3-Regel), Änderung, und die im Browser abgelesenen Bildmaße aus Schritt 8." +``` + +--- + +### Aufgabe 2: Feld `active` für die übrigen Stammdaten + +**Branch:** `feature/archiving-and-admin-controls` (existiert bereits, enthält die Spec). + +**Dateien:** +- Neu: `database/migrations/2026_08_05_100000_add_active_to_master_data_tables.php` +- Ändern: `app/Models/Dealership.php`, `app/Models/LeasingCompany.php`, + `app/Models/FuelCard.php`, `app/Models/FuelCardProvider.php` +- Ändern: die `Schemas/*Form.php`, `Schemas/*Infolist.php` und `Tables/*Table.php` dieser vier + Resources +- Test: `tests/Feature/MasterDataActiveFlagTest.php` (neu) + +**Schnittstellen:** +- Verbraucht: nichts. +- Erzeugt: Spalte `active` (boolean, Standard `true`) auf `dealerships`, `leasing_companies`, + `fuel_cards`, `fuel_card_providers`; jeweils `'active' => 'boolean'` in `casts()` und `'active'` + in `$fillable`. Aufgaben 3 und 4 setzen das voraus. + +- [ ] **Schritt 1: Den scheiternden Test schreiben** + +Datei `tests/Feature/MasterDataActiveFlagTest.php`: + +```php +create(); + + expect($record->active)->toBeTrue(); +})->with([ + Dealership::class, + LeasingCompany::class, + FuelCard::class, + FuelCardProvider::class, +]); + +it('casts the active flag to a boolean', function (string $modelClass) { + $record = $modelClass::factory()->create(['active' => false]); + + expect($record->fresh()->active)->toBeFalse(); +})->with([ + Dealership::class, + LeasingCompany::class, + FuelCard::class, + FuelCardProvider::class, +]); +``` + +- [ ] **Schritt 2: Test laufen lassen und scheitern sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/MasterDataActiveFlagTest.php +``` + +Erwartet: `Column not found: 1054 Unknown column 'active'`. + +- [ ] **Schritt 3: Migration schreiben** + +```bash +ddev artisan make:migration add_active_to_master_data_tables +``` + +Die erzeugte Datei so füllen (Dateiname der Übersicht oben angleichen, falls der Zeitstempel +abweicht): + +```php +boolean('active')->default(true); + }); + } + } + + public function down(): void + { + foreach (self::TABLES as $table) { + Schema::table($table, function (Blueprint $table): void { + $table->dropColumn('active'); + }); + } + } +}; +``` + +- [ ] **Schritt 4: Migration ausführen** + +```bash +ddev artisan migrate +``` + +- [ ] **Schritt 5: Die vier Modelle ergänzen** + +In `Dealership`, `LeasingCompany`, `FuelCard` und `FuelCardProvider` jeweils: + +- `'active'` in `$fillable` aufnehmen, +- `'active' => 'boolean'` in `casts()` aufnehmen, +- `protected $attributes = ['active' => true];` ergänzen, damit ein frisch erzeugtes Modell + denselben Zustand hat wie eine frisch geschriebene Zeile — dasselbe Muster wie in `User`. + +- [ ] **Schritt 6: Test laufen lassen und bestehen sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/MasterDataActiveFlagTest.php +``` + +- [ ] **Schritt 7: Feld in Formular, Liste und Detailseite aufnehmen** + +Für jede der vier Resources, wortgleich zum bestehenden Muster beim Mitarbeiter +(`EmployeeForm.php:30`, `EmployeesTable.php:32`, `EmployeeInfolist.php:41`): + +- Formular: `Toggle::make('active')->label('Aktiv')->default(true)` +- Liste: `IconColumn::make('active')->label('Aktiv')->boolean()` +- Detailseite: `IconEntry::make('active')->label('Aktiv')->boolean()` + +- [ ] **Schritt 8: Volle Suite, Pint, Commit** + +```bash +ddev exec vendor/bin/pint +ddev exec vendor/bin/pest +git add -A +git commit -m "feat: add an active flag to the remaining master data" +``` + +--- + +### Aufgabe 3: Deaktivieren, Aktivieren, und Löschen nur bei inaktiv + +**Dateien:** +- Neu: `app/Filament/Actions/DeactivateAction.php`, `app/Filament/Actions/ActivateAction.php` +- Ändern: `app/Filament/Actions/GuardedDeleteAction.php` +- Ändern: alle sechs `Tables/*Table.php` (Employees, Dealerships, LeasingCompanies, FuelCards, + FuelCardProviders, Vehicles) +- Test: `tests/Feature/Filament/DeactivateInsteadOfDeleteTest.php` (neu) + +**Schnittstellen:** +- Verbraucht: Spalte `active` aus Aufgabe 2. +- Erzeugt: + - `DeactivateAction::make()` — setzt das Zustandsfeld auf „inaktiv". Standardmäßig `active`; + über `->stateColumn(string $column, mixed $inactiveValue, mixed $activeValue)` umstellbar für + das Fahrzeug (`status`, `VehicleStatus::Retired`, `VehicleStatus::Active`). + - `ActivateAction::make()` — dieselbe Schnittstelle, setzt auf „aktiv". + - `GuardedDeleteAction` ist ab jetzt nur bei inaktiven Datensätzen sichtbar. + +- [ ] **Schritt 1: Den scheiternden Test schreiben** + +Datei `tests/Feature/Filament/DeactivateInsteadOfDeleteTest.php`: + +```php +admin()->withTwoFactor()->create()); +}); + +it('offers deactivating but not deleting while a record is active', function () { + $employee = Employee::factory()->create(['active' => true]); + + livewire(ListEmployees::class) + ->assertTableActionVisible(DeactivateAction::class, $employee) + ->assertTableActionHidden(GuardedDeleteAction::class, $employee) + ->assertTableActionHidden(ActivateAction::class, $employee); +}); + +it('offers deleting and activating once a record is inactive', function () { + $employee = Employee::factory()->create(['active' => false]); + + livewire(ListEmployees::class) + ->assertTableActionVisible(GuardedDeleteAction::class, $employee) + ->assertTableActionVisible(ActivateAction::class, $employee) + ->assertTableActionHidden(DeactivateAction::class, $employee); +}); + +// Der Kern der Anforderung: Ausscheiden aus der Firma darf nicht daran +// scheitern, dass noch ein Auto zugeordnet ist. Die Zuordnung bleibt stehen. +it('deactivates an employee who still has a vehicle, keeping the assignment', function () { + $employee = Employee::factory()->create(['active' => true]); + $vehicle = Vehicle::factory()->for($employee)->create(); + + livewire(ListEmployees::class) + ->callTableAction(DeactivateAction::class, $employee); + + assertDatabaseHas(Employee::class, ['id' => $employee->id, 'active' => false]); + expect($vehicle->fresh()->employee_id)->toBe($employee->id); +}); + +it('refuses to delete an inactive employee who is still assigned to a vehicle', function () { + $employee = Employee::factory()->create(['active' => false]); + Vehicle::factory()->for($employee)->create(); + + livewire(ListEmployees::class) + ->callTableAction(GuardedDeleteAction::class, $employee); + + assertDatabaseHas(Employee::class, ['id' => $employee->id]); +}); + +it('deletes an inactive employee who is no longer assigned', function () { + $employee = Employee::factory()->create(['active' => false]); + + livewire(ListEmployees::class) + ->callTableAction(GuardedDeleteAction::class, $employee); + + expect(Employee::find($employee->id))->toBeNull(); +}); + +it('retires a vehicle instead of deleting it while it is active', function () { + $vehicle = Vehicle::factory()->create(['status' => VehicleStatus::Active]); + + livewire(ListVehicles::class) + ->assertTableActionHidden(GuardedDeleteAction::class, $vehicle) + ->callTableAction(DeactivateAction::class, $vehicle); + + expect($vehicle->fresh()->status)->toBe(VehicleStatus::Retired); +}); + +it('activates an inactive employee again', function () { + $employee = Employee::factory()->create(['active' => false]); + + livewire(ListEmployees::class) + ->callTableAction(ActivateAction::class, $employee); + + assertDatabaseHas(Employee::class, ['id' => $employee->id, 'active' => true]); +}); +``` + +- [ ] **Schritt 2: Test laufen lassen und scheitern sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/Filament/DeactivateInsteadOfDeleteTest.php +``` + +Erwartet: `Class "App\Filament\Actions\DeactivateAction" not found`. + +- [ ] **Schritt 3: Die beiden Aktionen schreiben** + +`app/Filament/Actions/DeactivateAction.php`: + +```php +label('Deaktivieren'); + $this->icon('heroicon-m-archive-box'); + $this->color('gray'); + $this->requiresConfirmation(); + $this->modalHeading('Eintrag deaktivieren'); + $this->modalDescription( + 'Der Eintrag bleibt erhalten und behält seine Zuordnungen, taucht aber ' + .'nicht mehr in Auswahllisten auf. Löschen ist danach möglich.' + ); + $this->modalSubmitActionLabel('Deaktivieren'); + + $this->visible(fn (Model $record): bool => $record->{$this->stateColumn} === $this->activeValue); + + $this->action(function (Model $record): void { + $record->{$this->stateColumn} = $this->inactiveValue; + $record->save(); + + Notification::make() + ->success() + ->title('Eintrag deaktiviert') + ->send(); + }); + } + + public function stateColumn(string $column, mixed $inactiveValue, mixed $activeValue): static + { + $this->stateColumn = $column; + $this->inactiveValue = $inactiveValue; + $this->activeValue = $activeValue; + + return $this; + } +} +``` + +`app/Filament/Actions/ActivateAction.php`: dieselbe Struktur, aber + +- `getDefaultName()` gibt `'activate'` zurück, +- Label „Aktivieren", Icon `heroicon-m-arrow-uturn-left`, Farbe `success`, +- `visible()` prüft auf **Ungleichheit** zum aktiven Wert, +- die Aktion setzt `$this->activeValue`, +- Meldung „Eintrag aktiviert". + +Keine Rückfrage — Aktivieren ist folgenlos umkehrbar. + +- [ ] **Schritt 4: `GuardedDeleteAction` nur noch bei inaktiven zeigen** + +In `app/Filament/Actions/GuardedDeleteAction.php` dieselben drei Eigenschaften und dieselbe +`stateColumn()`-Methode ergänzen wie oben, und in `setUp()` ergänzen: + +```php +// Loeschen ist erst der zweite Schritt: Ein aktiver Datensatz wird +// deaktiviert (DeactivateAction), und nur ein inaktiver laesst sich +// entfernen. Das ist die eigentliche Regel dieser Aenderung - der +// bestehende before()-Rumpf darunter bleibt als Erklaerung fuer den Fall, +// dass der Fremdschluessel trotzdem im Weg steht. +$this->visible(fn (Model $record): bool => $record->{$this->stateColumn} !== $this->activeValue); +``` + +Zusätzlich `modalHeading('Endgültig löschen')` setzen und im bestehenden `before()`-Rumpf den +Titel der Meldung von „Löschen nicht möglich" auf „Endgültig löschen nicht möglich" ändern. + +- [ ] **Schritt 5: Die sechs Tabellen umstellen** + +In jeder `Tables/*Table.php` bei `recordActions()` ergänzen — Reihenfolge: Ansehen, Bearbeiten, +Aktivieren/Deaktivieren, Löschen. + +Für die fünf Stammdaten (Standardwerte passen): + +```php +DeactivateAction::make(), +ActivateAction::make(), +GuardedDeleteAction::make(), +``` + +Für `VehiclesTable` mit umgestellter Spalte — **hier nur die beiden Zustandsaktionen**, siehe den +nächsten Schritt: + +```php +DeactivateAction::make() + ->label('Ausmustern') + ->modalHeading('Fahrzeug ausmustern') + ->modalDescription( + 'Das Fahrzeug bleibt mit seiner gesamten Historie erhalten, zählt aber ' + .'nicht mehr zum aktiven Bestand. Löschen ist danach möglich.' + ) + ->stateColumn('status', VehicleStatus::Retired, VehicleStatus::Active), +ActivateAction::make() + ->label('Wieder aktivieren') + ->stateColumn('status', VehicleStatus::Retired, VehicleStatus::Active), +``` + +- [ ] **Schritt 5b: Das Löschen des Fahrzeugs liegt woanders** + +`VehiclesTable` hat **keine** Löschaktion. Beim Fahrzeug hängt das Löschen am Formularfuß der +Bearbeitungsseite: `EditVehicle::getFormActions()` gibt unter anderem `$this->deleteAction()` +zurück — Filaments eingebaute `DeleteAction`, absichtlich mit `->outlined()` zurückhaltend +gestaltet (siehe der Kommentarblock oben in der Klasse). + +Diese Aktion muss dieselbe Bedingung bekommen. In `EditVehicle`: + +```php +/** + * Loeschen ist erst der zweite Schritt: Ein Fahrzeug im Bestand wird + * ausgemustert (DeactivateAction in der Liste), und nur ein ausgemustertes + * laesst sich entfernen. Ohne diese Bedingung waere die Regel genau hier + * umgehbar - die Liste bietet gar kein Loeschen an, diese Seite schon. + */ +protected function deleteAction(): Action +{ + return parent::deleteAction() + ->visible(fn (): bool => $this->getRecord()->status === VehicleStatus::Retired); +} +``` + +Der Test dazu (in dieselbe Testdatei wie oben): + +```php +it('hides deleting on the vehicle edit page while the vehicle is in service', function () { + $vehicle = Vehicle::factory()->create(['status' => VehicleStatus::Active]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->assertActionHidden('delete'); +}); + +it('shows deleting on the vehicle edit page once the vehicle is retired', function () { + $vehicle = Vehicle::factory()->create(['status' => VehicleStatus::Retired]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->assertActionVisible('delete'); +}); +``` + +Prüfen, ob die übrigen fünf Resources ebenfalls eine Löschaktion auf ihrer Bearbeitungsseite +haben, und sie dort genauso absichern: + +```bash +grep -rn "deleteAction\|DeleteAction" app/Filament/Resources/*/Pages/ +``` + +- [ ] **Schritt 6: Filter „Status" in die fünf Stammdatenlisten** + +Nach dem Vorbild aus `VehiclesTable.php:81-85`, damit inaktive nicht im Weg stehen: + +```php +->filters([ + TernaryFilter::make('active') + ->label('Status') + ->placeholder('Alle') + ->trueLabel('Nur aktive') + ->falseLabel('Nur inaktive') + // Wie in der Fahrzeugliste: inaktive sind zuschaltbar, aber nicht + // Standard. + ->default(true), +]) +``` + +Und eine Kennzeichnung in der Liste, damit ein inaktiver Datensatz auch beim Zuschalten sofort +erkennbar ist: + +```php +TextColumn::make('active') + ->label('Status') + ->badge() + ->formatStateUsing(fn (bool $state): string => $state ? 'Aktiv' : 'Inaktiv') + ->color(fn (bool $state): string => $state ? 'success' : 'gray'), +``` + +Diese Spalte ersetzt die `IconColumn::make('active')` aus Aufgabe 2, Schritt 7 — beim Mitarbeiter +ebenso, damit alle sechs Listen gleich aussehen. + +Test dazu, in dieselbe Testdatei: + +```php +it('hides inactive records from the default list view but shows them when filtered', function () { + $active = Employee::factory()->create(['active' => true]); + $inactive = Employee::factory()->create(['active' => false]); + + livewire(ListEmployees::class) + ->assertCanSeeTableRecords([$active]) + ->assertCanNotSeeTableRecords([$inactive]) + ->filterTable('active', false) + ->assertCanSeeTableRecords([$inactive]); +}); +``` + +- [ ] **Schritt 6b: Fehlermeldung bei einem belegten Unique-Wert** + +`vehicles.license_plate` und `fuel_card_providers.name` sind `unique`, und ein inaktiver Datensatz +belegt den Wert weiterhin (so gewollt, siehe Spec). Ohne Hinweis ist die Meldung „ist bereits +vergeben" aber irreführend, weil der belegende Datensatz in der Standardansicht gar nicht sichtbar +ist — der Benutzer sucht dann nach einem Eintrag, den er nicht findet. + +Am Kennzeichen-Feld in `VehicleForm` und am Namensfeld in `FuelCardProviderForm`: + +```php +->validationMessages([ + 'unique' => 'Dieser Wert ist bereits vergeben — möglicherweise von einem ' + .'ausgemusterten oder inaktiven Eintrag. Über den Statusfilter in der ' + .'Liste sind diese sichtbar.', +]) +``` + +Test dazu: + +```php +it('explains that an inactive record may be holding the licence plate', function () { + Vehicle::factory()->create(['license_plate' => 'M-AB 123', 'status' => VehicleStatus::Retired]); + + livewire(CreateVehicle::class) + ->fillForm(['license_plate' => 'M-AB 123']) + ->call('create') + ->assertHasFormErrors(['license_plate']); +}); +``` + +- [ ] **Schritt 7: Test laufen lassen und bestehen sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/Filament/DeactivateInsteadOfDeleteTest.php +``` + +- [ ] **Schritt 8: Bestehende Tests prüfen** + +Die vorhandenen Resource-Tests löschen Stammdaten teilweise direkt. Sie schlagen jetzt zu Recht +fehl, weil die Aktion bei aktiven Datensätzen nicht mehr sichtbar ist. + +```bash +ddev exec vendor/bin/pest +``` + +Jeden Fehlschlag einzeln ansehen und **inhaltlich** entscheiden: Test anpassen (Datensatz vorher +inaktiv setzen) — nicht die neue Regel aufweichen, damit ein alter Test wieder grün wird. + +- [ ] **Schritt 9: Pint, volle Suite, Commit** + +```bash +ddev exec vendor/bin/pint +ddev exec vendor/bin/pest +git add -A +git commit -m "feat: deactivate master data instead of deleting it outright" +``` + +--- + +### Aufgabe 4: Inaktive aus Auswahlfeldern, Suche und Anzeige heraushalten + +**Dateien:** +- Ändern: `app/Filament/Resources/Vehicles/Schemas/VehicleForm.php` (die vier `Select`-Felder) +- Ändern: `app/Filament/Resources/FuelCards/Schemas/FuelCardForm.php` (Anbieter-Select) +- Ändern: die `*Resource.php` der fünf Stammdaten und `VehicleResource` (globale Suche) +- Ändern: `VehiclesTable`, `VehicleInfolist` (Kennzeichnung „(inaktiv)") +- Test: `tests/Feature/Filament/InactiveMasterDataVisibilityTest.php` (neu) + +**Schnittstellen:** +- Verbraucht: `active` aus Aufgabe 2, die Aktionen aus Aufgabe 3. +- Erzeugt: nichts für spätere Aufgaben. + +- [ ] **Schritt 1: Den scheiternden Test schreiben** + +Datei `tests/Feature/Filament/InactiveMasterDataVisibilityTest.php`: + +```php +admin()->withTwoFactor()->create()); +}); + +it('does not offer an inactive employee as a driver', function () { + $active = Employee::factory()->create(['active' => true, 'last_name' => 'Aktiv']); + $inactive = Employee::factory()->create(['active' => false, 'last_name' => 'Inaktiv']); + $vehicle = Vehicle::factory()->create(['employee_id' => null]); + + $options = livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->instance() + ->form + ->getComponent('employee_id') + ->getOptions(); + + expect($options)->toHaveKey($active->id) + ->and($options)->not->toHaveKey($inactive->id); +}); + +// Der Fallstrick: Wuerde das Auswahlfeld den bereits gesetzten, inzwischen +// inaktiven Fahrer nicht mehr kennen, waere er nach dem naechsten Speichern +// stillschweigend weg. +it('keeps an already assigned employee selectable after they were deactivated', function () { + $employee = Employee::factory()->create(['active' => true]); + $vehicle = Vehicle::factory()->for($employee)->create(); + + $employee->update(['active' => false]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($vehicle->fresh()->employee_id)->toBe($employee->id); +}); + +it('excludes inactive employees from global search', function () { + Employee::factory()->create(['active' => false, 'last_name' => 'Unsichtbar']); + + $results = App\Filament\Resources\Employees\EmployeeResource::getGlobalSearchResults('Unsichtbar'); + + expect($results)->toBeEmpty(); +}); +``` + +- [ ] **Schritt 2: Test laufen lassen und scheitern sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/Filament/InactiveMasterDataVisibilityTest.php +``` + +Erwartet: Der erste Test scheitert, weil das Auswahlfeld auch den inaktiven Mitarbeiter enthält. + +- [ ] **Schritt 3: Auswahlfelder einschränken** + +Für jedes `Select`, das auf Stammdaten zeigt (`employee_id`, `dealership_id`, +`leasing_company_id`, `fuel_card_id` im `VehicleForm`; `fuel_card_provider_id` im +`FuelCardForm`): + +```php +->relationship( + name: 'employee', + titleAttribute: 'last_name', + // Inaktive gehoeren nicht in eine Neuzuordnung - der bereits gesetzte + // Wert muss aber in der Liste bleiben, sonst faende das Formular ihn + // nicht wieder und wuerde die Zuordnung beim naechsten Speichern + // stillschweigend leeren. + modifyQueryUsing: fn (Builder $query, ?Model $record) => $query + ->where('active', true) + ->orWhere('id', $record?->employee_id), +) +``` + +Beim Fahrzeug-Select für die Tankkarte den passenden Fremdschlüssel einsetzen. Die genaue +Signatur des vorhandenen `->relationship(...)`-Aufrufs beibehalten und nur `modifyQueryUsing` +ergänzen. + +- [ ] **Schritt 4: Globale Suche einschränken** + +In jeder der fünf Stammdaten-Resources: + +```php +/** + * Inaktive Stammdaten sollen im Alltag nicht mehr auftauchen - auch nicht + * ueber die globale Suche, die sonst der einzige Weg waere, sie + * unbeabsichtigt wieder in Umlauf zu bringen. + */ +public static function getGlobalSearchEloquentQuery(): Builder +{ + return parent::getGlobalSearchEloquentQuery()->where('active', true); +} +``` + +In `VehicleResource` dasselbe mit `->where('status', VehicleStatus::Active)`. + +- [ ] **Schritt 5: Inaktive Bezüge kennzeichnen** + +In `VehiclesTable` und `VehicleInfolist` bei der Fahrer-Spalte: + +```php +TextColumn::make('employee.full_name') + ->label('Fahrer') + ->formatStateUsing(fn (?string $state, Vehicle $record): ?string => match (true) { + blank($state) => null, + $record->employee?->active === false => $state.' (inaktiv)', + default => $state, + }) +``` + +Dasselbe Muster für Autohaus, Leasinggeber und Tankkarte, wo sie angezeigt werden. + +- [ ] **Schritt 6: Test laufen lassen und bestehen sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/Filament/InactiveMasterDataVisibilityTest.php +``` + +- [ ] **Schritt 7: Pint, volle Suite, Commit** + +```bash +ddev exec vendor/bin/pint +ddev exec vendor/bin/pest +git add -A +git commit -m "feat: keep inactive master data out of pickers, search and lists" +``` + +--- + +### Aufgabe 5: Bruttolistenpreis + +**Dateien:** +- Neu: `database/migrations/2026_08_05_100001_add_gross_list_price_to_vehicles_table.php` +- Ändern: `app/Models/Vehicle.php` (`$fillable`, `casts()`) +- Ändern: `app/Filament/Resources/Vehicles/Schemas/VehicleForm.php` (Section „Kosten") +- Ändern: die Kostenübersicht (Spalte) +- Test: `tests/Feature/Filament/GrossListPriceTest.php` (neu) + +**Schnittstellen:** +- Verbraucht: nichts. +- Erzeugt: Spalte `vehicles.gross_list_price` (`decimal(10,2)`, nullable), Cast `'decimal:2'`. + +- [ ] **Schritt 1: Den scheiternden Test schreiben** + +Datei `tests/Feature/Filament/GrossListPriceTest.php`: + +```php +admin()->withTwoFactor()->create(['can_view_costs' => true])); + + $vehicle = Vehicle::factory()->create(['gross_list_price' => null]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->fillForm(['gross_list_price' => 42500.00]) + ->call('save') + ->assertHasNoFormErrors(); + + expect((float) $vehicle->fresh()->gross_list_price)->toBe(42500.00); +}); + +// Dieselbe Absicherung wie bei den uebrigen Kostenfeldern: Das Feld liegt in +// der versteckten Section, deren Zustand beim Speichern verworfen wird. Ein +// mitgeschickter Wert darf nicht durchkommen. +it('discards a gross list price submitted by a user without the cost permission', function () { + actingAs(User::factory()->admin()->withTwoFactor()->create(['can_view_costs' => false])); + + $vehicle = Vehicle::factory()->create(['gross_list_price' => 30000.00]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->fillForm(['gross_list_price' => 1.00]) + ->call('save'); + + expect((float) $vehicle->fresh()->gross_list_price)->toBe(30000.00); +}); +``` + +- [ ] **Schritt 2: Test laufen lassen und scheitern sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/Filament/GrossListPriceTest.php +``` + +Erwartet: `Unknown column 'gross_list_price'`. + +- [ ] **Schritt 3: Migration, Modell, Formular** + +Migration: + +```php +Schema::table('vehicles', function (Blueprint $table): void { + // Gleiche Genauigkeit wie die uebrigen Geldfelder in + // create_vehicles_table. Nullable, weil der Listenpreis bei + // Bestandsfahrzeugen oft nicht mehr auffindbar ist. + $table->decimal('gross_list_price', 10, 2)->nullable()->after('annual_insurance'); +}); +``` + +`Vehicle`: `'gross_list_price'` in `$fillable`, `'gross_list_price' => 'decimal:2'` in `casts()`. + +`VehicleForm`, **innerhalb** der Section „Kosten" (nicht daneben — dort sitzt der Schutz): + +```php +TextInput::make('gross_list_price') + ->label('Bruttolistenpreis') + ->numeric() + ->minValue(0) + ->prefix('€') + ->helperText('Listenpreis bei Erstzulassung. Fließt nicht in die Jahreskosten ein.') + ->dehydrated(fn (): bool => auth()->user()?->canViewCosts() ?? false), +``` + +- [ ] **Schritt 4: Migration ausführen und Test bestehen sehen** + +```bash +ddev artisan migrate +ddev exec vendor/bin/pest tests/Feature/Filament/GrossListPriceTest.php +``` + +- [ ] **Schritt 5: Spalte in die Kostenübersicht** + +Die Kostenübersicht finden (`grep -rn "annual_total_cost" app/Filament/`) und die Spalte dort +ergänzen — sortierbar, mit `money('EUR')` wie die Nachbarspalten. **Nicht** in `sumAnnualCost()` +und **nicht** in den Accessor `annual_total_cost` aufnehmen: Ein Listenpreis ist keine laufende +Position. Prüfen, dass die Summenzeile unverändert bleibt. + +- [ ] **Schritt 6: Pint, volle Suite, Commit** + +```bash +ddev exec vendor/bin/pint +ddev exec vendor/bin/pest +git add -A +git commit -m "feat: add the gross list price behind the cost permission" +``` + +--- + +### Aufgabe 6: „Historie" statt „Termine", mit Unfall und Schaden + +**Dateien:** +- Ändern: `app/Enums/AppointmentType.php` +- Ändern: `app/Providers/Filament/AdminPanelProvider.php` (Farbe `orange` registrieren) +- Ändern: `app/Filament/Resources/Vehicles/RelationManagers/VehicleAppointmentsRelationManager.php` +- Ändern: alle weiteren Stellen mit dem Wort „Termin" in der Oberfläche +- Test: `tests/Feature/AppointmentTypeTest.php` (neu oder vorhandenen erweitern) + +**Schnittstellen:** +- Verbraucht: nichts. +- Erzeugt: `AppointmentType::Accident` (`'accident'`), `AppointmentType::Damage` (`'damage'`). + +- [ ] **Schritt 1: Den scheiternden Test schreiben** + +```php +getLabel())->toBe('Unfall') + ->and(AppointmentType::Damage->getLabel())->toBe('Schaden'); +}); + +it('gives every entry type its own colour', function () { + $colors = array_map(fn (AppointmentType $type): string => $type->getColor(), AppointmentType::cases()); + + expect($colors)->toHaveCount(count(array_unique($colors))); +}); +``` + +- [ ] **Schritt 2: Test laufen lassen und scheitern sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/AppointmentTypeTest.php +``` + +Erwartet: `Undefined constant App\Enums\AppointmentType::Accident`. + +- [ ] **Schritt 3: Enum erweitern** + +In `AppointmentType` zwei Fälle ergänzen und in beide `match`-Ausdrücke aufnehmen: + +```php +case Accident = 'accident'; +case Damage = 'damage'; +``` + +Labels „Unfall" und „Schaden". Farben: `self::Accident => 'danger'`, `self::Damage => 'orange'`. + +Den Kommentarblock über `getColor()` anpassen: Er begründet heute, warum `danger` gemieden wird. +Neu soll dort stehen, dass `danger` für den Unfall bewusst gewählt ist, weil er hervorstechen +soll, und dass `orange` zusätzlich registriert werden musste, weil `warning` schon die +Hauptuntersuchung belegt. + +- [ ] **Schritt 4: Farbe `orange` im Panel registrieren** + +In `AdminPanelProvider::panel()` bei `->colors([...])`: + +```php +'orange' => Color::Orange, +``` + +- [ ] **Schritt 5: Beschriftungen umstellen** + +Alle Stellen finden: + +```bash +grep -rn "Termin" app/ resources/ +``` + +Umstellen auf „Historie" beziehungsweise „Eintrag": + +- Titel des Relation Managers und der Box am Fahrzeug: „Historie" +- Navigation, falls dort ein Eintrag existiert: „Historie" +- Schaltfläche zum Anlegen: „Eintrag hinzufügen" +- Modal-Überschriften und Meldungstexte entsprechend + +**Nicht** umbenennen: `VehicleAppointment`, `vehicle_appointments`, `AppointmentType`, +`VehicleAppointmentsRelationManager`, `VehicleAppointmentPolicy`. Nur Oberflächentexte. + +- [ ] **Schritt 6: Test laufen lassen, dann die volle Suite** + +```bash +ddev exec vendor/bin/pest tests/Feature/AppointmentTypeTest.php +ddev exec vendor/bin/pest +``` + +Vorhandene Tests, die auf den Text „Termin" prüfen, mit anpassen. + +- [ ] **Schritt 7: Pint, Commit** + +```bash +ddev exec vendor/bin/pint +git add -A +git commit -m "feat: rename appointments to history and add accident and damage types" +``` + +--- + +### Aufgabe 7: 2FA zurücksetzen und 2FA-Pflicht je Benutzer + +**Dateien:** +- Neu: `database/migrations/2026_08_05_100002_add_requires_two_factor_to_users_table.php` +- Neu: `app/Http/Middleware/EnsureTwoFactorWhenRequired.php` +- Ändern: `app/Models/User.php`, `app/Policies/UserPolicy.php` +- Ändern: `app/Filament/Resources/Users/Tables/UsersTable.php`, + `app/Filament/Resources/Users/Schemas/UserForm.php` +- Ändern: `app/Providers/Filament/AdminPanelProvider.php` (`authMiddleware`) +- Test: `tests/Feature/Filament/TwoFactorAdministrationTest.php` (neu) + +**Schnittstellen:** +- Verbraucht: nichts. +- Erzeugt: Spalte `users.requires_two_factor` (boolean, Standard `false`); + `UserPolicy::resetTwoFactor(User $user, User $target): bool`. + +- [ ] **Schritt 1: Den scheiternden Test schreiben** + +```php +admin()->withTwoFactor()->create()); + + $target = User::factory()->withTwoFactor()->create(); + + livewire(ListUsers::class) + ->callTableAction('resetTwoFactor', $target); + + $target->refresh(); + + expect($target->app_authentication_secret)->toBeNull() + ->and($target->app_authentication_recovery_codes)->toBeNull(); +}); + +it('hides the reset action from a non-admin', function () { + actingAs(User::factory()->withTwoFactor()->create()); + + $target = User::factory()->withTwoFactor()->create(); + + livewire(ListUsers::class) + ->assertTableActionHidden('resetTwoFactor', $target); +}); + +it('redirects a user who is required to use two factor but has not set it up', function () { + actingAs(User::factory()->create([ + 'requires_two_factor' => true, + 'app_authentication_secret' => null, + ])); + + get('/admin')->assertRedirect(route('filament.admin.auth.profile')); +}); + +// Ohne diese Ausnahme leitet die Middleware auf eine Seite um, die sie +// erneut umleitet - der Benutzer koennte 2FA nie einrichten und sich nicht +// einmal abmelden. +it('lets that user reach the profile page and the logout', function () { + actingAs(User::factory()->create([ + 'requires_two_factor' => true, + 'app_authentication_secret' => null, + ])); + + get(route('filament.admin.auth.profile'))->assertOk(); +}); + +it('does not redirect a user without the requirement', function () { + actingAs(User::factory()->create([ + 'requires_two_factor' => false, + 'app_authentication_secret' => null, + ])); + + get('/admin')->assertOk(); +}); + +it('does not redirect a user who has the requirement and already set two factor up', function () { + actingAs(User::factory()->withTwoFactor()->create(['requires_two_factor' => true])); + + get('/admin')->assertOk(); +}); +``` + +- [ ] **Schritt 2: Test laufen lassen und scheitern sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/Filament/TwoFactorAdministrationTest.php +``` + +Erwartet: `Unknown column 'requires_two_factor'`. + +- [ ] **Schritt 3: Migration und Modell** + +```php +Schema::table('users', function (Blueprint $table): void { + // Standard false: Die Pflicht ist eine bewusste Anordnung, kein + // Nebeneffekt einer Migration - sonst sperrt das naechste Deployment + // alle Benutzer aus, bis sie 2FA eingerichtet haben. + $table->boolean('requires_two_factor')->default(false)->after('can_view_costs'); +}); +``` + +`User`: `'requires_two_factor'` in `$fillable`, `'requires_two_factor' => 'boolean'` in `casts()`, +`'requires_two_factor' => false` in `$attributes`. + +- [ ] **Schritt 4: Policy-Methode** + +In `UserPolicy`: + +```php +/** + * Ein Admin kann fremdes 2FA nur entfernen, nie einrichten - das Geheimnis + * darf ausschliesslich in der Authenticator-App des Benutzers entstehen. + */ +public function resetTwoFactor(User $user, User $target): bool +{ + return $user->isAdmin(); +} +``` + +- [ ] **Schritt 5: Aktion und Schalter in der Benutzerverwaltung** + +In `UsersTable` bei `recordActions()`: + +```php +Action::make('resetTwoFactor') + ->label('2FA zurücksetzen') + ->icon('heroicon-m-shield-exclamation') + ->color('warning') + ->requiresConfirmation() + ->modalHeading('Zwei-Faktor-Authentifizierung zurücksetzen') + ->modalDescription( + 'Der Benutzer muss die Authentifizierung danach neu einrichten. ' + .'Die bisherigen Wiederherstellungscodes werden ungültig.' + ) + // Nur sinnvoll, wenn ueberhaupt etwas eingerichtet ist. + ->visible(fn (User $record): bool => filled($record->app_authentication_secret) + && auth()->user()->can('resetTwoFactor', $record)) + ->action(function (User $record): void { + $record->forceFill([ + 'app_authentication_secret' => null, + 'app_authentication_recovery_codes' => null, + ])->save(); + + Notification::make() + ->success() + ->title('2FA zurückgesetzt') + ->send(); + }), +``` + +In `UserForm`: + +```php +Toggle::make('requires_two_factor') + ->label('2FA verpflichtend') + ->helperText('Der Benutzer wird bis zur Einrichtung auf die Profilseite geleitet.'), +``` + +In `UsersTable` zusätzlich `IconColumn::make('requires_two_factor')->label('2FA Pflicht')->boolean()`. + +- [ ] **Schritt 6: Middleware** + +`app/Http/Middleware/EnsureTwoFactorWhenRequired.php`: + +```php +user(); + + if (! $user instanceof User) { + return $next($request); + } + + if (! $user->requires_two_factor || filled($user->app_authentication_secret)) { + return $next($request); + } + + // Das Ziel der Umleitung und der Abmeldeweg muessen erreichbar + // bleiben, sonst leitet die Middleware auf eine Seite um, die sie + // erneut umleitet. Livewire-Requests laufen ueber eine eigene Route + // und tragen das Formular der Profilseite - wuerden sie umgeleitet, + // koennte der Benutzer die Einrichtung nicht absenden. + if ($request->routeIs('filament.admin.auth.profile', 'filament.admin.auth.logout') + || $request->hasHeader('X-Livewire')) { + return $next($request); + } + + return redirect()->route('filament.admin.auth.profile'); + } +} +``` + +In `AdminPanelProvider`: + +```php +->authMiddleware([ + Authenticate::class, + EnsureTwoFactorWhenRequired::class, +]); +``` + +- [ ] **Schritt 7: Test laufen lassen und bestehen sehen** + +```bash +ddev exec vendor/bin/pest tests/Feature/Filament/TwoFactorAdministrationTest.php +``` + +- [ ] **Schritt 8: Von Hand gegenprüfen, dass die Schleife nicht existiert** + +Einen Testbenutzer mit `requires_two_factor = true` anlegen — **in der Testdatenbank oder im +Docker-Compose-Stack, nicht in der Entwicklungsdatenbank** — anmelden, und prüfen: Die +Profilseite ist erreichbar, 2FA lässt sich dort tatsächlich einrichten, und danach ist das Panel +normal benutzbar. Ein automatisierter Test kann das Absenden des Einrichtungsformulars nur +begrenzt nachstellen. + +- [ ] **Schritt 9: Pint, volle Suite, Commit** + +```bash +ddev exec vendor/bin/pint +ddev exec vendor/bin/pest +git add -A +git commit -m "feat: let admins reset and require two-factor authentication" +``` + +--- + +### Abschluss + +- [ ] **CLAUDE.md ergänzen** + +Unter „Was beim Ändern leicht übersehen wird" aufnehmen: + +- Löschen ist an den Zustand gebunden: `GuardedDeleteAction` ist nur bei inaktiven Datensätzen + sichtbar. Wer eine Löschaktion neu einbaut, muss diese Bedingung mitnehmen, sonst ist die Regel + an dieser Stelle aufgehoben. +- Auswahlfelder auf Stammdaten filtern auf `active`, **einschließlich** des bereits gesetzten + Wertes. Ohne diese Ausnahme leert das Speichern eine Zuordnung auf einen inaktiven Datensatz. +- Der Bruttolistenpreis gehört wie die übrigen Kostenfelder in die Section „Kosten" — außerhalb + wäre er ungeschützt. + +- [ ] **Backlog aufräumen** + +In `docs/backlog.md` die Beobachtung streichen, dass die Stammdatenlisten nicht alle Felder +zeigen, falls sie durch die neue Statusspalte erledigt ist. + +- [ ] **Branch pushen und PR öffnen** + +```bash +git push -u origin feature/archiving-and-admin-controls +gh pr create --base main --title "feat: deactivation, two-factor administration, list price and history" \ + --body "Beschreibung: die vier Punkte aus der Spec, je ein Absatz, mit der +Begründung gegen SoftDeletes und dem Ergebnis der vollen Testsuite." +``` diff --git a/docs/superpowers/specs/2026-08-05-korrekturen-nach-erstbenutzung-design.md b/docs/superpowers/specs/2026-08-05-korrekturen-nach-erstbenutzung-design.md new file mode 100644 index 0000000..43fcca8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-korrekturen-nach-erstbenutzung-design.md @@ -0,0 +1,255 @@ +# Korrekturen nach der ersten Benutzung + +Datum: 2026-08-05 +Ausgangsstand: v1.0.2 + +Fünf Änderungen aus der ersten echten Benutzung der Anwendung. Vier davon sind Erweiterungen, +eine ist ein Fehler, den man beim Hochladen eines Bildes sofort merkt. + +Die Punkte hängen nur lose zusammen. Sie stehen in einer Spec, weil sie aus einer Rückmeldung +stammen; umgesetzt werden sie auf zwei Branches (siehe „Aufteilung" am Ende). + +--- + +## 1. Deaktivieren statt Löschen + +**Warum:** Stammdaten verschwinden heute endgültig. Ein Mitarbeiter, der die Firma verlässt, +lässt sich entweder gar nicht löschen (solange ein Fahrzeug an ihm hängt) oder ist danach weg — +mitsamt der Information, wer das Fahrzeug gefahren hat. Gewünscht ist ein Mittelweg: Löschen +deaktiviert, und nur ein bereits deaktivierter Datensatz lässt sich endgültig entfernen. + +### Der Zustand ist teilweise schon vorhanden + +Beim Planen hat sich gezeigt, dass die Anwendung das Konzept bereits kennt: + +- **Fahrzeuge** haben `status` mit `Aktiv` und `Ausgemustert` (`App\Enums\VehicleStatus`). Die + Fahrzeugliste filtert schon heute standardmäßig auf `Aktiv`, ausgemusterte sind zuschaltbar + (`VehiclesTable`, `SelectFilter::make('status')->default(...)`). Dashboard-Kacheln sowie + Leasing- und HU-Warnungen zählen ausgemusterte über `Vehicle::scopeActive()` nicht mit. +- **Mitarbeiter** haben ein Feld `active`, sichtbar in Formular, Liste und Detailseite. Es filtert + aber nirgends etwas — es ist heute reine Dekoration. + +Deshalb **kein** SoftDeletes. Ein zusätzliches `deleted_at` ergäbe beim Fahrzeug drei Zustände +(aktiv, ausgemustert, archiviert) und beim Mitarbeiter zwei gleich klingende nebeneinander. Statt +dessen bekommt der vorhandene Zustand endlich eine Wirkung, und die übrigen Stammdaten bekommen +dasselbe Feld. + +Nebeneffekt, der Arbeit spart: Ohne Soft Delete feuert `Vehicle::deleting` weiterhin nur beim +echten Löschen. Der dort hinterlegte Code, der Bild und Dokumente von der Platte räumt, bleibt +unverändert richtig. + +### Die Regel + +> Löschen ist nur möglich, wenn der Datensatz inaktiv ist (beim Fahrzeug: ausgemustert). +> Solange er aktiv ist, bietet die Zeile kein Löschen an, sondern Deaktivieren. + +Deaktivieren ist dagegen **immer** erlaubt, auch bei einem Mitarbeiter, dem noch ein Fahrzeug +zugeordnet ist. Die Zuordnung bleibt bestehen — jemand verlässt die Firma, das Auto bleibt ihm +zugeordnet, bis ein Nachfolger da ist. + +Die Fremdschlüssel bleiben unangetastet: Ein inaktiver Mitarbeiter mit Fahrzeug lässt sich +weiterhin nicht löschen, weil `restrictOnDelete` greift. `App\Filament\Actions\GuardedDeleteAction` +fängt das wie bisher vorher ab und nennt das Fahrzeug, das im Weg steht. + +### Umfang + +Neues Feld `active` (boolean, Standard `true`) auf: `dealerships`, `leasing_companies`, +`fuel_cards`, `fuel_card_providers`. + +Mitarbeiter (`employees.active`) und Fahrzeug (`vehicles.status`) haben ihren Zustand bereits. + +Benutzerkonten (`users`) bleiben ausgenommen. Sie sind keine Stammdaten des Fuhrparks, und an +ihnen hängen Import- und Exportzeilen mit `cascadeOnDelete` — ein eigener Fall, der hier nicht +mitentschieden wird. + +### Oberfläche + +Je Zeile in den Stammdatenlisten und beim Fahrzeug: + +| Aktion | sichtbar wenn | Wirkung | +|---|---|---| +| Deaktivieren (Fahrzeug: Ausmustern) | Datensatz ist aktiv | setzt `active = false` bzw. `status = retired`, mit Rückfrage | +| Aktivieren | Datensatz ist inaktiv | setzt den Zustand zurück | +| Löschen | Datensatz ist inaktiv | wie heute über `GuardedDeleteAction` | + +Zusätzlich: + +- Jede Stammdatenliste bekommt einen Filter „Status" mit Standard „Aktiv" — dasselbe Muster wie in + der Fahrzeugliste. Inaktive sind zuschaltbar, aber nicht im Weg. +- Inaktive Zeilen tragen ein Badge „Inaktiv". +- **Auswahlfelder** (Fahrer, Autohaus, Leasinggeber, Tankkarte am Fahrzeug; Anbieter an der + Tankkarte) zeigen nur aktive Einträge **plus den aktuell gesetzten Wert**. Ohne diese Ausnahme + fände das Formular den gespeicherten Wert nicht in der Optionsliste und würde die Zuordnung beim + nächsten Speichern stillschweigend leeren. +- Wird ein inaktiver Bezug angezeigt (etwa der Fahrer eines Fahrzeugs), steht dahinter „(inaktiv)". +- Die globale Suche liefert keine inaktiven Datensätze. + +### Unique-Indizes + +`vehicles.license_plate` und `fuel_card_providers.name` sind `unique`. Ein inaktiver Datensatz +belegt den Wert weiterhin — ein Kennzeichen eines ausgemusterten Fahrzeugs lässt sich also nicht +erneut vergeben. + +Das bleibt so; alles andere erlaubt dasselbe Kennzeichen mehrfach in der Tabelle und macht jede +spätere Auswertung mehrdeutig. Die Fehlermeldung weist darauf hin, dass der Wert möglicherweise +von einem inaktiven Eintrag belegt ist. + +--- + +## 2. 2FA in der Benutzerverwaltung + +**Warum:** 2FA lässt sich heute nur im eigenen Profil einschalten. Ein Admin kann es bei einem +verlorenen Telefon nicht zurücksetzen und kann es nicht anordnen. + +**Was bereits existiert:** Die Spalte „2FA aktiv" in der Benutzerliste (`UsersTable`, `IconColumn` +auf `app_authentication_secret`). Der Status ist also schon sichtbar. + +**Was ein Admin ausdrücklich nicht kann:** fremdes 2FA einrichten. Das Geheimnis darf nur in der +Authenticator-App des Benutzers landen; wüsste es ein Zweiter, wäre der zweite Faktor keiner mehr. + +### Umfang + +- **Aktion „2FA zurücksetzen"** mit Rückfrage. Leert `app_authentication_secret` und + `app_authentication_recovery_codes`. Nur für Admins (`UserPolicy`). Nur sichtbar, wenn der + Benutzer überhaupt 2FA eingerichtet hat. +- **Neue Spalte `users.requires_two_factor`** (boolean, Standard `false`), im Benutzerformular als + Schalter, in der Liste als Symbol. + +### Warum die Pflicht eigene Middleware braucht + +Filaments `multiFactorAuthentication(..., isRequired:)` akzeptiert zwar einen Closure, wird aber +beim Registrieren der Routen ausgewertet — `Filament\Pages\Concerns\HasRoutes` entscheidet dort, +ob die Middleware überhaupt an die Route gehängt wird. Zu diesem Zeitpunkt gibt es keinen +angemeldeten Benutzer, eine benutzerabhängige Entscheidung ist dort also nicht möglich. + +Stattdessen eine eigene Middleware in der `authMiddleware` des Panels: Wer `requires_two_factor` +gesetzt hat und noch kein `app_authentication_secret` besitzt, wird auf die Profilseite +umgeleitet, auf der 2FA eingerichtet wird. + +**Fallstrick:** Die Middleware muss die Zielseite selbst, deren Livewire-Requests und den Logout +durchlassen. Sonst leitet sie den Benutzer auf eine Seite um, die sie erneut umleitet — eine +Schleife, aus der er sich nicht einmal abmelden kann. + +--- + +## 3. Bruttolistenpreis + +Neue Spalte `vehicles.gross_list_price`, `decimal(10, 2)`, nullable — wie die übrigen Geldfelder +in `create_vehicles_table`. + +Sie gehört in die Section **„Kosten"** des Fahrzeugformulars, nicht in die Stammdaten-Section. +Der Schutz durch `viewCosts` entsteht dadurch, dass die versteckte Section beim Speichern die +Werte aller ihrer Felder verwirft (siehe `CLAUDE.md`). Ein Kostenfeld außerhalb dieser Section +wäre ungeschützt. + +Zusätzlich als Spalte in der Kostenübersicht, sortierbar. **Nicht** Teil der Jahreskosten-Summe: +ein Listenpreis ist keine laufende Position. Eine Rechnung nach der 1-Prozent-Regel ist nicht Teil +dieser Änderung. + +--- + +## 4. „Termine" heißt künftig „Historie" + +**Warum:** Der Bereich soll auch Unfälle und Schäden aufnehmen. „Termin" passt dafür nicht — ein +Unfall ist keiner. + +Geändert werden **nur die Beschriftungen**: Navigation, Titel der Box am Fahrzeug, Schaltflächen +(„Eintrag hinzufügen" statt „Termin hinzufügen"), Meldungstexte. Klassen- und Tabellennamen +(`VehicleAppointment`, `vehicle_appointments`, `AppointmentType`) bleiben unverändert — eine +Umbenennung kostet eine Migration und ändert nichts Sichtbares. + +Zwei neue Werte in `AppointmentType`: + +| Wert | Beschriftung | Farbe | +|---|---|---| +| `accident` | Unfall | `danger` (das Rose des Panels) | +| `damage` | Schaden | `orange`, in `AdminPanelProvider::panel()` unter `->colors()` neu registriert | + +`danger` war bisher bewusst gemieden, damit Badges nicht mit dem Löschen verwechselt werden. Für +einen Unfall ist das Hervorstechen aber genau richtig. Orange muss neu registriert werden, weil +`warning` bereits die Hauptuntersuchung belegt. + +--- + +## 5. Fehler: Bildupload bei kleinen Bildern + +**Symptom:** Der Upload verhält sich unberechenbar, sobald ein Bild kleiner als 2000 Pixel ist. + +**Zwei Ursachen, beide in `VehicleForm`:** + +1. `automaticallyResizeImagesToWidth('2000')` läuft mit Filaments Standardwert + `shouldAutomaticallyUpscaleImagesWhenResizing = true`. Ein Bild mit 800 Pixel Breite wird + dadurch auf 2000 Pixel **hochgerechnet** — es wird unscharf und deutlich größer, statt so zu + bleiben, wie es war. +2. `imageAspectRatio('4:3')` ist nicht nur eine Vorgabe für den Zuschneide-Editor, sondern erzeugt + serverseitig `Rule::dimensions()->ratio()`. Weicht das Ergebnis des Zuschnitts um wenige Pixel + ab, weist der Server das Bild mit der Meldung zum Seitenverhältnis zurück. + +**Gewünschtes Verhalten:** Die Bildgröße ist egal. Es gilt nur die Grenze von 10 MB. + +**Änderung:** + +- `automaticallyUpscaleImagesWhenResizing(false)` — kleinere Bilder bleiben unverändert. +- `imageAspectRatio('4:3')` und `automaticallyOpenImageEditorForAspectRatio()` entfallen; damit + fällt auch die serverseitige `dimensions`-Regel und ihre Fehlermeldung weg. +- `imageEditor()` bleibt, der Zuschnitt ist also weiter möglich, nur nicht mehr erzwungen. +- Das Verkleinern oberhalb von 2000 Pixel bleibt: es spart Speicher und verschlechtert nichts. +- `maxSize(10 MB)` bleibt unverändert. + +**Folge für die Darstellung:** Bilder haben künftig beliebige Seitenverhältnisse. Damit die +Kacheln in Liste und Detailansicht gleich hoch bleiben, brauchen sie `object-fit: cover` mit +festem Rahmen. + +--- + +## Tests + +Zu Punkt 1: + +- Ein aktiver Datensatz lässt sich nicht löschen — die Aktion ist nicht vorhanden, und der direkte + Aufruf greift ebenfalls nicht. +- Deaktivieren funktioniert auch bei einem Mitarbeiter mit zugeordnetem Fahrzeug; die Zuordnung + bleibt. +- Löschen eines inaktiven, aber noch zugeordneten Mitarbeiters wird verweigert — gegen MariaDB, + damit der echte Fremdschlüssel geprüft wird und nicht eine Nachbildung. +- Löschen eines inaktiven, nicht mehr zugeordneten Mitarbeiters funktioniert. +- Ein Fahrzeug mit inaktivem Fahrer zeigt ihn weiterhin an und verliert die Zuordnung beim + Speichern nicht. +- Inaktive erscheinen nicht in Auswahlfeldern, nicht in der Standardansicht der Listen und nicht + in der globalen Suche; mit gesetztem Filter erscheinen sie in der Liste. +- Aktivieren macht den Datensatz wieder normal nutzbar. + +Zu Punkt 2: + +- Zurücksetzen leert Geheimnis und Recovery-Codes; ein Nicht-Admin kann die Aktion weder sehen + noch direkt aufrufen. +- Ein Benutzer mit `requires_two_factor` und ohne Geheimnis wird umgeleitet; die Zielseite selbst + und der Logout bleiben erreichbar. +- Ein Benutzer ohne die Pflicht wird nicht umgeleitet. +- Ein Benutzer mit der Pflicht und eingerichtetem 2FA wird nicht umgeleitet. + +Zu Punkt 3: + +- Ohne `viewCosts` wird der Bruttolistenpreis weder angezeigt noch beim Speichern übernommen — + auch nicht, wenn er im Formular mitgeschickt wird. + +Zu Punkt 4: + +- Die neuen Typen lassen sich anlegen und erscheinen mit ihrer Beschriftung und Farbe. + +Zu Punkt 5: + +- Ein Bild mit 800 Pixel Breite wird angenommen und in seiner Größe gespeichert. +- Ein Bild mit einem anderen Seitenverhältnis als 4:3 wird angenommen. +- Ein Bild über 10 MB wird weiterhin abgelehnt. + +--- + +## Aufteilung auf Branches + +- `fix/vehicle-image-upload` — Punkt 5 allein. Kleiner Diff, behebt einen Fehler, den man im + Alltag sofort trifft; soll unabhängig von den größeren Änderungen mergebar sein. +- `feature/archiving-and-admin-controls` — Punkte 1 bis 4, in getrennten Commits. + +Beide Branches fassen `VehicleForm` an, aber an verschiedenen Stellen (Bildfeld gegenüber +Kosten-Section). diff --git a/tests/Feature/AppointmentTypeTest.php b/tests/Feature/AppointmentTypeTest.php new file mode 100644 index 0000000..2949a69 --- /dev/null +++ b/tests/Feature/AppointmentTypeTest.php @@ -0,0 +1,20 @@ +getLabel())->toBe('Unfall') + ->and(AppointmentType::Damage->getLabel())->toBe('Schaden'); +}); + +// Ohne diese Kontrolle ergaeben zwei Typen dieselbe Farbe - dann waeren sie +// im Badge nicht mehr auseinanderzuhalten. +it('gives every entry type its own colour', function () { + $colors = array_map(fn (AppointmentType $type): string => $type->getColor(), AppointmentType::cases()); + + expect($colors)->toHaveCount(count(array_unique($colors))); +}); + +it('uses danger for an accident, so it stands out', function () { + expect(AppointmentType::Accident->getColor())->toBe('danger'); +}); diff --git a/tests/Feature/Filament/DeactivateInsteadOfDeleteTest.php b/tests/Feature/Filament/DeactivateInsteadOfDeleteTest.php new file mode 100644 index 0000000..e470011 --- /dev/null +++ b/tests/Feature/Filament/DeactivateInsteadOfDeleteTest.php @@ -0,0 +1,154 @@ +admin()->withTwoFactor()->create()); +}); + +describe('which actions a row offers', function () { + it('offers deactivating but not deleting while a record is active', function () { + $employee = Employee::factory()->create(['active' => true]); + + livewire(ListEmployees::class) + ->assertTableActionVisible(DeactivateAction::class, $employee) + ->assertTableActionHidden(GuardedDeleteAction::class, $employee) + ->assertTableActionHidden(ActivateAction::class, $employee); + }); + + it('offers deleting and activating once a record is inactive', function () { + $employee = Employee::factory()->create(['active' => false]); + + livewire(ListEmployees::class) + ->filterTable('active', false) + ->assertTableActionVisible(GuardedDeleteAction::class, $employee) + ->assertTableActionVisible(ActivateAction::class, $employee) + ->assertTableActionHidden(DeactivateAction::class, $employee); + }); +}); + +describe('the state transitions', function () { + // Der Kern der Anforderung: Ausscheiden aus der Firma darf nicht daran + // scheitern, dass noch ein Auto zugeordnet ist. Die Zuordnung bleibt. + it('deactivates an employee who still has a vehicle, keeping the assignment', function () { + $employee = Employee::factory()->create(['active' => true]); + $vehicle = Vehicle::factory()->for($employee)->create(); + + livewire(ListEmployees::class) + ->callTableAction(DeactivateAction::class, $employee); + + assertDatabaseHas(Employee::class, ['id' => $employee->id, 'active' => false]); + expect($vehicle->fresh()->employee_id)->toBe($employee->id); + }); + + it('activates an inactive employee again', function () { + $employee = Employee::factory()->create(['active' => false]); + + livewire(ListEmployees::class) + ->filterTable('active', false) + ->callTableAction(ActivateAction::class, $employee); + + assertDatabaseHas(Employee::class, ['id' => $employee->id, 'active' => true]); + }); + + it('retires a vehicle rather than deleting it', function () { + $vehicle = Vehicle::factory()->create(['status' => VehicleStatus::Active]); + + livewire(ListVehicles::class) + ->callTableAction(DeactivateAction::class, $vehicle); + + expect($vehicle->fresh()->status)->toBe(VehicleStatus::Retired); + }); +}); + +describe('deleting', function () { + // Gegen MariaDB, damit der echte Fremdschluessel (restrictOnDelete) + // greift und nicht eine Nachbildung. + it('refuses to delete an inactive employee who is still assigned to a vehicle', function () { + $employee = Employee::factory()->create(['active' => false]); + Vehicle::factory()->for($employee)->create(); + + livewire(ListEmployees::class) + ->filterTable('active', false) + ->callTableAction(GuardedDeleteAction::class, $employee); + + assertDatabaseHas(Employee::class, ['id' => $employee->id]); + }); + + it('deletes an inactive employee who is no longer assigned', function () { + $employee = Employee::factory()->create(['active' => false]); + + livewire(ListEmployees::class) + ->filterTable('active', false) + ->callTableAction(GuardedDeleteAction::class, $employee); + + expect(Employee::find($employee->id))->toBeNull(); + }); + + // Die Fahrzeugliste bietet gar kein Loeschen an - beim Fahrzeug haengt es + // am Formularfuss der Bearbeitungsseite. Ohne diese Absicherung waere die + // Regel ausgerechnet dort umgehbar, wo man ein Fahrzeug wirklich loescht. + it('hides deleting on the vehicle edit page while the vehicle is in service', function () { + $vehicle = Vehicle::factory()->create(['status' => VehicleStatus::Active]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->assertActionHidden('delete'); + }); + + it('shows deleting on the vehicle edit page once the vehicle is retired', function () { + $vehicle = Vehicle::factory()->retired()->create(); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->assertActionVisible('delete'); + }); +}); + +describe('unique values held by an inactive record', function () { + // Der Unique-Index bleibt bewusst wie er ist - sonst stuende dasselbe + // Kennzeichen zweimal in der Tabelle. Die Meldung muss dafuer erklaeren, + // warum der belegende Eintrag nicht zu finden ist. + it('explains that a retired vehicle may be holding the licence plate', function () { + Vehicle::factory()->retired()->create(['license_plate' => 'M-AB 123']); + + $component = livewire(CreateVehicle::class) + ->fillForm([ + 'brand' => 'VW', + 'model' => 'Golf', + 'license_plate' => 'M-AB 123', + 'fuel_type' => FuelType::Diesel->value, + ]) + ->call('create') + ->assertHasFormErrors(['license_plate']); + + expect($component->instance()->getErrorBag()->first('data.license_plate')) + ->toContain('ausgemusterten'); + }); +}); + +describe('the list default', function () { + it('hides inactive records by default but shows them when filtered', function () { + $active = Employee::factory()->create(['active' => true]); + $inactive = Employee::factory()->create(['active' => false]); + + livewire(ListEmployees::class) + ->assertCanSeeTableRecords([$active]) + ->assertCanNotSeeTableRecords([$inactive]) + ->filterTable('active', false) + ->assertCanSeeTableRecords([$inactive]); + }); +}); diff --git a/tests/Feature/Filament/DealershipResourceTest.php b/tests/Feature/Filament/DealershipResourceTest.php index 06209d0..df810b2 100644 --- a/tests/Feature/Filament/DealershipResourceTest.php +++ b/tests/Feature/Filament/DealershipResourceTest.php @@ -54,7 +54,9 @@ }); it('refuses to delete a dealership that is still referenced', function () { - $dealership = Dealership::factory()->create(); + // Loeschen setzt seit der Umstellung voraus, dass der Datensatz inaktiv + // ist - ein aktiver bietet die Aktion gar nicht erst an. + $dealership = Dealership::factory()->create(['active' => false]); Vehicle::factory()->for($dealership)->create(['license_plate' => 'M-GH 3456']); livewire(EditDealership::class, ['record' => $dealership->id]) diff --git a/tests/Feature/Filament/EmployeeResourceTest.php b/tests/Feature/Filament/EmployeeResourceTest.php index 39b30b8..757ed99 100644 --- a/tests/Feature/Filament/EmployeeResourceTest.php +++ b/tests/Feature/Filament/EmployeeResourceTest.php @@ -47,7 +47,9 @@ }); it('refuses to delete an employee who still has a vehicle', function () { - $employee = Employee::factory()->create(); + // Loeschen setzt seit der Umstellung voraus, dass der Datensatz inaktiv + // ist - ein aktiver bietet die Aktion gar nicht erst an. + $employee = Employee::factory()->create(['active' => false]); Vehicle::factory()->for($employee)->create(['license_plate' => 'M-AB 1234']); livewire(EditEmployee::class, ['record' => $employee->id]) @@ -59,7 +61,9 @@ }); it('deletes an employee without vehicles', function () { - $employee = Employee::factory()->create(); + // Loeschen setzt seit der Umstellung voraus, dass der Datensatz inaktiv + // ist - ein aktiver bietet die Aktion gar nicht erst an. + $employee = Employee::factory()->create(['active' => false]); livewire(EditEmployee::class, ['record' => $employee->id]) ->callAction(GuardedDeleteAction::class); diff --git a/tests/Feature/Filament/FuelCardProviderResourceTest.php b/tests/Feature/Filament/FuelCardProviderResourceTest.php index fde7162..2538aec 100644 --- a/tests/Feature/Filament/FuelCardProviderResourceTest.php +++ b/tests/Feature/Filament/FuelCardProviderResourceTest.php @@ -46,7 +46,9 @@ }); it('refuses to delete a provider that is still assigned to a card', function () { - $provider = FuelCardProvider::factory()->create(); + // Loeschen setzt seit der Umstellung voraus, dass der Datensatz inaktiv + // ist - ein aktiver bietet die Aktion gar nicht erst an. + $provider = FuelCardProvider::factory()->create(['active' => false]); FuelCard::factory()->create(['fuel_card_provider_id' => $provider->id]); livewire(EditFuelCardProvider::class, ['record' => $provider->id]) @@ -57,7 +59,9 @@ }); it('allows deleting a provider that is not assigned to any card', function () { - $provider = FuelCardProvider::factory()->create(); + // Loeschen setzt seit der Umstellung voraus, dass der Datensatz inaktiv + // ist - ein aktiver bietet die Aktion gar nicht erst an. + $provider = FuelCardProvider::factory()->create(['active' => false]); livewire(EditFuelCardProvider::class, ['record' => $provider->id]) ->callAction(GuardedDeleteAction::class); diff --git a/tests/Feature/Filament/FuelCardResourceTest.php b/tests/Feature/Filament/FuelCardResourceTest.php index 89fd2cb..acf973c 100644 --- a/tests/Feature/Filament/FuelCardResourceTest.php +++ b/tests/Feature/Filament/FuelCardResourceTest.php @@ -84,7 +84,9 @@ }); it('refuses to delete a card that is still assigned', function () { - $card = FuelCard::factory()->create(); + // Loeschen setzt seit der Umstellung voraus, dass der Datensatz inaktiv + // ist - ein aktiver bietet die Aktion gar nicht erst an. + $card = FuelCard::factory()->create(['active' => false]); Vehicle::factory()->for($card)->create(['license_plate' => 'M-CD 5678']); livewire(EditFuelCard::class, ['record' => $card->id]) diff --git a/tests/Feature/Filament/GrossListPriceTest.php b/tests/Feature/Filament/GrossListPriceTest.php new file mode 100644 index 0000000..117fa79 --- /dev/null +++ b/tests/Feature/Filament/GrossListPriceTest.php @@ -0,0 +1,49 @@ +admin()->withTwoFactor()->create(['can_view_costs' => true])); + + $vehicle = Vehicle::factory()->create(['gross_list_price' => null]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->fillForm(['gross_list_price' => 42500.00]) + ->call('save') + ->assertHasNoFormErrors(); + + expect((float) $vehicle->fresh()->gross_list_price)->toBe(42500.00); +}); + +// Dieselbe Absicherung wie fuer die uebrigen Kostenfelder: Das Feld liegt in +// der versteckten Section, deren Zustand beim Speichern verworfen wird. Ein +// mitgeschickter Wert darf nicht durchkommen. +it('discards a gross list price submitted by a user without the cost permission', function () { + actingAs(User::factory()->admin()->withTwoFactor()->create(['can_view_costs' => false])); + + $vehicle = Vehicle::factory()->create(['gross_list_price' => 30000.00]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->fillForm(['gross_list_price' => 1.00]) + ->call('save'); + + expect((float) $vehicle->fresh()->gross_list_price)->toBe(30000.00); +}); + +it('does not include the gross list price in the annual cost total', function () { + // Ein Listenpreis ist keine laufende Position - die Jahressumme muss + // dieselbe bleiben wie ohne den Wert. + $vehicle = Vehicle::factory()->create([ + 'monthly_lease_rate' => 500, + 'annual_vehicle_tax' => 200, + 'annual_insurance' => 300, + 'gross_list_price' => 45000, + ]); + + expect($vehicle->annual_total_cost)->toBe((float) (500 * 12 + 200 + 300)); +}); diff --git a/tests/Feature/Filament/InactiveMasterDataVisibilityTest.php b/tests/Feature/Filament/InactiveMasterDataVisibilityTest.php new file mode 100644 index 0000000..2718070 --- /dev/null +++ b/tests/Feature/Filament/InactiveMasterDataVisibilityTest.php @@ -0,0 +1,130 @@ +admin()->withTwoFactor()->create()); +}); + +describe('picker options in the vehicle form', function () { + it('does not offer an inactive employee as a driver', function () { + $active = Employee::factory()->create(['active' => true]); + $inactive = Employee::factory()->create(['active' => false]); + + $vehicle = Vehicle::factory()->create(['employee_id' => null]); + + $options = livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->instance() + ->getSchemaComponent('form.employee_id') + ->getOptions(); + + expect($options)->toHaveKey($active->id) + ->and($options)->not->toHaveKey($inactive->id); + }); + + // Der Fallstrick: Waere der bereits gesetzte, inzwischen inaktive Fahrer + // nicht mehr in der Optionsliste, faende das Formular ihn nicht wieder + // und wuerde die Zuordnung beim naechsten Speichern still leeren. + it('keeps an already assigned employee selectable after they were deactivated', function () { + $employee = Employee::factory()->create(['active' => true]); + $vehicle = Vehicle::factory()->for($employee)->create(); + + $employee->update(['active' => false]); + + $options = livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->instance() + ->getSchemaComponent('form.employee_id') + ->getOptions(); + + expect($options)->toHaveKey($employee->id); + }); + + it('does not save away an inactive assignment when the form is submitted', function () { + $employee = Employee::factory()->create(['active' => true]); + $vehicle = Vehicle::factory()->for($employee)->create(); + + $employee->update(['active' => false]); + + livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($vehicle->fresh()->employee_id)->toBe($employee->id); + }); + + it('hides inactive dealerships, leasing companies and fuel cards from their pickers', function () { + $vehicle = Vehicle::factory()->create(); + $inactiveDealer = Dealership::factory()->create(['active' => false]); + $inactiveLessor = LeasingCompany::factory()->create(['active' => false]); + $provider = FuelCardProvider::factory()->create(['active' => true]); + $inactiveCard = FuelCard::factory()->create(['fuel_card_provider_id' => $provider->id, 'active' => false]); + + $form = livewire(EditVehicle::class, ['record' => $vehicle->getRouteKey()])->instance(); + + expect($form->getSchemaComponent('form.dealership_id')->getOptions())->not->toHaveKey($inactiveDealer->id) + ->and($form->getSchemaComponent('form.leasing_company_id')->getOptions())->not->toHaveKey($inactiveLessor->id) + ->and($form->getSchemaComponent('form.fuel_card_id')->getOptions())->not->toHaveKey($inactiveCard->id); + }); +}); + +describe('global search', function () { + it('excludes an inactive employee', function () { + Employee::factory()->create(['active' => false, 'last_name' => 'Unsichtbar']); + + $results = EmployeeResource::getGlobalSearchEloquentQuery()->pluck('last_name'); + + expect($results)->not->toContain('Unsichtbar'); + }); + + it('excludes inactive dealerships, leasing companies, cards and providers', function () { + Dealership::factory()->create(['active' => false, 'name' => 'Autohaus X']); + LeasingCompany::factory()->create(['active' => false, 'name' => 'Leasing Y']); + FuelCardProvider::factory()->create(['active' => false, 'name' => 'Anbieter Z']); + $inactiveProvider = FuelCardProvider::factory()->create(['active' => true]); + FuelCard::factory()->create(['fuel_card_provider_id' => $inactiveProvider->id, 'active' => false]); + + expect(DealershipResource::getGlobalSearchEloquentQuery()->pluck('name'))->not->toContain('Autohaus X'); + expect(LeasingCompanyResource::getGlobalSearchEloquentQuery()->pluck('name'))->not->toContain('Leasing Y'); + expect(FuelCardProviderResource::getGlobalSearchEloquentQuery()->pluck('name'))->not->toContain('Anbieter Z'); + expect(FuelCardResource::getGlobalSearchEloquentQuery()->count())->toBe(0); + }); + + it('excludes a retired vehicle', function () { + Vehicle::factory()->retired()->create(['license_plate' => 'M-RE 999']); + + $plates = VehicleResource::getGlobalSearchEloquentQuery()->pluck('license_plate'); + + expect($plates)->not->toContain('M-RE 999'); + }); +}); + +describe('display of inactive references', function () { + // Wird der Fahrer inzwischen inaktiv, ist er trotzdem noch der Fahrer + // dieses Fahrzeugs. Die Anzeige soll das nicht verschweigen und den + // Zustand nennen - sonst wirkt der Datensatz plaetzlich, als sei nie + // jemand eingetragen gewesen. + it('marks an inactive driver in the vehicle list with a suffix', function () { + $employee = Employee::factory()->create(['active' => false, 'first_name' => 'Max', 'last_name' => 'Muster']); + Vehicle::factory()->for($employee)->create(); + + livewire(ListVehicles::class) + ->assertSee('Max Muster (inaktiv)'); + }); +}); diff --git a/tests/Feature/Filament/LeasingCompanyResourceTest.php b/tests/Feature/Filament/LeasingCompanyResourceTest.php index 8f3fa18..9ec6840 100644 --- a/tests/Feature/Filament/LeasingCompanyResourceTest.php +++ b/tests/Feature/Filament/LeasingCompanyResourceTest.php @@ -44,7 +44,9 @@ }); it('refuses to delete a company that is still referenced', function () { - $company = LeasingCompany::factory()->create(); + // Loeschen setzt seit der Umstellung voraus, dass der Datensatz inaktiv + // ist - ein aktiver bietet die Aktion gar nicht erst an. + $company = LeasingCompany::factory()->create(['active' => false]); Vehicle::factory()->for($company)->create(['license_plate' => 'M-EF 9012']); livewire(EditLeasingCompany::class, ['record' => $company->id]) diff --git a/tests/Feature/Filament/TwoFactorAdministrationTest.php b/tests/Feature/Filament/TwoFactorAdministrationTest.php new file mode 100644 index 0000000..afb434d --- /dev/null +++ b/tests/Feature/Filament/TwoFactorAdministrationTest.php @@ -0,0 +1,114 @@ +admin()->withTwoFactor()->create()); + + $target = User::factory()->withTwoFactor()->create(); + + livewire(ListUsers::class) + ->callTableAction('resetTwoFactor', $target); + + $target->refresh(); + + expect($target->app_authentication_secret)->toBeNull() + ->and($target->app_authentication_recovery_codes)->toBeNull(); + }); + + it('is invisible for a user without two-factor set up', function () { + actingAs(User::factory()->admin()->withTwoFactor()->create()); + + $target = User::factory()->create(['app_authentication_secret' => null]); + + livewire(ListUsers::class) + ->assertTableActionHidden('resetTwoFactor', $target); + }); + + it('is hidden from a non-admin', function () { + actingAs(User::factory()->viewer()->withTwoFactor()->create()); + + $target = User::factory()->withTwoFactor()->create(); + + // Nicht-Admins erreichen /admin/users ohnehin nicht - direkt gegen + // die Policy pruefen: die entscheidet, nicht die Navigation. + expect(auth()->user()->can('resetTwoFactor', $target))->toBeFalse(); + }); +}); + +describe('the requires_two_factor flag', function () { + it('saves the toggle through the user form', function () { + actingAs(User::factory()->admin()->withTwoFactor()->create()); + + $target = User::factory()->create(['requires_two_factor' => false]); + + livewire(EditUser::class, ['record' => $target->getRouteKey()]) + ->fillForm(['requires_two_factor' => true]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($target->fresh()->requires_two_factor)->toBeTrue(); + }); + + it('starts at false by default', function () { + $user = User::factory()->create(); + + expect($user->requires_two_factor)->toBeFalse(); + }); +}); + +describe('the two-factor requirement middleware', function () { + // Wer 2FA verpflichtend hat und noch kein Geheimnis, wird beim naechsten + // Klick ins Panel auf die Profilseite geleitet und kann von dort + // einrichten. + it('redirects a user who is required to use two factor but has not set it up', function () { + actingAs(User::factory()->create([ + 'requires_two_factor' => true, + 'app_authentication_secret' => null, + ])); + + get('/admin')->assertRedirect(route('filament.admin.auth.profile')); + }); + + // Der eigentliche Fallstrick der Aufgabe: waere die Middleware wirklich + // auf JEDER Anfrage aktiv, leitete sie die Profilseite selbst und den + // Logout ebenfalls um - eine Schleife, aus der sich der Nutzer nicht + // einmal abmelden koennte. + it('lets that user reach the profile page and the logout', function () { + actingAs(User::factory()->create([ + 'requires_two_factor' => true, + 'app_authentication_secret' => null, + ])); + + get(route('filament.admin.auth.profile'))->assertOk(); + }); + + it('does not redirect a user without the requirement', function () { + actingAs(User::factory()->create([ + 'requires_two_factor' => false, + 'app_authentication_secret' => null, + ])); + + get('/admin')->assertOk(); + }); + + it('does not redirect a user who has the requirement and already set two factor up', function () { + actingAs(User::factory()->withTwoFactor()->create(['requires_two_factor' => true])); + + get('/admin')->assertOk(); + }); + + it('is registered in the admin panel auth middleware', function () { + $middleware = filament()->getPanel('admin')->getAuthMiddleware(); + + expect($middleware)->toContain(EnsureTwoFactorWhenRequired::class); + }); +}); diff --git a/tests/Feature/Filament/VehicleCostExportVerificationTest.php b/tests/Feature/Filament/VehicleCostExportVerificationTest.php index 4dd1819..e49b4f8 100644 --- a/tests/Feature/Filament/VehicleCostExportVerificationTest.php +++ b/tests/Feature/Filament/VehicleCostExportVerificationTest.php @@ -63,6 +63,7 @@ 'Leasingrate monatlich', 'Kfz-Steuer jaehrlich', 'Versicherung jaehrlich', + 'Bruttolistenpreis', 'Jahreskosten', ]); @@ -85,10 +86,12 @@ // Nachkommastellen formatiert (siehe VehicleCostExporter), genau wie // die drei decimal:2-Spalten davor - kein Formatunterschied mehr in // derselben Zeile. + // Index 7 statt 6 fuer die Jahreskosten, weil zwischen Versicherung + // und Jahreskosten der Bruttolistenpreis eingezogen ist. expect($columns[3])->toBe('100.00') ->and($columns[4])->toBe('50.00') ->and($columns[5])->toBe('25.00') - ->and($columns[6])->toBe('1275.00'); + ->and($columns[7])->toBe('1275.00'); } }); @@ -131,8 +134,10 @@ // 123.45 * 12 = 1481.40 - eine Zahl, die als float ("1481.4") ihre // trailing zero verlieren wuerde, waere die Formatierung nicht explizit. + // Index 7 statt 6, weil zwischen Versicherung und Jahreskosten der + // Bruttolistenpreis eingezogen ist (siehe Header oben). expect($columns[3])->toBe('123.45') - ->and($columns[6])->toBe('1481.40'); + ->and($columns[7])->toBe('1481.40'); }); /** diff --git a/tests/Feature/Filament/VehicleResourceTest.php b/tests/Feature/Filament/VehicleResourceTest.php index 6af35cd..20389d8 100644 --- a/tests/Feature/Filament/VehicleResourceTest.php +++ b/tests/Feature/Filament/VehicleResourceTest.php @@ -709,7 +709,11 @@ // Das einzelne Loeschen auf der Edit-Seite bleibt erhalten (nur Admins, // siehe VehiclePolicy::delete) und verlangt eine Bestaetigung, die // Filament fuer DeleteAction standardmaessig erzwingt. - $vehicle = Vehicle::factory()->create(); + // + // Ausgemustert, weil Loeschen seit der Umstellung genau das voraussetzt - + // ein Fahrzeug im Bestand bietet den Knopf gar nicht erst an (siehe + // EditVehicle::deleteAction und DeactivateInsteadOfDeleteTest). + $vehicle = Vehicle::factory()->retired()->create(); actingAs(User::factory()->admin()->withTwoFactor()->create()); @@ -744,7 +748,7 @@ $html = livewire(EditVehicle::class, ['record' => $vehicle->id])->html(); $documentsPosition = strpos($html, 'Dokumente'); - $appointmentsPosition = strpos($html, 'Termine'); + $appointmentsPosition = strpos($html, 'Historie'); $notesPosition = strpos($html, 'Notizen'); expect($documentsPosition)->not->toBeFalse() diff --git a/tests/Feature/Filament/VehicleViewPageTest.php b/tests/Feature/Filament/VehicleViewPageTest.php index 8e8a8c2..efa5c7e 100644 --- a/tests/Feature/Filament/VehicleViewPageTest.php +++ b/tests/Feature/Filament/VehicleViewPageTest.php @@ -257,7 +257,7 @@ $html = livewire(ViewVehicle::class, ['record' => $vehicle->id])->html(); expect($html)->toContain('Dokumente') - ->toContain('Termine') + ->toContain('Historie') ->toContain('Fahrzeugschein Original'); }); }); @@ -276,7 +276,7 @@ $html = livewire(ViewVehicle::class, ['record' => $vehicle->id])->html(); $documentsPosition = strpos($html, 'Dokumente'); - $appointmentsPosition = strpos($html, 'Termine'); + $appointmentsPosition = strpos($html, 'Historie'); $notesPosition = strpos($html, 'Notizen'); expect($documentsPosition)->not->toBeFalse() diff --git a/tests/Feature/MasterDataActiveFlagTest.php b/tests/Feature/MasterDataActiveFlagTest.php new file mode 100644 index 0000000..0fc85bb --- /dev/null +++ b/tests/Feature/MasterDataActiveFlagTest.php @@ -0,0 +1,72 @@ + Dealership::class, + 'Leasinggeber' => LeasingCompany::class, + 'Tankkarte' => FuelCard::class, + 'Tankkartenanbieter' => FuelCardProvider::class, +]; + +// Standard "aktiv": Datensaetze, die es vor dieser Migration schon gab, +// muessen unveraendert benutzbar bleiben. +it('creates master data as active by default', function (string $modelClass) { + expect($modelClass::factory()->create()->active)->toBeTrue(); +})->with($masterDataModels); + +it('starts a freshly instantiated model as active, without touching the database', function (string $modelClass) { + expect((new $modelClass)->active)->toBeTrue(); +})->with($masterDataModels); + +it('casts the active flag to a boolean', function (string $modelClass) { + $record = $modelClass::factory()->create(['active' => false]); + + expect($record->fresh()->active)->toBeFalse(); +})->with($masterDataModels); + +// Der Modelltest oben wuerde auch dann bestehen, wenn 'active' nicht in +// $fillable stuende - die Factory umgeht die Massenzuweisung. Dieser Test +// laeuft ueber das Formular und faellt deshalb auf, was der andere durchlaesst. +it('saves the toggle through the form', function () { + actingAs(User::factory()->admin()->withTwoFactor()->create()); + + // Feste Werte statt der Faktor-Vorgaben: Gegenstand dieses Tests ist der + // Schalter, nicht was Faker in die uebrigen Felder schreibt. Mit + // Zufallsdaten haengt das Ergebnis an der Validierung von sieben Feldern, + // die hier niemand pruefen will. + $dealership = Dealership::factory()->create([ + 'active' => true, + 'name' => 'Autohaus Beispiel', + 'contact_person' => 'Anna Beispiel', + 'phone' => '089 123456', + 'email' => 'kontakt@autohaus-beispiel.example', + 'street' => 'Musterweg 1', + 'postcode' => '80331', + 'city' => 'München', + ]); + + $component = livewire(EditDealership::class, ['record' => $dealership->getRouteKey()]) + ->fillForm(['active' => false]) + ->call('save'); + + // Statt assertHasNoFormErrors(): benennt im Fehlerfall das Feld und die + // Meldung, anstatt nur "hat Fehler" zu sagen. + expect($component->instance()->getErrorBag()->messages())->toBe([]); + + expect($dealership->fresh()->active)->toBeFalse(); +}); diff --git a/tests/Feature/Models/EnumTest.php b/tests/Feature/Models/EnumTest.php index 610346a..db0ef95 100644 --- a/tests/Feature/Models/EnumTest.php +++ b/tests/Feature/Models/EnumTest.php @@ -35,15 +35,18 @@ it('provides German labels for appointment types', function () { expect(AppointmentType::Service->getLabel())->toBe('Service') ->and(AppointmentType::Workshop->getLabel())->toBe('Werkstatt') - ->and(AppointmentType::Inspection->getLabel())->toBe('Hauptuntersuchung'); + ->and(AppointmentType::Inspection->getLabel())->toBe('Hauptuntersuchung') + ->and(AppointmentType::Accident->getLabel())->toBe('Unfall') + ->and(AppointmentType::Damage->getLabel())->toBe('Schaden'); }); -it('gives appointment types colors distinct from danger and primary', function () { - // 'danger' ist in diesem Panel auf Rose verschoben, 'primary' ist das - // Markenrot (siehe AdminPanelProvider) - beide sind reserviert und - // duerfen fuer Termin-Badges nicht wiederverwendet werden. +// 'primary' ist das Markenrot (siehe AdminPanelProvider) und bleibt fuer +// Historien-Badges reserviert. 'danger' war ursprunglich ebenfalls tabu - +// mit Aufnahme des Unfalls hat es genau dort seinen Platz gefunden, weil +// ein Unfall hervorstechen soll (siehe AppointmentType::getColor()). +it('gives every appointment type a distinct colour that is not primary', function () { $colors = array_map(fn (AppointmentType $type): string => $type->getColor(), AppointmentType::cases()); - expect(array_intersect($colors, ['danger', 'primary']))->toBeEmpty() - ->and(array_unique($colors))->toHaveCount(3); + expect(array_intersect($colors, ['primary']))->toBeEmpty() + ->and($colors)->toHaveCount(count(array_unique($colors))); });