Перейти к содержанию

Timeline Events — UI-клиент action-публикаций

Слой: frontend домена publications.

1. Назначение

Timeline Events — это специальный сценарий action-публикации, при котором её JSON-ответ потребляется встроенным SPA-видом: таблицей с горизонтальной шкалой времени, на которой события отображаются как цветные отрезки с группировкой строк, фильтрацией и кастомными кнопками массовых действий.

Сценарии использования: - График отпусков сотрудников (строки — сотрудники, отрезки — отпуска, цвет — статус, кнопки — согласовать/отклонить). - Расписание дежурств, командировок, обучений. - Распределение ресурсов: техника, переговорные, аудитории по дням. - Производственный план: партии × этапы. - Roadmap инициатив по командам.

Когда не подходит: - Нужны иерархия задач и зависимости — используется Гант категории. - События привязаны к календарным датам системы (CalendarEvents) — используется штатный календарь. - Данные не требуют временной шкалы — используется грид-представление.

2. Архитектура

Цепочка запросов при открытии экрана:

Browser
  │  GET /timeline-events/<alias>?<queryParams>
  ▼
SPA-страница
  │  GET /app/v1.2/api/publications/action/<alias>?<queryParams>
  ▼
config-публикация (action)
  │  возвращает JSON-конфиг экрана
  ▼
View-компонент рендерится, дальше по конфигу:
  │  POST <config.dataUrl>     ← обычно тоже action-публикация (data)
  │       тело: { date, filters }
  │       ответ: events + dictionaries
  │
  │  GET /app/v1.0/api/filters/config/report/<config.filter>
  │       (только если в конфиге задано поле filter)
  │       ответ: схема отчёта-фильтра
  │
  │  POST <config.buttons[].url>     ← action-публикации действий
  │       (по клику пользователя)
  │       тело: { events, promptData }
  │       ответ: 200/204 → фронт перезапрашивает data

Что является публикацией: - config — обязательная action-публикация (точка входа экрана). - data — обычно action-публикация; технически можно указать любой URL, отдающий JSON в нужном формате. - buttons — обычно action-публикации; технически любой POST-эндпоинт.

Что не является публикацией: - Схема фильтра — отдельный API отчётов-фильтров. - Сама страница и view-компонент — встроенные части SPA, не настраиваются.

3. Точка входа

3.1 URL и параметры

Формат:

/timeline-events/<alias>?<param1>=<value1>&<param2>=<value2>...

<alias> — alias action-публикации, отдающей конфиг. Этот alias играет роль идентификатора экрана: один alias = один экран Timeline Events. Чтобы появился новый экран — создаётся новая публикация со своим alias.

Все query-параметры из URL прозрачно пробрасываются в публикацию-конфиг и попадают в SmartScript в eventParam0.queryString. Это используется для: - передачи контекста (id задачи-контейнера, фильтра, периода); - ветвления логики SmartScript; - проверки прав через SmartExpression на ExternalObject.

3.2 Привязка к задаче-контейнеру

Типовой паттерн — экран Timeline Events открывается из карточки задачи-контейнера (например, HR-задачи «Согласование графика отпусков 2026»). Параметры экрана берутся из этой задачи.

Реализация:

  1. URL экрана содержит query-параметр с id задачи: /timeline-events/vacations-2026?tasks=12345.
  2. Имя параметра (tasks, taskId, containerId — любое) — это конвенция SmartScript, фронт не знает про конкретное имя.
  3. SmartScript публикации-конфига читает этот параметр:
DECLARE @containerTaskId INT = JSON_VALUE(@eventParam0, '$.queryString.tasks');
  1. По задаче-контейнеру SmartScript определяет:
  2. заголовок экрана (текст задачи, ДП «Год», ДП «Подразделение»);
  3. период (даты ДП «Период с/по»);
  4. выборку событий (исполнители категории отпусков, входящие в подразделение из задачи);
  5. права на действия (статус задачи, текущий участник цикла согласования).

  6. То же значение передаётся в data-публикацию через query-параметр в dataUrl:

"dataUrl": "/app/v1.2/api/publications/action/vacations-data?tasks=12345"

Контроль доступа: на ExternalObject публикации-конфига настраивается SmartExpression, проверяющий права пользователя на задачу-контейнер. Тогда экран не открывается у тех, у кого нет доступа к задаче.

