Compare commits
2 Commits
bfc6247322
...
8e11f3efca
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e11f3efca | |||
| 99496071ad |
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
21
app/Http/Controllers/StoreSearchTemplateController.php
Normal file
21
app/Http/Controllers/StoreSearchTemplateController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/UpdateStoreSearchTemplateRequest.php
Normal file
32
app/Http/Requests/UpdateStoreSearchTemplateRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
17
deploy.sh
17
deploy.sh
@ -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}"
|
||||
|
||||
1
html/build/assets/app-B_qr2xXh.css
Normal file
1
html/build/assets/app-B_qr2xXh.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user