- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
259 lines
7.7 KiB
TypeScript
259 lines
7.7 KiB
TypeScript
import { useState, useRef, useCallback } from 'react';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Calculator } from 'lucide-react';
|
||
|
||
interface FormulaInputProps {
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
availableNames: string[];
|
||
formulaFieldCodes?: string[];
|
||
placeholder?: string;
|
||
className?: string;
|
||
rows?: number;
|
||
useInput?: boolean;
|
||
'data-testid'?: string;
|
||
}
|
||
|
||
function getOpenBraceInfo(el: HTMLTextAreaElement | HTMLInputElement) {
|
||
const pos = el.selectionStart ?? 0;
|
||
const text = el.value;
|
||
const beforeCursor = text.slice(0, pos);
|
||
const braceIdx = beforeCursor.lastIndexOf('{');
|
||
if (braceIdx === -1) return null;
|
||
const afterBrace = beforeCursor.slice(braceIdx + 1);
|
||
if (afterBrace.includes('}')) return null;
|
||
return { braceIdx, filterText: afterBrace };
|
||
}
|
||
|
||
function FormulaTextarea({
|
||
value,
|
||
onChange,
|
||
onKeyDown,
|
||
onBlur,
|
||
placeholder,
|
||
className,
|
||
rows,
|
||
dataTestId,
|
||
onRef,
|
||
}: {
|
||
value: string;
|
||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||
onBlur: () => void;
|
||
placeholder?: string;
|
||
className?: string;
|
||
rows?: number;
|
||
dataTestId?: string;
|
||
onRef: (el: HTMLTextAreaElement | null) => void;
|
||
}) {
|
||
return (
|
||
<Textarea
|
||
ref={onRef}
|
||
value={value}
|
||
onChange={onChange}
|
||
onKeyDown={onKeyDown}
|
||
onBlur={onBlur}
|
||
placeholder={placeholder}
|
||
className={className}
|
||
rows={rows}
|
||
data-testid={dataTestId}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function FormulaInputEl({
|
||
value,
|
||
onChange,
|
||
onKeyDown,
|
||
onBlur,
|
||
placeholder,
|
||
className,
|
||
dataTestId,
|
||
onRef,
|
||
}: {
|
||
value: string;
|
||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||
onBlur: () => void;
|
||
placeholder?: string;
|
||
className?: string;
|
||
dataTestId?: string;
|
||
onRef: (el: HTMLInputElement | null) => void;
|
||
}) {
|
||
return (
|
||
<Input
|
||
ref={onRef}
|
||
value={value}
|
||
onChange={onChange}
|
||
onKeyDown={onKeyDown}
|
||
onBlur={onBlur}
|
||
placeholder={placeholder}
|
||
className={className}
|
||
data-testid={dataTestId}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function FormulaInput({
|
||
value,
|
||
onChange,
|
||
availableNames,
|
||
formulaFieldCodes,
|
||
placeholder,
|
||
className,
|
||
rows = 2,
|
||
useInput = false,
|
||
'data-testid': dataTestId,
|
||
}: FormulaInputProps) {
|
||
const formulaCodesSet = new Set(formulaFieldCodes ?? []);
|
||
const [showDropdown, setShowDropdown] = useState(false);
|
||
const [dropdownItems, setDropdownItems] = useState<string[]>([]);
|
||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
const getActiveEl = () => useInput ? inputRef.current : textareaRef.current;
|
||
|
||
const processChange = useCallback((newValue: string, el: HTMLTextAreaElement | HTMLInputElement) => {
|
||
onChange(newValue);
|
||
const info = getOpenBraceInfo(el);
|
||
if (info !== null) {
|
||
const filter = info.filterText.toLowerCase();
|
||
const filtered = availableNames.filter(n => n.toLowerCase().startsWith(filter));
|
||
if (filtered.length > 0) {
|
||
setDropdownItems(filtered);
|
||
setSelectedIndex(0);
|
||
setShowDropdown(true);
|
||
return;
|
||
}
|
||
}
|
||
setShowDropdown(false);
|
||
}, [onChange, availableNames]);
|
||
|
||
const handleTextareaChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||
processChange(e.target.value, e.target);
|
||
}, [processChange]);
|
||
|
||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||
processChange(e.target.value, e.target);
|
||
}, [processChange]);
|
||
|
||
const insertItem = useCallback((name: string) => {
|
||
const el = getActiveEl();
|
||
if (!el) return;
|
||
const info = getOpenBraceInfo(el);
|
||
if (info === null) return;
|
||
const pos = el.selectionStart ?? 0;
|
||
const text = el.value;
|
||
const before = text.slice(0, info.braceIdx);
|
||
const after = text.slice(pos);
|
||
const newValue = before + `{${name}}` + after;
|
||
onChange(newValue);
|
||
setShowDropdown(false);
|
||
requestAnimationFrame(() => {
|
||
const newPos = before.length + name.length + 2;
|
||
el.selectionStart = newPos;
|
||
el.selectionEnd = newPos;
|
||
el.focus();
|
||
});
|
||
}, [onChange, useInput]);
|
||
|
||
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (!showDropdown) return;
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setSelectedIndex(i => Math.min(i + 1, dropdownItems.length - 1));
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
setSelectedIndex(i => Math.max(i - 1, 0));
|
||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||
if (dropdownItems[selectedIndex]) {
|
||
e.preventDefault();
|
||
insertItem(dropdownItems[selectedIndex]);
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
setShowDropdown(false);
|
||
}
|
||
}, [showDropdown, dropdownItems, selectedIndex, insertItem]);
|
||
|
||
const handleInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (!showDropdown) return;
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setSelectedIndex(i => Math.min(i + 1, dropdownItems.length - 1));
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
setSelectedIndex(i => Math.max(i - 1, 0));
|
||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||
if (dropdownItems[selectedIndex]) {
|
||
e.preventDefault();
|
||
insertItem(dropdownItems[selectedIndex]);
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
setShowDropdown(false);
|
||
}
|
||
}, [showDropdown, dropdownItems, selectedIndex, insertItem]);
|
||
|
||
const handleBlur = useCallback(() => {
|
||
setTimeout(() => setShowDropdown(false), 150);
|
||
}, []);
|
||
|
||
const sharedClassName = `font-mono text-sm ${className || ''}`;
|
||
|
||
return (
|
||
<div className="relative">
|
||
{useInput ? (
|
||
<FormulaInputEl
|
||
value={value}
|
||
onChange={handleInputChange}
|
||
onKeyDown={handleInputKeyDown}
|
||
onBlur={handleBlur}
|
||
placeholder={placeholder}
|
||
className={sharedClassName}
|
||
dataTestId={dataTestId}
|
||
onRef={(el) => { inputRef.current = el; }}
|
||
/>
|
||
) : (
|
||
<FormulaTextarea
|
||
value={value}
|
||
onChange={handleTextareaChange}
|
||
onKeyDown={handleTextareaKeyDown}
|
||
onBlur={handleBlur}
|
||
placeholder={placeholder}
|
||
className={sharedClassName}
|
||
rows={rows}
|
||
dataTestId={dataTestId}
|
||
onRef={(el) => { textareaRef.current = el; }}
|
||
/>
|
||
)}
|
||
|
||
{showDropdown && dropdownItems.length > 0 && (
|
||
<div className="absolute z-50 mt-1 w-full max-h-40 overflow-y-auto border rounded-md bg-popover shadow-md text-popover-foreground text-xs">
|
||
{dropdownItems.map((item, idx) => {
|
||
const isFormula = formulaCodesSet.has(item);
|
||
return (
|
||
<div
|
||
key={item}
|
||
className={`px-2 py-1 cursor-pointer select-none font-mono flex items-center justify-between gap-2 ${idx === selectedIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'}`}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
insertItem(item);
|
||
}}
|
||
>
|
||
<span>{'{' + item + '}'}</span>
|
||
{isFormula && (
|
||
<span className="flex items-center gap-0.5 text-[10px] font-sans font-medium text-muted-foreground shrink-0">
|
||
<Calculator className="w-3 h-3" />
|
||
формула
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|