From b2a518e3496ddcdcbcb4c551743138e86dee9e1c Mon Sep 17 00:00:00 2001 From: Stefan Zwischenbrugger Date: Sun, 29 Mar 2026 18:10:02 +0200 Subject: [PATCH] Einkaufsliste-UI, Prompts, Apache-Setup ignorieren --- .gitignore | 3 + .../Controllers/ShoppingListController.php | 35 +++- .../Requests/ToggleShoppingItemRequest.php | 10 ++ .../Requests/UpdateShoppingItemRequest.php | 54 ++++++ prompt_einkaufsliste.txt | 76 +++++++++ resources/views/shopping-list/index.blade.php | 160 ++++++++++-------- .../partials/item-pencil-panel.blade.php | 125 ++++++++++++++ routes/web.php | 1 + 8 files changed, 384 insertions(+), 80 deletions(-) create mode 100644 app/Http/Requests/UpdateShoppingItemRequest.php create mode 100644 prompt_einkaufsliste.txt create mode 100644 resources/views/shopping-list/partials/item-pencil-panel.blade.php diff --git a/.gitignore b/.gitignore index b71b1ea..a545aec 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ Homestead.json Homestead.yaml Thumbs.db + +# Versehentlicher Nested-Clone desselben Repos (ohne gueltigen Checkout) +/Einkaufsliste diff --git a/app/Http/Controllers/ShoppingListController.php b/app/Http/Controllers/ShoppingListController.php index 01153d4..9257ff0 100644 --- a/app/Http/Controllers/ShoppingListController.php +++ b/app/Http/Controllers/ShoppingListController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Http\Requests\StoreShoppingItemRequest; use App\Http\Requests\ToggleShoppingItemRequest; +use App\Http\Requests\UpdateShoppingItemRequest; use App\Models\ItemPriceLog; use App\Models\ShoppingItem; use App\Models\Store; @@ -68,6 +69,27 @@ class ShoppingListController extends Controller return back()->with('status', 'Eintrag wurde hinzugefuegt.'); } + public function update(UpdateShoppingItemRequest $request, ShoppingItem $shoppingItem): RedirectResponse + { + if ($shoppingItem->user_id !== $request->user()->id) { + abort(403); + } + + $storeId = $this->resolveStoreId( + $request->integer('store_id') ?: null, + $request->input('new_store_name'), + $request->user()->id + ); + + $shoppingItem->update([ + 'product_name' => $request->string('product_name')->toString(), + 'quantity' => $request->filled('quantity') ? $request->string('quantity')->toString() : null, + 'store_id' => $storeId, + ]); + + return back()->with('status', 'Eintrag wurde gespeichert.'); + } + public function toggle(ToggleShoppingItemRequest $request, ShoppingItem $shoppingItem): RedirectResponse { if ($shoppingItem->user_id !== $request->user()->id) { @@ -82,27 +104,30 @@ class ShoppingListController extends Controller ); DB::transaction(function () use ($request, $shoppingItem, $isDone, $storeId): void { - $shoppingItem->update([ + $payload = [ 'is_done' => $isDone, 'done_at' => $isDone ? Carbon::now() : null, 'store_id' => $storeId, - ]); + ]; + if ($request->filled('quantity')) { + $payload['quantity'] = $request->string('quantity')->toString(); + } + $shoppingItem->update($payload); if (!$isDone) { return; } $photoPath = $request->file('photo')?->store('price-photos', 'public'); - $price = $request->input('price_decimal'); - if ($price === null && $photoPath === null) { + if (!$request->filled('price_decimal') && $photoPath === null) { return; } ItemPriceLog::query()->create([ 'shopping_item_id' => $shoppingItem->id, 'store_id' => $storeId, - 'price_decimal' => $price ?? 0, + 'price_decimal' => $request->input('price_decimal') ?? 0, 'currency' => 'EUR', 'logged_at' => Carbon::now(), 'photo_path' => $photoPath, diff --git a/app/Http/Requests/ToggleShoppingItemRequest.php b/app/Http/Requests/ToggleShoppingItemRequest.php index be41ff0..0900d58 100644 --- a/app/Http/Requests/ToggleShoppingItemRequest.php +++ b/app/Http/Requests/ToggleShoppingItemRequest.php @@ -14,6 +14,13 @@ class ToggleShoppingItemRequest extends FormRequest return $this->user() !== null; } + protected function prepareForValidation(): void + { + if ($this->has('store_id') && $this->input('store_id') === '') { + $this->merge(['store_id' => null]); + } + } + /** * Get the validation rules that apply to the request. * @@ -23,6 +30,7 @@ class ToggleShoppingItemRequest extends FormRequest { return [ 'is_done' => ['required', 'boolean'], + 'quantity' => ['nullable', 'string', 'max:255'], 'store_id' => ['nullable', 'integer', 'exists:stores,id'], 'new_store_name' => ['nullable', 'string', 'max:255'], 'price_decimal' => ['nullable', 'numeric', 'min:0', 'max:999999.99'], @@ -36,6 +44,7 @@ class ToggleShoppingItemRequest extends FormRequest 'is_done.required' => 'Statusfeld fehlt.', 'is_done.boolean' => 'Ungueltiger Statuswert.', 'store_id.exists' => 'Das ausgewaehlte Geschaeft existiert nicht.', + 'quantity.max' => 'Die Menge darf maximal 255 Zeichen lang sein.', 'new_store_name.max' => 'Der neue Geschaeftsname darf maximal 255 Zeichen lang sein.', 'price_decimal.numeric' => 'Der Preis muss eine Zahl sein.', 'price_decimal.min' => 'Der Preis darf nicht negativ sein.', @@ -48,6 +57,7 @@ class ToggleShoppingItemRequest extends FormRequest public function attributes(): array { return [ + 'quantity' => 'Menge', 'store_id' => 'Geschaeft', 'new_store_name' => 'neues Geschaeft', 'price_decimal' => 'Preis', diff --git a/app/Http/Requests/UpdateShoppingItemRequest.php b/app/Http/Requests/UpdateShoppingItemRequest.php new file mode 100644 index 0000000..f42bcba --- /dev/null +++ b/app/Http/Requests/UpdateShoppingItemRequest.php @@ -0,0 +1,54 @@ +user() !== null; + } + + protected function prepareForValidation(): void + { + if ($this->has('store_id') && $this->input('store_id') === '') { + $this->merge(['store_id' => null]); + } + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'product_name' => ['required', 'string', 'max:255'], + 'quantity' => ['nullable', 'string', 'max:255'], + 'store_id' => ['nullable', 'integer', 'exists:stores,id'], + 'new_store_name' => ['nullable', 'string', 'max:255'], + ]; + } + + public function messages(): array + { + return [ + 'product_name.required' => 'Bitte gib einen Produktnamen ein.', + 'product_name.max' => 'Der Produktname darf maximal 255 Zeichen lang sein.', + 'quantity.max' => 'Die Menge darf maximal 255 Zeichen lang sein.', + 'store_id.exists' => 'Das ausgewaehlte Geschaeft existiert nicht.', + 'new_store_name.max' => 'Der neue Geschaeftsname darf maximal 255 Zeichen lang sein.', + ]; + } + + public function attributes(): array + { + return [ + 'product_name' => 'Produktname', + 'quantity' => 'Menge', + 'store_id' => 'Geschaeft', + 'new_store_name' => 'neues Geschaeft', + ]; + } +} diff --git a/prompt_einkaufsliste.txt b/prompt_einkaufsliste.txt new file mode 100644 index 0000000..62453bf --- /dev/null +++ b/prompt_einkaufsliste.txt @@ -0,0 +1,76 @@ +Einkaufsliste – Produktspezifikation (Gesamtprompt) +================================================== + +Ziel-URL: https://einkauf.pauker.at +Lokal: http://pauker/einkauf + +Dieses Dokument ist die verbindliche Sammel-Spezifikation fuer dieses Projekt (Laravel-App „einkauf“). +Technische Details der Umsetzung: Code im Repo; bei Abweichungen Prompt aktualisieren. + + +Plattform & Darstellung +----------------------- +- Desktop: gut bedienbar (grosse Klickflächen, übersichtlich). +- Smartphone: wie eine „App“ nutzbar (responsive UI). +- Offline: **soll möglich sein** → PWA mit Web-App-Manifest und Service-Worker; statische Shell und gecachte Assets; bei Bedarf später Strategie für Listendaten offline (Cache/API, Sync nach Reconnect – schrittweise ausarbeiten). + + +Authentifizierung & Sicherheit +------------------------------ +- Registrierung / Login mit E-Mail und Passwort; Session merken („eingeloggt bleiben“ über Laravel-Session/Cookie wie üblich). +- Passwort nur als Hash speichern (bcrypt/Argon2 – Laravel-Standard). +- Passwort vergessen: Link per E-Mail, mit dem ein neues Passwort gesetzt werden kann (Password-Reset-Flow). + + +Listenfunktionalität – Schnellfluss (Pflicht-UX) +----------------------------------------------- +- Neuer Eintrag: zuerst **nur der Produktname**; Absenden mit **Enter** (zusaetzlicher Button „Hinzufuegen“ ist ok). +- Eintrag erscheint in der **offenen** Liste. +- **Erledigen wie in Microsoft To Do:** in der offenen Liste **Checkbox** links; **Ankreuzen** sendet den Eintrag als erledigt (**ohne** Pflicht zu Geschaeft, Menge, Preis, Foto). Klick auf den Produktnamen toggelt die Checkbox (Label). +- **Erledigte Eintraege:** weiterhin sichtbar; **angekreuzte Checkbox**; **Abwaehlen** setzt den Eintrag wieder auf **offen**. + + +Bearbeiten & Zusatzangaben (ein Einstieg) +----------------------------------------- +- Rechts neben jeder Zeile ein **Bleistift-Icon**; ein Klick oeffnet **ein** Panel mit allen Aenderungsmoeglichkeiten (kein getrenntes „Bearbeiten“ und „Details“ mehr). +- **Stammdaten:** Produktname, Menge, Geschaeft (Auswahl und/oder freier Name) – **„Aenderungen speichern“** (PATCH update), **ohne** Erledigt-Status zu aendern. +- **Nur bei offenen Eintraegen** zusaetzlich im selben Panel: optional **Preis**, **Foto**, ggf. Menge/Geschaeft beim Abhaken – **„Erledigt mit Angaben“** (PATCH toggle); alles optional; schnelles Abhaken per **Checkbox** bleibt unabhaengig. +- Beim Anlegen weiterhin nur Name im Eingabefeld oben; Stueck/Geschaeft ueber das Bleistift-Panel. + + +Preise & Historie +----------------- +- Preis **manuell** eintragbar; optional **Foto**-Upload; **kein OCR-/Scan-Zwang** – zuerst reicht manuelle Eingabe; OCR hoechstens **viel spaeter** optional. +- Erfasster Preis mit **Datum**, **Geschaeft**, **Produkt** speichern (Preishistorie / Logs wo im Backend vorgesehen). +- **Auswertung:** Anzeige, wie viel der Einkauf (nach erfassten Preisen) **pro Geschaeft** und **gesamt** kostet. + + +Geschäfte (Stores) +------------------ +- Mehrere Geschäfte anlegbar/benutzbar (z. B. Spar, Lidl, Obi …), frei erweiterbar. + + +Externe / Online-Preise +----------------------- +- Automatisch Online-Preise zu Listeneinträgen beziehen: **noch nicht** – fuer **spaeter** vormerken; keine Umsetzung bis Quellen/API geklärt sind. + + +Leitplanken fuer Code & Texte +------------------------------ +- UI-Sprache: Deutsch; im Code bei fehlenden Umlauten konsistent **ae / oe / ue** wie im bestehenden Projekt. +- Backend: **Laravel** (dieses Repo). +- Aenderungen fokussiert; keine unnötigen Grossrefactors. + + +Festgelegte Produktentscheidungen +--------------------------------- +1. Preis: **zuerst manuell** (+ optional Foto); **kein** OCR-Meilenstein in naher Planung. +2. Online-Preise automatisch: **spaeter**. +3. **Offline:** mit PWA/SW **anstreben** (Umsetzung iterativ). + + +Implementierungs-Stand (Referenz, bei Features nachpflegen) +----------------------------------------------------------- +- Routen u. a.: `shopping-items.store`, `shopping-items.update` (PATCH), `shopping-items.toggle` (PATCH, erledigt/offen, optionale Preis-/Foto-Daten). +- Views: Einkaufsliste-Dashboard mit offenen/erledigten Listen, Checkboxen, **Bleistift-Panel** (`item-pencil-panel`) pro Eintrag, Summen pro Geschaeft. +- PWA (Manifest/Service-Worker): laut obigen Zielen noch **auszubauen**, sofern nicht bereits erledigt. diff --git a/resources/views/shopping-list/index.blade.php b/resources/views/shopping-list/index.blade.php index 3226ed2..d16813e 100644 --- a/resources/views/shopping-list/index.blade.php +++ b/resources/views/shopping-list/index.blade.php @@ -29,109 +29,119 @@ @endif
-

