Files
iistwin/client/src/components/CreateTaskFormContent.tsx
Ильяс Султанов 4d65049e02 feat: рейтинговые автоматизации, мультивыбор user/role-полей, график history-number
Инфраструктура автоматизаций:
- триггер task.status_changed оживлён: вызов из executeTaskTransition (fire-and-forget) + фильтр triggerConfig.statusId
- ctx.users.setFieldValue(userId, fieldCodeOrId, value): запись user_profile_field_values с аудитом; для history-number — append в user_field_history (миграция 0078)
- документация sandbox обновлена (Automations.tsx, mcp.ts, AGENTS.md) по критическому правилу 2

Мультивыбор пользователей и ролей в user-поле:
- чекбокс «Множественный выбор (пользователи и роли)» в настройках поля (form_fields.allow_multiple)
- значение — массив ['user:ID','role:ID'], обратная совместимость с одиночными/legacy значениями
- UserRoleMultiSelect в карточке задачи, реестре и диалоге создания (роли добавлены и в одиночный сценарий создания)
- синк в task_assignees итерирует массив; role:ID раскрывается в членов роли; PATCH single — diff старого/нового значений
- отображение массивов: реестр, title-шаблоны, условия полей, MCP update_task_fields
- список орг-ролей (/api/roles) загружается всем пользователям (сервер и так был открыт) — роли доступны не только админам

График history-number в профиле:
- GET /api/users/:id/field-history/:fieldId?period=week|month|quarter|year (агрегация AVG по бакетам)
- диалог с LineChart (recharts) и переключателем периодов по клику на иконку у поля
2026-09-04 19:39:10 +03:00

