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() {