Compare commits

...

2 Commits

Author SHA1 Message Date
8e11f3efca deploy.sh: Composer-Pfad automatisch ermitteln (PATH /usr/bin)
Vermeidet Abbruch wenn /usr/local/bin/composer fehlt; sonst kein npm build und alte Assets.

Made-with: Cursor
2026-03-30 17:54:26 +02:00
99496071ad Such-URL-Vorlagen, Zeilen-Bearbeitung, Loeschen, Chevron-CSS
- Geschaefte: search_url_template (Migration), Pflege-UI eingeklappt mit Kacheln, Testen-Link

- Eintraege: Zeile oeffnet Panel statt Icon; DELETE mit Foto-Bereinigung; Such-Links im Panel

- details-Chevron-Drehung ueber app.css (.details-chevron), Build in html/build aktualisiert

Hinweis: StoreSearchController/SparSearchService bleiben unversioniert (nicht angebunden).
Made-with: Cursor
2026-03-30 17:51:26 +02:00
13 changed files with 536 additions and 197 deletions

View File

@ -10,10 +10,13 @@ use App\Models\ItemPriceLog;
use App\Models\ShoppingItem;
use App\Models\ShoppingList;
use App\Models\Store;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ShoppingListController extends Controller
{
@ -22,7 +25,7 @@ class ShoppingListController extends Controller
public function index(): View
{
$request = request();
/** @var \App\Models\User $user */
/** @var User $user */
$user = $request->user();
abort_if($user === null, 403);
@ -36,6 +39,9 @@ class ShoppingListController extends Controller
->with(['store', 'latestPriceLog.store', 'latestPhotoLog', 'creator'])
->latest()
->get();
$items->each(function (ShoppingItem $item): void {
$item->setAttribute('calculated_total', $this->calculatedItemTotal($item));
});
$openItems = $items->where('is_done', false);
$doneItems = $items->where('is_done', true);
@ -46,7 +52,7 @@ class ShoppingListController extends Controller
->groupBy(function (ShoppingItem $item) {
return $item->latestPriceLog->store->name ?? 'Ohne Geschäft';
})
->map(fn ($group) => $group->sum(fn (ShoppingItem $item) => (float) $item->latestPriceLog->price_decimal))
->map(fn ($group) => $group->sum(fn (ShoppingItem $item) => $this->calculatedItemTotal($item)))
->sortDesc();
$members = $currentList->members()->orderBy('name')->get();
@ -131,6 +137,26 @@ class ShoppingListController extends Controller
return back()->with('status', 'Eintrag wurde gespeichert.');
}
public function destroy(Request $request, ShoppingItem $shoppingItem): RedirectResponse
{
$currentList = $this->currentShoppingList($request);
$this->authorize('view', $currentList);
$this->assertItemInList($shoppingItem, $currentList);
DB::transaction(function () use ($shoppingItem): void {
$shoppingItem->load('priceLogs');
foreach ($shoppingItem->priceLogs as $log) {
$path = $log->photo_path;
if ($path !== null && $path !== '') {
Storage::disk('public')->delete($path);
}
}
$shoppingItem->delete();
});
return back()->with('status', 'Eintrag wurde geloescht.');
}
public function toggle(ToggleShoppingItemRequest $request, ShoppingItem $shoppingItem): RedirectResponse
{
$currentList = $this->currentShoppingList($request);
@ -198,10 +224,45 @@ class ShoppingListController extends Controller
['normalized_name' => $normalized],
[
'name' => $newStoreName,
'search_url_template' => Store::defaultSearchTemplateForName($normalized),
'created_by' => $userId,
]
);
return $store->id;
}
private function calculatedItemTotal(ShoppingItem $item): float
{
$log = $item->latestPriceLog;
if ($log === null || $log->price_decimal === null) {
return 0.0;
}
$quantity = $this->extractQuantityNumber($item->quantity);
$unitPrice = (float) $log->price_decimal;
if ($log->tier_min_qty !== null && $log->tier_price_decimal !== null && $quantity >= (float) $log->tier_min_qty) {
$unitPrice = (float) $log->tier_price_decimal;
}
return $quantity * $unitPrice;
}
private function extractQuantityNumber(?string $quantityRaw): float
{
$quantityRaw = trim((string) $quantityRaw);
if ($quantityRaw === '') {
return 1.0;
}
if (! preg_match('/(\d+(?:[.,]\d+)?)/', $quantityRaw, $matches)) {
return 1.0;
}
$normalized = str_replace(',', '.', $matches[1]);
$quantity = (float) $normalized;
return $quantity > 0 ? $quantity : 1.0;
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdateStoreSearchTemplateRequest;
use App\Models\Store;
use Illuminate\Http\RedirectResponse;
class StoreSearchTemplateController extends Controller
{
public function __invoke(UpdateStoreSearchTemplateRequest $request, Store $store): RedirectResponse
{
$store->update([
'search_url_template' => $request->filled('search_url_template')
? $request->string('search_url_template')->toString()
: null,
]);
return back()->with('status', 'Such-URL fuer Geschaeft gespeichert.');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateStoreSearchTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'search_url_template' => ['nullable', 'string', 'max:255', 'regex:/^https?:\/\/.+%s.*$/i'],
];
}
public function messages(): array
{
return [
'search_url_template.regex' => 'Die Such-URL muss mit http(s) beginnen und den Platzhalter %s enthalten.',
'search_url_template.max' => 'Die Such-URL darf maximal 255 Zeichen lang sein.',
];
}
}

