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

View File

@@ -0,0 +1,194 @@
import { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { apiRequest } from "@/lib/queryClient";
import { useQuery } from "@tanstack/react-query";
import { FileSignature, Loader2 } from "lucide-react";
export interface ContractNumberValue {
mode: 'mine' | 'customer';
value: string;
legalEntityTaskId?: number;
}
export interface ContractNumberConfig {
legalEntityFormId: number;
prefixFieldCode: string;
startNumber: number;
sequencePadding: number;
}
interface ContractNumberFieldProps {
value?: ContractNumberValue | string;
onChange: (value: ContractNumberValue) => void;
disabled?: boolean;
config?: ContractNumberConfig;
formId?: number;
fieldId?: number;
}
function parseValue(val: ContractNumberValue | string | undefined): ContractNumberValue {
if (!val) return { mode: 'mine', value: '' };
if (typeof val === 'string') {
try {
return JSON.parse(val) as ContractNumberValue;
} catch {
return { mode: 'customer', value: val };
}
}
return val;
}
export default function ContractNumberField({
value,
onChange,
disabled = false,
config,
formId,
fieldId,
}: ContractNumberFieldProps) {
const parsed = parseValue(value);
const [mode, setMode] = useState<'mine' | 'customer'>(parsed.mode);
const [customerValue, setCustomerValue] = useState(parsed.mode === 'customer' ? parsed.value : '');
const [preview, setPreview] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
const legalEntityFormId = config?.legalEntityFormId;
const legalEntityTaskId = parsed.legalEntityTaskId;
// Fetch tasks from legal entity form (always, regardless of mode)
const { data: tasksData } = useQuery<{ tasks: Array<{ id: number; title: string }> }>({
queryKey: ['/api/forms', legalEntityFormId, 'tasks'],
enabled: !!legalEntityFormId,
});
// Fetch preview when mode=mine and legalEntityTaskId changes
const fetchPreview = useCallback(async () => {
if (mode !== 'mine' || !config || !legalEntityTaskId || !formId || !fieldId) {
setPreview('');
return;
}
setLoading(true);
setError('');
try {
const response = await apiRequest('POST', '/api/tasks/preview-contract-number', {
formId,
legalEntityTaskId,
fieldId,
});
const data = await response.json();
if (data.success) {
setPreview(data.preview);
} else {
setPreview('');
setError(data.error || '');
}
} catch (err) {
setPreview('');
setError('Ошибка загрузки preview');
} finally {
setLoading(false);
}
}, [mode, config, legalEntityTaskId, formId, fieldId]);
useEffect(() => {
fetchPreview();
}, [fetchPreview]);
const handleModeChange = (newMode: 'mine' | 'customer') => {
setMode(newMode);
if (newMode === 'mine') {
onChange({ mode: 'mine', value: '', legalEntityTaskId: parsed.legalEntityTaskId });
} else {
onChange({ mode: 'customer', value: customerValue, legalEntityTaskId: parsed.legalEntityTaskId });
}
};
const handleCustomerChange = (val: string) => {
setCustomerValue(val);
onChange({ mode: 'customer', value: val, legalEntityTaskId: parsed.legalEntityTaskId });
};
const handleLegalEntityChange = (taskId: string) => {
const num = taskId ? parseInt(taskId) : undefined;
onChange({ mode, value: mode === 'mine' ? (parsed.value || '') : customerValue, legalEntityTaskId: num });
};
const isGenerated = !!(parsed.mode === 'mine' && parsed.value && parsed.value !== '');
return (
<div className="space-y-2">
<div className="flex items-center space-x-3">
<Switch
id={`contract-number-mode-${fieldId}`}
checked={mode === 'mine'}
onCheckedChange={(checked: boolean) => handleModeChange(checked ? 'mine' : 'customer')}
disabled={disabled || isGenerated}
/>
<Label htmlFor={`contract-number-mode-${fieldId}`} className="cursor-pointer">
{mode === 'mine' ? 'Мой номер' : 'Номер заказчика'}
</Label>
</div>
<Select
value={legalEntityTaskId?.toString() || ''}
onValueChange={handleLegalEntityChange}
disabled={disabled || isGenerated}
>
<SelectTrigger>
<SelectValue placeholder="Выберите юрлицо" />
</SelectTrigger>
<SelectContent>
{tasksData?.tasks?.map((task) => (
<SelectItem key={task.id} value={task.id.toString()}>
#{task.id} {task.title}
</SelectItem>
))}
</SelectContent>
</Select>
{mode === 'customer' ? (
<Input
type="text"
value={customerValue}
onChange={(e) => handleCustomerChange(e.target.value)}
placeholder="Введите шифр заказчика"
disabled={disabled}
/>
) : (
<div className="relative">
<div className="flex items-center space-x-2 rounded-md border border-input bg-muted/30 px-3 py-2">
<FileSignature className="h-4 w-4 text-muted-foreground shrink-0" />
{isGenerated ? (
<span className="text-sm font-medium">{parsed.value}</span>
) : loading ? (
<div className="flex items-center space-x-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Загрузка...</span>
</div>
) : preview ? (
<span className="text-sm font-medium text-primary">{preview}</span>
) : (
<span className="text-sm text-muted-foreground">
{legalEntityTaskId ? 'Не удалось загрузить номер' : 'Выберите юрлицо'}
</span>
)}
</div>
{error && (
<p className="text-xs text-destructive mt-1">{error}</p>
)}
</div>
)}
</div>
);
}