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:
parent
d152af316c
commit
f9993b4c73
@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user