Files
iistwin/client/src/components/FormulaInput.tsx
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

259 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}