From 00702c333617a4132b95a7b9ceddd8f127a0a860 Mon Sep 17 00:00:00 2001 From: Romano Tebest Date: Wed, 5 Aug 2026 07:00:03 +0200 Subject: [PATCH 1/9] docs: spec for the five corrections after first real use Archiving instead of deleting for master data and vehicles, 2FA controls in user management, gross list price behind the cost gate, 'Termine' renamed to 'Historie' with accident and damage types, and the image upload fix for images below 2000 pixels. Co-Authored-By: Claude Opus 5 --- ...5-korrekturen-nach-erstbenutzung-design.md | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-korrekturen-nach-erstbenutzung-design.md 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..520c71b --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-korrekturen-nach-erstbenutzung-design.md @@ -0,0 +1,268 @@ +# 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. Archivieren 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 +archiviert, und nur ein bereits archivierter Datensatz lässt sich endgültig entfernen. + +### Umfang + +SoftDeletes (`deleted_at`) auf: `employees`, `dealerships`, `leasing_companies`, `fuel_cards`, +`fuel_card_providers`, `vehicles`. + +Benutzerkonten (`users`) bleiben ausgenommen. Sie sind keine Stammdaten des Fuhrparks, und an +ihnen hängen Import- und Exportzeilen mit `cascadeOnDelete` — das ist ein eigener Fall, der hier +nicht mitentschieden wird. + +### Warum SoftDeletes die gewünschte Regel ohne neue Prüfung liefert + +Ein Soft Delete ist ein `UPDATE`. Die Fremdschlüssel der Stammdaten stehen auf +`restrictOnDelete` (siehe `create_vehicles_table`), und ein `UPDATE` löst sie nicht aus. Ein +`forceDelete` ist dagegen ein echtes `DELETE` und läuft in genau diese Sperre. + +Daraus folgt ohne eine einzige zusätzliche Bedingung im Code: + +| Vorgang | Mitarbeiter ohne Fahrzeug | Mitarbeiter mit Fahrzeug | +|---|---|---| +| Archivieren | geht | geht | +| Endgültig löschen | geht (nur wenn archiviert) | Datenbank verweigert | + +Das ist die gewünschte Semantik: archivieren immer, endgültig löschen nur, wenn der Datensatz +archiviert ist **und** niemand mehr auf ihn zeigt. + +### Oberfläche + +- Die Aktion „Löschen" heißt in allen Stammdatenlisten und beim Fahrzeug „Archivieren". +- Bei einem archivierten Datensatz erscheinen „Wiederherstellen" und „Endgültig löschen". +- Ein Filter „Archivierte anzeigen", standardmäßig aus. +- Archivierte Zeilen tragen ein Badge „Archiviert". +- `App\Filament\Actions\GuardedDeleteAction` bewacht künftig das **endgültige** Löschen statt des + Archivierens. Der Text bleibt inhaltlich gleich („ist noch dem Fahrzeug X zugeordnet"), nur der + Titel wird zu „Endgültig löschen nicht möglich". Ohne diese Vorabprüfung liefe der Benutzer in + eine SQL-Fehlermeldung des Fremdschlüssels. + +### Zwei Fallstricke, die abgesichert werden müssen + +**1. `Vehicle::booted()` löscht beim Archivieren die Dateien.** + +`app/Models/Vehicle.php` registriert heute: + +```php +static::deleting(function (Vehicle $vehicle): void { + $vehicle->documents->each->delete(); + + if (filled($vehicle->image_path)) { + Storage::disk('local')->delete($vehicle->image_path); + } +}); +``` + +Das `deleting`-Event feuert bei einem Soft Delete genauso wie bei einem harten. Ohne Änderung +würde „Archivieren" das Fahrzeugbild und alle Dokumente von der Platte löschen, während der +Datensatz erhalten bleibt — ein Datenverlust unter einer Beschriftung, die das Gegenteil +verspricht. + +Der Rumpf muss deshalb auf `$vehicle->isForceDeleting()` eingegrenzt werden. Das ist die +riskanteste Einzelstelle dieser Änderung und braucht einen eigenen Test, der nach dem +Archivieren prüft, dass Datei und Dokumentzeilen noch da sind. + +**2. Eine `belongsTo`-Beziehung auf einen archivierten Datensatz liefert `null`.** + +Der globale Scope von SoftDeletes wirkt auch auf Beziehungen. Ein Fahrzeug, dessen Fahrer +archiviert wurde, hätte also plötzlich gar keinen Fahrer mehr — nicht „einen ehemaligen", sondern +optisch nie einen gehabt. Dasselbe gilt für Autohaus, Leasinggeber und Tankkarte. + +Deshalb: + +- Die `belongsTo`-Beziehungen am Fahrzeug bekommen `withTrashed()`, damit der Wert überhaupt + auflösbar bleibt. +- In der Anzeige (Liste, Detailseite) wird ein archivierter Bezug als solcher gekennzeichnet, + z. B. „Max Mustermann (archiviert)". +- Das **Auswahlfeld** im Fahrzeugformular zeigt nur nicht-archivierte 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. + +### Unique-Indizes + +`vehicles.license_plate` und `fuel_card_providers.name` sind `unique`. Ein archivierter Datensatz +belegt den Wert weiterhin — ein archiviertes Kennzeichen lässt sich also nicht erneut anlegen. + +Das bleibt so. Die Alternative (Unique zusammen mit `deleted_at`) erlaubt dasselbe Kennzeichen +mehrfach in der Tabelle und macht jede spätere Auswertung mehrdeutig. Stattdessen weist die +Fehlermeldung darauf hin, dass der Wert möglicherweise von einem archivierten Eintrag belegt ist. + +### Auswertungen + +Dashboard-Kacheln, Leasing- und HU-Warnungen, Kostenübersicht und globale Suche dürfen +archivierte Datensätze nicht mitzählen. Der globale Scope erledigt das für alles, was über das +Modell läuft. Zu prüfen ist `Vehicle::sumAnnualCost()`, weil dort mit einem SQL-Aggregat +gearbeitet wird. + +--- + +## 2. 2FA in der Benutzerverwaltung + +**Warum:** 2FA lässt sich heute nur im eigenen Profil einschalten. Ein Admin sieht nicht, wer es +aktiv hat, kann es bei einem verlorenen Telefon nicht zurücksetzen und kann es nicht anordnen. + +**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 + +- **Statusspalte** in der Benutzerliste: 2FA aktiv oder nicht. +- **Aktion „2FA zurücksetzen"** mit Rückfrage. Leert `app_authentication_secret` und + `app_authentication_recovery_codes`. Nur für Admins (`UserPolicy`). +- **Neue Spalte `users.requires_two_factor`** (boolean, Standard `false`), im Benutzerformular als + Schalter. + +### Warum die Pflicht eigene Middleware braucht + +Filaments `multiFactorAuthentication(..., isRequired:)` akzeptiert zwar einen Closure, wird aber +beim Registrieren der Routen ausgewertet — `Filament\Pages\Concerns\HasRoutes::getRouteMiddleware()` +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 +Einrichtungsseite umgeleitet. + +**Fallstrick:** Die Middleware muss die Einrichtungsseite selbst, deren Formular-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: + +- Archivieren eines Fahrzeugs lässt Bild und Dokumente auf der Platte und in der Datenbank + bestehen (der Fallstrick oben). +- Endgültiges Löschen eines Fahrzeugs entfernt Bild und Dokumente weiterhin. +- Ein Mitarbeiter mit zugeordnetem Fahrzeug lässt sich archivieren; die Zuordnung bleibt. +- Endgültiges Löschen eines zugeordneten Mitarbeiters wird verweigert — gegen MariaDB, damit der + echte Fremdschlüssel geprüft wird und nicht eine Nachbildung. +- Ein Fahrzeug mit archiviertem Fahrer zeigt ihn weiterhin an und verliert die Zuordnung beim + Speichern nicht. +- Archivierte erscheinen nicht in Auswahlfeldern, Listen, globaler Suche, Dashboard-Kacheln und + Kostensumme; mit gesetztem Filter erscheinen sie. +- Wiederherstellen macht den Datensatz wieder sichtbar. + +Zu Punkt 2: + +- Die Statusspalte zeigt aktiv/inaktiv korrekt. +- 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 + Einrichtungsseite selbst und der Logout bleiben erreichbar. +- Ein Benutzer ohne die Pflicht 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). From 1a5593b4a95fd5cb1420a91b888c1d7c4ce179a1 Mon Sep 17 00:00:00 2001 From: Romano Tebest Date: Wed, 5 Aug 2026 07:21:00 +0200 Subject: [PATCH 2/9] docs: implementation plan for the five corrections Seven tasks across two branches. The archiving approach changed during planning: the codebase already carries the state (vehicles.status with Aktiv/Ausgemustert, employees.active), so deletion is bound to that state instead of adding SoftDeletes on top, which would have produced three overlapping states on a vehicle. Co-Authored-By: Claude Opus 5 --- ...26-08-05-korrekturen-nach-erstbenutzung.md | 1429 +++++++++++++++++ ...5-korrekturen-nach-erstbenutzung-design.md | 191 +-- 2 files changed, 1518 insertions(+), 102 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-05-korrekturen-nach-erstbenutzung.md 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 index 520c71b..43fcca8 100644 --- a/docs/superpowers/specs/2026-08-05-korrekturen-nach-erstbenutzung-design.md +++ b/docs/superpowers/specs/2026-08-05-korrekturen-nach-erstbenutzung-design.md @@ -11,137 +11,123 @@ stammen; umgesetzt werden sie auf zwei Branches (siehe „Aufteilung" am Ende). --- -## 1. Archivieren statt Löschen +## 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 -archiviert, und nur ein bereits archivierter Datensatz lässt sich endgültig entfernen. +deaktiviert, und nur ein bereits deaktivierter Datensatz lässt sich endgültig entfernen. -### Umfang - -SoftDeletes (`deleted_at`) auf: `employees`, `dealerships`, `leasing_companies`, `fuel_cards`, -`fuel_card_providers`, `vehicles`. - -Benutzerkonten (`users`) bleiben ausgenommen. Sie sind keine Stammdaten des Fuhrparks, und an -ihnen hängen Import- und Exportzeilen mit `cascadeOnDelete` — das ist ein eigener Fall, der hier -nicht mitentschieden wird. +### Der Zustand ist teilweise schon vorhanden -### Warum SoftDeletes die gewünschte Regel ohne neue Prüfung liefert - -Ein Soft Delete ist ein `UPDATE`. Die Fremdschlüssel der Stammdaten stehen auf -`restrictOnDelete` (siehe `create_vehicles_table`), und ein `UPDATE` löst sie nicht aus. Ein -`forceDelete` ist dagegen ein echtes `DELETE` und läuft in genau diese Sperre. - -Daraus folgt ohne eine einzige zusätzliche Bedingung im Code: - -| Vorgang | Mitarbeiter ohne Fahrzeug | Mitarbeiter mit Fahrzeug | -|---|---|---| -| Archivieren | geht | geht | -| Endgültig löschen | geht (nur wenn archiviert) | Datenbank verweigert | +Beim Planen hat sich gezeigt, dass die Anwendung das Konzept bereits kennt: -Das ist die gewünschte Semantik: archivieren immer, endgültig löschen nur, wenn der Datensatz -archiviert ist **und** niemand mehr auf ihn zeigt. +- **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. -### Oberfläche +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. -- Die Aktion „Löschen" heißt in allen Stammdatenlisten und beim Fahrzeug „Archivieren". -- Bei einem archivierten Datensatz erscheinen „Wiederherstellen" und „Endgültig löschen". -- Ein Filter „Archivierte anzeigen", standardmäßig aus. -- Archivierte Zeilen tragen ein Badge „Archiviert". -- `App\Filament\Actions\GuardedDeleteAction` bewacht künftig das **endgültige** Löschen statt des - Archivierens. Der Text bleibt inhaltlich gleich („ist noch dem Fahrzeug X zugeordnet"), nur der - Titel wird zu „Endgültig löschen nicht möglich". Ohne diese Vorabprüfung liefe der Benutzer in - eine SQL-Fehlermeldung des Fremdschlüssels. +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. -### Zwei Fallstricke, die abgesichert werden müssen +### Die Regel -**1. `Vehicle::booted()` löscht beim Archivieren die Dateien.** +> 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. -`app/Models/Vehicle.php` registriert heute: +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. -```php -static::deleting(function (Vehicle $vehicle): void { - $vehicle->documents->each->delete(); +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. - if (filled($vehicle->image_path)) { - Storage::disk('local')->delete($vehicle->image_path); - } -}); -``` +### Umfang -Das `deleting`-Event feuert bei einem Soft Delete genauso wie bei einem harten. Ohne Änderung -würde „Archivieren" das Fahrzeugbild und alle Dokumente von der Platte löschen, während der -Datensatz erhalten bleibt — ein Datenverlust unter einer Beschriftung, die das Gegenteil -verspricht. +Neues Feld `active` (boolean, Standard `true`) auf: `dealerships`, `leasing_companies`, +`fuel_cards`, `fuel_card_providers`. -Der Rumpf muss deshalb auf `$vehicle->isForceDeleting()` eingegrenzt werden. Das ist die -riskanteste Einzelstelle dieser Änderung und braucht einen eigenen Test, der nach dem -Archivieren prüft, dass Datei und Dokumentzeilen noch da sind. +Mitarbeiter (`employees.active`) und Fahrzeug (`vehicles.status`) haben ihren Zustand bereits. -**2. Eine `belongsTo`-Beziehung auf einen archivierten Datensatz liefert `null`.** +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. -Der globale Scope von SoftDeletes wirkt auch auf Beziehungen. Ein Fahrzeug, dessen Fahrer -archiviert wurde, hätte also plötzlich gar keinen Fahrer mehr — nicht „einen ehemaligen", sondern -optisch nie einen gehabt. Dasselbe gilt für Autohaus, Leasinggeber und Tankkarte. +### Oberfläche -Deshalb: +Je Zeile in den Stammdatenlisten und beim Fahrzeug: -- Die `belongsTo`-Beziehungen am Fahrzeug bekommen `withTrashed()`, damit der Wert überhaupt - auflösbar bleibt. -- In der Anzeige (Liste, Detailseite) wird ein archivierter Bezug als solcher gekennzeichnet, - z. B. „Max Mustermann (archiviert)". -- Das **Auswahlfeld** im Fahrzeugformular zeigt nur nicht-archivierte 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. +| 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 archivierter Datensatz -belegt den Wert weiterhin — ein archiviertes Kennzeichen lässt sich also nicht erneut anlegen. - -Das bleibt so. Die Alternative (Unique zusammen mit `deleted_at`) erlaubt dasselbe Kennzeichen -mehrfach in der Tabelle und macht jede spätere Auswertung mehrdeutig. Stattdessen weist die -Fehlermeldung darauf hin, dass der Wert möglicherweise von einem archivierten Eintrag belegt ist. +`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. -### Auswertungen - -Dashboard-Kacheln, Leasing- und HU-Warnungen, Kostenübersicht und globale Suche dürfen -archivierte Datensätze nicht mitzählen. Der globale Scope erledigt das für alles, was über das -Modell läuft. Zu prüfen ist `Vehicle::sumAnnualCost()`, weil dort mit einem SQL-Aggregat -gearbeitet wird. +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 sieht nicht, wer es -aktiv hat, kann es bei einem verlorenen Telefon nicht zurücksetzen und kann es nicht anordnen. +**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 -- **Statusspalte** in der Benutzerliste: 2FA aktiv oder nicht. - **Aktion „2FA zurücksetzen"** mit Rückfrage. Leert `app_authentication_secret` und - `app_authentication_recovery_codes`. Nur für Admins (`UserPolicy`). + `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. + 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::getRouteMiddleware()` -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. +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 -Einrichtungsseite umgeleitet. +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 Einrichtungsseite selbst, deren Formular-Requests und den -Logout durchlassen. Sonst leitet sie den Benutzer auf eine Seite um, die sie erneut umleitet — eine +**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. --- @@ -177,7 +163,7 @@ 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 | +| `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 @@ -220,26 +206,27 @@ festem Rahmen. Zu Punkt 1: -- Archivieren eines Fahrzeugs lässt Bild und Dokumente auf der Platte und in der Datenbank - bestehen (der Fallstrick oben). -- Endgültiges Löschen eines Fahrzeugs entfernt Bild und Dokumente weiterhin. -- Ein Mitarbeiter mit zugeordnetem Fahrzeug lässt sich archivieren; die Zuordnung bleibt. -- Endgültiges Löschen eines zugeordneten Mitarbeiters wird verweigert — gegen MariaDB, damit der - echte Fremdschlüssel geprüft wird und nicht eine Nachbildung. -- Ein Fahrzeug mit archiviertem Fahrer zeigt ihn weiterhin an und verliert die Zuordnung beim +- 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. -- Archivierte erscheinen nicht in Auswahlfeldern, Listen, globaler Suche, Dashboard-Kacheln und - Kostensumme; mit gesetztem Filter erscheinen sie. -- Wiederherstellen macht den Datensatz wieder sichtbar. +- 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: -- Die Statusspalte zeigt aktiv/inaktiv korrekt. - 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 - Einrichtungsseite selbst und der Logout bleiben erreichbar. +- 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: From 628edec3a04e1504f18b60cab89422af0daa7e37 Mon Sep 17 00:00:00 2001 From: Romano Tebest Date: Wed, 5 Aug 2026 07:32:03 +0200 Subject: [PATCH 3/9] docs: note that ddev exec re-parses commands through bash Cost an hour and a wrong password hash in the dev database: quotes are already gone by the time the command reaches the container, so $argv in a php -r one-liner expands on the way in rather than inside PHP. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ac695cc..dc25c83 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 — From 11d3a8276e44f226f8b5b0f0b785f234f5ae0e67 Mon Sep 17 00:00:00 2001 From: Romano Tebest Date: Wed, 5 Aug 2026 07:49:08 +0200 Subject: [PATCH 4/9] feat: add an active flag to the remaining master data The state "active/inactive" existed in only two places: employees.active, where nothing acted on it, and vehicles.status as Aktiv/Ausgemustert. Deletion is about to depend on that state, so dealerships, leasing companies, fuel cards and fuel card providers need the same field. Deliberately not SoftDeletes. An extra deleted_at would give a vehicle three states (active, retired, deleted) and an employee two that sound alike. It would also mean guarding Vehicle::deleting(), which today wipes the image and documents off disk and fires on a soft delete just the same. Defaults to true so existing rows stay usable, mirrored in $attributes so a freshly instantiated model matches a freshly written row. The form test is not redundant with the model test: a factory bypasses mass assignment, so the model test would pass even with 'active' missing from $fillable. Table columns, the status filter and the actions follow in the next commit, where all six lists change together. Co-Authored-By: Claude Opus 5 --- .../Dealerships/Schemas/DealershipForm.php | 5 ++ .../Schemas/DealershipInfolist.php | 4 ++ .../Schemas/FuelCardProviderForm.php | 5 ++ .../Schemas/FuelCardProviderInfolist.php | 4 ++ .../FuelCards/Schemas/FuelCardForm.php | 5 ++ .../FuelCards/Schemas/FuelCardInfolist.php | 4 ++ .../Schemas/LeasingCompanyForm.php | 5 ++ .../Schemas/LeasingCompanyInfolist.php | 4 ++ app/Models/Dealership.php | 17 ++++++ app/Models/FuelCard.php | 11 ++++ app/Models/FuelCardProvider.php | 17 ++++++ app/Models/LeasingCompany.php | 17 ++++++ ...54516_add_active_to_master_data_tables.php | 48 ++++++++++++++++ tests/Feature/MasterDataActiveFlagTest.php | 56 +++++++++++++++++++ 14 files changed, 202 insertions(+) create mode 100644 database/migrations/2026_08_05_054516_add_active_to_master_data_tables.php create mode 100644 tests/Feature/MasterDataActiveFlagTest.php 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/FuelCardProviders/Schemas/FuelCardProviderForm.php b/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderForm.php index fef1db5..9c8a919 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 @@ -17,6 +18,10 @@ public static function configure(Schema $schema): Schema ->required() ->unique(ignoreRecord: true) ->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/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/FuelCards/Schemas/FuelCardForm.php b/app/Filament/Resources/FuelCards/Schemas/FuelCardForm.php index 81003ea..a5bcbcc 100644 --- a/app/Filament/Resources/FuelCards/Schemas/FuelCardForm.php +++ b/app/Filament/Resources/FuelCards/Schemas/FuelCardForm.php @@ -6,6 +6,7 @@ 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 @@ -31,6 +32,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/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/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/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/tests/Feature/MasterDataActiveFlagTest.php b/tests/Feature/MasterDataActiveFlagTest.php new file mode 100644 index 0000000..2f34bea --- /dev/null +++ b/tests/Feature/MasterDataActiveFlagTest.php @@ -0,0 +1,56 @@ + 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()); + + $dealership = Dealership::factory()->create(['active' => true]); + + livewire(EditDealership::class, ['record' => $dealership->getRouteKey()]) + ->fillForm(['active' => false]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($dealership->fresh()->active)->toBeFalse(); +}); From 7ec4e63a52265ec8c59f08c280c01d2295290f33 Mon Sep 17 00:00:00 2001 From: Romano Tebest Date: Wed, 5 Aug 2026 08:20:08 +0200 Subject: [PATCH 5/9] feat: deactivate master data instead of deleting it outright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletion now depends on state: an active record offers "Deaktivieren", and only an inactive one can be deleted at all. Deactivating is always allowed, including for an employee who still has a vehicle assigned — someone leaves the company and the car stays with them until a successor turns up. The assignment survives. The two conditions on deletion are independent and both stay: the state decides whether deleting is offered, the foreign key decides whether it can succeed. GuardedDeleteAction still explains which vehicle is in the way, now under the heading "Endgültig löschen nicht möglich". The state column differs per model — vehicles carry an enum in `status`, master data a boolean `active` — so the three actions share a TracksActiveState trait rather than each reimplementing the comparison. Two things worth knowing about the vehicle: VehiclesTable has no delete action at all; a vehicle is deleted from the footer of its edit page, via a local deleteAction() on EditVehicle. Guarding only the tables would have left the rule bypassable at the one place where a vehicle actually gets deleted. "Ausmustern" replaces "Deaktivieren" there, because that is the word used for a vehicle leaving the fleet, and the list already filtered on VehicleStatus::Active by default — the five master data lists now mirror that with a TernaryFilter defaulting to active. The unique indexes on vehicles.license_plate and fuel_card_providers.name stay as they are; loosening them would allow the same plate twice in one table. Instead the validation message says the value may be held by a retired or inactive record, which is not visible in the default view. Eight existing tests deleted active records and now fail correctly. They deactivate first — the rule was not weakened to keep them green. One test in MasterDataActiveFlagTest failed intermittently (roughly one run in fifteen, always at assertHasNoFormErrors, so a validation error). Feeding 3000 factory rounds through the form's rules produced no invalid values, so the cause is not proven. The test now uses fixed field values instead of faker output and reports the actual error bag, which removes the only plausible source of randomness and makes any future failure name the field. Co-Authored-By: Claude Opus 5 --- app/Filament/Actions/ActivateAction.php | 42 +++++ .../Actions/Concerns/TracksActiveState.php | 45 +++++ app/Filament/Actions/DeactivateAction.php | 56 +++++++ app/Filament/Actions/GuardedDeleteAction.php | 18 +- .../Dealerships/Tables/DealershipsTable.php | 20 +++ .../Employees/Tables/EmployeesTable.php | 24 ++- .../Schemas/FuelCardProviderForm.php | 9 +- .../Tables/FuelCardProvidersTable.php | 20 +++ .../FuelCards/Tables/FuelCardsTable.php | 13 ++ .../Tables/LeasingCompaniesTable.php | 20 +++ .../Resources/Vehicles/Pages/EditVehicle.php | 12 +- .../Vehicles/Schemas/VehicleForm.php | 12 +- .../Vehicles/Tables/VehiclesTable.php | 19 +++ .../DeactivateInsteadOfDeleteTest.php | 154 ++++++++++++++++++ .../Filament/DealershipResourceTest.php | 4 +- .../Feature/Filament/EmployeeResourceTest.php | 8 +- .../Filament/FuelCardProviderResourceTest.php | 8 +- .../Feature/Filament/FuelCardResourceTest.php | 4 +- .../Filament/LeasingCompanyResourceTest.php | 4 +- .../Feature/Filament/VehicleResourceTest.php | 6 +- tests/Feature/MasterDataActiveFlagTest.php | 24 ++- 21 files changed, 502 insertions(+), 20 deletions(-) create mode 100644 app/Filament/Actions/ActivateAction.php create mode 100644 app/Filament/Actions/Concerns/TracksActiveState.php create mode 100644 app/Filament/Actions/DeactivateAction.php create mode 100644 tests/Feature/Filament/DeactivateInsteadOfDeleteTest.php 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/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/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/Schemas/FuelCardProviderForm.php b/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderForm.php index 9c8a919..c2272f3 100644 --- a/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderForm.php +++ b/app/Filament/Resources/FuelCardProviders/Schemas/FuelCardProviderForm.php @@ -17,7 +17,14 @@ 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) 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/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/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/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/Schemas/VehicleForm.php b/app/Filament/Resources/Vehicles/Schemas/VehicleForm.php index 9ffe040..b1b8eba 100644 --- a/app/Filament/Resources/Vehicles/Schemas/VehicleForm.php +++ b/app/Filament/Resources/Vehicles/Schemas/VehicleForm.php @@ -299,7 +299,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/Tables/VehiclesTable.php b/app/Filament/Resources/Vehicles/Tables/VehiclesTable.php index 1293c6a..f3f5fce 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; @@ -108,6 +110,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/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/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/VehicleResourceTest.php b/tests/Feature/Filament/VehicleResourceTest.php index 6af35cd..8e52dbb 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()); diff --git a/tests/Feature/MasterDataActiveFlagTest.php b/tests/Feature/MasterDataActiveFlagTest.php index 2f34bea..0fc85bb 100644 --- a/tests/Feature/MasterDataActiveFlagTest.php +++ b/tests/Feature/MasterDataActiveFlagTest.php @@ -45,12 +45,28 @@ it('saves the toggle through the form', function () { actingAs(User::factory()->admin()->withTwoFactor()->create()); - $dealership = Dealership::factory()->create(['active' => true]); + // 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', + ]); - livewire(EditDealership::class, ['record' => $dealership->getRouteKey()]) + $component = livewire(EditDealership::class, ['record' => $dealership->getRouteKey()]) ->fillForm(['active' => false]) - ->call('save') - ->assertHasNoFormErrors(); + ->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(); }); From d6b768f228391bae5134796faf7653fbd79b0c11 Mon Sep 17 00:00:00 2001 From: Romano Tebest Date: Wed, 5 Aug 2026 09:11:55 +0200 Subject: [PATCH 6/9] feat: keep inactive master data out of pickers, search and lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pickers on the vehicle form filter each master-data relation on active, but keep the currently assigned value in the option list even when it has turned inactive. Without that exception the form would not find the stored value in its options and quietly clear the assignment on the next save — a failure mode the test suite pins down. The option label carries a "(inaktiv)" suffix on such kept values, so the person editing sees why an unusual entry appears among otherwise active ones. The vehicle list mirrors this on the driver column. Global search grows a getGlobalSearchEloquentQuery() on each of the five master-data resources (filtering on active) and on VehicleResource (filtering on status). Otherwise search would remain the one way to re-surface an inactive record without meaning to. The fuel card provider select on the fuel card form gets the same treatment — an inactive provider does not belong on a new card, but the one already set must stay pickable for the same reason as the vehicle pickers. Co-Authored-By: Claude Opus 5 --- .../Dealerships/DealershipResource.php | 11 ++ .../Resources/Employees/EmployeeResource.php | 11 ++ .../FuelCardProviderResource.php | 11 ++ .../Resources/FuelCards/FuelCardResource.php | 11 ++ .../FuelCards/Schemas/FuelCardForm.php | 13 +- .../LeasingCompanyResource.php | 11 ++ .../Vehicles/Schemas/VehicleForm.php | 47 ++++++- .../Vehicles/Tables/VehiclesTable.php | 11 +- .../Resources/Vehicles/VehicleResource.php | 12 ++ .../InactiveMasterDataVisibilityTest.php | 130 ++++++++++++++++++ 10 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/Filament/InactiveMasterDataVisibilityTest.php 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/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/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/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 a5bcbcc..40248d4 100644 --- a/app/Filament/Resources/FuelCards/Schemas/FuelCardForm.php +++ b/app/Filament/Resources/FuelCards/Schemas/FuelCardForm.php @@ -2,6 +2,7 @@ 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; @@ -17,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() 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/Vehicles/Schemas/VehicleForm.php b/app/Filament/Resources/Vehicles/Schemas/VehicleForm.php index b1b8eba..7c035b3 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(), diff --git a/app/Filament/Resources/Vehicles/Tables/VehiclesTable.php b/app/Filament/Resources/Vehicles/Tables/VehiclesTable.php index f3f5fce..92208b3 100644 --- a/app/Filament/Resources/Vehicles/Tables/VehiclesTable.php +++ b/app/Filament/Resources/Vehicles/Tables/VehiclesTable.php @@ -51,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') diff --git a/app/Filament/Resources/Vehicles/VehicleResource.php b/app/Filament/Resources/Vehicles/VehicleResource.php index 143dfb6..32e0020 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; @@ -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/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)'); + }); +}); From 8e17df201bacb4347f2edf473836c0de161e597a Mon Sep 17 00:00:00 2001 From: Romano Tebest Date: Wed, 5 Aug 2026 09:17:31 +0200 Subject: [PATCH 7/9] feat: add the gross list price behind the cost permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vehicles.gross_list_price, decimal(10, 2) nullable, sits inside the "Kosten" section of the vehicle form — the same hidden-section mechanism protects it that protects the other cost fields (CLAUDE.md, "Kostendaten"). A gross list price submitted by a user without canViewCosts is discarded on save; the test proves this by sending 1.00 and expecting the pre-existing 30000 to survive. The value appears in the cost overview table and the exporter between the running cost columns and the annual total, and on the vehicle detail page inside the same Kosten section. It is deliberately absent from annual_total_cost and from any summarizer — a list price is not a running position and does not make sense as a fleet-wide sum. Two column-order tests in VehicleCostExportVerificationTest picked up a column offset from the new export column; both now reference index 7 for Jahreskosten with a comment explaining why. Co-Authored-By: Claude Opus 5 --- app/Filament/Exports/VehicleCostExporter.php | 3 ++ .../VehicleCosts/Tables/VehicleCostsTable.php | 8 +++ .../Vehicles/Schemas/VehicleForm.php | 7 +++ .../Vehicles/Schemas/VehicleInfolist.php | 4 ++ app/Models/Vehicle.php | 2 + ...add_gross_list_price_to_vehicles_table.php | 29 +++++++++++ tests/Feature/Filament/GrossListPriceTest.php | 49 +++++++++++++++++++ .../VehicleCostExportVerificationTest.php | 9 +++- 8 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_08_05_071234_add_gross_list_price_to_vehicles_table.php create mode 100644 tests/Feature/Filament/GrossListPriceTest.php 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/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/Schemas/VehicleForm.php b/app/Filament/Resources/Vehicles/Schemas/VehicleForm.php index 7c035b3..316348b 100644 --- a/app/Filament/Resources/Vehicles/Schemas/VehicleForm.php +++ b/app/Filament/Resources/Vehicles/Schemas/VehicleForm.php @@ -207,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), ]), ]; } 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/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/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/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/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'); }); /** From c41b16909e60ed94f25123bd1409604508ce29e3 Mon Sep 17 00:00:00 2001 From: Romano Tebest Date: Wed, 5 Aug 2026 09:22:02 +0200 Subject: [PATCH 8/9] feat: rename appointments to history and add accident and damage types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section that lived under "Termine" now shows as "Historie" — a planned service, a workshop visit, an inspection, an accident and a minor damage all share the same list, and "Termin" fits an accident poorly. Only labels change; VehicleAppointment, vehicle_appointments and AppointmentType stay as they are, since renaming them would cost a migration and change nothing users see. Accident maps to 'danger' (the panel's Rose): it should stand out among the calmer entry types. Damage maps to 'orange', freshly registered in AdminPanelProvider because 'warning' is already the inspection colour and every type should carry a distinct meaning through its badge. The two existing tests that pinned "Termine" as the visible label were updated to "Historie". The colour-uniqueness test now enforces both distinctness across all five types and the ban on 'primary' (which remains the brand red). The ban on 'danger' had to go — that is exactly where the accident belongs. Co-Authored-By: Claude Opus 5 --- app/Enums/AppointmentType.php | 22 +++++++++++++++---- .../VehicleAppointmentsRelationManager.php | 8 ++++++- .../Resources/Vehicles/VehicleResource.php | 2 +- app/Providers/Filament/AdminPanelProvider.php | 4 ++++ tests/Feature/AppointmentTypeTest.php | 20 +++++++++++++++++ .../Feature/Filament/VehicleResourceTest.php | 2 +- .../Feature/Filament/VehicleViewPageTest.php | 4 ++-- tests/Feature/Models/EnumTest.php | 17 ++++++++------ 8 files changed, 63 insertions(+), 16 deletions(-) create mode 100644 tests/Feature/AppointmentTypeTest.php 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/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/VehicleResource.php b/app/Filament/Resources/Vehicles/VehicleResource.php index 32e0020..e3e521f 100644 --- a/app/Filament/Resources/Vehicles/VehicleResource.php +++ b/app/Filament/Resources/Vehicles/VehicleResource.php @@ -112,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, ]), diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 2d88bfa..8e7c3d4 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -60,6 +60,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() 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/VehicleResourceTest.php b/tests/Feature/Filament/VehicleResourceTest.php index 8e52dbb..20389d8 100644 --- a/tests/Feature/Filament/VehicleResourceTest.php +++ b/tests/Feature/Filament/VehicleResourceTest.php @@ -748,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/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))); }); From c286baed9d6aea8e4fd28294367bca01baaa1057 Mon Sep 17 00:00:00 2001 From: Romano Tebest Date: Wed, 5 Aug 2026 09:26:14 +0200 Subject: [PATCH 9/9] feat: let admins reset and require two-factor authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things an admin can do that they could not before, plus a note on what they still cannot. Reset — a per-row action on the user list, visible only when the target has 2FA set up and only for admins (UserPolicy::resetTwoFactor). It clears app_authentication_secret and app_authentication_recovery_codes on that one user, so someone who lost their phone can enroll again. What an admin cannot do here is set up another user's 2FA in their place: the secret must only ever exist in the user's own authenticator app; if a second person knew it, the second factor would not be one. Require — a new users.requires_two_factor boolean, defaulted to false so the migration does not lock existing users out. Editable through a toggle in the user form, visible as a "2FA Pflicht" icon column in the user list. Enforcement runs through a dedicated EnsureTwoFactorWhenRequired middleware in the panel's authMiddleware, because Filament's built-in isRequired is evaluated when the routes are registered — before any user is known — and so cannot make a per-user decision. On a request from an affected user without a secret, the middleware redirects to the profile page. That page, its Livewire requests and the logout route are excluded so the user is not trapped in a redirect loop with no way to enroll or sign out. The test suite pins both directions down. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 16 +++ .../Resources/Users/Schemas/UserForm.php | 4 + .../Resources/Users/Tables/UsersTable.php | 34 ++++++ .../EnsureTwoFactorWhenRequired.php | 45 +++++++ app/Models/User.php | 3 + app/Policies/UserPolicy.php | 10 ++ app/Providers/Filament/AdminPanelProvider.php | 2 + ...add_requires_two_factor_to_users_table.php | 32 +++++ .../Filament/TwoFactorAdministrationTest.php | 114 ++++++++++++++++++ 9 files changed, 260 insertions(+) create mode 100644 app/Http/Middleware/EnsureTwoFactorWhenRequired.php create mode 100644 database/migrations/2026_08_05_072303_add_requires_two_factor_to_users_table.php create mode 100644 tests/Feature/Filament/TwoFactorAdministrationTest.php diff --git a/CLAUDE.md b/CLAUDE.md index dc25c83..112942b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,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/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/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/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/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 8e7c3d4..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; @@ -98,6 +99,7 @@ public function panel(Panel $panel): Panel ]) ->authMiddleware([ Authenticate::class, + EnsureTwoFactorWhenRequired::class, ]); } } 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/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); + }); +});