fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

141
_tablefield_orig.txt Normal file
View File

@@ -0,0 +1,141 @@

> function TableFieldSelect({ formField, value, onChange }: {
formField: FormFieldData;
value: any;
onChange: (value: string) => void;
}) {
const options = formField.options && !Array.isArray(formField.options) ? formField.options : {};
const tableId = options.tableId;
const displayColumn = options.displayColumn || '';
const { data: tableData, isLoading, error } = useQuery<{ table: { rows: Array<{ id: number; values: string[] }>; co
lumns: Array<{ name: string }> } }>({
queryKey: ['/api/directories', tableId],
enabled: !!tableId,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: false,
});
const [searchTerm, setSearchTerm] = useState('');
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
if (!tableId) {
return <div className="text-sm text-muted-foreground">Таблица не настроена</div>;
}
if (isLoading) {
return <Input disabled className="h-8" placeholder="Загрузка..." />;
}
if (error) {
return <div className="text-sm text-destructive">Нет доступа к таблице</div>;
}
const table = tableData?.table;
if (!table || !table.rows?.length) {
return <div className="text-sm text-muted-foreground">Таблица пуста</div>;
}
// Находим индекс столбца для отображения, с fallback на первый столбец
let displayColumnIndex = table.columns?.findIndex(c => c.name === displayColumn);
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
displayColumnIndex = 0;
}
const getDisplayValue = (rowId: string | number) => {
const row = table.rows?.find(r => r.id.toString() === rowId?.toString());
return row?.values[displayColumnIndex!] || row?.values[0] || '';
};
const selectedRow = table.rows?.find(r => r.id.toString() === value?.toString());
const selectedDisplay = selectedRow ? (selectedRow.values[displayColumnIndex!] || selectedRow.values[0] || '') : ''
;
const sortedRows = useMemo(() => {
return [...table.rows].sort((a, b) => {
const av = a.values[displayColumnIndex!] || a.values[0] || '';
const bv = b.values[displayColumnIndex!] || b.values[0] || '';
return av.localeCompare(bv, 'ru');
});
}, [table.rows, displayColumnIndex]);
const filtered = useMemo(() => {
if (!searchTerm.trim()) return sortedRows;
const term = searchTerm.toLowerCase();
return sortedRows.filter(r => {
const display = r.values[displayColumnIndex!] || r.values[0] || '';
return display.toLowerCase().includes(term);
});
}, [sortedRows, searchTerm, displayColumnIndex]);
const handleSelect = (rowId: string) => {
onChange(rowId);
setSearchTerm('');
setIsOpen(false);
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverAnchor asChild>
<div className="relative w-full">
<Input
ref={inputRef}
className="h-8"
placeholder={formField.placeholder || 'Выберите значение...'}
value={isOpen ? searchTerm : (selectedDisplay || '')}
onChange={(e) => {
setSearchTerm(e.target.value);
setIsOpen(true);
}}
onFocus={() => {
setSearchTerm('');
setIsOpen(true);
}}
data-testid={`select-table-${formField.code}`}
/>
</div>
</PopoverAnchor>
<PopoverContent
className="p-0"
style={{ width: 'var(--radix-popper-anchor-width)' }}
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (inputRef.current && inputRef.current.contains(e.target as Node)) {
e.preventDefault();
}
}}
>
<ul className="max-h-60 overflow-auto py-1">
<li
className="cursor-pointer px-3 py-2 hover:bg-accent text-sm text-muted-foreground"
onMouseDown={(e) => { e.preventDefault(); handleSelect(''); }}
>
—
</li>
{filtered.map(row => (
<li
key={row.id}
className="cursor-pointer px-3 py-2 hover:bg-accent"
onMouseDown={(e) => { e.preventDefault(); handleSelect(row.id.toString()); }}
>
<div className="flex items-center gap-2 text-sm">
<Table className="w-3 h-3 text-muted-foreground" />
{row.values[displayColumnIndex!] || row.values[0]}
</div>
</li>
))}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</li>
)}
</ul>
</PopoverContent>
</Popover>
);
}
// Компонент выбора связанной задачи из другой формы
function TaskFieldSelect({ formField, value, onChange }: {