Откуда пользователь попадает на экран: - ДП-ссылка на карточке задачи-контейнера (тип «Ссылка», в шаблоне URL — tasks={TaskID}). - Кнопка панели задачи с переходом на URL экрана. - JS-include на карточке задачи с программным открытием. - Ссылка с портала (без подстановки taskId — для общесистемного экрана).

4. Контракт config-публикации

config-публикация (тип action, метод GET) должна вернуть JSON-конфиг экрана.

Поля конфига:

Поле Тип Обяз. Описание
title string да Заголовок экрана над таблицей
date object да Период отображения: { start, end } в формате YYYY-MM-DD
year number да Начальный год для шкалы и логики переключения периодов
columns array да Описание колонок таблицы (см. 4.1)
groups array of string да Ключи колонок для группировки строк; минимум один существующий key колонки (см. 4.1 — «Группировка обязательна»)
dataUrl string да URL data-публикации (POST), отдающей события и словари
buttons array нет Кнопки массовых действий над выделенными событиями (см. 4.2). Если кнопки не нужны — рекомендуется передавать []; при undefined в консоли появится TypeError при выделении события (визуально шкала не ломается, кнопок просто не будет)
filter number нет ID отчёта-фильтра для интерактивной фильтрации (см. 4.3)
colorLegend array нет Расшифровка цветов событий (см. 4.4)
eventTooltipTemplate string нет Mustache-шаблон тултипа события (см. 4.4)

Минимальный пример:

{
  "title": "График отпусков",
  "date": { "start": "2026-01-01", "end": "2026-12-31" },
  "year": 2026,
  "columns": [
    {
      "key": "worker",
      "name": "Сотрудник",
      "type": "dict",
      "position": "start",
      "options": { "dict": { "key": "info.userId", "source": "users" } }
    },
    {
      "key": "orgUnit",
      "name": "Подразделение",
      "type": "dict",
      "position": "start",
      "options": { "dict": { "key": "info.orgUnitId", "source": "orgUnits" } }
    }
  ],
  "groups": ["orgUnit"],
  "dataUrl": "/app/v1.2/api/publications/action/vacations-data"
}

4.1 Колонки

Каждая колонка таблицы — объект со следующими полями:

Поле Тип Обяз. Описание
key string да Внутренний ключ колонки; используется в groups и при ссылках
name string да Заголовок колонки в UI
type string да Тип отображения; dict — значение через словарь
position "start" | "end" да Сторона шкалы: слева (start) или справа (end)
width string нет Фиксированная ширина в CSS-формате (например, "160px"); по умолчанию 200px
options.dict.key string да Путь к значению в событии (точечная нотация: info.userId)
options.dict.source string да Имя словаря в dictionaries, по которому ищется значение

Маппинг через словарь работает так: для каждого события компонент берёт значение по options.dict.key (например, event.info.userId = 233422), затем находит в dictionaries[source] запись с этим id и подставляет её value. Если записи нет — ячейка остаётся пустой.

Группировка обязательна. Массив groups в конфиге должен содержать минимум один key существующей колонки. Это ограничение текущей реализации: - если groups отсутствует или равен null — компонент падает на чтении конфига (groups.includes(...)); - если groups равен [] — шкала отрисуется, но события на ней не появятся (внутренняя логика рендера строк требует выбранной группы).

Группирующая колонка указывается в массиве groups по key и не отображается в таблице как обычная колонка — её значения становятся заголовками групп строк. Чтобы избежать ситуации «нужная колонка стала заголовком группы», в columns обычно добавляют отдельную колонку специально под группировку (по подразделению, по проекту, по статусу).

Пример с группировкой по подразделению:

"columns": [
  { "key": "worker",  "name": "Сотрудник",     "type": "dict", "position": "start",
    "options": { "dict": { "key": "info.userId",    "source": "users"    } } },
  { "key": "orgUnit", "name": "Подразделение", "type": "dict", "position": "start",
    "options": { "dict": { "key": "info.orgUnitId", "source": "orgUnits" } } }
],
"groups": ["orgUnit"]

4.2 Кнопки

Кнопки массовых действий располагаются над таблицей и отображаются только тогда, когда среди выделенных событий есть хотя бы одно с подходящим allowedActions.