889 lines
46 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, 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<string, string>;
initialValues?: Record<string, unknown>;
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<z.infer<typeof createTaskSchema>>({
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<Set<string>>(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<string>();
(fieldsData?.fields ?? []).forEach(f => {
if (f.type === 'company' && f.companyAutofill && typeof f.companyAutofill === 'object') {
Object.values(f.companyAutofill as Record<string, string>).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<FieldConditionContext>(() =>
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<typeof createTaskSchema>) => {
const initialStatus = statuses.find(s => s.isInitial);
if (!initialStatus) {
throw new Error('Не найден начальный статус для формы');
}
// Разделяем основные данные и кастомные поля
const { assignedTo, dueDate, dueTime, ...rest } = data;
// Собираем кастомные поля
const customFields: Record<string, any> = {};
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 (
<>
<DialogHeader className="pb-2">
<DialogTitle className="text-sm font-medium">Создать новую запись</DialogTitle>
<DialogDescription className="text-xs">
{formInfo?.titleTemplate
? `Название сформируется автоматически по шаблону`
: `Название будет: «${formInfo?.name}»`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<Form {...form}>
{/* Сетка полей в стиле NocoBase/antd: колонки и отступы — из Темы 2.0 (дефолт: 2 колонки на десктопе) */}
<form
onSubmit={form.handleSubmit((data) => 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') && (
/* Ответственный */
<FormField
control={form.control}
name="assignedTo"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel className={fieldLabelClass}>Ответственный</FormLabel>
<FormControl>
<UserFieldSelect
formField={{ id: 0, code: 'assignedTo', name: 'Ответственный', type: 'user', options: {} } as FormFieldData}
users={users}
value={field.value?.toString()}
onChange={(value) => field.onChange(value === '' ? undefined : parseInt(value))}
/>
</FormControl>
<FormMessage className={fieldErrorClass} />
</FormItem>
)}
/>
)}
{!formInfo?.hiddenSystemFields?.includes('dueDate') && (
/* Срок выполнения */
<FormField
control={form.control}
name="dueDate"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel className={fieldLabelClass}>Срок выполнения</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input
type="date"
{...field}
data-testid="input-task-due-date"
className={fieldControlClass('form', 'flex-1')}
/>
<Input
type="time"
{...form.register('dueTime')}
data-testid="input-task-due-time"
className={fieldControlClass('form', 'w-[110px]')}
/>
</div>
</FormControl>
<FormMessage className={fieldErrorClass} />
</FormItem>
)}
/>
)}
{/* Кастомные поля формы */}
{fields && fields.length > 0 && (
<>
<div className="border-t md:col-span-2" />
{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 (
<FormField
key={formField.id}
control={form.control}
name={fieldName as any}
render={({ field }) => (
<FormItem className={cn('space-y-1', isWideField && 'md:col-span-2')}>
<FormLabel className={fieldLabelClass}>
{isFieldRequiredForCreate(formField) && <span className="text-destructive mr-0.5">*</span>}
{formField.name}
</FormLabel>
<FormControl>
<fieldset disabled={isImmutable} className="min-w-0 border-0 p-0 m-0">
{(() => {
switch (formField.type) {
case 'text':
return (
<Input
placeholder={formField.placeholder || `Введите ${formField.name.toLowerCase()}...`}
{...field}
data-testid={`input-custom-${formField.code}`}
className={fieldControlClass('form')}
/>
);
case 'number':
return (
<Input
type="text"
inputMode="numeric"
placeholder={formField.placeholder || `Введите ${formField.name.toLowerCase()}...`}
{...field}
value={field.value ?? ''}
onChange={(e) => 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 (
<div className="space-y-1">
<Input
type={formField.type === 'datetime' ? 'datetime-local' : 'date'}
value={isProlongation ? '' : (field.value ? String(field.value).slice(0, formField.type === 'datetime' ? 16 : 10) : '')}
onChange={(e) => field.onChange(e.target.value)}
disabled={isProlongation}
data-testid={`input-custom-${formField.code}`}
className={fieldControlClass('form')}
/>
{formField.autoProlongation && (
<label className="flex items-center space-x-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={isProlongation}
onChange={(e) => field.onChange(e.target.checked ? '__auto_prolongation__' : '')}
className="rounded border-gray-300"
/>
<span>{formField.autoProlongationLabel || 'Автопролонгация'}</span>
</label>
)}
</div>
);
}
case 'textarea':
return (
<Textarea
placeholder={formField.placeholder || `Введите ${formField.name.toLowerCase()}...`}
className="resize-none text-sm"
rows={2}
{...field}
data-testid={`textarea-custom-${formField.code}`}
/>
);
case 'rich-text':
return (
<Textarea
placeholder={formField.placeholder || 'HTML: <b>жирный</b>, <i>курсив</i>, <ul><li>список</li></ul>'}
className="resize-none font-mono text-xs"
rows={4}
{...field}
data-testid={`textarea-custom-${formField.code}`}
/>
);
case 'radio-group': {
const radioOptions = normalizeSelectOptions(formField.options);
return (
<RadioGroup
value={field.value != null ? String(field.value) : ''}
onValueChange={field.onChange}
className="flex flex-wrap gap-x-4 gap-y-1.5 pt-1"
data-testid={`radio-custom-${formField.code}`}
>
{radioOptions.map((option) => (
<label key={option.value} className="flex items-center gap-1.5 text-sm cursor-pointer">
<RadioGroupItem value={option.value} />
<span>{option.label}</span>
</label>
))}
</RadioGroup>
);
}
case 'toggle':
return (
<div className="flex items-center gap-2 h-8">
<Switch
checked={field.value === true || field.value === 'true'}
onCheckedChange={field.onChange}
data-testid={`toggle-custom-${formField.code}`}
/>
<span className="text-sm text-muted-foreground">
{field.value === true || field.value === 'true' ? 'Да' : 'Нет'}
</span>
</div>
);
case 'select': {
const selectOptions = normalizeSelectOptions(formField.options).map((option) => ({
value: option.value,
label: option.label,
}));
if (formField.allowMultiple) {
const selected = Array.isArray(field.value)
? new Set(field.value.map((v: any) => String(v)))
: field.value != null && field.value !== ''
? new Set([String(field.value)])
: new Set<string>();
return (
<SearchableMultiSelect
options={selectOptions}
selected={selected}
onChange={(next) => field.onChange(Array.from(next))}
placeholder={formField.placeholder || `Выберите ${formField.name.toLowerCase()}...`}
dataTestid={`select-multi-custom-${formField.code}`}
/>
);
}
return (
<SearchableSelect
options={selectOptions}
value={field.value}
onChange={field.onChange}
placeholder={formField.placeholder || `Выберите ${formField.name.toLowerCase()}...`}
dataTestid={`select-custom-${formField.code}`}
/>
);
}
case 'checkbox':
return (
<div className="flex items-center space-x-2">
<input
type="checkbox"
id={fieldName}
checked={!!field.value}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
data-testid={`checkbox-custom-${formField.code}`}
/>
<label htmlFor={fieldName} className="text-sm">
{formField.placeholder || formField.name}
</label>
</div>
);
case 'checklist': {
const checklistItems = Array.isArray(formField.options) ? formField.options : [];
const checklistValue = Array.isArray(field.value) ? field.value : [];
const checkedCount = checklistValue.filter((v: any) => v?.checked).length;
const totalCount = checklistItems.length;
const progressPercent = totalCount > 0 ? Math.round((checkedCount / totalCount) * 100) : 0;
return (
<div className="space-y-3">
{totalCount > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Выполнено {checkedCount} из {totalCount}</span>
<span>{progressPercent}%</span>
</div>
<div className="h-2 w-full bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
<div className="space-y-2 max-h-96 overflow-y-auto pr-1">
{checklistItems.map((item: any, idx: number) => {
const itemId = item.id || item.value || String(idx);
const itemLabel = item.label || item.name || String(item);
const itemPeriod = item.period || '';
const checkedItem = checklistValue.find((v: any) => (v.itemId || v.id) === itemId);
const isChecked = !!checkedItem?.checked;
return (
<label key={itemId} className="flex items-start space-x-2 cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
const newValue = checklistItems.map((it: any, i: number) => {
const id = it.id || it.value || String(i);
const existing = checklistValue.find((v: any) => (v.itemId || v.id) === id);
if (id === itemId) {
return { itemId: id, checked: e.target.checked };
}
return { itemId: id, checked: !!existing?.checked };
});
field.onChange(newValue);
}}
className="rounded border-gray-300 mt-0.5"
/>
<div className="flex-1 text-sm leading-tight">
<span>{itemLabel}</span>
{itemPeriod && (
<span className="text-xs text-muted-foreground ml-1">({itemPeriod})</span>
)}
</div>
</label>
);
})}
</div>
</div>
);
}
case 'table': {
const opts = formField.options && !Array.isArray(formField.options) ? (formField.options as Record<string, any>) : {};
const showSubdirectory = !!opts.showSubdirectory;
if (showSubdirectory) {
return (
<TableFieldWithSubdirectory
fieldId={formField.id}
fieldCode={formField.code}
placeholder={formField.placeholder}
options={formField.options}
value={field.value}
onChange={field.onChange}
allowMultiple={formField.allowMultiple || false}
/>
);
}
if (formField.allowMultiple) {
return (
<TableFieldMultiSelect
fieldId={formField.id}
fieldCode={formField.code}
placeholder={formField.placeholder}
options={formField.options}
value={field.value}
onChange={field.onChange}
/>
);
}
return (
<TableFieldSelect
fieldId={formField.id}
fieldCode={formField.code}
placeholder={formField.placeholder}
options={formField.options}
value={field.value}
onChange={field.onChange}
/>
);
}
case 'task':
return (
<TaskFieldSelect
formField={formField}
value={field.value}
onChange={field.onChange}
/>
);
case 'user':
return (
<UserFieldSelect
formField={formField}
users={users}
roles={orgRoles}
value={field.value}
onChange={field.onChange}
/>
);
case 'file':
return (
<FileUploadField
value={field.value ?? null}
onChange={field.onChange}
field={formField}
/>
);
case 'company': {
const fieldName = `customField_${formField.id}`;
const handleCompanySelect = (company: DadataCompany) => {
field.onChange(company.value);
setSelectedCompanyFields(prev => new Set(prev).add(fieldName));
if (formField.companyAutofill) {
const getDadataValue = (key: string): string => {
switch (key) {
case 'name.full_with_opf':
return company.data.name.full_with_opf || '';
case 'name.short_with_opf':
return company.data.name.short_with_opf || '';
case 'inn':
return company.data.inn || '';
case 'kpp':
return company.data.kpp || '';
case 'ogrn':
return company.data.ogrn || '';
case 'management.name':
return company.data.management?.name || '';
case 'management.post':
return company.data.management?.post || '';
default:
return '';
}
};
Object.entries(formField.companyAutofill).forEach(([dadataKey, targetFieldCode]) => {
if (!targetFieldCode || targetFieldCode === 'none') return;
const targetField = fields.find(f => f.code === targetFieldCode);
if (targetField) {
const value = getDadataValue(dadataKey);
const targetFieldName = `customField_${targetField.id}`;
form.setValue(targetFieldName as any, value, { shouldDirty: true, shouldValidate: true });
}
});
}
};
const handleCompanyClear = () => {
setSelectedCompanyFields(prev => {
const next = new Set(prev);
next.delete(fieldName);
return next;
});
if (formField.companyAutofill) {
Object.values(formField.companyAutofill as Record<string, string>).forEach((targetFieldCode) => {
if (!targetFieldCode || targetFieldCode === 'none') return;
const targetField = fields.find(f => f.code === targetFieldCode);
if (targetField) {
const targetFieldName = `customField_${targetField.id}`;
form.setValue(targetFieldName as any, '', { shouldDirty: true, shouldValidate: true });
}
});
}
};
return (
<CompanyFieldInput
value={field.value?.toString() || ''}
onChange={field.onChange}
onSelect={handleCompanySelect}
onClear={handleCompanyClear}
placeholder={formField.placeholder || 'Начните вводить название компании...'}
data-testid={`input-company-${formField.code}`}
/>
);
}
case 'geo':
return (
<GeoField
value={field.value}
onChange={field.onChange}
disabled={false}
/>
);
case 'contract-number': {
const cnConfig = (formField.options as any)?.contractNumber;
return (
<ContractNumberField
value={field.value}
onChange={field.onChange}
disabled={false}
config={cnConfig}
formId={formId}
fieldId={formField.id}
/>
);
}
case 'contact':
return (
<ContactField
value={field.value}
onChange={field.onChange}
disabled={false}
/>
);
default:
return (
<Input
placeholder={formField.placeholder || `Введите ${formField.name.toLowerCase()}...`}
{...field}
data-testid={`input-custom-${formField.code}`}
className={fieldControlClass('form')}
/>
);
}
})()}
</fieldset>
</FormControl>
<FormMessage className={fieldErrorClass} />
</FormItem>
)}
/>
);
})}
</>
)}
{/* Кнопки действий — липкий футер внутри скроллящегося контента диалога */}
<div className="sticky bottom-0 bg-background border-t flex justify-end gap-2 pt-3 pb-1 md:col-span-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
data-testid="button-cancel-task"
size="sm"
>
Отмена
</Button>
<Button
type="submit"
disabled={createTaskMutation.isPending || companyNotFilled}
data-testid="button-submit-task"
size="sm"
>
{createTaskMutation.isPending ? 'Создание...' : 'Создать запись'}
</Button>
</div>
</form>
</Form>
</div>
</>
);
}