diff --git a/resources/js/components/InlineEditInput.tsx b/resources/js/components/InlineEditInput.tsx new file mode 100644 index 0000000..793c129 --- /dev/null +++ b/resources/js/components/InlineEditInput.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState } from 'react'; + +interface InlineEditInputProps { + value: number; + onSave: (value: number) => Promise; + formatDisplay?: (value: number) => string; + min?: number; + step?: string; + className?: string; + disabled?: boolean; +} + +type Status = 'idle' | 'editing' | 'saving' | 'success' | 'error'; + +export default function InlineEditInput({ + value, + onSave, + formatDisplay = (v) => String(v), + min, + step, + className = '', + disabled = false, +}: InlineEditInputProps) { + const [status, setStatus] = useState('idle'); + const [editValue, setEditValue] = useState(''); + const inputRef = useRef(null); + const savingRef = useRef(false); + + useEffect(() => { + if (status === 'editing' && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [status]); + + const startEditing = () => { + if (disabled) return; + setEditValue(String(value)); + setStatus('editing'); + }; + + const cancel = () => { + setStatus('idle'); + }; + + const save = async () => { + if (savingRef.current) return; + + const parsed = Number(editValue); + if (isNaN(parsed)) { + setStatus('idle'); + return; + } + if (parsed === value) { + setStatus('idle'); + return; + } + + savingRef.current = true; + setStatus('saving'); + try { + await onSave(parsed); + setStatus('success'); + setTimeout(() => setStatus('idle'), 1500); + } catch { + setStatus('error'); + setTimeout(() => setStatus('idle'), 1500); + } finally { + savingRef.current = false; + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + save(); + } else if (e.key === 'Escape') { + cancel(); + } + }; + + if (status === 'editing' || status === 'saving') { + return ( + setEditValue(e.target.value)} + onBlur={save} + onKeyDown={handleKeyDown} + min={min} + step={step} + disabled={status === 'saving'} + className={`w-24 rounded border border-blue-300 bg-white px-2 py-0.5 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-blue-500 ${className}`} + /> + ); + } + + return ( + + {formatDisplay(value)} + {status === 'success' && } + {status === 'error' && } + + ); +} diff --git a/resources/js/components/InlineEditSelect.tsx b/resources/js/components/InlineEditSelect.tsx new file mode 100644 index 0000000..750febe --- /dev/null +++ b/resources/js/components/InlineEditSelect.tsx @@ -0,0 +1,98 @@ +import { useEffect, useRef, useState } from 'react'; + +interface Option { + value: string; + label: string; +} + +interface InlineEditSelectProps { + value: string; + options: Option[]; + onSave: (value: string) => Promise; + displayLabel?: string; + className?: string; + disabled?: boolean; +} + +type Status = 'idle' | 'editing' | 'saving' | 'success' | 'error'; + +export default function InlineEditSelect({ + value, + options, + onSave, + displayLabel, + className = '', + disabled = false, +}: InlineEditSelectProps) { + const [status, setStatus] = useState('idle'); + const selectRef = useRef(null); + + useEffect(() => { + if (status === 'editing' && selectRef.current) { + selectRef.current.focus(); + } + }, [status]); + + const startEditing = () => { + if (disabled) return; + setStatus('editing'); + }; + + const handleChange = async (e: React.ChangeEvent) => { + const newValue = e.target.value; + if (newValue === value) { + setStatus('idle'); + return; + } + + setStatus('saving'); + try { + await onSave(newValue); + setStatus('success'); + setTimeout(() => setStatus('idle'), 1500); + } catch { + setStatus('error'); + setTimeout(() => setStatus('idle'), 1500); + } + }; + + const handleBlur = () => { + if (status === 'editing') { + setStatus('idle'); + } + }; + + if (status === 'editing' || status === 'saving') { + return ( + + ); + } + + return ( + + {displayLabel || value} + {status === 'success' && } + {status === 'error' && } + + ); +}