Поле Тип Обяз. Описание
action string да Код действия; должен совпадать с одним из allowedActions события
name string да Подпись на кнопке
url string да URL action-публикации, обрабатывающей нажатие (POST)
color string нет Цвет фона кнопки (CSS)
textColor string нет Цвет текста кнопки (CSS)
prompt object нет Диалог с дополнительными полями ввода (см. ниже)

Поле prompt описывает модальное окно, открывающееся перед запросом:

"prompt": {
  "title": "Текст резолюции",
  "elements": [
    { "key": "reason", "type": "textarea", "label": "Причина", "required": true }
  ]
}

Поля элемента prompt:

Поле Тип Обяз. Описание
key string да Ключ значения; именно под этим ключом значение попадает в promptData запроса
type string да Тип контрола (textarea, text, …)
label string да Подпись поля
required boolean нет Обязательное поле; при пустом значении кнопка отправки выдаёт сообщение «Заполните обязательные поля» и запрос не отправляется

Встроенная валидация: для элементов с type: "textarea" и required: true проверяется минимальная длина — короче 4 символов сообщение «Длина текста должна превышать 3 символа». Эта проверка зашита в компонент диалога и не настраивается.

После заполнения формы (или сразу, если prompt не задан) фронт делает POST на url с телом:

{
  "events": [ /* массив выделенных событий */ ],
  "promptData": { "reason": "..." }
}

Семантика action ↔ allowedActions: каждое событие в data несёт массив allowedActions: ["accept", "decline"]. Кнопка с action: "accept" будет отображаться, если среди выделенных событий хотя бы у одного accept есть в allowedActions. При нажатии в публикацию уйдут только те выделенные события, у которых accept присутствует.

4.3 Фильтр

Поле filter в конфиге — это ID отчёта-фильтра, не публикации. Если указано, в правом верхнем углу появляется иконка фильтра, по клику открывается модальное окно с параметрами этого отчёта.

Логика:

  1. Фронт запрашивает схему фильтра: GET /app/v1.0/api/filters/config/report/<id>.
  2. По схеме рендерится форма с типизированными полями (даты, лукапы, селекты).
  3. Пользователь задаёт значения и применяет фильтр.
  4. Значения уходят в data-публикацию в теле POST в поле filters — массив объектов IFilterParamForStore:
{
  "date": { "start": "2026-01-01", "end": "2026-12-31" },
  "filters": [
    {
      "id": 12345,
      "name": "orgUnit",
      "type": "Lookup",
      "value": [15, 16],
      "isRequired": false,
      "filterApplied": true
    },
    {
      "id": 12346,
      "name": "status",
      "type": "Select",
      "value": "approved",
      "isRequired": false,
      "filterApplied": true
    }
  ]
}

SmartScript data-публикации обычно использует только name и value для применения фильтра к выборке. Поля id, type, isRequired, filterApplied — служебные для фронта; их можно игнорировать на бэкенде.

  1. SmartScript data-публикации читает filters и применяет к выборке.

Сами параметры фильтра в config-публикации не описываются — они полностью определяются настройкой отчёта-фильтра.

4.4 Легенда и тултипы

colorLegend — массив объектов под таблицей, расшифровывающий цвета событий:

"colorLegend": [
  { "color": "#03a9f4", "text": "Черновик" },
  { "color": "#4caf50", "text": "На согласовании" },
  { "color": "#ffee58", "text": "Запланирован" },
  { "color": "#9e9e9e", "text": "Использован" }
]

eventTooltipTemplate — строка-шаблон с подстановкой полей события через двойные фигурные скобки:

"eventTooltipTemplate": "Отпуск №{{info.taskId}}\n{{info.description}}\n{{dateFrom}} — {{dateTo}}"

Доступны все поля события на верхнем уровне (dateFrom, dateTo, color, context) и поля в info.*. Если поле отсутствует — подставляется пустая строка.

5. Контракт data-публикации

data-публикация (тип action, метод POST) принимает на вход:

{
  "date": { "start": "2026-01-01", "end": "2026-12-31" },
  "filters": [ { "name": "...", "value": "..." } ]
}

Это тело автоматически формирует фронт по выбранному периоду и значениям фильтра.

В ответ публикация должна вернуть JSON:

