Инфраструктура автоматизаций: - триггер 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) и переключателем периодов по клику на иконку у поля
576 lines
27 KiB
TypeScript
576 lines
27 KiB
TypeScript
import { useState } from 'react';
|
||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||
import { queryClient, apiRequest } from '@/lib/queryClient';
|
||
import { useAuth } from '@/hooks/useAuth';
|
||
import { hasPermission } from '@/lib/permissions';
|
||
import { useToast } from '@/hooks/use-toast';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||
import { Switch } from '@/components/ui/switch';
|
||
import { Label } from '@/components/ui/label';
|
||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||
import { Zap, Plus, Pencil, Trash2, Play, ChevronRight, Circle, X, Terminal } from 'lucide-react';
|
||
|
||
|
||
type Automation = {
|
||
id: number;
|
||
name: string;
|
||
description: string | null;
|
||
code: string;
|
||
trigger: string;
|
||
triggerConfig: Record<string, unknown> | null;
|
||
isActive: boolean;
|
||
runOffline: boolean;
|
||
clientCompatible: boolean;
|
||
createdAt: string | null;
|
||
updatedAt: string | null;
|
||
};
|
||
|
||
type Form = { id: number; name: string };
|
||
|
||
const TRIGGER_LABELS: Record<string, string> = {
|
||
manual: 'Вручную',
|
||
'task.before_create': 'Перед созданием задачи',
|
||
'task.created': 'Задача создана',
|
||
'task.status_changed': 'Статус изменён',
|
||
'qr.scan': 'Сканирование QR',
|
||
schedule: 'По расписанию',
|
||
};
|
||
|
||
const CODE_TEMPLATE = `// ============================================================
|
||
// СПРАВОЧНИК ПО АВТОМАТИЗАЦИЯМ
|
||
// ============================================================
|
||
//
|
||
// Переменные контекста:
|
||
// ctx.organizationId — ID текущей организации
|
||
// ctx.triggerData — данные события (только для triggered-автоматизаций)
|
||
// ctx.result — объект результата, который увидит CRM
|
||
//
|
||
// Данные события ctx.triggerData:
|
||
// • task.before_create: { formId, task: { formId, title, currentStatusId, dueDate, customFields } }
|
||
// • task.created: { formId, taskId, task: { ... } }
|
||
// • task.status_changed:{ formId, taskId, oldStatusId, newStatusId, task: { ... } }
|
||
// • qr.scan: { template, objectId, scannedByUserId }
|
||
// • schedule: { trigger: 'schedule', scheduledAt }
|
||
//
|
||
// Логирование:
|
||
// ctx.log(msg) — запись в лог выполнения
|
||
//
|
||
// Работа с формами:
|
||
// ctx.forms.list() — список форм
|
||
// ctx.forms.get(formId) — получить форму
|
||
// ctx.forms.getFields(formId) — поля формы
|
||
// ctx.forms.getStatuses(formId) — статусы формы
|
||
//
|
||
// Работа с задачами:
|
||
// ctx.tasks.list(formId) — задачи формы
|
||
// ctx.tasks.get(taskId) — получить задачу
|
||
// ctx.tasks.create({ formId, title, assignedTo?, dueDate? }) — создать задачу
|
||
// ctx.tasks.update(taskId, updates) — обновить задачу
|
||
// ctx.tasks.delete(taskId) — удалить задачу
|
||
// ctx.tasks.getFieldValues(taskId) — значения полей задачи
|
||
// ctx.tasks.getAssignees(taskId) — назначенные пользователи
|
||
// ctx.tasks.setFieldValue(taskId, fieldCode, value) — установить значение поля
|
||
//
|
||
// Работа с пользователями:
|
||
// ctx.users.list() — список пользователей организации
|
||
// ctx.users.get(id) — получить пользователя
|
||
// ctx.users.update(id, updates) — обновить пользователя (поля firstName, lastName, position, statusId и др.)
|
||
// ctx.users.updateStatus(id, statusId) — сменить статус пользователя
|
||
// ctx.users.setFieldValue(id, fieldCodeOrId, value) — записать значение кастомного поля профиля
|
||
// пользователя (по id или code поля, с аудитом от имени «Автоматизация»). Для полей типа
|
||
// «history-number» значение дополнительно добавляется в историю user_field_history
|
||
// (с привязкой к задаче триггера); нечисловое значение вызовет ошибку автоматизации.
|
||
//
|
||
// Триггеры:
|
||
// manual — запуск вручную
|
||
// task.before_create — перед созданием задачи (можно отменить через ctx.result = { allow: false, error: '...' })
|
||
// task.created — после создания задачи
|
||
// task.status_changed — при изменении статуса задачи
|
||
// (triggerConfig: { formId?, statusId? }; statusId — срабатывать только при переходе В этот статус)
|
||
// qr.scan — при сканировании QR-кода в PWA (только офлайн-автоматизации)
|
||
// schedule — ежедневно в заданное время по МСК (triggerConfig: { time: 'HH:MM' })
|
||
//
|
||
// Результат автоматизации (ctx.result):
|
||
// { allow: false, error: '...' } — отменить действие (task.before_create)
|
||
// { action: 'open_task', taskId: 123 } — открыть задачу после скана QR
|
||
// { action: 'show_list', tasks: [{ id, title }] } — показать список задач
|
||
// { action: 'create_task', formId: 11, prefill: {} } — открыть диалог создания задачи
|
||
// { action: 'error', message: '...' } — показать ошибку
|
||
//
|
||
// Действия для офлайн-автоматизаций (qr.scan, PWA):
|
||
// ctx.result.actions.push({ type: 'notify', title?, message, variant? }) — показать уведомление
|
||
// ctx.result.actions.push({ type: 'navigate', path: '/forms/...' }) — перейти по маршруту
|
||
// ctx.result.actions.push({ type: 'updateFormCache', taskId, values: { fieldCode: value } }) — обновить поля задачи офлайн
|
||
// ctx.result.actions.push({ type: 'createOfflineTask', formId, title?, values: { fieldCode: value } }) — создать задачу офлайн
|
||
//
|
||
// Пример: подсчёт задач по всем формам
|
||
const forms = await ctx.forms.list();
|
||
ctx.log('Форм в системе: ' + forms.length);
|
||
|
||
for (const form of forms) {
|
||
const tasks = await ctx.tasks.list(form.id);
|
||
ctx.log(\`Форма "\${form.name}": \${tasks.length} задач\`);
|
||
}
|
||
|
||
// Пример для триггера task.status_changed — начислить рейтинг исполнителю
|
||
// при переходе задачи в нужный статус (statusId задаётся в настройках триггера):
|
||
// const { taskId, oldStatusId, newStatusId, task } = ctx.triggerData;
|
||
// ctx.log('Задача ' + taskId + ': статус ' + oldStatusId + ' -> ' + newStatusId);
|
||
// if (task.assignedTo) {
|
||
// await ctx.users.setFieldValue(task.assignedTo, 'rating', 5);
|
||
// }
|
||
`;
|
||
|
||
export function AutomationsContent() {
|
||
const { toast } = useToast();
|
||
const { user } = useAuth();
|
||
const [editItem, setEditItem] = useState<Automation | null>(null);
|
||
const [isCreating, setIsCreating] = useState(false);
|
||
const [runLogs, setRunLogs] = useState<{ id: number; logs: string[]; error?: string } | null>(null);
|
||
const [isRunning, setIsRunning] = useState(false);
|
||
|
||
const [formName, setFormName] = useState('');
|
||
const [formDesc, setFormDesc] = useState('');
|
||
const [formCode, setFormCode] = useState(CODE_TEMPLATE);
|
||
const [formTrigger, setFormTrigger] = useState('manual');
|
||
const [formTriggerFormId, setFormTriggerFormId] = useState('');
|
||
const [formTriggerTime, setFormTriggerTime] = useState('08:00');
|
||
const [formActive, setFormActive] = useState(true);
|
||
const [formRunOffline, setFormRunOffline] = useState(false);
|
||
const [formClientCompatible, setFormClientCompatible] = useState(false);
|
||
|
||
const { data, isLoading } = useQuery<{ success: boolean; automations: Automation[] }>({
|
||
queryKey: ['/api/automations'],
|
||
enabled: hasPermission(user, 'automations.manage'),
|
||
});
|
||
|
||
const { data: formsData } = useQuery<{ success: boolean; forms: Form[] }>({
|
||
queryKey: ['/api/forms'],
|
||
enabled: hasPermission(user, 'automations.manage'),
|
||
});
|
||
|
||
const items = data?.automations ?? [];
|
||
const forms = formsData?.forms ?? [];
|
||
|
||
const saveMutation = useMutation({
|
||
mutationFn: async (payload: Partial<Automation> & { name: string }) => {
|
||
if (editItem && !isCreating) {
|
||
const res = await apiRequest('PUT', `/api/automations/${editItem.id}`, payload);
|
||
return res.json();
|
||
} else {
|
||
const res = await apiRequest('POST', '/api/automations', payload);
|
||
return res.json();
|
||
}
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['/api/automations'] });
|
||
closeDialog();
|
||
toast({ title: 'Сохранено' });
|
||
},
|
||
onError: () => toast({ title: 'Ошибка сохранения', variant: 'destructive' }),
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: async (id: number) => {
|
||
const res = await apiRequest('DELETE', `/api/automations/${id}`);
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['/api/automations'] });
|
||
toast({ title: 'Удалено' });
|
||
},
|
||
onError: () => toast({ title: 'Ошибка удаления', variant: 'destructive' }),
|
||
});
|
||
|
||
const toggleMutation = useMutation({
|
||
mutationFn: async ({ id, isActive }: { id: number; isActive: boolean }) => {
|
||
const res = await apiRequest('PUT', `/api/automations/${id}`, { isActive });
|
||
return res.json();
|
||
},
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['/api/automations'] }),
|
||
});
|
||
|
||
function openCreate() {
|
||
setIsCreating(true);
|
||
setEditItem(null);
|
||
setFormName('');
|
||
setFormDesc('');
|
||
setFormCode(CODE_TEMPLATE);
|
||
setFormTrigger('manual');
|
||
setFormTriggerFormId('all');
|
||
setFormTriggerTime('08:00');
|
||
setFormActive(true);
|
||
setFormRunOffline(false);
|
||
setFormClientCompatible(false);
|
||
}
|
||
|
||
function openEdit(item: Automation) {
|
||
setIsCreating(false);
|
||
setEditItem(item);
|
||
setFormName(item.name);
|
||
setFormDesc(item.description ?? '');
|
||
setFormCode(item.code);
|
||
setFormTrigger(item.trigger);
|
||
setFormTriggerFormId(String((item.triggerConfig as any)?.formId ?? 'all'));
|
||
setFormTriggerTime(String((item.triggerConfig as any)?.time ?? '08:00'));
|
||
setFormActive(item.isActive);
|
||
setFormRunOffline(item.runOffline ?? false);
|
||
setFormClientCompatible(item.clientCompatible ?? false);
|
||
}
|
||
|
||
function closeDialog() {
|
||
setEditItem(null);
|
||
setIsCreating(false);
|
||
}
|
||
|
||
function handleSave() {
|
||
if (!formName.trim()) return;
|
||
const triggerConfig: Record<string, unknown> = {};
|
||
if (formTrigger === 'schedule') {
|
||
triggerConfig.time = formTriggerTime || '08:00';
|
||
} else if (formTrigger !== 'manual' && formTriggerFormId && formTriggerFormId !== 'all') {
|
||
triggerConfig.formId = parseInt(formTriggerFormId);
|
||
}
|
||
// For qr.scan trigger, object matching uses triggerConfig.templateKey
|
||
if (formTrigger === 'qr.scan') {
|
||
triggerConfig.templateKey = (editItem?.triggerConfig as any)?.templateKey ?? formName.trim().toLowerCase().replace(/\s+/g, '-');
|
||
if (formTriggerFormId && formTriggerFormId !== 'all') {
|
||
triggerConfig.formId = parseInt(formTriggerFormId);
|
||
}
|
||
}
|
||
saveMutation.mutate({
|
||
name: formName.trim(),
|
||
description: formDesc || null,
|
||
code: formCode,
|
||
trigger: formTrigger,
|
||
triggerConfig: Object.keys(triggerConfig).length ? triggerConfig : null,
|
||
isActive: formActive,
|
||
runOffline: formRunOffline,
|
||
clientCompatible: formClientCompatible,
|
||
} as any);
|
||
}
|
||
|
||
async function handleRun(item: Automation) {
|
||
setIsRunning(true);
|
||
setRunLogs({ id: item.id, logs: ['⏳ Запуск...'], error: undefined });
|
||
try {
|
||
const res = await apiRequest('POST', `/api/automations/${item.id}/run`, {});
|
||
const data = await res.json();
|
||
setRunLogs({ id: item.id, logs: data.logs ?? [], error: data.error });
|
||
if (data.success) {
|
||
toast({ title: 'Выполнено успешно' });
|
||
} else {
|
||
toast({ title: 'Ошибка выполнения', variant: 'destructive' });
|
||
}
|
||
} catch {
|
||
setRunLogs({ id: item.id, logs: ['Ошибка сети'], error: 'Network error' });
|
||
} finally {
|
||
setIsRunning(false);
|
||
}
|
||
}
|
||
|
||
const isDialogOpen = isCreating || editItem !== null;
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs text-muted-foreground">
|
||
JavaScript-скрипты для автоматизации задач. ИИ может создавать и редактировать их через MCP.
|
||
</p>
|
||
<Button size="sm" className="h-7 px-2.5 text-xs gap-1" onClick={openCreate}>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
Создать
|
||
</Button>
|
||
</div>
|
||
|
||
<details className="text-[10px] text-muted-foreground bg-muted/50 rounded p-2 -mt-1">
|
||
<summary className="cursor-pointer font-medium text-foreground/80 select-none">
|
||
Доступные функции и переменные
|
||
</summary>
|
||
<div className="mt-2 max-h-72 overflow-y-auto space-y-1.5 font-mono text-[10px] leading-relaxed whitespace-pre-wrap pr-1">
|
||
{`Переменные:
|
||
ctx.organizationId — ID организации
|
||
ctx.triggerData — данные события (для triggered-автоматизаций)
|
||
ctx.result — результат автоматизации
|
||
|
||
События ctx.triggerData:
|
||
task.before_create: { formId, task: { formId, title, currentStatusId, dueDate, customFields } }
|
||
task.created: { formId, taskId, task: { ... } }
|
||
task.status_changed:{ formId, taskId, oldStatusId, newStatusId, task: { ... } }
|
||
qr.scan: { template, objectId, scannedByUserId }
|
||
schedule: { trigger: 'schedule', scheduledAt }
|
||
|
||
Формы:
|
||
ctx.forms.list() / ctx.forms.get(formId) / ctx.forms.getFields(formId) / ctx.forms.getStatuses(formId)
|
||
|
||
Задачи:
|
||
ctx.tasks.list(formId) / ctx.tasks.get(taskId)
|
||
ctx.tasks.create({ formId, title, assignedTo?, dueDate? })
|
||
ctx.tasks.update(taskId, updates) / ctx.tasks.delete(taskId)
|
||
ctx.tasks.getFieldValues(taskId) / ctx.tasks.getAssignees(taskId)
|
||
ctx.tasks.setFieldValue(taskId, fieldCode, value)
|
||
|
||
Пользователи:
|
||
ctx.users.list() / ctx.users.get(userId)
|
||
ctx.users.update(userId, updates) / ctx.users.updateStatus(userId, statusId)
|
||
ctx.users.setFieldValue(userId, fieldCodeOrId, value) — записать кастомное поле профиля
|
||
(с аудитом; для полей «history-number» значение также пишется в историю user_field_history,
|
||
нечисловое значение — ошибка автоматизации)
|
||
|
||
Логирование: ctx.log(msg)
|
||
|
||
Триггеры:
|
||
manual — запуск вручную
|
||
task.before_create — перед созданием задачи (можно отменить через ctx.result = { allow: false, error: '...' })
|
||
task.created — после создания задачи
|
||
task.status_changed — при изменении статуса задачи (triggerConfig: { formId?, statusId? };
|
||
statusId — срабатывать только при переходе В этот статус)
|
||
qr.scan — при сканировании QR-кода в PWA
|
||
schedule — ежедневно в заданное время по МСК (настройка: время запуска)
|
||
|
||
Результат автоматизации (ctx.result):
|
||
{ allow: false, error: '...' } — отменить действие (task.before_create)
|
||
{ action: 'open_task', taskId: 123 } — открыть задачу
|
||
{ action: 'show_list', tasks: [{ id, title }] } — показать список задач
|
||
{ action: 'create_task', formId: 11, prefill: {} } — открыть диалог создания
|
||
{ action: 'error', message: '...' } — показать ошибку
|
||
|
||
Блокировка создания (task.before_create):
|
||
ctx.result = { allow: false, error: 'Пояснение...' }`}
|
||
</div>
|
||
</details>
|
||
|
||
{isLoading ? (
|
||
<div className="text-xs text-muted-foreground py-4 text-center">Загрузка...</div>
|
||
) : items.length === 0 ? (
|
||
<div className="border border-dashed rounded-md py-8 text-center text-xs text-muted-foreground">
|
||
<Zap className="w-5 h-5 mx-auto mb-2 opacity-40" />
|
||
Нет автоматизаций. Создайте первую или попросите ИИ написать скрипт через MCP.
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1">
|
||
{items.map(item => (
|
||
<div key={item.id} className="border rounded-md">
|
||
<div className="flex items-center gap-2 px-3 py-2">
|
||
<Switch
|
||
checked={item.isActive}
|
||
onCheckedChange={(v) => toggleMutation.mutate({ id: item.id, isActive: v })}
|
||
className="scale-75"
|
||
/>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-medium truncate">{item.name}</span>
|
||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4">
|
||
{TRIGGER_LABELS[item.trigger] ?? item.trigger}
|
||
</Badge>
|
||
{!item.isActive && (
|
||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4">Отключена</Badge>
|
||
)}
|
||
{item.runOffline && (
|
||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 text-blue-600 border-blue-200">
|
||
Offline
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
{item.description && (
|
||
<div className="text-[10px] text-muted-foreground truncate mt-0.5">{item.description}</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 w-6 p-0"
|
||
onClick={() => handleRun(item)}
|
||
disabled={isRunning}
|
||
title="Запустить"
|
||
>
|
||
<Play className="w-3 h-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 w-6 p-0"
|
||
onClick={() => openEdit(item)}
|
||
title="Редактировать"
|
||
>
|
||
<Pencil className="w-3 h-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||
onClick={() => deleteMutation.mutate(item.id)}
|
||
title="Удалить"
|
||
>
|
||
<Trash2 className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{runLogs?.id === item.id && (
|
||
<div className="border-t bg-muted/30 rounded-b-md px-3 py-2">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-[10px] font-medium flex items-center gap-1">
|
||
<Terminal className="w-3 h-3" />
|
||
Лог выполнения
|
||
</span>
|
||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0" onClick={() => setRunLogs(null)}>
|
||
<X className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
<ScrollArea className="h-32">
|
||
<pre className="text-[10px] font-mono whitespace-pre-wrap text-foreground/80">
|
||
{runLogs.logs.join('\n') || '(нет вывода)'}
|
||
{runLogs.error && `\n\n❌ ${runLogs.error}`}
|
||
</pre>
|
||
</ScrollArea>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={isDialogOpen} onOpenChange={(v) => { if (!v) closeDialog(); }}>
|
||
<DialogContent className="max-w-3xl w-full">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-sm">
|
||
{isCreating ? 'Новая автоматизация' : `Редактировать: ${editItem?.name}`}
|
||
</DialogTitle>
|
||
<DialogDescription className="sr-only">
|
||
{isCreating ? 'Создание новой автоматизации' : `Редактирование автоматизации ${editItem?.name}`}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">Название</Label>
|
||
<Input
|
||
className="h-7 text-xs"
|
||
placeholder="Например: Подсчёт итогов"
|
||
value={formName}
|
||
onChange={e => setFormName(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">Триггер</Label>
|
||
<Select value={formTrigger} onValueChange={setFormTrigger}>
|
||
<SelectTrigger className="h-7 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="manual">Вручную</SelectItem>
|
||
<SelectItem value="task.before_create">Перед созданием задачи</SelectItem>
|
||
<SelectItem value="task.created">При создании задачи</SelectItem>
|
||
<SelectItem value="task.status_changed">При изменении статуса</SelectItem>
|
||
<SelectItem value="qr.scan">Сканирование QR</SelectItem>
|
||
<SelectItem value="schedule">По расписанию (ежедневно, МСК)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{formTrigger === 'schedule' && (
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">Время запуска (МСК)</Label>
|
||
<Input
|
||
type="time"
|
||
className="h-7 text-xs w-auto"
|
||
value={formTriggerTime}
|
||
onChange={e => setFormTriggerTime(e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{formTrigger !== 'manual' && formTrigger !== 'schedule' && (
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">Форма (опционально)</Label>
|
||
<Select value={formTriggerFormId} onValueChange={setFormTriggerFormId}>
|
||
<SelectTrigger className="h-7 text-xs">
|
||
<SelectValue placeholder="Все формы" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">Все формы</SelectItem>
|
||
{forms.map(f => (
|
||
<SelectItem key={f.id} value={String(f.id)}>{f.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">Описание</Label>
|
||
<Textarea
|
||
className="text-xs resize-none"
|
||
rows={2}
|
||
placeholder="Что делает эта автоматизация?"
|
||
value={formDesc}
|
||
onChange={e => setFormDesc(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">JavaScript-код</Label>
|
||
<textarea
|
||
className="flex min-h-[300px] w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
|
||
value={formCode}
|
||
onChange={e => setFormCode(e.target.value)}
|
||
placeholder="// Используйте ctx для доступа к данным"
|
||
spellCheck={false}
|
||
/>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
Код выполняется на сервере в изолированной среде Node.js. Используйте <code className="bg-muted px-0.5 rounded">ctx</code> для доступа к данным.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Switch
|
||
checked={formRunOffline}
|
||
onCheckedChange={(checked) => {
|
||
setFormRunOffline(checked);
|
||
if (!checked) setFormClientCompatible(false);
|
||
}}
|
||
/>
|
||
<Label className="text-xs">Разрешить офлайн-выполнение в PWA</Label>
|
||
</div>
|
||
|
||
{formRunOffline && (
|
||
<div className="flex items-center gap-2 pl-6">
|
||
<Switch checked={formClientCompatible} onCheckedChange={setFormClientCompatible} />
|
||
<Label className="text-xs">Код безопасен для выполнения на устройстве</Label>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Switch checked={formActive} onCheckedChange={setFormActive} />
|
||
<Label className="text-xs">Активна</Label>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={closeDialog}>
|
||
Отмена
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
className="h-7 text-xs"
|
||
onClick={handleSave}
|
||
disabled={saveMutation.isPending || !formName.trim()}
|
||
>
|
||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|