# Timeline Events — экранный вид action-публикаций

Timeline Events — специальный сценарий action-публикации, при котором JSON-ответ рендерится встроенным SPA-видом: таблицей с горизонтальной шкалой времени, цветными отрезками событий, группировкой строк, фильтрацией и кнопками массовых действий. Типовые применения: график отпусков, расписание дежурств, распределение ресурсов, производственный план, roadmap инициатив. Документ описывает контракт config- и data-публикаций, архитектуру запросов и пошаговый гайд по настройке. Для разработчиков SmartScript и интеграторов.

> Клиентская часть (интерфейс) домена «Публикации».

## 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 и query-параметры

Формат:

```
/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 публикации-конфига читает этот параметр:

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

4. По задаче-контейнеру SmartScript определяет:
   - заголовок экрана (текст задачи, ДП «Год», ДП «Подразделение»);
   - период (даты ДП «Период с/по»);
   - выборку событий (исполнители категории отпусков, входящие в подразделение из задачи);
   - права на действия (статус задачи, текущий участник цикла согласования).

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

   ```json
   "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-конфиг экрана.

### 4.1 Спецификация полей config-публикации

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

| Поле | Тип | Обяз. | Описание |
|------|-----|-------|----------|
| `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) |

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

```json
{
  "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` обычно добавляют отдельную колонку специально под группировку (по подразделению, по проекту, по статусу).

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

```json
"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`** описывает модальное окно, открывающееся перед запросом:

```json
"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` с телом:

```json
{
  "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`:

   ```json
   {
     "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` — служебные для интерфейса; их можно игнорировать на серверной части.

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

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

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

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

```json
"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) принимает на вход:

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

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

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

```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`.

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

```json
{
  "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 }`:

```json
"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). На вход публикация получает:

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

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

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

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

## 7. Поведение экрана Timeline Events

**Масштаб шкалы.** Переключатель неделя / месяц. Текущий выбор сохраняется в 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`, и пользователь не может его сменить через интерфейс — переключателей года/квартала на форме нет (режимы переключаются только в паре неделя ↔ месяц). Если нужен другой период — открыть экран с другим конфигом или другими `queryParams`.

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

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

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

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

```json
{
  "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": "Причина" }]
      }
    }
  ]
}
```

### 9.1 Ответ data-публикации и пошаговое поведение

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

```json
{
  "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. Гайд по настройке Timeline Events

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

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

### Шаг 1. Создание data-публикации

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

**Каркас SmartScript:**

```sql
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:**

```sql
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:**

```sql
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-публикации:**

```sql
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;
- проверить, что текущий пользователь — участник цикла согласования или имеет доступ к задаче.

Без настроенного ExternalObject публикация недоступна из браузера — возвращается 401 или 403. Начинать отладку рекомендуется с `visibleToAnyone: true`, затем переключаться на конкретные группы.

### Шаг 6. Сборка ссылки и контрольная проверка

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

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

Например:

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

**Эта ссылка размещается:**

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

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

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

### Типовые ошибки и проверки Timeline Events

Наиболее частые проблемы при настройке и эксплуатации экрана.

| Симптом | Вероятная причина | Что проверить |
|---------|-------------------|---------------|
| Открытие 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 |