{
  "events": [ /* массив событий, см. 5.1 */ ],
  "dictionaries": { /* словари по ключам source, см. 5.2 */ }
}

5.1 События

Каждое событие — объект со следующими полями:

Поле Тип Обяз. Описание
dateFrom string да Начало отрезка в формате YYYY-MM-DD
dateTo string да Конец отрезка в формате YYYY-MM-DD
color string да Цвет отрезка в формате CSS; должен совпадать с одним из colorLegend
context string нет Семантический ярлык ("task", "vacation", ...) — для логики на стороне SmartScript кнопок
allowedActions array of string да Коды действий, доступных для этого события; маппятся на buttons[].action. Минимум — пустой массив []: при undefined фронт падает с TypeError при выделении события или вычислении доступных кнопок
info object условно Произвольная нагрузка для подстановки в колонки и тултипы. Обязательно, если хотя бы одна колонка ссылается на info.* через options.dict.key или eventTooltipTemplate использует {{info.*}} — иначе TypeError при рендере событий. Если ни колонки, ни тултип не используют info.* — поле можно опустить

Поле info — свободная структура. Туда кладётся всё, что упоминается в column.options.dict.key или в eventTooltipTemplate. Например, для колонки сотрудников нужен info.userId, для тултипа с описанием — info.description.

Пример события:

{
  "dateFrom": "2026-08-12",
  "dateTo": "2026-08-25",
  "color": "#4caf50",
  "context": "vacation",
  "allowedActions": ["accept", "decline"],
  "info": {
    "taskId": 11563,
    "userId": 233422,
    "orgUnitId": 15,
    "positionId": 2,
    "description": "Ежегодный оплачиваемый отпуск"
  }
}

5.2 Словари

dictionaries — объект, где ключи — имена словарей (значения column.options.dict.source), значения — массивы записей { id, value }:

"dictionaries": {
  "users": [
    { "id": 233421, "value": "Иванов Иван" },
    { "id": 233422, "value": "Иванов Иван" }
  ],
  "orgUnits": [
    { "id": null, "value": "" },
    { "id": 15,   "value": "Отдел продаж" },
    { "id": 16,   "value": "Отдел маркетинга" }
  ]
}

Запись с id: null используется как «пусто» (применяется, когда поле события содержит null).

Каждая колонка типа dict ссылается на свой словарь. Один словарь может использоваться несколькими колонками. Имена словарей — произвольные строки; единственное требование — совпадение между column.options.dict.source и ключом в dictionaries.

6. Контракт публикаций-кнопок

Каждая кнопка ссылается на отдельную action-публикацию (метод POST). На вход публикация получает:

{
  "events": [
    { /* событие 1, как в 5.1 */ },
    { /* событие 2 */ }
  ],
  "promptData": {
    "reason": "..."
  }
}

events — массив выделенных событий, у которых action кнопки присутствует в allowedActions. promptData — данные из диалога prompt (или null, если prompt не задан).

В ответе публикация может вернуть: - HTTP 200 / 204 — успех; фронт автоматически перезапрашивает data-публикацию и обновляет шкалу. - HTTP 4xx / 5xx — ошибка; на UI показывается стандартное сообщение.

Семантика обработки — целиком на SmartScript-е публикации: проверка прав, обновление состояния задач, отправка уведомлений, запись в журнал и т. п.

7. UX и поведение

Масштаб шкалы. Переключатель неделя / месяц. Текущий выбор сохраняется в localStorage пользователя.

Зум. Кнопки + / пропорционально увеличивают и уменьшают плотность дней по горизонтали; кнопка «Сбросить» возвращает базовый масштаб. Значение сохраняется в localStorage.

Группировка. Если в groups указан ключ колонки, строки группируются по значению этого поля. Каждая группа разворачивается и сворачивается отдельно; кнопка «Развернуть все / Свернуть все» применяется ко всем группам сразу.

Выделение событий. Чекбоксы у строки и у группы. Выделение строки добавляет в выбор все её события. Кнопки массовых действий работают только с выделенным набором.

Подсветка пересечений. При выделении события другие события, пересекающиеся с ним по датам, визуально выделяются — это помогает находить накладки в графике.

Тултип события. При наведении показывается результат подстановки полей события в eventTooltipTemplate.

Горизонтальный скролл. Заголовок шкалы, тело таблицы и нижняя полоса прокрутки синхронизированы — прокрутка любой из них прокручивает всё.

