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

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

428 lines
15 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, useMemo, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { Link2, Plus, Upload, FileText, X as XIcon } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { normalizeSelectOptions } from '@/lib/fieldOptions';
import { CreateTaskDialog } from '@/components/CreateTaskDialog';
export interface FormFieldData {
id: number;
name: string;
code: string;
type: string;
isRequired: boolean;
placeholder?: string;
options?: any[] | { tableId?: number; displayColumn?: string; [key: string]: any };
defaultValue?: string;
validationRules?: any;
requiredRules?: {
enabled?: boolean;
statusId?: number | "__creation__" | null;
logic?: "and" | "or";
conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>;
} | null;
immutableRules?: {
enabled?: boolean;
statusId?: number | "__creation__" | null;
logic?: "and" | "or";
conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>;
} | null;
visibilityRules?: {
enabled?: boolean;
statusId?: number | "__creation__" | null;
logic?: "and" | "or";
conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>;
} | null;
linkedFormId?: number | null;
companyAutofill?: Record<string, string>;
maxFileCount?: number | null;
maxFileSizeMB?: number | null;
autoProlongation?: boolean;
autoProlongationLabel?: string;
allowMultiple?: boolean;
ganttRole?: 'start' | 'end' | 'milestone' | null;
displayOrder?: number;
}
export interface UserData {
id: number;
firstName: string;
lastName: string;
middleName?: string;
email: string;
}
export type FileFieldValue = { url: string; name: string; size: number };
// Компонент выбора связанной задачи из другой формы
export function TaskFieldSelect({ formField, value, onChange, showCreateButton = true }: {
formField: FormFieldData;
value: any;
onChange: (value: string) => void;
showCreateButton?: boolean;
}) {
const linkedFormId = formField.linkedFormId;
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Загружаем задачи связанной формы
const { data: tasksData, isLoading, error } = useQuery<{ tasks: Array<{ id: number; title: string }> }>({
queryKey: ['/api/forms', linkedFormId, 'tasks'],
enabled: !!linkedFormId,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: false,
});
const [searchTerm, setSearchTerm] = useState('');
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const tasks = tasksData?.tasks || [];
const selectedTask = tasks.find(t => t.id.toString() === value?.toString());
const sortedTasks = useMemo(() => {
return [...tasks].sort((a, b) => (a.title || '').localeCompare(b.title || '', 'ru'));
}, [tasks]);
const filtered = useMemo(() => {
if (!searchTerm.trim()) return sortedTasks;
const term = searchTerm.toLowerCase();
return sortedTasks.filter(t =>
t.title.toLowerCase().includes(term) ||
t.id.toString().includes(term)
);
}, [sortedTasks, searchTerm]);
const handleSelect = (taskId: string) => {
onChange(taskId);
setSearchTerm('');
setIsOpen(false);
};
if (!linkedFormId) {
return <div className="text-sm text-muted-foreground">Связанная форма не настроена</div>;
}
const inputPlaceholder = isLoading
? 'Загрузка задач...'
: error
? 'Нет доступа к связанной форме'
: !tasks.length
? 'Нет задач в связанной форме'
: (formField.placeholder || 'Выберите задачу...');
return (
<>
<Popover open={isOpen} onOpenChange={setIsOpen} modal={false}>
<PopoverAnchor asChild>
<div className="relative w-full flex items-center gap-2">
<Input
ref={inputRef}
className="h-8 flex-1"
placeholder={inputPlaceholder}
disabled={isLoading || !!error}
value={isOpen ? searchTerm : (selectedTask ? selectedTask.title : '')}
onChange={(e) => {
setSearchTerm(e.target.value);
setIsOpen(true);
}}
onClick={() => {
if (isLoading || error || !tasks.length) return;
setSearchTerm('');
setIsOpen(true);
}}
data-testid={`select-task-${formField.code}`}
/>
{showCreateButton && (
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
title="Создать связанную запись"
onClick={(e) => {
e.stopPropagation();
setCreateDialogOpen(true);
}}
data-testid={`button-create-linked-task-${formField.code}`}
>
<Plus className="w-4 h-4" />
</Button>
)}
</div>
</PopoverAnchor>
{!isLoading && !error && (
<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(task => (
<li
key={task.id}
className="cursor-pointer px-3 py-2 hover:bg-accent"
onMouseDown={(e) => { e.preventDefault(); handleSelect(task.id.toString()); }}
>
<div className="flex items-center gap-2 text-sm">
<Link2 className="w-3 h-3 text-muted-foreground" />
{task.title}
</div>
</li>
))}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</li>
)}
</ul>
</PopoverContent>
)}
</Popover>
<CreateTaskDialog
formId={linkedFormId}
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onCreated={(task) => {
onChange(task.id.toString());
queryClient.invalidateQueries({ queryKey: ['/api/forms', linkedFormId, 'tasks'] });
setCreateDialogOpen(false);
}}
/>
</>
);
}
// Компонент выбора пользователя с учетом фильтра из настроек формы
export function UserFieldSelect({
formField,
users,
value,
onChange
}: {
formField: FormFieldData;
users: UserData[];
value: any;
onChange: (value: string) => void;
}) {
const [searchTerm, setSearchTerm] = useState('');
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Применяем первичный фильтр из настроек формы (userFilter)
const userFilter = (formField.options as any)?.userFilter as { userIds?: number[]; roleIds?: number[] } | undefined;
const allowedUsers = useMemo(() => {
if (!userFilter?.userIds || userFilter.userIds.length === 0) return users;
return users.filter((u) => userFilter.userIds!.includes(u.id));
}, [users, userFilter]);
const selectedUser = allowedUsers.find(u => u.id.toString() === value?.toString());
const sortedUsers = useMemo(() => {
return [...allowedUsers].sort((a, b) =>
`${a.firstName} ${a.middleName || ''} ${a.lastName}`.localeCompare(`${b.firstName} ${b.middleName || ''} ${b.lastName}`, 'ru')
);
}, [allowedUsers]);
const filtered = useMemo(() => {
if (!searchTerm.trim()) return sortedUsers;
const term = searchTerm.toLowerCase();
return sortedUsers.filter(u =>
`${u.firstName} ${u.middleName || ''} ${u.lastName}`.toLowerCase().includes(term) ||
u.email?.toLowerCase().includes(term)
);
}, [sortedUsers, searchTerm]);
const handleSelect = (userId: string) => {
onChange(userId);
setSearchTerm('');
setIsOpen(false);
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen} modal={false}>
<PopoverAnchor asChild>
<div className="relative w-full">
<Input
ref={inputRef}
className="h-8"
placeholder={formField.placeholder || 'Выберите пользователя...'}
value={isOpen ? searchTerm : (selectedUser ? `${selectedUser.firstName} ${selectedUser.middleName || ''} ${selectedUser.lastName}` : '')}
onChange={(e) => {
setSearchTerm(e.target.value);
setIsOpen(true);
}}
onClick={() => {
setSearchTerm('');
setIsOpen(true);
}}
data-testid={`select-user-${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(user => (
<li
key={user.id}
className="cursor-pointer px-3 py-2 hover:bg-accent"
onMouseDown={(e) => { e.preventDefault(); handleSelect(user.id.toString()); }}
>
<div className="text-sm">{user.firstName} {user.middleName} {user.lastName}</div>
{user.email && <div className="text-xs text-muted-foreground">{user.email}</div>}
</li>
))}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</li>
)}
</ul>
</PopoverContent>
</Popover>
);
}
// Компонент загрузки файлов для формы создания задачи (поддержка нескольких файлов)
export function FileUploadField({ value, onChange, field }: {
value: FileFieldValue[] | null;
onChange: (v: FileFieldValue[] | null) => void;
field?: FormFieldData;
}) {
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [isUploading, setIsUploading] = useState(false);
const files: FileFieldValue[] = Array.isArray(value) ? value : [];
const countLimitReached = field?.maxFileCount != null && files.length >= field.maxFileCount;
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (field?.maxFileCount != null && files.length >= field.maxFileCount) {
toast({
title: 'Превышен лимит файлов',
description: `Максимальное количество файлов для этого поля: ${field.maxFileCount}`,
variant: 'destructive',
});
if (fileInputRef.current) fileInputRef.current.value = '';
return;
}
if (field?.maxFileSizeMB != null) {
const currentTotalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0);
const limitBytes = field.maxFileSizeMB * 1024 * 1024;
if (currentTotalBytes + file.size > limitBytes) {
toast({
title: 'Превышен лимит размера',
description: `Суммарный размер файлов не должен превышать ${field.maxFileSizeMB} МБ`,
variant: 'destructive',
});
if (fileInputRef.current) fileInputRef.current.value = '';
return;
}
}
setIsUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const uploadUrl = field?.id ? `/api/upload?fieldId=${field.id}` : '/api/upload';
const res = await fetch(uploadUrl, {
method: 'POST',
credentials: 'include',
body: formData,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Ошибка загрузки файла');
}
const uploaded = await res.json();
const newFiles = [...files, { url: String(uploaded.url), name: String(uploaded.name), size: Number(uploaded.size) }];
onChange(newFiles);
toast({ title: 'Файл добавлен', description: String(uploaded.name) });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Ошибка';
toast({ title: 'Ошибка загрузки', description: msg, variant: 'destructive' });
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleRemove = (index: number) => {
const newFiles = files.filter((_, i) => i !== index);
onChange(newFiles.length > 0 ? newFiles : null);
};
return (
<div className="flex flex-col gap-1">
{field?.maxFileCount != null && (
<span className="text-xs text-muted-foreground">{files.length}/{field.maxFileCount} файлов</span>
)}
{files.map((fileInfo, idx) => (
<span key={idx} className="inline-flex items-center gap-1 text-sm text-primary">
<FileText className="w-3 h-3" />
{fileInfo.name}
<button
type="button"
onClick={() => handleRemove(idx)}
className="ml-1 text-muted-foreground hover:text-destructive"
title="Удалить файл"
>
<XIcon className="w-3 h-3" />
</button>
</span>
))}
{!countLimitReached && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-fit"
disabled={isUploading}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-3.5 h-3.5 mr-1.5" />
{isUploading ? 'Загрузка...' : files.length > 0 ? 'Добавить файл' : 'Выбрать файл'}
</Button>
)}
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
}