View File

@ -11,6 +11,7 @@ class Store extends Model
protected $fillable = [
'name',
'normalized_name',
'search_url_template',
'created_by',
];
@ -19,9 +20,21 @@ class Store extends Model
static::saving(function (Store $store): void {
$store->name = trim($store->name);
$store->normalized_name = mb_strtolower($store->name);
if (! $store->search_url_template) {
$store->search_url_template = self::defaultSearchTemplateForName($store->normalized_name);
}
});
}
public static function defaultSearchTemplateForName(string $normalizedName): ?string
{
return match ($normalizedName) {
'spar' => 'https://www.spar.at/suche?q=%s',
'lidl' => 'https://www.lidl.at/q/search?q=%s',
default => null,
};
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('stores', function (Blueprint $table) {
$table->string('search_url_template')->nullable()->after('normalized_name');
});
DB::table('stores')
->where('normalized_name', 'spar')
->update(['search_url_template' => 'https://www.spar.at/suche?q=%s']);
DB::table('stores')
->where('normalized_name', 'lidl')
->update(['search_url_template' => 'https://www.lidl.at/q/search?q=%s']);
}
public function down(): void
{
Schema::table('stores', function (Blueprint $table) {
$table->dropColumn('search_url_template');
});
}
};

View File

@ -6,16 +6,29 @@ set -Eeuo pipefail
# Optional env vars:
# APP_DIR=/web/einkauf (Standard; anpassen wenn die App woanders liegt)
# PHP_BIN=/usr/bin/php
# COMPOSER_BIN=/usr/local/bin/composer
# COMPOSER_BIN=/usr/bin/composer (optional; sonst PATH oder uebliche Pfade)
# NPM_BIN=/usr/bin/npm
# RUN_SEED=true
APP_DIR="${APP_DIR:-/web/einkauf}"
PHP_BIN="${PHP_BIN:-/usr/bin/php}"
COMPOSER_BIN="${COMPOSER_BIN:-/usr/local/bin/composer}"
NPM_BIN="${NPM_BIN:-/usr/bin/npm}"
RUN_SEED="${RUN_SEED:-false}"
if [ -n "${COMPOSER_BIN:-}" ]; then
:
elif command -v composer >/dev/null 2>&1; then
COMPOSER_BIN="$(command -v composer)"
elif [ -x /usr/bin/composer ]; then
COMPOSER_BIN=/usr/bin/composer
elif [ -x /usr/local/bin/composer ]; then
COMPOSER_BIN=/usr/local/bin/composer
else
echo "Fehler: composer nicht gefunden (PATH, /usr/bin/composer, /usr/local/bin/composer)." >&2
echo "Installiere Composer oder setze z. B. COMPOSER_BIN=/pfad/zu/composer" >&2
exit 1
fi
cd "${APP_DIR}"
echo "==> Deploy startet in ${APP_DIR}"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"resources/css/app.css": {
"file": "assets/app-CEwZte8_.css",
"file": "assets/app-B_qr2xXh.css",
"src": "resources/css/app.css",
"isEntry": true,
"name": "app",

View File

@ -1,3 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Pfeil in <summary>: Drehung bei geoeffnetem <details> (group-open ist je nach Build unzuverlaessig) */
details[open] > summary svg.details-chevron {
transform: rotate(180deg);
}
svg.details-chevron {
transition: transform 150ms ease;
}

View File

@ -63,7 +63,7 @@
<form
method="POST"
action="{{ route('shopping-items.toggle', $item) }}"
class="flex min-w-0 flex-1 gap-3"
class="shrink-0 pt-0.5"
>
@csrf
@method('PATCH')
@ -72,36 +72,45 @@
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"
class="h-6 w-6 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->creator && (int) $item->creator->id !== (int) auth()->id())
<div class="text-xs text-gray-500 mt-0.5">von {{ $item->creator->name }}</div>
@endif
@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,
])
<details class="flex-1 min-w-0 open:shadow-md">
<summary class="cursor-pointer list-none flex items-start justify-between gap-3 min-w-0 rounded-lg py-1 ps-1 -ms-1 text-left hover:bg-gray-50 marker:content-[''] [&::-webkit-details-marker]:hidden">
<div class="min-w-0 flex-1">
<span class="block text-base font-medium leading-snug text-gray-900">
{{ $item->product_name }}
</span>
@if($item->creator && (int) $item->creator->id !== (int) auth()->id())
<span class="block text-xs text-gray-500 mt-0.5">von {{ $item->creator->name }}</span>
@endif
@if($item->quantity || $item->store)
<span class="block 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
</span>
@endif
</div>
<svg class="details-chevron h-5 w-5 shrink-0 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<div class="mt-2">
@include('shopping-list.partials.item-pencil-panel', [
'item' => $item,
'stores' => $stores,
])
</div>
</details>
</div>
</div>
@empty
@ -116,57 +125,71 @@
@forelse($doneItems as $item)
<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">
<form method="POST" action="{{ route('shopping-items.toggle', $item) }}" class="shrink-0 pt-0.5">
@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"
class="h-6 w-6 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>
@if($item->creator && (int) $item->creator->id !== (int) auth()->id())
<div class="text-xs text-gray-500">von {{ $item->creator->name }}</div>
@endif
<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:
@if($item->latestPriceLog?->price_decimal !== null)
{{ number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') }} EUR/Stk
@else
-
@endif
@if($item->latestPriceLog?->tier_min_qty !== null && $item->latestPriceLog?->tier_price_decimal !== null)
<span class="mx-1">·</span>
{{ number_format((float) $item->latestPriceLog->tier_price_decimal, 2, ',', '.') }} EUR ab {{ (int) $item->latestPriceLog->tier_min_qty }} Stk
@endif
</div>
@if($item->latestPhotoLog)
<div class="mt-2">
<p class="text-xs text-gray-500 mb-1">Kassenbon / Foto</p>
<a href="{{ $item->latestPhotoLog->photoUrl() }}" target="_blank" rel="noopener noreferrer" class="block">
<img
src="{{ $item->latestPhotoLog->photoUrl() }}"
alt="Kassenbon"
class="max-h-48 w-full max-w-xs rounded-lg border border-gray-200 object-contain bg-gray-50"
loading="lazy"
>
</a>
<a href="{{ $item->latestPhotoLog->photoUrl() }}" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:underline mt-1 inline-block">Foto oeffnen (bei HEIC oft noetig)</a>
</div>
@endif
</div>
</form>
@include('shopping-list.partials.item-pencil-panel', [
'item' => $item,
'stores' => $stores,
])
<details class="flex-1 min-w-0 open:shadow-md">
<summary class="cursor-pointer list-none flex items-start justify-between gap-3 min-w-0 rounded-lg py-1 ps-1 -ms-1 text-left hover:bg-gray-50 marker:content-[''] [&::-webkit-details-marker]:hidden">
<div class="min-w-0 flex-1">
<span class="block font-medium text-gray-700 line-through">{{ $item->product_name }}</span>
@if($item->creator && (int) $item->creator->id !== (int) auth()->id())
<span class="block text-xs text-gray-500">von {{ $item->creator->name }}</span>
@endif
<span class="block text-sm text-gray-600 mt-0.5">
Erledigt: {{ optional($item->done_at)->format('d.m.Y H:i') ?: '-' }}
</span>
<span class="block text-sm text-gray-600">
Letzter Preis:
@if($item->latestPriceLog?->price_decimal !== null)
{{ number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') }} EUR/Stk
@else
-
@endif
@if($item->latestPriceLog?->tier_min_qty !== null && $item->latestPriceLog?->tier_price_decimal !== null)
<span class="mx-1">·</span>
{{ number_format((float) $item->latestPriceLog->tier_price_decimal, 2, ',', '.') }} EUR ab {{ (int) $item->latestPriceLog->tier_min_qty }} Stk
@endif
@if(($item->calculated_total ?? 0) > 0)
<span class="block font-medium text-gray-700 mt-0.5">
Gesamtpreis: {{ number_format((float) $item->calculated_total, 2, ',', '.') }} EUR
</span>
@endif
</span>
@if($item->latestPhotoLog)
<div class="mt-2" onclick="event.stopPropagation()">
<p class="text-xs text-gray-500 mb-1">Kassenbon / Foto</p>
<a href="{{ $item->latestPhotoLog->photoUrl() }}" target="_blank" rel="noopener noreferrer" class="block">
<img
src="{{ $item->latestPhotoLog->photoUrl() }}"
alt="Kassenbon"
class="max-h-48 w-full max-w-xs rounded-lg border border-gray-200 object-contain bg-gray-50"
loading="lazy"
>
</a>
<a href="{{ $item->latestPhotoLog->photoUrl() }}" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:underline mt-1 inline-block">Foto oeffnen (bei HEIC oft noetig)</a>
</div>
@endif
</div>
<svg class="details-chevron h-5 w-5 shrink-0 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<div class="mt-2">
@include('shopping-list.partials.item-pencil-panel', [
'item' => $item,
'stores' => $stores,
])
</div>
</details>
</div>
</div>
@empty
@ -241,6 +264,56 @@
@endif
</div>
<details class="bg-white overflow-hidden shadow-sm sm:rounded-xl open:shadow-md">
<summary class="cursor-pointer list-none p-4 sm:p-6 flex items-center justify-between gap-3 text-lg font-semibold text-gray-900 hover:bg-gray-50 rounded-xl marker:content-[''] [&::-webkit-details-marker]:hidden">
<span>Such-URLs pro Geschaeft</span>
<svg class="details-chevron h-5 w-5 shrink-0 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<div class="px-4 sm:px-6 pb-4 sm:pb-6 pt-0 border-t border-gray-100">
<p class="text-sm text-gray-600 mb-4 pt-4">Platzhalter <code class="text-xs bg-gray-100 px-1 rounded">%s</code> steht fuer den Produktnamen, z. B. <code class="text-xs bg-gray-100 px-1 rounded break-all">https://www.lidl.at/q/search?q=%s</code>.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
@foreach($stores as $store)
@php
$testUrl = $store->search_url_template
? str_replace('%s', urlencode('Milch'), $store->search_url_template)
: null;
@endphp
<div class="rounded-xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm flex flex-col gap-3 min-w-0">
<h4 class="text-base font-semibold text-gray-900 truncate" title="{{ $store->name }}">{{ $store->name }}</h4>
<form method="POST" action="{{ route('stores.search-template.update', $store) }}" class="flex flex-col gap-3 flex-1">
@csrf
@method('PATCH')
<label class="sr-only" for="search-url-{{ $store->id }}">Such-URL fuer {{ $store->name }}</label>
<input
id="search-url-{{ $store->id }}"
type="text"
name="search_url_template"
value="{{ old('search_url_template', $store->search_url_template) }}"
placeholder="https://...%s..."
class="w-full rounded-md border-gray-300 text-sm min-h-[44px]"
>
<div class="flex flex-wrap gap-2 mt-auto">
<button type="submit" class="flex-1 min-w-[7rem] rounded-md bg-gray-800 text-white px-4 min-h-[44px] text-sm font-medium hover:bg-gray-900">
Speichern
</button>
<a
href="{{ $testUrl ?: '#' }}"
target="_blank"
rel="noopener noreferrer"
class="flex-1 min-w-[7rem] rounded-md border border-gray-300 px-4 min-h-[44px] text-sm font-medium inline-flex items-center justify-center {{ $testUrl ? 'text-gray-700 bg-white hover:bg-gray-50' : 'text-gray-400 pointer-events-none bg-gray-100' }}"
>
Testen
</a>
</div>
</form>
</div>
@endforeach
</div>
</div>
</details>
@can('manageMembers', $currentList)
<div class="bg-white overflow-hidden shadow-sm sm:rounded-xl p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-2">Liste teilen</h3>
@ -284,4 +357,39 @@
@endcan
</div>
</div>
<script>
document.addEventListener('click', async (event) => {
const pasteBtn = event.target.closest('.js-paste-price');
if (!pasteBtn) {
return;
}
const inputId = pasteBtn.dataset.priceInput || '';
const input = inputId ? document.getElementById(inputId) : null;
if (!input) {
return;
}
const applyText = (text) => {
const match = (text || '').replace(',', '.').match(/(\d+(?:\.\d+)?)/);
if (!match) {
return false;
}
input.value = Number.parseFloat(match[1]).toFixed(2);
input.dispatchEvent(new Event('input', { bubbles: true }));
return true;
};
try {
const text = await navigator.clipboard.readText();
if (!applyText(text)) {
alert('Kein Preis in der Zwischenablage gefunden.');
}
} catch (error) {
const fallback = prompt('Preis einfuegen (z. B. 1,49):', '');
if (fallback !== null && !applyText(fallback)) {
alert('Kein gueltiger Preis erkannt.');
}
}
});
</script>
</x-app-layout>

View File

@ -1,132 +1,180 @@
{{-- Ein Bleistift oeffnet ein Formular: Stammdaten, Erledigt-Status, optional Preis/Foto --}}
<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-1 text-sm font-semibold text-gray-900">Eintrag bearbeiten</p>
<p class="mb-3 text-xs text-gray-500">Produkt, Geschaeft, Erledigt-Status sowie optional Preis und Foto in einem Schritt speichern.</p>
{{-- Inhalt des Bearbeitungs-Panels (wird unter der Zeile eingeblendet) --}}
@php
$storeSearchLinks = $stores
->filter(fn ($store) => filled($store->search_url_template) && str_contains((string) $store->search_url_template, '%s'))
->map(function ($store) use ($item) {
return [
'name' => $store->name,
'url' => str_replace('%s', urlencode($item->product_name), (string) $store->search_url_template),
];
})
->values();
@endphp
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-lg">
<p class="mb-1 text-sm font-semibold text-gray-900">Eintrag bearbeiten</p>
<p class="mb-3 text-xs text-gray-500">Produkt, Geschaeft, Erledigt-Status sowie optional Preis und Foto in einem Schritt speichern.</p>
@if($item->latestPhotoLog)
<div class="mb-4 rounded-lg border border-emerald-200 bg-emerald-50/80 p-3">
<p class="text-xs font-medium text-emerald-900 mb-2">Gespeichertes Foto</p>
<a href="{{ $item->latestPhotoLog->photoUrl() }}" target="_blank" rel="noopener noreferrer" class="block">
<img
src="{{ $item->latestPhotoLog->photoUrl() }}"
alt="Kassenbon"
class="max-h-40 w-full rounded border border-emerald-200/80 object-contain bg-white"
loading="lazy"
>
</a>
<a href="{{ $item->latestPhotoLog->photoUrl() }}" target="_blank" rel="noopener noreferrer" class="text-xs text-emerald-800 hover:underline mt-2 inline-block">Foto gross oeffnen</a>
</div>
@endif
<form
method="POST"
action="{{ route('shopping-items.update', $item) }}"
enctype="multipart/form-data"
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]"
>
<input type="hidden" name="is_done" value="0">
<label class="flex items-start gap-2 mt-1 cursor-pointer">
<input
type="checkbox"
name="is_done"
value="1"
@checked($item->is_done)
class="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 shadow-sm focus:ring-blue-500"
@if($item->latestPhotoLog)
<div class="mb-4 rounded-lg border border-emerald-200 bg-emerald-50/80 p-3">
<p class="text-xs font-medium text-emerald-900 mb-2">Gespeichertes Foto</p>
<a href="{{ $item->latestPhotoLog->photoUrl() }}" target="_blank" rel="noopener noreferrer" class="block">
<img
src="{{ $item->latestPhotoLog->photoUrl() }}"
alt="Kassenbon"
class="max-h-40 w-full rounded border border-emerald-200/80 object-contain bg-white"
loading="lazy"
>
<span class="text-xs text-gray-700">Als erledigt markieren</span>
</label>
<p class="text-xs text-gray-500 -mt-1">Preis und Foto koennen auch ohne „erledigt“ gespeichert werden.</p>
</a>
<a href="{{ $item->latestPhotoLog->photoUrl() }}" target="_blank" rel="noopener noreferrer" class="text-xs text-emerald-800 hover:underline mt-2 inline-block">Foto gross oeffnen</a>
</div>
@endif
<form
method="POST"
action="{{ route('shopping-items.update', $item) }}"
enctype="multipart/form-data"
class="grid grid-cols-1 gap-2"
>
@csrf
@method('PATCH')
<label class="block 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="block 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="block 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]"
>
<input type="hidden" name="is_done" value="0">
<label class="flex items-start gap-2 mt-1 cursor-pointer">
<input
type="checkbox"
name="is_done"
value="1"
@checked($item->is_done)
class="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 shadow-sm focus:ring-blue-500"
>
<span class="text-xs text-gray-700">Als erledigt markieren</span>
</label>
<p class="text-xs text-gray-500 -mt-1">Preis und Foto koennen auch ohne „erledigt“ gespeichert werden.</p>
<input
type="number"
step="0.01"
min="0"
name="price_decimal"
id="price-decimal-{{ $item->id }}"
placeholder="Preis pro 1 Stueck (optional) z. B. 3.00"
class="rounded-md border-gray-300 text-sm min-h-[44px]"
>
<div class="grid grid-cols-2 gap-2">
<input
type="number"
min="2"
step="1"
name="tier_min_qty"
placeholder="ab Menge (optional) z. B. 2"
class="rounded-md border-gray-300 text-sm min-h-[44px]"
>
<input
type="number"
step="0.01"
min="0"
name="price_decimal"
placeholder="Preis pro 1 Stueck (optional) z. B. 3.00"
name="tier_price_decimal"
placeholder="Staffelpreis (optional) z. B. 2.00"
class="rounded-md border-gray-300 text-sm min-h-[44px]"
>
<div class="grid grid-cols-2 gap-2">
<input
type="number"
min="2"
step="1"
name="tier_min_qty"
placeholder="ab Menge (optional) z. B. 2"
class="rounded-md border-gray-300 text-sm min-h-[44px]"
>
<input
type="number"
step="0.01"
min="0"
name="tier_price_decimal"
placeholder="Staffelpreis (optional) z. B. 2.00"
class="rounded-md border-gray-300 text-sm min-h-[44px]"
>
</div>
<label class="text-xs text-gray-600">Foto Kassenbon (optional)</label>
<input type="file" name="photo" accept="image/*" class="text-sm min-h-[44px]">
<button
type="button"
class="js-paste-price mt-1 inline-block rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200 w-fit"
data-price-input="price-decimal-{{ $item->id }}"
>
Preis aus Zwischenablage uebernehmen
</button>
@if($item->latestPriceLog)
<div class="text-xs text-gray-500">
Letzter Preis:
{{ $item->latestPriceLog->price_decimal !== null ? number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') . ' EUR/Stk' : '-' }}
@if($item->latestPriceLog->tier_min_qty !== null && $item->latestPriceLog->tier_price_decimal !== null)
<span class="mx-1">·</span>
{{ number_format((float) $item->latestPriceLog->tier_price_decimal, 2, ',', '.') }} EUR ab {{ (int) $item->latestPriceLog->tier_min_qty }} Stk
@endif
@if(($item->calculated_total ?? 0) > 0)
<div class="mt-1 font-medium text-gray-700">
Gesamtpreis (nach Menge): {{ number_format((float) $item->calculated_total, 2, ',', '.') }} EUR
</div>
@endif
bei {{ $item->latestPriceLog->store?->name ?: 'ohne Geschaeft' }}
({{ optional($item->latestPriceLog->logged_at)->format('d.m.Y H:i') }})
</div>
<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:
{{ $item->latestPriceLog->price_decimal !== null ? number_format((float) $item->latestPriceLog->price_decimal, 2, ',', '.') . ' EUR/Stk' : '-' }}
@if($item->latestPriceLog->tier_min_qty !== null && $item->latestPriceLog->tier_price_decimal !== null)
<span class="mx-1">·</span>
{{ number_format((float) $item->latestPriceLog->tier_price_decimal, 2, ',', '.') }} EUR ab {{ (int) $item->latestPriceLog->tier_min_qty }} Stk
@endif
bei {{ $item->latestPriceLog->store?->name ?: 'ohne Geschaeft' }}
({{ optional($item->latestPriceLog->logged_at)->format('d.m.Y H:i') }})
</div>
@endif
<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"
>
Speichern
</button>
</form>
</div>
</details>
@endif
<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"
>
Speichern
</button>
<div class="mt-2 rounded-md border border-gray-200 p-2">
<p class="text-xs font-medium text-gray-700 mb-1">Such-Links zu Geschaeften</p>
@forelse($storeSearchLinks as $link)
<a
href="{{ $link['url'] }}"
target="_blank"
rel="noopener noreferrer"
class="block text-xs text-blue-600 hover:underline py-0.5"
>
{{ $link['name'] }}: {{ $item->product_name }}
</a>
@empty
<p class="text-xs text-gray-500">Keine Such-Links hinterlegt.</p>
@endforelse
</div>
</form>
<form
method="POST"
action="{{ route('shopping-items.destroy', $item) }}"
class="mt-4 border-t border-gray-200 pt-4"
onsubmit="return confirm('Diesen Eintrag endgueltig loeschen?');"
>
@csrf
@method('DELETE')
<button
type="submit"
class="inline-flex w-full min-h-[44px] items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 text-sm font-medium text-red-800 hover:bg-red-100"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-5 w-5 shrink-0" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.134-2.05-2.134h-5.4c-1.14 0-2.05.955-2.05 2.134v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
Eintrag loeschen
</button>
</form>
</div>

View File

@ -5,6 +5,7 @@ use App\Http\Controllers\ShoppingListController;
use App\Http\Controllers\ShoppingListCreateController;
use App\Http\Controllers\ShoppingListMemberController;
use App\Http\Controllers\ShoppingListSwitchController;
use App\Http\Controllers\StoreSearchTemplateController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
@ -15,12 +16,14 @@ 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::delete('/shopping-items/{shoppingItem}', [ShoppingListController::class, 'destroy'])->name('shopping-items.destroy');
Route::patch('/shopping-items/{shoppingItem}/toggle', [ShoppingListController::class, 'toggle'])->name('shopping-items.toggle');
Route::post('/shopping-lists', ShoppingListCreateController::class)->name('shopping-lists.store');
Route::post('/shopping-lists/{shoppingList}/switch', ShoppingListSwitchController::class)->name('shopping-lists.switch');
Route::post('/shopping-lists/{shoppingList}/members', [ShoppingListMemberController::class, 'store'])->name('shopping-lists.members.store');
Route::delete('/shopping-lists/{shoppingList}/members/{user}', [ShoppingListMemberController::class, 'destroy'])->name('shopping-lists.members.destroy');
Route::patch('/stores/{store}/search-template', StoreSearchTemplateController::class)->name('stores.search-template.update');
});
Route::middleware('auth')->group(function () {