import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useQuery, useMutation } from '@tanstack/react-query'; import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { apiRequest, queryClient, BadRequestError } from '@/lib/queryClient'; import { useToast } from '@/hooks/use-toast'; import { parseOrgDateTime } from '@/lib/timezone'; import { normalizeSelectOptions } from '@/lib/fieldOptions'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Textarea } from '@/components/ui/textarea'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; import { fieldControlClass, fieldLabelClass, fieldErrorClass } from '@/components/fields/field-styles'; import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import CompanyFieldInput, { DadataCompany } from '@/components/CompanyFieldInput'; import GeoField from '@/components/GeoField'; import ContractNumberField from '@/components/ContractNumberField'; import TableFieldSelect from '@/components/TableFieldSelect'; import TableFieldMultiSelect from '@/components/TableFieldMultiSelect'; import TableFieldWithSubdirectory from '@/components/TableFieldWithSubdirectory'; import ContactField from '@/components/ContactField'; import { SearchableSelect } from '@/components/fields/SearchableSelect'; import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect'; import { TaskFieldSelect, UserFieldSelect, FileUploadField, FormFieldData, UserData, } from '@/components/task-form-fields'; import { useAuth } from '@/hooks/useAuth'; import { isAdmin } from '@/lib/permissions'; import { useSkinComponents } from '@/themes/ThemeProvider'; import { computeFieldState, buildRequiredConditionContext, getAllConditionFields, validateRequiredFieldsDetailed, type FieldConditionContext, type ClientFormField, type FieldState, } from '@/lib/fieldConditions'; interface FormData { id: number; name: string; description?: string; isActive: boolean; titleTemplate?: string | null; hiddenSystemFields?: string[] | null; } interface FormStatusData { id: number; name: string; color: string; isInitial: boolean; isFinal: boolean; position: number; } interface OrgRole { id: number; name: string; parentId: number | null; } // Схема для создания задачи (название генерируется автоматически из шаблона формы) const createTaskSchema = z.object({ assignedTo: z.coerce.number().optional(), dueDate: z.string().optional(), dueTime: z.string().optional(), }).catchall(z.any()); // Позволяет дополнительные поля export interface CreateTaskFormContentProps { formId: number; onSuccess?: (task: { id: number; title: string }) => void; onCancel?: () => void; prefillValues?: Record; initialValues?: Record; parentTaskId?: number; open?: boolean; } export function CreateTaskFormContent({ formId, onSuccess, onCancel, prefillValues, initialValues, parentTaskId, open = true, }: CreateTaskFormContentProps) { const { toast } = useToast(); // Тема 2.0: колонки формы и отступы сетки из вариантов скина const { form: formComponents } = useSkinComponents(); const { user: currentUser } = useAuth(); const hasAppliedPrefillRef = useRef(false); const wasOpenRef = useRef(false); // Загрузка данных формы const { data: formData } = useQuery<{ form: FormData }>({ queryKey: ['/api/forms', formId], enabled: !!formId, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, retry: false, }); // Загрузка полей формы const { data: fieldsData } = useQuery<{ fields: FormFieldData[] }>({ queryKey: ['/api/forms', formId, 'fields'], enabled: !!formId, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, }); // Загрузка статусов формы const { data: statusesData } = useQuery<{ statuses: FormStatusData[] }>({ queryKey: ['/api/forms', formId, 'statuses'], enabled: !!formId, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, }); // Загрузка пользователей для назначения const { data: usersData } = useQuery<{ users: UserData[] }>({ queryKey: ['/api/users/list'], staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, }); // Загрузка ролей организации (только admin) const { data: orgRolesData } = useQuery<{ success: boolean; roles: OrgRole[] }>({ queryKey: ['/api/roles'], enabled: isAdmin(currentUser), staleTime: 60 * 1000, }); const form = useForm>({ resolver: zodResolver(createTaskSchema), defaultValues: { assignedTo: undefined, dueDate: '', dueTime: '', }, }); // Применяем предзаполненные значения только при первом открытии диалога. useEffect(() => { if (open && !hasAppliedPrefillRef.current && fieldsData?.fields) { const hasPrefill = prefillValues && Object.keys(prefillValues).length > 0; const hasInitial = initialValues && Object.keys(initialValues).length > 0; if (!hasPrefill && !hasInitial) return; hasAppliedPrefillRef.current = true; const fieldByCode = new Map(fieldsData.fields.map(f => [f.code, f])); // Сначала строковый prefill (обратная совместимость с URL/QR) if (hasPrefill) { for (const [prefillName, value] of Object.entries(prefillValues)) { // Поддерживаем оба формата: prefill_field_3 и prefill_customField_185 let targetName = prefillName; if (prefillName.startsWith('field_')) { const field = fieldByCode.get(prefillName); if (field) { targetName = `customField_${field.id}`; } } form.setValue(targetName as any, value); } } // Затем объектные initialValues (копирование из текущей задачи) if (hasInitial) { for (const [targetName, value] of Object.entries(initialValues)) { form.setValue(targetName as any, value); } } } }, [open, prefillValues, initialValues, fieldsData?.fields, form]); // Сбрасываем форму при закрытии диалога useEffect(() => { if (wasOpenRef.current && !open) { form.reset(); form.clearErrors(); setSelectedCompanyFields(new Set()); } wasOpenRef.current = open; }, [open, form]); // Отслеживаем, какие company-поля были выбраны из подсказок (а не набраны вручную) const [selectedCompanyFields, setSelectedCompanyFields] = useState>(new Set()); const formInfo = formData?.form; const fields: FormFieldData[] = (fieldsData?.fields || []) .slice() .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); const statuses: FormStatusData[] = statusesData?.statuses || []; const users: UserData[] = usersData?.users || []; const orgRoles: OrgRole[] = orgRolesData?.roles || []; // Коды полей, которые автозаполняются через DaData — в диалоге создания не показываем const companyAutofillTargets = useMemo(() => { const targets = new Set(); (fieldsData?.fields ?? []).forEach(f => { if (f.type === 'company' && f.companyAutofill && typeof f.companyAutofill === 'object') { Object.values(f.companyAutofill as Record).forEach(code => { if (code && code !== 'none') targets.add(code); }); } }); return targets; }, [fieldsData?.fields]); // Имена полей company, видимых в диалоге создания (для блокировки кнопки «Создать») const visibleCompanyFieldNames = useMemo(() => { return (fieldsData?.fields ?? []) .filter(f => f.type === 'company' && !companyAutofillTargets.has(f.code)) .map(f => `customField_${f.id}`); }, [fieldsData?.fields, companyAutofillTargets]); // Отслеживаем все значения формы реактивно — нужно для блокировки кнопки по company-полям const allWatchedValues = useWatch({ control: form.control }); const companyNotFilled = visibleCompanyFieldNames.length > 0 && visibleCompanyFieldNames.some(name => !allWatchedValues[name] || !selectedCompanyFields.has(name)); // Контекст для проверки условной обязательности полей при создании задачи const initialStatusForValidation = useMemo(() => statuses.find((s) => s.isInitial), [statuses]); const watchedFieldValues = useMemo(() => { const values: Array<{ fieldId: number; value: unknown }> = []; if (!fields) return values; for (const f of fields) { const v = allWatchedValues[`customField_${f.id}`]; values.push({ fieldId: f.id, value: v }); } return values; }, [allWatchedValues, fields]); const allConditionFields = useMemo(() => getAllConditionFields(fields || []), [fields]); const creationConditionContext = useMemo(() => buildRequiredConditionContext( users || [], orgRoles || [], true, statuses.map((s) => ({ id: s.id, name: s.name, position: s.position, isFinal: s.isFinal })) ), [users, orgRoles, statuses]); const getFieldStateForCreate = useCallback((formField: FormFieldData): FieldState => { return computeFieldState( formField as ClientFormField, { currentStatusId: initialStatusForValidation?.id }, watchedFieldValues, allConditionFields as ClientFormField[], creationConditionContext ); }, [initialStatusForValidation, watchedFieldValues, allConditionFields, creationConditionContext]); const isFieldRequiredForCreate = useCallback((formField: FormFieldData) => { return getFieldStateForCreate(formField).required; }, [getFieldStateForCreate]); const isFieldImmutableForCreate = useCallback((formField: FormFieldData) => { return getFieldStateForCreate(formField).immutable; }, [getFieldStateForCreate]); const isFieldVisibleForCreate = useCallback((formField: FormFieldData) => { return getFieldStateForCreate(formField).visible; }, [getFieldStateForCreate]); const createTaskMutation = useMutation({ mutationFn: async (data: z.infer) => { const initialStatus = statuses.find(s => s.isInitial); if (!initialStatus) { throw new Error('Не найден начальный статус для формы'); } // Разделяем основные данные и кастомные поля const { assignedTo, dueDate, dueTime, ...rest } = data; // Собираем кастомные поля const customFields: Record = {}; Object.entries(rest).forEach(([key, value]) => { if (key.startsWith('customField_') && value !== undefined && value !== '') { customFields[key] = value; } }); // Собираем dueDate: если указано только дата — all-day (передаём строку даты); // если указано и время — передаём полный ISO в часовом поясе организации (Europe/Moscow). let parsedDueDate: string | undefined; if (dueDate && dueDate.trim() !== '') { if (dueTime && dueTime.trim() !== '') { parsedDueDate = parseOrgDateTime(dueDate, dueTime).toISOString(); } else { parsedDueDate = dueDate; // date-only string → сервер сохранит как 00:00 UTC (all-day) } } // Название: бэкенд заменит через titleTemplate; fallback = имя формы const taskData = { title: formInfo?.name ?? 'Новая запись', assignedTo: assignedTo || undefined, dueDate: parsedDueDate, formId, currentStatusId: initialStatus.id, ...(parentTaskId && { parentTaskId }), ...(Object.keys(customFields).length > 0 && { customFields }) }; // Проверяем условную обязательность полей перед созданием const missingRequired = validateRequiredFieldsDetailed( { currentStatusId: initialStatus.id }, fields || [], watchedFieldValues, users || [], orgRoles || [], true, statuses.map((s) => ({ id: s.id, name: s.name, position: s.position, isFinal: s.isFinal })) ); if (missingRequired.length > 0) { throw new BadRequestError({ error: `Заполните обязательные поля: ${missingRequired.map((e) => e.message).join(', ')}`, fields: missingRequired, }); } const res = await apiRequest('POST', `/api/forms/${formId}/tasks`, taskData); const result = (await res.json()) as { success: boolean; task: { id: number; title: string } }; return result.task; }, onSuccess: (createdTask) => { queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); form.reset(); form.clearErrors(); setSelectedCompanyFields(new Set()); toast({ title: 'Задача создана', description: 'Новая задача успешно добавлена', }); onSuccess?.(createdTask); }, onError: (error: any) => { // Подсвечиваем незаполненные обязательные поля if (error instanceof BadRequestError && error.serverData && typeof error.serverData === 'object') { const serverFields = (error.serverData as { fields?: Array<{ fieldId?: number; message?: string }> }).fields; if (Array.isArray(serverFields)) { for (const f of serverFields) { if (f.fieldId != null && f.message) { form.setError(`customField_${f.fieldId}` as any, { type: 'manual', message: f.message }); } } } } toast({ title: 'Ошибка создания задачи', description: error.message || 'Произошла ошибка при создании задачи', variant: 'destructive', }); }, }); return ( <> Создать новую запись {formInfo?.titleTemplate ? `Название сформируется автоматически по шаблону` : `Название будет: «${formInfo?.name}»`}
{/* Сетка полей в стиле NocoBase/antd: колонки и отступы — из Темы 2.0 (дефолт: 2 колонки на десктопе) */} createTaskMutation.mutate(data))} className={cn( 'grid gap-x-[var(--field-gap-x,1rem)] gap-y-[var(--field-gap-y,0.75rem)]', formComponents.columns === 2 && 'md:grid-cols-2', )} > {!formInfo?.hiddenSystemFields?.includes('assignedTo') && ( /* Ответственный */ ( Ответственный field.onChange(value === '' ? undefined : parseInt(value))} /> )} /> )} {!formInfo?.hiddenSystemFields?.includes('dueDate') && ( /* Срок выполнения */ ( Срок выполнения
)} /> )} {/* Кастомные поля формы */} {fields && fields.length > 0 && ( <>
{fields.filter((formField) => isFieldVisibleForCreate(formField) && !companyAutofillTargets.has(formField.code) && // document_template и button действуют на существующую задачу — в форме создания не рендерим formField.type !== 'document_template' && formField.type !== 'button' ).map((formField) => { const fieldName = `customField_${formField.id}`; const isImmutable = isFieldImmutableForCreate(formField); // Широкие составные поля занимают обе колонки сетки const isWideField = ['textarea', 'rich-text', 'geo', 'contact', 'company', 'file', 'checklist'].includes(formField.type); return ( ( {isFieldRequiredForCreate(formField) && *} {formField.name}
{(() => { switch (formField.type) { case 'text': return ( ); case 'number': return ( field.onChange(e.target.value.replace(/\s/g, ''))} data-testid={`input-custom-${formField.code}`} className={fieldControlClass('form')} /> ); case 'date': case 'datetime': { const isProlongation = field.value === '__auto_prolongation__'; return (
field.onChange(e.target.value)} disabled={isProlongation} data-testid={`input-custom-${formField.code}`} className={fieldControlClass('form')} /> {formField.autoProlongation && ( )}
); } case 'textarea': return (