Kassabon-Korrekturen speichern und Listen-Scroll stabilisieren.

Receipt-Scan: Artikelzeilen gehen mit Daten speichern in raw_meta; ein Formular fuer Kopf und Zeilen; OCR per separatem Form ohne Verschachtelung; apply-items Route akzeptiert POST und PATCH.

Einkaufsliste: Scrollposition nach Erledigt-Toggle wiederherstellen und overflow-anchor am Zwei-Spalten-Grid abschalten.
Made-with: Cursor
This commit is contained in:
Stefan Zwischenbrugger 2026-04-04 15:33:32 +02:00
parent d152af316c
commit f9993b4c73
5 changed files with 163 additions and 39 deletions

View File

@ -161,6 +161,53 @@ class ReceiptScanController extends Controller
'total_decimal' => $request->filled('total_decimal') ? $request->input('total_decimal') : null,
]);
if ($request->has('row_labels') && is_array($request->input('row_labels'))) {
$receiptScan->refresh();
$meta = is_array($receiptScan->raw_meta) ? $receiptScan->raw_meta : [];
$baseSuggestions = $this->normalizeItemSuggestions(
$meta['item_suggestions'] ?? $this->extractItemSuggestions($receiptScan->ocr_text)
);
$labels = $request->input('row_labels', []);
$prices = $request->input('row_prices', []);
$qtys = $request->input('row_qty', []);
$take = $request->input('row_take', []);
$merged = [];
foreach ($labels as $i => $labelRaw) {
$label = trim((string) $labelRaw);
if ($label === '') {
continue;
}
$priceRaw = trim((string) ($prices[$i] ?? ''));
$qtyRaw = trim((string) ($qtys[$i] ?? ''));
$uncertain = (bool) ($baseSuggestions[$i]['is_uncertain'] ?? false);
$merged[] = [
'label' => $label,
'price_raw' => $this->stripVatLetterFromPriceField($priceRaw),
'quantity_raw' => $qtyRaw,
'is_uncertain' => $uncertain,
];
}
$selectionState = [];
foreach ($labels as $i => $labelRaw) {
$label = trim((string) $labelRaw);
if ($label === '') {
continue;
}
$priceRaw = trim((string) ($prices[$i] ?? ''));
$qtyRaw = trim((string) ($qtys[$i] ?? ''));
$selectionKey = $this->suggestionRowKey($label, $priceRaw, $qtyRaw);
$selectionState[$selectionKey] = isset($take[$i]);
}
if ($merged !== []) {
$meta['item_suggestions'] = $merged;
$meta['item_selection_state'] = $selectionState;
$receiptScan->update(['raw_meta' => $meta]);
}
}
return back()->with('status', 'Kassazettel-Daten aktualisiert.');
}

View File

@ -17,6 +17,14 @@ class UpdateReceiptScanRequest extends FormRequest
'store_name' => ['nullable', 'string', 'max:255'],
'receipt_date' => ['nullable', 'date'],
'total_decimal' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
'row_labels' => ['nullable', 'array'],
'row_labels.*' => ['nullable', 'string', 'max:255'],
'row_prices' => ['nullable', 'array'],
'row_prices.*' => ['nullable', 'string', 'max:64'],
'row_qty' => ['nullable', 'array'],
'row_qty.*' => ['nullable', 'string', 'max:64'],
'row_take' => ['nullable', 'array'],
'row_take.*' => ['nullable', 'in:1'],
];
}
}

View File

