Kündigungsdatum: Cursor bleibt beim Tippen im Feld

Bug: bei Eingabe der Jahreszahl im Kündigungsbestätigungs-Datum
sprang der Fokus raus und die Seite scrollte hoch. Ursache:
<input type=date> 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 15:32:25 +02:00
co-authored by Claude Opus 4.7
parent f2703ed6b7
commit 70724e35dc
2 changed files with 84 additions and 14 deletions
@@ -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 `<input type="date">` 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 (
<input
type="date"
value={local}
onChange={(e) => 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}
/>
);
}
@@ -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() {
<div className="mt-2">
<label className="text-xs text-gray-500 block mb-1">Bestätigung erhalten am</label>
<div className="flex items-center gap-2">
<input
type="date"
<AutosaveDateInput
value={c.cancellationConfirmationDate ? c.cancellationConfirmationDate.split('T')[0] : ''}
onChange={(e) => {
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 && (
<button
@@ -2463,14 +2459,9 @@ export default function ContractDetail() {
<div className="mt-2">
<label className="text-xs text-gray-500 block mb-1">Bestätigung erhalten am</label>
<div className="flex items-center gap-2">
<input
type="date"
<AutosaveDateInput
value={c.cancellationConfirmationOptionsDate ? c.cancellationConfirmationOptionsDate.split('T')[0] : ''}
onChange={(e) => {
const value = e.target.value || null;
updateCancellationOptionsDateMutation.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) => updateCancellationOptionsDateMutation.mutate(value)}
/>
{c.cancellationConfirmationOptionsDate && (
<button