Skip to content
Merged
23 changes: 23 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -67,6 +74,22 @@ Feldnamen ab.
(`storage/app/private`) und dürfen ausschließlich über den autorisierten
`VehicleDocumentDownloadController` ausgeliefert werden — kein `Storage::url()`, kein
`public/storage`-Symlink, keine signierte URL.
- **Löschen ist an den Zustand gebunden.** Ein aktiver Datensatz bietet die Löschaktion gar nicht
erst an; sichtbar wird sie erst bei einem inaktiven (bzw. beim Fahrzeug: ausgemusterten). Die
Regel steckt in `GuardedDeleteAction::visible()` und beim Fahrzeug zusätzlich in
`EditVehicle::deleteAction()`. Wer eine neue Löschaktion einbaut, muss die Bedingung mitnehmen —
sonst ist die Regel an dieser Stelle aufgehoben.
- **Auswahlfelder auf Stammdaten filtern auf `active`**, ergänzt um den bereits gesetzten Wert
(`->orWhere('id', $record?->…)`). Ohne diese Ausnahme leert das nächste Speichern still eine
Zuordnung, wenn der Bezug inzwischen inaktiv geworden ist. Vorbilder: die vier `Select`s in
`VehicleForm` und der Anbieter-Select in `FuelCardForm`.
- **Der Bruttolistenpreis gehört wie die übrigen Kostenfelder in die Section „Kosten"** —
außerhalb wäre er ungeschützt (siehe erster Punkt in dieser Liste).
- **2FA-Pflicht liegt in eigener Middleware.** Filaments `multiFactorAuthentication(...,
isRequired:)` wertet den Wert beim Registrieren der Routen aus, wo noch kein Benutzer bekannt
ist. `EnsureTwoFactorWhenRequired` in der `authMiddleware` des Panels leitet auf die
Profilseite; die muss samt Livewire-Requests und Logout durchgelassen werden, sonst leitet die
Middleware auf eine Seite um, die sie erneut umleitet — eine Schleife.

## Dokumentation

Expand Down
22 changes: 18 additions & 4 deletions app/Enums/AppointmentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,42 @@ 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
{
return match ($this) {
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
{
return match ($this) {
self::Service => 'info',
self::Workshop => 'gray',
self::Inspection => 'warning',
self::Accident => 'danger',
self::Damage => 'orange',
};
}
}
42 changes: 42 additions & 0 deletions app/Filament/Actions/ActivateAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace App\Filament\Actions;

use App\Filament\Actions\Concerns\TracksActiveState;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Model;

/**
* Gegenstueck zu DeactivateAction. Bewusst ohne Rueckfrage: Aktivieren ist
* folgenlos umkehrbar, eine Sicherheitsabfrage waere hier nur im Weg.
*/
class ActivateAction extends Action
{
use TracksActiveState;

public static function getDefaultName(): ?string
{
return 'activate';
}

protected function setUp(): void
{
parent::setUp();

$this->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();
});
}
}
45 changes: 45 additions & 0 deletions app/Filament/Actions/Concerns/TracksActiveState.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace App\Filament\Actions\Concerns;

use Illuminate\Database\Eloquent\Model;

/**
* Der Zustand "aktiv/inaktiv" steckt nicht ueberall in derselben Spalte: das
* Fahrzeug fuehrt ihn als Enum in `status` (Aktiv/Ausgemustert), die uebrigen
* Stammdaten als boolesches `active`. Damit sich Deaktivieren, Aktivieren und
* Loeschen trotzdem gleich verhalten, kapseln alle drei Aktionen den Zugriff
* hier - und nicht jede fuer sich.
*/
trait TracksActiveState
{
protected string $stateColumn = 'active';

protected mixed $inactiveValue = false;

protected mixed $activeValue = true;

/**
* Stellt Spalte und Werte um, z. B. fuer das Fahrzeug:
* ->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();
}
}
56 changes: 56 additions & 0 deletions app/Filament/Actions/DeactivateAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace App\Filament\Actions;

use App\Filament\Actions\Concerns\TracksActiveState;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Model;

/**
* Tritt an die Stelle des Loeschens, solange ein Datensatz aktiv ist.
*
* Die Anwendung verwirft Stammdaten nicht mehr sofort: erst wird deaktiviert,
* und nur ein inaktiver Datensatz laesst sich ueberhaupt loeschen (siehe
* GuardedDeleteAction). Deaktivieren selbst ist immer erlaubt - auch bei einem
* Mitarbeiter, dem noch ein Fahrzeug zugeordnet ist. Das spiegelt die
* Wirklichkeit: jemand verlaesst die Firma, das Auto bleibt ihm zugeordnet,
* bis ein Nachfolger da ist.
*/
class DeactivateAction extends Action
{
use TracksActiveState;

public static function getDefaultName(): ?string
{
return 'deactivate';
}

protected function setUp(): void
{
parent::setUp();

$this->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();
});
}
}
18 changes: 17 additions & 1 deletion app/Filament/Actions/GuardedDeleteAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Filament\Actions;

use App\Filament\Actions\Concerns\TracksActiveState;
use Closure;
use Filament\Actions\DeleteAction;
use Filament\Notifications\Notification;
Expand All @@ -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';

/**
Expand All @@ -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();
Expand All @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions app/Filament/Exports/VehicleCostExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions app/Filament/Resources/Dealerships/DealershipResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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('—')
Expand Down
20 changes: 20 additions & 0 deletions app/Filament/Resources/Dealerships/Tables/DealershipsTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
]);
}
Expand Down
Loading