Цветовая легенда. Отображается под заголовком экрана.

Фильтр. Иконка-воронка в правом верхнем углу (если в конфиге задан filter); при изменении значений фильтра data-публикация перезапрашивается автоматически.

8. Жизненный цикл и сетевые запросы

Триггер Запрос Назначение
Открытие URL /timeline-events/<alias>?... GET /app/v1.2/api/publications/action/<alias>?... Получение конфига экрана
Конфиг получен POST <config.dataUrl> с телом { date, filters: [] } Первоначальная загрузка событий и словарей
В конфиге указан filter GET /app/v1.0/api/filters/config/report/<filter> Получение схемы отчёта-фильтра
Изменение значений фильтра POST <config.dataUrl> с обновлёнными filters Перезагрузка событий с фильтром
Клик кнопки массового действия POST <button.url> с телом { events, promptData } Выполнение действия
Успешный ответ кнопки POST <config.dataUrl> (повторно) Обновление шкалы после действия
Смена масштаба, зум, сворачивание групп, скролл Только UI, без HTTP

Период фиксирован. В текущей версии экран открывается с периодом из config.date и пользователь не может его сменить через UI: переключателей года/квартала на форме нет, метод changeDate() существует в коде, но из шаблона не вызывается; кнопки квартальной навигации скрыты за условием view === 'quarter', который недостижим (переключатель режимов работает только в паре неделя ↔ месяц). Если нужен другой период — открыть экран с другим конфигом или другими queryParams.

9. Полный пример: график отпусков

Сценарий. HR ведёт ежегодное согласование графика отпусков. Создана задача-контейнер на 2026 год; в ней указаны период и подразделение. Карточка задачи содержит ДП-ссылку «Открыть график» с URL /timeline-events/vacations-config?tasks={TaskID}.

Что видит пользователь. При открытии экрана отображается таблица: строки — сотрудники, сгруппированные по подразделениям; столбцы слева — ФИО, должность; столбец справа — суммарное число дней; на шкале — отрезки отпусков, цвет соответствует статусу. Над таблицей — кнопки «Согласовать» и «Отклонить», каждая открывает диалог с полем «Причина».

Ответ config-публикации:

{
  "title": "Согласование графика отпусков 2026",
  "year": 2026,
  "date": { "start": "2026-01-01", "end": "2026-12-31" },
  "columns": [
    { "key": "worker",   "name": "Сотрудник",     "type": "dict", "position": "start",
      "options": { "dict": { "key": "info.userId",     "source": "users"     } } },
    { "key": "orgUnit",  "name": "Подразделение", "type": "dict", "position": "start",
      "options": { "dict": { "key": "info.orgUnitId",  "source": "orgUnits"  } } },
    { "key": "position", "name": "Должность",     "type": "dict", "position": "start",
      "options": { "dict": { "key": "info.positionId", "source": "positions" } } },
    { "key": "total",    "name": "Всего",         "type": "dict", "position": "end",
      "options": { "dict": { "key": "info.userId",     "source": "totals"    } } }
  ],
  "groups": ["orgUnit"],
  "dataUrl": "/app/v1.2/api/publications/action/vacations-data?tasks=12345",
  "filter": 7042,
  "eventTooltipTemplate": "Отпуск №{{info.taskId}}\n{{info.description}}\n{{dateFrom}} — {{dateTo}}",
  "colorLegend": [
    { "color": "#03a9f4", "text": "Черновик" },
    { "color": "#4caf50", "text": "На согласовании" },
    { "color": "#ffee58", "text": "Запланирован" },
    { "color": "#9e9e9e", "text": "Использован" }
  ],
  "buttons": [
    {
      "action": "accept", "name": "Согласовать",
      "color": "#2196f3", "textColor": "#fff",
      "url": "/app/v1.2/api/publications/action/vacations-accept",
      "prompt": {
        "title": "Текст резолюции",
        "elements": [{ "key": "reason", "type": "textarea", "label": "Причина" }]
      }
    },
    {
      "action": "decline", "name": "Отклонить",
      "color": "#f44336", "textColor": "#fff",
      "url": "/app/v1.2/api/publications/action/vacations-decline",
      "prompt": {
        "title": "Текст резолюции",
        "elements": [{ "key": "reason", "type": "textarea", "label": "Причина" }]
      }
    }
  ]
}

