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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user