Einkaufsliste-UI, Prompts, Apache-Setup ignorieren
This commit is contained in:
parent
2c48f68190
commit
b2a518e349
3
.gitignore
vendored
3
.gitignore
vendored
@ -22,3 +22,6 @@
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
|
||||
# Versehentlicher Nested-Clone desselben Repos (ohne gueltigen Checkout)
|
||||
/Einkaufsliste
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
54
app/Http/Requests/UpdateShoppingItemRequest.php
Normal file
54
app/Http/Requests/UpdateShoppingItemRequest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateShoppingItemRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('store_id') && $this->input('store_id') === '') {
|
||||
$this->merge(['store_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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',
|
||||
];
|
||||
}
|
||||
}
|
||||
76
prompt_einkaufsliste.txt
Normal file
76
prompt_einkaufsliste.txt
Normal file
@ -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.
|
||||
@ -29,109 +29,119 @@
|
||||
@endif
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-xl p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Neuen Eintrag erfassen</h3>
|
||||
<form method="POST" action="{{ route('shopping-items.store') }}" class="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<h3 class="text-lg font-semibold mb-3">Neuer Eintrag</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">Nur Namen eingeben, mit <kbd class="px-1 bg-gray-100 rounded text-xs">Enter</kbd> speichern.</p>
|
||||
<form method="POST" action="{{ route('shopping-items.store') }}" class="flex flex-col sm:flex-row gap-2">
|
||||
@csrf
|
||||
<input
|
||||
type="text"
|
||||
name="product_name"
|
||||
id="new-item-name"
|
||||
value="{{ old('product_name') }}"
|
||||
placeholder="Produktname"
|
||||
class="rounded border-gray-300 min-h-[44px]"
|
||||
placeholder="z. B. Milch"
|
||||
class="flex-1 rounded-md border-gray-300 shadow-sm min-h-[44px] text-base"
|
||||
required
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="quantity"
|
||||
value="{{ old('quantity') }}"
|
||||
placeholder="Menge (optional)"
|
||||
class="rounded border-gray-300 min-h-[44px]"
|
||||
<button
|
||||
type="submit"
|
||||
class="shrink-0 rounded-md bg-blue-600 text-white px-5 min-h-[44px] text-base font-medium hover:bg-blue-700"
|
||||
>
|
||||
<select name="store_id" class="rounded border-gray-300 min-h-[44px]">
|
||||
<option value="">Geschaeft (optional)</option>
|
||||
@foreach($stores as $store)
|
||||
<option value="{{ $store->id }}" @selected(old('store_id') == $store->id)>
|
||||
{{ $store->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
name="new_store_name"
|
||||
value="{{ old('new_store_name') }}"
|
||||
placeholder="Geschaeft frei eingeben (optional)"
|
||||
list="store-options"
|
||||
class="rounded border-gray-300 min-h-[44px]"
|
||||
>
|
||||
<button class="rounded bg-blue-600 text-white px-4 py-2 min-h-[44px] text-base hover:bg-blue-700">
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-xl p-4 sm:p-6">
|
||||
<div class="bg-white overflow-visible shadow-sm sm:rounded-xl p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Offene Eintraege</h3>
|
||||
<div class="space-y-3">
|
||||
@forelse($openItems as $item)
|
||||
<form method="POST" action="{{ route('shopping-items.toggle', $item) }}" enctype="multipart/form-data" class="border rounded-lg p-3">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="hidden" name="is_done" value="1">
|
||||
<div class="font-medium">{{ $item->product_name }}</div>
|
||||
<div class="text-sm text-gray-600 mb-2">
|
||||
Menge: {{ $item->quantity ?: '-' }} | Geschaeft: {{ $item->store?->name ?: '-' }}
|
||||
<div class="border border-gray-200 rounded-xl p-3 sm:p-4">
|
||||
<div class="flex gap-2 items-start">
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('shopping-items.toggle', $item) }}"
|
||||
class="flex min-w-0 flex-1 gap-3"
|
||||
>
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_done"
|
||||
value="1"
|
||||
id="open-item-{{ $item->id }}"
|
||||
class="mt-1 h-6 w-6 shrink-0 rounded border-gray-300 text-blue-600 shadow-sm focus:ring-blue-500 cursor-pointer"
|
||||
aria-label="Als erledigt markieren"
|
||||
onchange="this.form.requestSubmit()"
|
||||
>
|
||||
<div class="min-w-0 flex-1 pt-0.5">
|
||||
<label for="open-item-{{ $item->id }}" class="cursor-pointer text-base font-medium leading-snug text-gray-900">
|
||||
{{ $item->product_name }}
|
||||
</label>
|
||||
@if($item->quantity || $item->store)
|
||||
<div class="mt-0.5 text-sm text-gray-500">
|
||||
@if($item->quantity)
|
||||
<span>Menge: {{ $item->quantity }}</span>
|
||||
@endif
|
||||
@if($item->quantity && $item->store)
|
||||
<span class="mx-1">·</span>
|
||||
@endif
|
||||
@if($item->store)
|
||||
<span>{{ $item->store->name }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
@include('shopping-list.partials.item-pencil-panel', [
|
||||
'item' => $item,
|
||||
'stores' => $stores,
|
||||
'showToggleExtras' => true,
|
||||
])
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<select name="store_id" class="rounded border-gray-300 text-sm min-h-[44px]">
|
||||
<option value="">Geschaeft waehlen</option>
|
||||
@foreach($stores as $store)
|
||||
<option value="{{ $store->id }}" @selected($item->store_id === $store->id)>
|
||||
{{ $store->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="text" name="new_store_name" placeholder="Oder Geschaeft eingeben" list="store-options" class="rounded border-gray-300 text-sm min-h-[44px]">
|
||||
<input type="number" step="0.01" min="0" name="price_decimal" placeholder="Preis in EUR (optional)" class="rounded border-gray-300 text-sm min-h-[44px]">
|
||||
</div>
|
||||
<input type="file" name="photo" accept="image/*" class="rounded border-gray-300 text-sm mt-2 min-h-[44px]">
|
||||
@if($item->latestPriceLog)
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
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') }})
|
||||
</div>
|
||||
@endif
|
||||
<button class="mt-2 rounded bg-green-600 text-white px-3 py-2 text-sm min-h-[44px] hover:bg-green-700">
|
||||
Als erledigt markieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-gray-600">Keine offenen Eintraege.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-xl p-4 sm:p-6">
|
||||
<div class="bg-white overflow-visible shadow-sm sm:rounded-xl p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Erledigte Eintraege</h3>
|
||||
<div class="space-y-3">
|
||||
@forelse($doneItems as $item)
|
||||
<form method="POST" action="{{ route('shopping-items.toggle', $item) }}" class="border rounded-lg p-3">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="hidden" name="is_done" value="0">
|
||||
<div class="font-medium line-through text-gray-700">{{ $item->product_name }}</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
Erledigt: {{ optional($item->done_at)->format('d.m.Y H:i') ?: '-' }}
|
||||
<div class="border rounded-lg p-3">
|
||||
<div class="flex gap-2 items-start">
|
||||
<form method="POST" action="{{ route('shopping-items.toggle', $item) }}" class="flex min-w-0 flex-1 gap-3">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="hidden" name="is_done" value="1" class="js-toggle-done-flag">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
class="mt-1 h-6 w-6 shrink-0 rounded border-gray-300 text-blue-600 shadow-sm focus:ring-blue-500 cursor-pointer"
|
||||
aria-label="Erledigt. Abwaehlen, um wieder in die offene Liste zu setzen."
|
||||
onchange="this.form.querySelector('.js-toggle-done-flag').value = this.checked ? '1' : '0'; this.form.requestSubmit();"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-gray-700 line-through">{{ $item->product_name }}</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
Erledigt: {{ optional($item->done_at)->format('d.m.Y H:i') ?: '-' }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
Letzter Preis: {{ $item->latestPriceLog?->price_decimal !== null ? number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') . ' EUR' : '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@include('shopping-list.partials.item-pencil-panel', [
|
||||
'item' => $item,
|
||||
'stores' => $stores,
|
||||
'showToggleExtras' => false,
|
||||
])
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 mb-2">
|
||||
Letzter Preis: {{ $item->latestPriceLog?->price_decimal !== null ? number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') . ' EUR' : '-' }}
|
||||
</div>
|
||||
<button class="rounded bg-gray-600 text-white px-3 py-2 text-sm min-h-[44px] hover:bg-gray-700">
|
||||
Wieder offen setzen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-gray-600">Noch keine erledigten Eintraege.</p>
|
||||
@endforelse
|
||||
|
||||
@ -0,0 +1,125 @@
|
||||
{{-- Ein Bleistift oeffnet alle Aenderungsoptionen: Stammdaten (update) + optional Preis/Foto beim Abhaken (nur wenn $showToggleExtras) --}}
|
||||
@php
|
||||
$showToggleExtras = $showToggleExtras ?? false;
|
||||
@endphp
|
||||
|
||||
<details class="group relative shrink-0">
|
||||
<summary
|
||||
class="list-none cursor-pointer flex h-11 w-11 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 shadow-sm hover:bg-gray-50 hover:text-gray-900 [&::-webkit-details-marker]:hidden"
|
||||
aria-label="Eintrag bearbeiten"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="absolute right-0 z-20 mt-2 w-[min(calc(100vw-2rem),22rem)] rounded-xl border border-gray-200 bg-white p-4 shadow-lg sm:w-80">
|
||||
<p class="mb-3 text-sm font-medium text-gray-900">Eintrag anpassen</p>
|
||||
|
||||
<form method="POST" action="{{ route('shopping-items.update', $item) }}" class="grid grid-cols-1 gap-2">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<label class="text-xs font-medium text-gray-600">Produktname</label>
|
||||
<input
|
||||
type="text"
|
||||
name="product_name"
|
||||
value="{{ $item->product_name }}"
|
||||
required
|
||||
class="rounded-md border-gray-300 text-sm min-h-[44px]"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label class="text-xs font-medium text-gray-600">Menge (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="quantity"
|
||||
value="{{ $item->quantity }}"
|
||||
placeholder="z. B. 2 Stueck"
|
||||
class="rounded-md border-gray-300 text-sm min-h-[44px]"
|
||||
>
|
||||
<label class="text-xs font-medium text-gray-600">Geschaeft (optional)</label>
|
||||
<select name="store_id" class="rounded-md border-gray-300 text-sm min-h-[44px]">
|
||||
<option value="">—</option>
|
||||
@foreach($stores as $store)
|
||||
<option value="{{ $store->id }}" @selected($item->store_id === $store->id)>
|
||||
{{ $store->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
name="new_store_name"
|
||||
value=""
|
||||
placeholder="Neues Geschaeft (optional)"
|
||||
list="store-options"
|
||||
class="rounded-md border-gray-300 text-sm min-h-[44px]"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-1 rounded-lg bg-blue-600 text-white px-4 py-2.5 min-h-[44px] text-sm font-medium hover:bg-blue-700"
|
||||
>
|
||||
Aenderungen speichern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if($showToggleExtras)
|
||||
<div class="my-4 border-t border-gray-200"></div>
|
||||
<p class="mb-2 text-xs font-medium text-gray-700">Beim Abhaken (optional)</p>
|
||||
<p class="mb-3 text-xs text-gray-500">Preis und Foto nur noetig, wenn du sie gleich mit erfassen willst – reicht auch die Checkbox in der Liste.</p>
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('shopping-items.toggle', $item) }}"
|
||||
enctype="multipart/form-data"
|
||||
class="grid grid-cols-1 gap-2"
|
||||
>
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="hidden" name="is_done" value="1">
|
||||
<input
|
||||
type="text"
|
||||
name="quantity"
|
||||
value="{{ $item->quantity }}"
|
||||
placeholder="Menge (optional)"
|
||||
class="rounded-md border-gray-300 text-sm min-h-[44px]"
|
||||
>
|
||||
<select name="store_id" class="rounded-md border-gray-300 text-sm min-h-[44px]">
|
||||
<option value="">Geschaeft (optional)</option>
|
||||
@foreach($stores as $store)
|
||||
<option value="{{ $store->id }}" @selected($item->store_id === $store->id)>
|
||||
{{ $store->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
name="new_store_name"
|
||||
placeholder="Geschaeft frei eingeben (optional)"
|
||||
list="store-options"
|
||||
class="rounded-md border-gray-300 text-sm min-h-[44px]"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
name="price_decimal"
|
||||
placeholder="Preis in EUR (optional)"
|
||||
class="rounded-md border-gray-300 text-sm min-h-[44px]"
|
||||
>
|
||||
<label class="text-xs text-gray-600">Foto Kassenbon (optional)</label>
|
||||
<input type="file" name="photo" accept="image/*" class="text-sm min-h-[44px]">
|
||||
@if($item->latestPriceLog)
|
||||
<div class="text-xs text-gray-500">
|
||||
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') }})
|
||||
</div>
|
||||
@endif
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-emerald-600 text-white px-4 py-2.5 min-h-[44px] text-sm font-medium hover:bg-emerald-700"
|
||||
>
|
||||
Erledigt mit Angaben
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</details>
|
||||
@ -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');
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user