@ -92,44 +92,42 @@
· {{ $validatedAtFormatted }}
</div>
@endif
<form method="POST" action="{{ route('receipt-scans.update', $scan) }}" class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<form id="receipt-scan-ocr-{{ $scan->id }}" method="POST" action="{{ route('receipt-scans.ocr-reprocess', $scan) }}" class="hidden" aria-hidden="true">@csrf</form>
<div class="mt-1 flex flex-wrap gap-2 items-center">
<button
type="submit"
form="receipt-scan-ocr-{{ $scan->id }}"
class="rounded-md border border-gray-300 bg-white px-3 min-h-[36px] text-sm text-gray-800 hover:bg-gray-50"
>
OCR erneut ausfuehren
</button>
@if($ocrAvailable && ! $scan->ocr_text)
<span class="text-xs text-gray-500">Ohne neues Foto; z. B. nach Tesseract-Pfad oder OCR-Update.</span>
@endif
</div>
<form method="POST" action="{{ route('receipt-scans.update', $scan) }}" class="space-y-3">
@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 class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<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>
<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>
@if(! $scan->ocr_text && is_string($scan->raw_meta['hint'] ?? null) && $scan->raw_meta['hint'] !== '')
<div class="mt-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
{{ $scan->raw_meta['hint'] }}
</div>
@endif
<div class="mt-3 flex flex-wrap gap-2 items-center">
<form method="POST" action="{{ route('receipt-scans.ocr-reprocess', $scan) }}">
@csrf
<button type="submit" class="rounded-md border border-gray-300 bg-white px-3 min-h-[36px] text-sm text-gray-800 hover:bg-gray-50">
OCR erneut ausfuehren
</button>
</form>
@if($ocrAvailable && ! $scan->ocr_text)
<span class="text-xs text-gray-500">Ohne neues Foto; z. B. nach Tesseract-Pfad oder OCR-Update.</span>
@endif
</div>
<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>
@ -166,10 +164,9 @@
Unsichere Treffer sind markiert und standardmaessig nicht angehakt.
Bei Einrueckung (z. B. Biskotten, darunter „2 x 1,49): Menge und Einzelpreis werden erkannt; Gesamt daneben ist nur Kontrolle.
Buchstaben A/B/E hinter dem Betrag sind nur MwSt-Kennzeichen (kein Teil des Preises).
Aenderungen an Artikelzeilen mit <span class="font-medium">Daten speichern</span> sichern.
</p>
<form method="POST" action="{{ route('receipt-scans.apply-items', $scan) }}" class="mt-3 space-y-3">
@csrf
<div class="space-y-2" id="receipt-items-{{ $scan->id }}">
<div class="space-y-2 mt-3" id="receipt-items-{{ $scan->id }}">
@foreach($suggestions as $sug)
@php
$label = is_array($sug) ? ($sug['label'] ?? '') : (string) $sug;
@ -242,18 +239,31 @@
</div>
@endforeach
</div>
<div class="flex flex-wrap gap-2 items-center">
<button type="submit" class="rounded-md bg-blue-600 text-white px-4 min-h-[40px] text-sm font-medium hover:bg-blue-700">
<div class="flex flex-wrap gap-2 items-center pt-1">
<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>
<button
type="submit"
formaction="{{ route('receipt-scans.apply-items', $scan) }}"
formmethod="post"
class="rounded-md bg-blue-600 text-white px-4 min-h-[40px] text-sm font-medium hover:bg-blue-700"
>
Ausgewaehlte als erledigt uebernehmen
</button>
</div>
</form>
@else
<p class="mt-1 text-xs text-blue-800">
Keine sicheren Artikel erkannt. Du kannst OCR-Text manuell pruefen.
</p>
<div class="flex flex-wrap gap-2 items-center pt-2">
<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>
@endif
</div>
</form>
@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

View File

@ -59,7 +59,7 @@
</form>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 [overflow-anchor:none]">
<div class="bg-white overflow-visible shadow-sm sm:rounded-xl p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-4">Offene Eintraege</h3>
<div class="space-y-3">
@ -69,7 +69,7 @@
<form
method="POST"
action="{{ route('shopping-items.toggle', $item) }}"
class="shrink-0 pt-0.5"
class="js-preserve-scroll-on-submit shrink-0 pt-0.5"
>
@csrf
@method('PATCH')
@ -131,7 +131,7 @@
@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="shrink-0 pt-0.5">
<form method="POST" action="{{ route('shopping-items.toggle', $item) }}" class="js-preserve-scroll-on-submit shrink-0 pt-0.5">
@csrf
@method('PATCH')
<input type="hidden" name="is_done" value="1" class="js-toggle-done-flag">
@ -364,6 +364,65 @@
</div>
</div>
<script>
(function () {
const scrollKey = 'einkauf_shopping_list_scroll_y';
const pathKey = 'einkauf_shopping_list_scroll_path';
document.addEventListener(
'submit',
(event) => {
const form = event.target;
if (! (form instanceof HTMLFormElement)) {
return;
}
if (! form.classList.contains('js-preserve-scroll-on-submit')) {
return;
}
try {
sessionStorage.setItem(scrollKey, String(window.scrollY));
sessionStorage.setItem(pathKey, window.location.pathname + window.location.search);
} catch (_e) {
// no-op
}
},
true
);
const restoreListScroll = () => {
if (! document.getElementById('shopping-list-page')) {
return;
}
try {
const yRaw = sessionStorage.getItem(scrollKey);
const p = sessionStorage.getItem(pathKey);
const here = window.location.pathname + window.location.search;
if (yRaw === null || p !== here) {
return;
}
const y = parseInt(yRaw, 10);
if (! Number.isFinite(y) || y < 0) {
return;
}
const apply = () => window.scrollTo(0, y);
apply();
requestAnimationFrame(apply);
setTimeout(apply, 0);
setTimeout(apply, 80);
sessionStorage.removeItem(scrollKey);
sessionStorage.removeItem(pathKey);
} catch (_e) {
// no-op
}
};
window.addEventListener('pageshow', restoreListScroll);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', restoreListScroll);
} else {
restoreListScroll();
}
})();
document.addEventListener('DOMContentLoaded', () => {
const pageRoot = document.getElementById('shopping-list-page');
const shouldFocusNewItem = pageRoot?.dataset.focusNewItem === '1';

View File

@ -29,7 +29,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/receipt-scans', [ReceiptScanController::class, 'store'])->name('receipt-scans.store');
Route::patch('/receipt-scans/{receiptScan}', [ReceiptScanController::class, 'update'])->name('receipt-scans.update');
Route::post('/receipt-scans/{receiptScan}/ocr-reprocess', [ReceiptScanController::class, 'reprocessOcr'])->name('receipt-scans.ocr-reprocess');
Route::post('/receipt-scans/{receiptScan}/apply-items', [ReceiptScanController::class, 'applyItems'])->name('receipt-scans.apply-items');
Route::match(['post', 'patch'], '/receipt-scans/{receiptScan}/apply-items', [ReceiptScanController::class, 'applyItems'])->name('receipt-scans.apply-items');
});
Route::middleware('auth')->group(function () {