From 70724e35dcc43a846be4b9055a4886d60cc0fb33 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 11 Jul 2026 15:32:25 +0200 Subject: [PATCH] =?UTF-8?q?K=C3=BCndigungsdatum:=20Cursor=20bleibt=20beim?= =?UTF-8?q?=20Tippen=20im=20Feld?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: bei Eingabe der Jahreszahl im Kündigungsbestätigungs-Datum sprang der Fokus raus und die Seite scrollte hoch. Ursache: onChange feuert bei jedem Tastendruck. Sobald "18.08.0002" ein gültiges Datum ergab, feuerte der Mutation-Aufruf eine PUT-Request, die Server-Antwort re-renderte den Input und der Cursor landete am Feldanfang – der Rest der Jahreszahl kam im nächsten Feld an. Fix: neue kleine AutosaveDateInput-Komponente – hält den Wert während der Eingabe lokal, feuert onSave erst bei onBlur oder Enter. Escape verwirft die Änderung. Kein Save bei unverändertem Wert. Angewendet auf beide betroffenen Stellen in ContractDetail (Bestätigung erhalten am – Kündigungsschreiben + Options-Variante). Andere Datumsfelder der App laufen entweder über react-hook-form (batched, save auf Submit) oder halten den Wert lokal – die sind vom Bug nicht betroffen. Co-Authored-By: Claude Opus 4.7 --- .../src/components/ui/AutosaveDateInput.tsx | 79 +++++++++++++++++++ .../src/pages/contracts/ContractDetail.tsx | 19 ++--- 2 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/ui/AutosaveDateInput.tsx diff --git a/frontend/src/components/ui/AutosaveDateInput.tsx b/frontend/src/components/ui/AutosaveDateInput.tsx new file mode 100644 index 00000000..cc400bc5 --- /dev/null +++ b/frontend/src/components/ui/AutosaveDateInput.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Datums-Input, der beim Tippen KEIN onSave feuert, sondern erst + * onBlur oder bei Enter/Escape. Grund: + * + * Ein `` feuert `onChange` bei jedem Tastendruck, + * sobald das Feld ein syntaktisch gültiges Datum ergibt. Beim Tippen + * einer Jahreszahl (z.B. "2026") wird das Feld schon bei "0002" + * gültig, ein direkt daran gehängter `mutate()`-Aufruf feuert eine + * PUT-Request, die Server-Antwort re-rendert den Input und der Cursor + * springt aus dem Feld – die Seite scrollt hoch, weiteres Tippen + * landet im nächsten Feld. + * + * Deshalb: lokaler State während der Eingabe, Save erst wenn der + * User fertig ist (Blur / Enter). Escape verwirft die lokale Änderung. + */ +interface AutosaveDateInputProps { + /** Aktueller Wert vom Server als ISO-Date-String (`YYYY-MM-DD` oder leer). */ + value: string; + /** Wird aufgerufen, wenn der User das Feld verlässt oder Enter drückt und + * der lokale Wert vom Server-Wert abweicht. `null` bedeutet "leer". */ + onSave: (value: string | null) => void; + className?: string; + disabled?: boolean; +} + +export default function AutosaveDateInput({ + value, + onSave, + className = 'block w-full max-w-[180px] px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500', + disabled, +}: AutosaveDateInputProps) { + const [local, setLocal] = useState(value); + // Ref hält den letzten "committeten" Wert, damit wir nach dem Speichern + // bei einem Prop-Update den lokalen State nur updaten wenn der User + // nicht gerade tippt. + const isFocused = useRef(false); + + // Wenn der Server-Wert sich ändert und wir nicht gerade fokussiert + // sind, den lokalen State nachziehen. Sonst würde ein externes Update + // während der Eingabe das getippte überschreiben. + useEffect(() => { + if (!isFocused.current) { + setLocal(value); + } + }, [value]); + + const commit = () => { + const trimmed = local.trim(); + // Nur speichern, wenn sich WIRKLICH was geändert hat. + if (trimmed === (value || '')) return; + onSave(trimmed === '' ? null : trimmed); + }; + + return ( + setLocal(e.target.value)} + onFocus={() => { isFocused.current = true; }} + onBlur={() => { + isFocused.current = false; + commit(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.currentTarget as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + setLocal(value); + (e.currentTarget as HTMLInputElement).blur(); + } + }} + disabled={disabled} + className={className} + /> + ); +} diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index 743263fa..1845af40 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -18,6 +18,7 @@ import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, Exter import JpgToPdfModal from '../../components/ui/JpgToPdfModal'; import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations'; import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton'; +import AutosaveDateInput from '../../components/ui/AutosaveDateInput'; import { formatDate } from '../../utils/dateFormat'; import { useProviderSettings } from '../../hooks/useProviderSettings'; import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types'; @@ -2325,14 +2326,9 @@ export default function ContractDetail() {
- { - const value = e.target.value || null; - updateCancellationDateMutation.mutate(value); - }} - className="block w-full max-w-[180px] px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + onSave={(value) => updateCancellationDateMutation.mutate(value)} /> {c.cancellationConfirmationDate && (