fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
194
client/src/components/ContractNumberField.tsx
Normal file
194
client/src/components/ContractNumberField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user