Ответ data-публикации (фрагмент):

{
  "events": [
    {
      "dateFrom": "2026-08-12",
      "dateTo":   "2026-08-25",
      "color":    "#4caf50",
      "context":  "vacation",
      "allowedActions": ["accept", "decline"],
      "info": {
        "taskId": 11563, "userId": 233422,
        "orgUnitId": 15, "positionId": 2,
        "description": "Ежегодный оплачиваемый отпуск"
      }
    }
  ],
  "dictionaries": {
    "users":     [ { "id": 233422, "value": "Иванов Иван" } ],
    "orgUnits":  [ { "id": 15,     "value": "Отдел продаж" } ],
    "positions": [ { "id": 2,      "value": "Руководитель подразделения" } ],
    "totals":    [ { "id": 233422, "value": "11" } ]
  }
}

Поведение:

  1. Пользователь раскрывает группу «Отдел продаж» и видит отрезки отпусков сотрудников.
  2. Включает фильтр: оставляет только должность «Руководитель подразделения».
  3. Выделяет два отрезка чекбоксами; в верхней панели появляются кнопки «Согласовать» / «Отклонить».
  4. Нажимает «Согласовать» → диалог с полем «Причина» → подтверждает.
  5. Цвет выделенных отрезков меняется на «Запланирован» после возврата ответа от data-публикации.

10. Гайд по настройке

Шаги пройдут от данных к точке входа.

Каркасы SmartScript ниже — минимальные шаблоны. Имена таблиц, ID категорий и состояний нужно адаптировать под конкретную инсталляцию. Возврат HTTP-ответа делается через смарт-действие «HTTP ответ» в пакете публикации.

Шаг 1. data-публикация

Создаётся action-публикация, метод POST, alias по соглашению <тематика>-data (например, vacations-data).

Каркас SmartScript:

DECLARE @body NVARCHAR(MAX) = JSON_QUERY(@eventParam0, '$.body');
DECLARE @containerTaskId INT = JSON_VALUE(@eventParam0, '$.queryString.tasks');
DECLARE @startDate DATE = JSON_VALUE(@body, '$.date.start');
DECLARE @endDate   DATE = JSON_VALUE(@body, '$.date.end');

DECLARE @result NVARCHAR(MAX) = (
    SELECT
        (SELECT
            t.StartDate AS dateFrom,
            t.DueDate   AS dateTo,
            CASE t.StateID
                WHEN 100 THEN '#03a9f4'
                WHEN 200 THEN '#4caf50'
                WHEN 300 THEN '#ffee58'
                ELSE          '#9e9e9e'
            END AS color,
            'vacation' AS context,
            JSON_QUERY(N'["accept","decline"]') AS allowedActions,
            t.TaskID     AS [info.taskId],
            t.OwnerID    AS [info.userId],
            u.OrgUnitID  AS [info.orgUnitId],
            u.PositionID AS [info.positionId],
            t.TaskText   AS [info.description]
         FROM Tasks t
         JOIN Users u ON u.UserID = t.OwnerID
         WHERE t.SubcatID  = 5500          -- ID категории отпусков
           AND t.StartDate <= @endDate
           AND t.DueDate   >= @startDate
         FOR JSON PATH
        ) AS events,
        (SELECT
            (SELECT u.UserID AS id, u.UserName AS value
             FROM Users u WHERE u.IsFired = 0
             FOR JSON PATH) AS users,
            (SELECT o.OrgUnitID AS id, o.OrgUnitName AS value
             FROM OrgUnits o
             FOR JSON PATH) AS orgUnits
         FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
        ) AS dictionaries
    FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);

-- вернуть @result как HTTP-ответ через смарт-действие «HTTP ответ»

Минимально нужно вернуть events[] и dictionaries{}. Какие поля попадут в info, какие словари добавлены — определяется конфигом (см. шаг 2). Имена словарей в JSON-ответе должны точно совпадать со значениями column.options.dict.source в config-публикации.

После создания публикации в ExternalObject настраиваются права (анонимный доступ для тестов, потом — конкретные группы или SmartExpression).

Шаг 2. config-публикация

Создаётся action-публикация, метод GET, alias = идентификатор экрана (например, vacations-config).

