Files
iistwin/client/src/pages/Automations.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

576 lines
27 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 } 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>
);
}