Инфраструктура автоматизаций: - триггер 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) и переключателем периодов по клику на иконку у поля
889 lines
46 KiB
TypeScript
889 lines
46 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|