Kassazettel-Upload mit OCR-Auswertung und Produktvorschlaege einfuehren.
Es gibt jetzt einen eigenen Kassazettel-Bereich mit Foto-Upload, OCR-Extraktion (Tesseract) und editierbaren Vorschlagsfeldern fuer Geschaeft, Datum und Summe. Zusaetzlich zeigt das schnelle Neueintrag-Feld Vorschlaege aus bestehenden Produkten der aktuellen Liste als Dropdown. Made-with: Cursor
This commit is contained in:
parent
9e354d8ef5
commit
0c90213539
84
app/Http/Controllers/ReceiptScanController.php
Normal file
84
app/Http/Controllers/ReceiptScanController.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesCurrentShoppingList;
|
||||
use App\Http\Requests\StoreReceiptScanRequest;
|
||||
use App\Http\Requests\UpdateReceiptScanRequest;
|
||||
use App\Models\ReceiptScan;
|
||||
use App\Services\ReceiptOcr\ReceiptOcrService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ReceiptScanController extends Controller
|
||||
{
|
||||
use ResolvesCurrentShoppingList;
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$request = request();
|
||||
$currentList = $this->currentShoppingList($request);
|
||||
$this->authorize('view', $currentList);
|
||||
$ocrService = app(ReceiptOcrService::class);
|
||||
|
||||
$scans = ReceiptScan::query()
|
||||
->where('shopping_list_id', $currentList->id)
|
||||
->latest()
|
||||
->paginate(12);
|
||||
|
||||
return view('receipt-scans.index', [
|
||||
'currentList' => $currentList,
|
||||
'scans' => $scans,
|
||||
'uploadLimits' => [
|
||||
'upload_max_filesize' => (string) ini_get('upload_max_filesize'),
|
||||
'post_max_size' => (string) ini_get('post_max_size'),
|
||||
'max_file_uploads' => (string) ini_get('max_file_uploads'),
|
||||
'memory_limit' => (string) ini_get('memory_limit'),
|
||||
],
|
||||
'ocrAvailable' => $ocrService->isAvailable(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreReceiptScanRequest $request, ReceiptOcrService $ocrService): RedirectResponse
|
||||
{
|
||||
$currentList = $this->currentShoppingList($request);
|
||||
$this->authorize('view', $currentList);
|
||||
|
||||
$path = $request->file('receipt_photo')->store('receipt-scans', 'public');
|
||||
$absolute = Storage::disk('public')->path($path);
|
||||
$ocr = $ocrService->extractFromImage($absolute);
|
||||
|
||||
ReceiptScan::query()->create([
|
||||
'shopping_list_id' => $currentList->id,
|
||||
'created_by' => $request->user()->id,
|
||||
'image_path' => $path,
|
||||
'ocr_text' => $ocr['text'],
|
||||
'store_name' => $request->filled('store_name') ? $request->string('store_name')->toString() : $ocr['store_name'],
|
||||
'receipt_date' => $request->filled('receipt_date') ? $request->date('receipt_date')?->toDateString() : $ocr['receipt_date'],
|
||||
'total_decimal' => $request->filled('total_decimal') ? $request->input('total_decimal') : $ocr['total_decimal'],
|
||||
'raw_meta' => $ocr['meta'],
|
||||
]);
|
||||
|
||||
$message = $ocr['ok']
|
||||
? 'Kassazettel gespeichert und OCR ausgewertet.'
|
||||
: 'Kassazettel gespeichert. OCR war nicht verfuegbar (Bild kann spaeter manuell erfasst werden).';
|
||||
|
||||
return back()->with('status', $message);
|
||||
}
|
||||
|
||||
public function update(UpdateReceiptScanRequest $request, ReceiptScan $receiptScan): RedirectResponse
|
||||
{
|
||||
$currentList = $this->currentShoppingList($request);
|
||||
$this->authorize('view', $currentList);
|
||||
abort_if((int) $receiptScan->shopping_list_id !== (int) $currentList->id, 403);
|
||||
|
||||
$receiptScan->update([
|
||||
'store_name' => $request->filled('store_name') ? $request->string('store_name')->toString() : null,
|
||||
'receipt_date' => $request->filled('receipt_date') ? $request->date('receipt_date')?->toDateString() : null,
|
||||
'total_decimal' => $request->filled('total_decimal') ? $request->input('total_decimal') : null,
|
||||
]);
|
||||
|
||||
return back()->with('status', 'Kassazettel-Daten aktualisiert.');
|
||||
}
|
||||
}
|
||||
@ -46,6 +46,13 @@ class ShoppingListController extends Controller
|
||||
$openItems = $items->where('is_done', false);
|
||||
$doneItems = $items->where('is_done', true);
|
||||
$stores = Store::query()->orderBy('name')->get();
|
||||
$productSuggestions = $items
|
||||
->pluck('product_name')
|
||||
->map(fn ($name) => trim((string) $name))
|
||||
->filter()
|
||||
->unique(fn ($name) => mb_strtolower($name))
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
$totalsByStore = $items
|
||||
->filter(fn (ShoppingItem $item) => $item->latestPriceLog !== null)
|
||||
@ -63,6 +70,7 @@ class ShoppingListController extends Controller
|
||||
'members' => $members,
|
||||
'openItems' => $openItems,
|
||||
'doneItems' => $doneItems,
|
||||
'productSuggestions' => $productSuggestions,
|
||||
'stores' => $stores,
|
||||
'totalsByStore' => $totalsByStore,
|
||||
'totalAll' => $totalsByStore->sum(),
|
||||
|
||||
33
app/Http/Requests/StoreReceiptScanRequest.php
Normal file
33
app/Http/Requests/StoreReceiptScanRequest.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreReceiptScanRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'receipt_photo' => ['required', 'file', 'max:15360', 'mimes:jpeg,jpg,png,webp,heic,heif,pdf'],
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'receipt_date' => ['nullable', 'date'],
|
||||
'total_decimal' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'receipt_photo.required' => 'Bitte ein Kassazettel-Foto auswaehlen.',
|
||||
'receipt_photo.max' => 'Das Kassazettel-Foto darf maximal 15 MB gross sein.',
|
||||
'receipt_photo.mimes' => 'Erlaubte Formate: jpg, png, webp, heic, heif, pdf.',
|
||||
'receipt_photo.uploaded' => 'Das Kassazettel-Foto konnte nicht hochgeladen werden. Haeufige Ursache: PHP-Limits (upload_max_filesize / post_max_size) sind zu klein.',
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Http/Requests/UpdateReceiptScanRequest.php
Normal file
22
app/Http/Requests/UpdateReceiptScanRequest.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateReceiptScanRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'receipt_date' => ['nullable', 'date'],
|
||||
'total_decimal' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Models/ReceiptScan.php
Normal file
44
app/Models/ReceiptScan.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ReceiptScan extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'shopping_list_id',
|
||||
'created_by',
|
||||
'image_path',
|
||||
'ocr_text',
|
||||
'store_name',
|
||||
'receipt_date',
|
||||
'total_decimal',
|
||||
'raw_meta',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'receipt_date' => 'date',
|
||||
'total_decimal' => 'decimal:2',
|
||||
'raw_meta' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function shoppingList(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ShoppingList::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function imageUrl(): string
|
||||
{
|
||||
return asset('storage/'.ltrim($this->image_path, '/'));
|
||||
}
|
||||
}
|
||||
143
app/Services/ReceiptOcr/ReceiptOcrService.php
Normal file
143
app/Services/ReceiptOcr/ReceiptOcrService.php
Normal file
@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ReceiptOcr;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ReceiptOcrService
|
||||
{
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
$bin = (string) config('app.receipt_ocr_bin', env('RECEIPT_OCR_BIN', 'tesseract'));
|
||||
$command = sprintf('%s --version 2>&1', escapeshellcmd($bin));
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
@exec($command, $output, $exitCode);
|
||||
|
||||
return $exitCode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, text: string|null, store_name: string|null, receipt_date: string|null, total_decimal: string|null, meta: array<string, mixed>}
|
||||
*/
|
||||
public function extractFromImage(string $absolutePath): array
|
||||
{
|
||||
$bin = (string) config('app.receipt_ocr_bin', env('RECEIPT_OCR_BIN', 'tesseract'));
|
||||
if (! $this->isAvailable()) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'text' => null,
|
||||
'store_name' => null,
|
||||
'receipt_date' => null,
|
||||
'total_decimal' => null,
|
||||
'meta' => [
|
||||
'error' => 'ocr_unavailable',
|
||||
'hint' => 'Installiere tesseract und setze optional RECEIPT_OCR_BIN.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$command = sprintf(
|
||||
'%s %s stdout -l deu+eng 2>&1',
|
||||
escapeshellcmd($bin),
|
||||
escapeshellarg($absolutePath)
|
||||
);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
@exec($command, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'text' => null,
|
||||
'store_name' => null,
|
||||
'receipt_date' => null,
|
||||
'total_decimal' => null,
|
||||
'meta' => [
|
||||
'error' => 'ocr_failed',
|
||||
'exit_code' => $exitCode,
|
||||
'command' => $command,
|
||||
'output' => implode("\n", $output),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$text = trim(implode("\n", $output));
|
||||
$store = $this->guessStoreName($text);
|
||||
$receiptDate = $this->guessDate($text);
|
||||
$total = $this->guessTotal($text);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'text' => $text !== '' ? $text : null,
|
||||
'store_name' => $store,
|
||||
'receipt_date' => $receiptDate,
|
||||
'total_decimal' => $total,
|
||||
'meta' => [
|
||||
'engine' => 'tesseract',
|
||||
'command' => $command,
|
||||
'exit_code' => $exitCode,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function guessStoreName(string $text): ?string
|
||||
{
|
||||
foreach (preg_split('/\R+/', $text) ?: [] as $line) {
|
||||
$candidate = trim($line);
|
||||
if ($candidate === '' || mb_strlen($candidate) < 2 || mb_strlen($candidate) > 60) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/\d{2}[.\/-]\d{2}[.\/-]\d{2,4}/', $candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^[\p{L}\p{N}\s&\-.]+$/u', $candidate) === 1) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function guessDate(string $text): ?string
|
||||
{
|
||||
if (! preg_match('/\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/u', $text, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$day = str_pad($matches[1], 2, '0', STR_PAD_LEFT);
|
||||
$month = str_pad($matches[2], 2, '0', STR_PAD_LEFT);
|
||||
$year = $matches[3];
|
||||
if (strlen($year) === 2) {
|
||||
$year = ((int) $year > 69 ? '19' : '20').$year;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::createFromFormat('Y-m-d', "{$year}-{$month}-{$day}")->toDateString();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function guessTotal(string $text): ?string
|
||||
{
|
||||
$normalized = str_replace(',', '.', $text);
|
||||
$normalized = preg_replace('/(?<=\d)\.(?=\d{3}\b)/', '', $normalized) ?? $normalized;
|
||||
$patterns = [
|
||||
'/(?:summe|gesamt|zu\s*zahlen|betrag)\D{0,20}(\d{1,5}(?:\.\d{1,2})?)/iu',
|
||||
'/(\d{1,5}(?:\.\d{1,2})?)\s*(?:eur|€)/iu',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match_all($pattern, $normalized, $matches) && isset($matches[1])) {
|
||||
$candidate = end($matches[1]);
|
||||
if ($candidate !== false && is_numeric($candidate)) {
|
||||
return number_format((float) $candidate, 2, '.', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('receipt_scans', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('shopping_list_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('image_path');
|
||||
$table->longText('ocr_text')->nullable();
|
||||
$table->string('store_name')->nullable();
|
||||
$table->date('receipt_date')->nullable();
|
||||
$table->decimal('total_decimal', 10, 2)->nullable();
|
||||
$table->json('raw_meta')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['shopping_list_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('receipt_scans');
|
||||
}
|
||||
};
|
||||
@ -15,6 +15,9 @@
|
||||
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||
{{ __('Einkaufsliste') }}
|
||||
</x-nav-link>
|
||||
<x-nav-link :href="route('receipt-scans.index')" :active="request()->routeIs('receipt-scans.*')">
|
||||
{{ __('Kassazettel') }}
|
||||
</x-nav-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -70,6 +73,9 @@
|
||||
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||
{{ __('Einkaufsliste') }}
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('receipt-scans.index')" :active="request()->routeIs('receipt-scans.*')">
|
||||
{{ __('Kassazettel') }}
|
||||
</x-responsive-nav-link>
|
||||
</div>
|
||||
|
||||
<!-- Responsive Settings Options -->
|
||||
|
||||
127
resources/views/receipt-scans/index.blade.php
Normal file
127
resources/views/receipt-scans/index.blade.php
Normal file
@ -0,0 +1,127 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Kassazettel scannen - {{ $currentList->name }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
@if (session('status'))
|
||||
<div class="bg-green-100 border border-green-300 text-green-800 px-4 py-3 rounded">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="bg-red-100 border border-red-300 text-red-800 px-4 py-3 rounded">
|
||||
<ul class="list-disc ms-5">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-xl p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold mb-3">Neuen Kassazettel erfassen</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Foto aufnehmen oder Bild/PDF hochladen. Die OCR-Auswertung (V2) versucht automatisch Geschaeft, Datum und Summe zu erkennen.
|
||||
</p>
|
||||
<div class="mb-3 rounded-md border px-3 py-2 text-xs {{ $ocrAvailable ? 'border-green-200 bg-green-50 text-green-800' : 'border-amber-200 bg-amber-50 text-amber-900' }}">
|
||||
@if($ocrAvailable)
|
||||
OCR-Engine aktiv: Tesseract erkannt.
|
||||
@else
|
||||
OCR-Engine nicht verfuegbar: Bitte Tesseract installieren (Server) oder RECEIPT_OCR_BIN setzen. Upload funktioniert trotzdem.
|
||||
@endif
|
||||
</div>
|
||||
<div class="mb-4 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
|
||||
<p class="font-semibold mb-1">Aktive PHP-Upload-Limits auf diesem System</p>
|
||||
<p>
|
||||
upload_max_filesize: <span class="font-mono">{{ $uploadLimits['upload_max_filesize'] }}</span>
|
||||
· post_max_size: <span class="font-mono">{{ $uploadLimits['post_max_size'] }}</span>
|
||||
· max_file_uploads: <span class="font-mono">{{ $uploadLimits['max_file_uploads'] }}</span>
|
||||
· memory_limit: <span class="font-mono">{{ $uploadLimits['memory_limit'] }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('receipt-scans.store') }}" enctype="multipart/form-data" class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
@csrf
|
||||
<div class="md:col-span-3">
|
||||
<label class="text-sm text-gray-700 block mb-1">Kassazettel-Foto</label>
|
||||
<input type="file" name="receipt_photo" accept="image/*,.pdf" capture="environment" required class="w-full rounded-md border-gray-300 min-h-[44px]">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-700 block mb-1">Geschaeft (optional)</label>
|
||||
<input type="text" name="store_name" value="{{ old('store_name') }}" class="w-full rounded-md border-gray-300 min-h-[44px]" placeholder="z. B. Spar">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-700 block mb-1">Datum (optional)</label>
|
||||
<input type="date" name="receipt_date" value="{{ old('receipt_date') }}" class="w-full rounded-md border-gray-300 min-h-[44px]">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-700 block mb-1">Summe (optional)</label>
|
||||
<input type="number" name="total_decimal" step="0.01" min="0" value="{{ old('total_decimal') }}" class="w-full rounded-md border-gray-300 min-h-[44px]" placeholder="z. B. 18.90">
|
||||
</div>
|
||||
<div class="md:col-span-3">
|
||||
<button type="submit" class="rounded-md bg-blue-600 text-white px-5 min-h-[44px] text-base font-medium hover:bg-blue-700">
|
||||
Foto speichern und auswerten
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-xl p-4 sm:p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Letzte Kassazettel</h3>
|
||||
<div class="space-y-4">
|
||||
@forelse($scans as $scan)
|
||||
<div class="rounded-xl border border-gray-200 p-3 sm:p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<a href="{{ $scan->imageUrl() }}" target="_blank" rel="noopener noreferrer" class="block">
|
||||
<img src="{{ $scan->imageUrl() }}" alt="Kassazettel" class="max-h-64 w-full rounded-lg border border-gray-200 object-contain bg-gray-50">
|
||||
</a>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<form method="POST" action="{{ route('receipt-scans.update', $scan) }}" class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<div>
|
||||
<label class="text-xs text-gray-600 block mb-1">Geschaeft</label>
|
||||
<input type="text" name="store_name" value="{{ $scan->store_name }}" class="w-full rounded-md border-gray-300 min-h-[40px] text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-600 block mb-1">Datum</label>
|
||||
<input type="date" name="receipt_date" value="{{ optional($scan->receipt_date)->toDateString() }}" class="w-full rounded-md border-gray-300 min-h-[40px] text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-600 block mb-1">Summe</label>
|
||||
<input type="number" name="total_decimal" step="0.01" min="0" value="{{ $scan->total_decimal }}" class="w-full rounded-md border-gray-300 min-h-[40px] text-sm">
|
||||
</div>
|
||||
<div class="sm:col-span-3">
|
||||
<button type="submit" class="rounded-md bg-gray-800 text-white px-4 min-h-[40px] text-sm font-medium hover:bg-gray-900">
|
||||
Daten speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-sm text-gray-600 hover:text-gray-900">OCR-Text anzeigen</summary>
|
||||
<pre class="mt-2 whitespace-pre-wrap rounded-md bg-gray-50 p-3 text-xs text-gray-700 border border-gray-200">{{ $scan->ocr_text ?: 'Kein OCR-Text vorhanden.' }}</pre>
|
||||
</details>
|
||||
@if(($scan->raw_meta['error'] ?? null) === 'ocr_unavailable')
|
||||
<p class="mt-2 text-xs text-amber-700">OCR war beim Upload nicht verfuegbar.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-gray-600">Noch keine Kassazettel erfasst.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
{{ $scans->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@ -12,6 +12,11 @@
|
||||
<option value="{{ $store->name }}"></option>
|
||||
@endforeach
|
||||
</datalist>
|
||||
<datalist id="product-options">
|
||||
@foreach($productSuggestions as $productSuggestion)
|
||||
<option value="{{ $productSuggestion }}"></option>
|
||||
@endforeach
|
||||
</datalist>
|
||||
|
||||
@if (session('status'))
|
||||
<div class="bg-green-100 border border-green-300 text-green-800 px-4 py-3 rounded">
|
||||
@ -37,11 +42,12 @@
|
||||
type="text"
|
||||
name="product_name"
|
||||
id="new-item-name"
|
||||
list="product-options"
|
||||
value="{{ old('product_name') }}"
|
||||
placeholder="z. B. Milch"
|
||||
class="flex-1 rounded-md border-gray-300 shadow-sm min-h-[44px] text-base"
|
||||
required
|
||||
autocomplete="off"
|
||||
autocomplete="on"
|
||||
autofocus
|
||||
>
|
||||
<button
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\ReceiptScanController;
|
||||
use App\Http\Controllers\ShoppingListController;
|
||||
use App\Http\Controllers\ShoppingListCreateController;
|
||||
use App\Http\Controllers\ShoppingListMemberController;
|
||||
@ -24,6 +25,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
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::get('/receipt-scans', [ReceiptScanController::class, 'index'])->name('receipt-scans.index');
|
||||
Route::post('/receipt-scans', [ReceiptScanController::class, 'store'])->name('receipt-scans.store');
|
||||
Route::patch('/receipt-scans/{receiptScan}', [ReceiptScanController::class, 'update'])->name('receipt-scans.update');
|
||||
});
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user