Каркас SmartScript:

DECLARE @containerTaskId INT = JSON_VALUE(@eventParam0, '$.queryString.tasks');

DECLARE @title NVARCHAR(255), @year INT, @startDate DATE, @endDate DATE;
SELECT
    @title     = N'График отпусков ' + CAST(YEAR(t.StartDate) AS NVARCHAR(4)),
    @year      = YEAR(t.StartDate),
    @startDate = t.StartDate,
    @endDate   = t.DueDate
FROM Tasks t WHERE t.TaskID = @containerTaskId;

DECLARE @config NVARCHAR(MAX) = (
    SELECT
        @title AS title,
        @year  AS year,
        (SELECT @startDate AS [start], @endDate AS [end]
         FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS [date],
        JSON_QUERY(N'[
            {"key":"worker","name":"Сотрудник","type":"dict","position":"start",
             "options":{"dict":{"key":"info.userId","source":"users"}}},
            {"key":"orgUnit","name":"Подразделение","type":"dict","position":"start",
             "options":{"dict":{"key":"info.orgUnitId","source":"orgUnits"}}}
        ]') AS columns,
        JSON_QUERY(N'["orgUnit"]') AS groups,
        N'/app/v1.2/api/publications/action/vacations-data?tasks=' +
            CAST(@containerTaskId AS NVARCHAR(20)) AS dataUrl,
        JSON_QUERY(N'[
            {"action":"accept","name":"Согласовать","color":"#2196f3","textColor":"#fff",
             "url":"/app/v1.2/api/publications/action/vacations-accept"}
        ]') AS buttons
    FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
);

-- вернуть @config как HTTP-ответ через смарт-действие «HTTP ответ»

Ключевое: - dataUrl собирается с тем же tasks=<id>, чтобы data-публикация работала в том же контексте. - Имена словарей (source) должны совпадать с теми, что вернёт data-публикация. - Если используются кнопки — их action должен совпадать с allowedActions событий.

Шаг 3. Публикации-кнопки (опционально)

Для каждого действия — отдельная action-публикация (метод POST), alias по соглашению <тематика>-<действие> (например, vacations-accept).

Каркас SmartScript:

DECLARE @body NVARCHAR(MAX) = JSON_QUERY(@eventParam0, '$.body');
DECLARE @reason NVARCHAR(MAX) = JSON_VALUE(@body, '$.promptData.reason');

DECLARE @taskIds TABLE (TaskID INT);
INSERT INTO @taskIds (TaskID)
SELECT JSON_VALUE(e.value, '$.info.taskId')
FROM OPENJSON(@body, '$.events') e;

-- здесь выполнить бизнес-логику над задачами из @taskIds:
-- сменить состояние через смарт-действие «Изменить состояние задачи»
--   (прямой UPDATE Tasks обходит триггеры и журналирование);
-- добавить комментарий с резолюцией через смарт-действие «Добавить комментарий»
--   (прямой INSERT INTO TaskComments не рекомендуется — обходит правила и уведомления);
-- отправить уведомления;
-- записать аудит в журнал.

-- вернуть HTTP 204 через смарт-действие «HTTP ответ»

В теле обработки — любая бизнес-логика: смена статуса, добавление комментариев, запись в журнал, отправка уведомлений. Все мутации задач и комментариев — через соответствующие смарт-действия пакета, а не прямыми SQL-командами. Возвращать 200 или 204 — фронт после успеха перезапросит data-публикацию.

Шаг 4. Отчёт-фильтр (опционально)

Если экрану нужна интерактивная фильтрация:

  1. Создаётся отчёт-фильтр обычным способом (через настройку отчётов).
  2. ID отчёта-фильтра прописывается в config.filter.
  3. data-публикация дорабатывается: читает массив filters из тела запроса и применяет к выборке.

Минимальная обработка фильтра в data-публикации:

DECLARE @filters NVARCHAR(MAX) = JSON_QUERY(@body, '$.filters');

DECLARE @orgUnitIds TABLE (id INT);
INSERT INTO @orgUnitIds (id)
SELECT v.value
FROM OPENJSON(@filters) f
CROSS APPLY OPENJSON(f.value, '$.value') v
WHERE JSON_VALUE(f.value, '$.name') = 'orgUnit';

-- далее в основном запросе добавить условие:
--   AND (NOT EXISTS (SELECT 1 FROM @orgUnitIds)
--        OR u.OrgUnitID IN (SELECT id FROM @orgUnitIds))

Шаг 5. ExternalObject и доступ

На каждой из созданных публикаций (config, data, кнопки) настраиваются права через ExternalObject: - для тестирования — visibleToAnyone: true; - для прода — конкретные группы доступа или SmartExpression на основе задачи-контейнера.

Если используется задача-контейнер, типичный SmartExpression на конфиге: - получить taskId из query; - проверить, что текущий пользователь — участник цикла согласования или имеет доступ к задаче.

Шаг 6. Сборка ссылки и проверка

Финальная ссылка на экран:

/timeline-events/<alias-config-публикации>?<имя>=<id-задачи-контейнера>

Например:

/timeline-events/vacations-config?tasks=12345

Эта ссылка размещается: - в ДП-ссылке «Открыть график» на карточке задачи-контейнера (tasks={TaskID} подставляется автоматически); - в кнопке панели задачи; - на портальной странице (с фиксированным или динамическим tasks=).

Контрольная проверка:

  1. Открыть URL анонимно (или под тестовым пользователем без прав) — должна быть ошибка 401/403.
  2. Открыть под пользователем с правами — должен загрузиться экран с заголовком и шкалой.
  3. Раскрыть группу — увидеть отрезки событий.
  4. Выделить событие чекбоксом — увидеть кнопки массовых действий.
  5. Нажать кнопку → подтвердить → дождаться обновления шкалы.

11. Типовые ошибки и проверки

Симптом Вероятная причина Что проверить
Открытие URL → редирект на /svc/error config-публикация не найдена / неактивна / тип не Action Alias в URL совпадает с alias публикации; isActive: true; publicationType: Action
Открытие URL → 401 / 403 Нет прав в ExternalObject публикации-конфига Группы доступа или SmartExpression; на время отладки — visibleToAnyone: true
Экран открылся, но шкала пустая data.events[] пуст или события вне периода config.date Соответствие периодов; вызвать data-публикацию напрямую через curl
Шкала отрисована, но событий не видно ни в одной группе config.groups пустой или указан key, которого нет в columns Указать в groups минимум один существующий key колонки (см. §4.1 — «Группировка обязательна»)
Открытие URL → JS-ошибка Cannot read properties of undefined (reading 'includes') config.groups отсутствует или равен null Передать массив (минимум ["<key>"]) — поле обязательное в текущей реализации
Колонки отображаются, но значения пустые Имя словаря в column.options.dict.source не совпадает с ключом в dictionaries Совпадение строк; регистр; отсутствие пробелов
Колонка не отображается совсем Указана в groups (становится заголовком группы, а не колонкой) Убрать из groups или добавить отдельную колонку под группировку
Тултип события пустой или с шаблонным текстом В info нет полей, на которые ссылается eventTooltipTemplate Добавить поля в info data-публикации
Кнопки массовых действий не появляются action кнопки не пересекается с allowedActions событий Совпадение строк действий
Кнопка появляется, но при клике 404 / 401 URL кнопки указывает на несуществующую публикацию или нет прав Проверить URL и ExternalObject публикации-кнопки
Диалог prompt не закрывается при «Отправить» Пустое обязательное поле или textarea короче 4 символов (встроенная валидация) Заполнить required-поля; для обязательных textarea ввести минимум 4 символа
Фильтр не открывается config.filter указывает на несуществующий или удалённый отчёт Проверить отчёт; вызвать схему напрямую: GET /app/v1.0/api/filters/config/report/<id>
Фильтр применяется, но не влияет на выборку data-публикация не читает body.filters Доработать SmartScript
После клика кнопки шкала не обновилась Публикация-кнопка вернула 4xx / 5xx или зависла Проверить лог публикации в AutomationScriptsLog
Цвета событий не соответствуют легенде event.color не совпадает с цветами в colorLegend Согласовать строки цветов
На экране «чужие» данные Публикации не учитывают queryString.tasks (контейнер не влияет на выборку) Добавить чтение taskId в SmartScript обеих публикаций
Пользователь без прав на задачу-контейнер видит экран На ExternalObject не настроен SmartExpression Добавить проверку прав на tasks в SmartExpression