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»). Параметры экрана берутся из этой задачи.
Реализация:
- URL экрана содержит query-параметр с id задачи:
/timeline-events/vacations-2026?tasks=12345. - Имя параметра (
tasks,taskId,containerId— любое) — это конвенция SmartScript, фронт не знает про конкретное имя. - SmartScript публикации-конфига читает этот параметр:
DECLARE @containerTaskId INT = JSON_VALUE(@eventParam0, '$.queryString.tasks');
- По задаче-контейнеру SmartScript определяет:
- заголовок экрана (текст задачи, ДП «Год», ДП «Подразделение»);
- период (даты ДП «Период с/по»);
- выборку событий (исполнители категории отпусков, входящие в подразделение из задачи);
-
права на действия (статус задачи, текущий участник цикла согласования).
-
То же значение передаётся в 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 отчёта-фильтра, не публикации. Если указано, в правом верхнем углу появляется иконка фильтра, по клику открывается модальное окно с параметрами этого отчёта.
Логика:
- Фронт запрашивает схему фильтра:
GET /app/v1.0/api/filters/config/report/<id>. - По схеме рендерится форма с типизированными полями (даты, лукапы, селекты).
- Пользователь задаёт значения и применяет фильтр.
- Значения уходят в 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 — служебные для фронта; их можно игнорировать на бэкенде.
- 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" } ]
}
}
Поведение:
- Пользователь раскрывает группу «Отдел продаж» и видит отрезки отпусков сотрудников.
- Включает фильтр: оставляет только должность «Руководитель подразделения».
- Выделяет два отрезка чекбоксами; в верхней панели появляются кнопки «Согласовать» / «Отклонить».
- Нажимает «Согласовать» → диалог с полем «Причина» → подтверждает.
- Цвет выделенных отрезков меняется на «Запланирован» после возврата ответа от 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. Отчёт-фильтр (опционально)¶
Если экрану нужна интерактивная фильтрация:
- Создаётся отчёт-фильтр обычным способом (через настройку отчётов).
- ID отчёта-фильтра прописывается в
config.filter. - 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=).
Контрольная проверка:
- Открыть URL анонимно (или под тестовым пользователем без прав) — должна быть ошибка 401/403.
- Открыть под пользователем с правами — должен загрузиться экран с заголовком и шкалой.
- Раскрыть группу — увидеть отрезки событий.
- Выделить событие чекбоксом — увидеть кнопки массовых действий.
- Нажать кнопку → подтвердить → дождаться обновления шкалы.
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 |