Add recipe versioning: auto-bump on change, history sub-page
Deploy / deploy (push) Successful in 16s

Each time the recipe name or spices are changed on the product edit
page, a new version is created instead of overwriting the current one.
Unchanged saves are skipped. A 'Rezept-Versionen' button opens a
sub-page (/produkt/{id}/rezept-versionen) listing all versions with
their spices and timestamps; the active version is highlighted in green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Malek TellissiandClaude Sonnet 4.6 committed 2026-09-12 05:52:59 +02:00
1 parent 4775cffaae
commit a7e7375edd
4 files changed
+117 -10

No files matched your search

@@ -196,6 +196,7 @@ class ProductResource extends Resource {
'index' => Pages\ListProducts::route('/'), 'index' => Pages\ListProducts::route('/'),
'create' => Pages\CreateProduct::route('/create'), 'create' => Pages\CreateProduct::route('/create'),
'edit' => Pages\EditProduct::route('/{record}/edit'), 'edit' => Pages\EditProduct::route('/{record}/edit'),
'recipe-history' => Pages\RecipeHistory::route('/{record}/rezept-versionen'),
]; ];
} }
@@ -14,16 +14,23 @@ class EditProduct extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\Action::make('rezept-versionen')
->label('Rezept-Versionen')
->icon('heroicon-o-clock')
->color('gray')
->url(fn () => ProductResource::getUrl('recipe-history', ['record' => $this->record])),
Actions\DeleteAction::make(), Actions\DeleteAction::make(),
]; ];
} }
protected function afterSave(): void { protected function afterSave(): void
{
$this->syncSizes(); $this->syncSizes();
$this->syncRecipe(); $this->syncRecipe();
} }
protected function syncSizes(): void { protected function syncSizes(): void
{
$product = $this->record; $product = $this->record;
$sizes = collect($this->data['sizes'] ?? []) $sizes = collect($this->data['sizes'] ?? [])
@@ -52,22 +59,41 @@ class EditProduct extends EditRecord
return; return;
} }
$pivotData = collect($spices)->mapWithKeys(fn ($s) => [ $newPivot = collect($spices)->mapWithKeys(fn ($s) => [
$s['spice_id'] => ['amount_in_percent' => $s['amount_in_percent']], (int) $s['spice_id'] => (float) $s['amount_in_percent'],
])->toArray(); ])->toArray();
if ($product->recipe) { $current = $product->recipe;
$product->recipe->update(['name' => $name]);
$product->recipe->spices()->sync($pivotData); if ($current) {
} else { $current->load('spices');
$existingPivot = $current->spices->mapWithKeys(fn ($s) => [
$s->id => (float) $s->pivot->amount_in_percent,
])->toArray();
$nameChanged = $current->name !== $name;
$spicesChanged = $existingPivot != $newPivot;
if (! $nameChanged && ! $spicesChanged) {
return;
}
}
$nextVersion = Recipe::where('product_id', $product->id)->max('version') + 1; $nextVersion = Recipe::where('product_id', $product->id)->max('version') + 1;
$recipe = Recipe::create([ $recipe = Recipe::create([
'name' => $name, 'name' => $name,
'product_id' => $product->id, 'product_id' => $product->id,
'version' => $nextVersion, 'version' => $nextVersion,
]); ]);
$recipe->spices()->sync($pivotData);
$recipe->spices()->sync(
collect($newPivot)->mapWithKeys(fn ($pct, $id) => [
$id => ['amount_in_percent' => $pct],
])->toArray()
);
$product->update(['recipes_id' => $recipe->id]); $product->update(['recipes_id' => $recipe->id]);
} }
} }
}
@@ -0,0 +1,77 @@
<?php
namespace App\Filament\User\Resources\ProductResource\Pages;
use App\Filament\User\Resources\ProductResource;
use App\Models\Product;
use App\Models\Recipe;
use Filament\Actions\Action;
use Filament\Resources\Pages\Page;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
class RecipeHistory extends Page implements HasTable
{
use InteractsWithTable;
protected static string $resource = ProductResource::class;
protected static string $view = 'filament.user.resources.product-resource.pages.recipe-history';
public Product $record;
public function mount(int|string $record): void
{
$this->record = Product::with('recipe')->findOrFail($record);
}
public function getTitle(): string
{
return 'Rezept-Versionen · ' . $this->record->title;
}
protected function getHeaderActions(): array
{
return [
Action::make('back')
->label('Zurück zum Produkt')
->icon('heroicon-o-arrow-left')
->color('gray')
->url(ProductResource::getUrl('edit', ['record' => $this->record])),
];
}
public function table(Table $table): Table
{
$currentRecipeId = $this->record->recipes_id;
return $table
->query(
Recipe::query()
->where('product_id', $this->record->id)
->with('spices')
->orderByDesc('version')
)
->columns([
TextColumn::make('version')
->label('Version')
->formatStateUsing(fn ($state) => 'v' . $state)
->badge()
->color(fn (Recipe $recipe) => $recipe->id === $currentRecipeId ? 'success' : 'gray'),
TextColumn::make('name')
->label('Name'),
TextColumn::make('spices_summary')
->label('Zutaten')
->state(fn (Recipe $recipe) =>
$recipe->spices->map(fn ($s) => $s->name . ' ' . $s->pivot->amount_in_percent . '%')->implode(' · ')
),
TextColumn::make('created_at')
->label('Erstellt am')
->dateTime('d.m.Y H:i')
->sortable(),
])
->paginated(false);
}
}
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>