Neuen Eintrag erfassen

-
+

Neuer Eintrag

+

Nur Namen eingeben, mit Enter speichern.

+ @csrf - - - -
-
+

Offene Eintraege

@forelse($openItems as $item) -
- @csrf - @method('PATCH') - -
{{ $item->product_name }}
-
- Menge: {{ $item->quantity ?: '-' }} | Geschaeft: {{ $item->store?->name ?: '-' }} +
+
+ + @csrf + @method('PATCH') + +
+ + @if($item->quantity || $item->store) +
+ @if($item->quantity) + Menge: {{ $item->quantity }} + @endif + @if($item->quantity && $item->store) + · + @endif + @if($item->store) + {{ $item->store->name }} + @endif +
+ @endif +
+ + @include('shopping-list.partials.item-pencil-panel', [ + 'item' => $item, + 'stores' => $stores, + 'showToggleExtras' => true, + ])
-
- - - -
- - @if($item->latestPriceLog) -
- Letzter Preis: - {{ number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') }} EUR - bei {{ $item->latestPriceLog->store?->name ?: 'ohne Geschaeft' }} - ({{ optional($item->latestPriceLog->logged_at)->format('d.m.Y H:i') }}) -
- @endif - - +
@empty

Keine offenen Eintraege.

@endforelse
-
+

Erledigte Eintraege

@forelse($doneItems as $item) -
- @csrf - @method('PATCH') - -
{{ $item->product_name }}
-
- Erledigt: {{ optional($item->done_at)->format('d.m.Y H:i') ?: '-' }} +
+
+ + @csrf + @method('PATCH') + + +
+
{{ $item->product_name }}
+
+ Erledigt: {{ optional($item->done_at)->format('d.m.Y H:i') ?: '-' }} +
+
+ Letzter Preis: {{ $item->latestPriceLog?->price_decimal !== null ? number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') . ' EUR' : '-' }} +
+
+ + @include('shopping-list.partials.item-pencil-panel', [ + 'item' => $item, + 'stores' => $stores, + 'showToggleExtras' => false, + ])
-
- Letzter Preis: {{ $item->latestPriceLog?->price_decimal !== null ? number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') . ' EUR' : '-' }} -
- - +
@empty

Noch keine erledigten Eintraege.

@endforelse diff --git a/resources/views/shopping-list/partials/item-pencil-panel.blade.php b/resources/views/shopping-list/partials/item-pencil-panel.blade.php new file mode 100644 index 0000000..bf4cfeb --- /dev/null +++ b/resources/views/shopping-list/partials/item-pencil-panel.blade.php @@ -0,0 +1,125 @@ +{{-- Ein Bleistift oeffnet alle Aenderungsoptionen: Stammdaten (update) + optional Preis/Foto beim Abhaken (nur wenn $showToggleExtras) --}} +@php + $showToggleExtras = $showToggleExtras ?? false; +@endphp + +
+ + + +
+

Eintrag anpassen

+ +
+ @csrf + @method('PATCH') + + + + + + + + +
+ + @if($showToggleExtras) +
+

Beim Abhaken (optional)

+

Preis und Foto nur noetig, wenn du sie gleich mit erfassen willst – reicht auch die Checkbox in der Liste.

+
+ @csrf + @method('PATCH') + + + + + + + + @if($item->latestPriceLog) +
+ Letzter Preis: + {{ number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') }} EUR + bei {{ $item->latestPriceLog->store?->name ?: 'ohne Geschaeft' }} + ({{ optional($item->latestPriceLog->logged_at)->format('d.m.Y H:i') }}) +
+ @endif + +
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index b133a97..4ce3dd2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ Route::get('/', function () { Route::middleware(['auth', 'verified'])->group(function () { Route::get('/dashboard', [ShoppingListController::class, 'index'])->name('dashboard'); Route::post('/shopping-items', [ShoppingListController::class, 'store'])->name('shopping-items.store'); + Route::patch('/shopping-items/{shoppingItem}', [ShoppingListController::class, 'update'])->name('shopping-items.update'); Route::patch('/shopping-items/{shoppingItem}/toggle', [ShoppingListController::class, 'toggle'])->name('shopping-items.toggle'); });