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; 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(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
Связанная форма не настроена
; } const inputPlaceholder = isLoading ? 'Загрузка задач...' : error ? 'Нет доступа к связанной форме' : !tasks.length ? 'Нет задач в связанной форме' : (formField.placeholder || 'Выберите задачу...'); return ( <>
{ setSearchTerm(e.target.value); setIsOpen(true); }} onClick={() => { if (isLoading || error || !tasks.length) return; setSearchTerm(''); setIsOpen(true); }} data-testid={`select-task-${formField.code}`} /> {showCreateButton && ( )}
{!isLoading && !error && ( e.preventDefault()} onInteractOutside={(e) => { if (inputRef.current && inputRef.current.contains(e.target as Node)) { e.preventDefault(); } }} >
  • { e.preventDefault(); handleSelect(''); }} > —
  • {filtered.map(task => (
  • { e.preventDefault(); handleSelect(task.id.toString()); }} >
    {task.title}
  • ))} {filtered.length === 0 && (
  • Нет совпадений
  • )}
)}
{ 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(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 (
{ setSearchTerm(e.target.value); setIsOpen(true); }} onClick={() => { setSearchTerm(''); setIsOpen(true); }} data-testid={`select-user-${formField.code}`} />
e.preventDefault()} onInteractOutside={(e) => { if (inputRef.current && inputRef.current.contains(e.target as Node)) { e.preventDefault(); } }} >
  • { e.preventDefault(); handleSelect(''); }} > —
  • {filtered.map(user => (
  • { e.preventDefault(); handleSelect(user.id.toString()); }} >
    {user.firstName} {user.middleName} {user.lastName}
    {user.email &&
    {user.email}
    }
  • ))} {filtered.length === 0 && (
  • Нет совпадений
  • )}
); } // Компонент загрузки файлов для формы создания задачи (поддержка нескольких файлов) export function FileUploadField({ value, onChange, field }: { value: FileFieldValue[] | null; onChange: (v: FileFieldValue[] | null) => void; field?: FormFieldData; }) { const { toast } = useToast(); const fileInputRef = useRef(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) => { 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 (
{field?.maxFileCount != null && ( {files.length}/{field.maxFileCount} файлов )} {files.map((fileInfo, idx) => ( {fileInfo.name} ))} {!countLimitReached && ( )}
); }