sync: latest changes before Gitea migration

This commit is contained in:
Агальцов Антон
2026-04-26 17:09:31 +03:00
parent c2cc439fc5
commit acba5dd5f1
148 changed files with 13001 additions and 696 deletions
+11
View File
@@ -0,0 +1,11 @@
---
title: "Leadera"
weight: 1
---
# Leadera
SaaS-система управления IT-ландшафтом с диаграммами Ганта.
- **Прод:** [leadera.pro](https://leadera.pro)
- **Дев:** [sand.a2v.space](https://sand.a2v.space)
@@ -0,0 +1,103 @@
---
title: "Leadera — Экспорт диаграммы Ганта в Excel"
date: 2026-04-09
lastmod: 2026-04-09
tags: ["leadera", "gantt", "excel", "export"]
weight: 120
---
# Экспорт диаграммы Ганта в Excel
**Дата:** 09.04.2026
**Статус:** Требования
**Проект:** Leadera
---
## Общее описание
Пользователь нажимает кнопку экспорта в интерфейсе диаграммы Ганта. Система запрашивает диапазон дат выгрузки (по умолчанию — текущий видимый диапазон из интерфейса). После указания диапазона скачивается Excel-файл с визуальным представлением диаграммы Ганта.
---
## Вводные данные
- **Источник:** текущая диаграмма Ганта проекта (один проект = одна диаграмма)
- **Диапазон дат:** пользователь указывает начальную и конечную дату. По умолчанию подставляется видимый в момент запроса диапазон из интерфейса
- **Масштаб таймлайна:** берётся тот, который выбран пользователем в интерфейсе в момент запроса (не сохранённый в системе, а текущий). Варианты: по ячейкам или по дням
---
## Структура Excel-файла
### Шапка (первые строки)
- Название диаграммы (название проекта)
- Диапазон дат, в рамках которого сделана выгрузка
### Лист — один
Вся диаграмма размещается на одном листе.
### Секции
- Секции отображаются как **визуальная группировка строк** (не отдельные листы)
- Название секции — строка-заголовок над группой задач
### Задачи
Каждая задача — отдельная строка. Данные задачи:
- **Название** (текстовая колонка слева)
- **Дата начала** (текстовая колонка слева)
- **Дата конца** (текстовая колонка слева)
- **Цвет** — ячейки таймлайна, соответствующие задаче, закрашены в цвет задачи
### Таймлайн
- Таймлайн представлен колонками дат в верхней части (горизонтальная ось времени)
- Масштаб колонок соответствует выбранному пользователем в момент экспорта (по ячейкам / по дням)
- Задачи отображаются как **merged cells** по соответствующим колонкам дат — визуальная полоса диаграммы Ганта
### Вехи
- Отображаются **отдельной строкой** (как задача, но с маркировкой вехи)
- Позиционируются на соответствующей дате в таймлайне
### Зависимости
- **Не отображаются** (стрелочки/связи между задачами не нужны)
---
## Пользовательский сценарий
1. Пользователь открывает диаграмму Ганта проекта
2. Настраивает масштаб таймлайна (по ячейкам / по дням)
3. Нажимает кнопку «Экспорт в Excel»
4. Система показывает диалог с выбором диапазона дат (предзаполнен текущим видимым диапазоном)
5. Пользователь подтверждает / корректирует диапазон
6. Система генерирует Excel-файл и инициирует скачивание
---
## Технические требования
### Backend (Go API)
- Новый endpoint для генерации Excel (например, `GET /api/v1/gantt/:projectId/export`)
- Query-параметры: `dateFrom`, `dateTo`, `scale` (cell|day)
- Ответ: binary Excel-файл (`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`)
- Авторизация: обязательна (только участники проекта)
- Библиотека для генерации Excel: `excelize` (Go)
### Frontend (Angular)
- Кнопка экспорта в тулбаре диаграммы Ганта
- Модальное окно выбора диапазона дат с datepicker'ами
- Предзаполнение диапазона из текущего видимого viewport'а
- Скачивание файла через `Blob` / `saveAs`
### Фильтрация данных
- Выгружаются все задачи и вехи, которые:
- Начинаются в диапазоне, ИЛИ
- Заканчиваются в диапазоне, ИЛИ
- Начинаются до диапазона и заканчиваются после (перекрывают целиком)
- Секции включаются, если в них есть хотя бы одна попавшая задача
---
## Открытые вопросы
_(Нет)_
+278
View File
@@ -0,0 +1,278 @@
---
title: "Leadera — Фильтры и реорганизация списка диаграмм Ганта"
date: 2026-04-07
lastmod: 2026-04-07
tags: ["leadera", "gantt", "api", "frontend", "requirements"]
weight: 120
---
# Фильтры и реорганизация списка диаграмм Ганта
**Дата:** 07.04.2026
**Статус:** Требования
**Проект:** Leadera (dev — sand.a2v.space)
---
## 1. Постановка задачи
Список диаграмм Ганта должен показывать **все доступные пользователю диаграммы** из всех пространств (spaces), с возможностью фильтрации и поиска. Сейчас список привязан к одному пространству — нужно убрать эту зависимость и добавить удобный toolbar.
---
## 2. Изменения API
### 2.1. Новый endpoint списка диаграмм
**Было:**
```
GET /api/v1/spaces/{spaceId}/gantt-charts?page=1&per_page=10
```
**Стало:**
```
GET /api/v1/gantt-charts?page=1&per_page=10
```
Пользователь видит все диаграммы из **всех пространств**, где он является подтверждённым участником (статус `accepted`).
### 2.2. Параметры фильтрации и поиска
| Параметр | Тип | Описание |
|----------|-----|----------|
| `page` | int | Номер страницы (default: 1) |
| `per_page` | int | Записей на странице (default: 10) |
| `space_id` | UUID (optional) | Фильтр по конкретному пространству |
| `created_by` | UUID (optional) | Фильтр по создателю диаграммы |
| `search` | string (optional) | Поиск по назвению и описанию (ILIKE, case-insensitive) |
| `sort` | string (optional) | Сортировка: `created_at`, `updated_at`, `title` (default: `updated_at`) |
| `order` | string (optional) | `asc` / `desc` (default: `desc`) |
**Примеры:**
```
GET /api/v1/gantt-charts?search=миграция
GET /api/v1/gantt-charts?space_id=0b334ded-...&created_by=a1b2c3d4-...
GET /api/v1/gantt-charts?page=2&per_page=20&sort=title&order=asc
```
### 2.3. Изменение структуры ответа
Добавить поле `space` в каждую диаграмму:
```json
{
"data": [
{
"id": "uuid",
"title": "Миграция backend",
"description": "...",
"created_by": "uuid",
"created_by_name": "Антон Агальцов",
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-07T09:00:00Z",
"space": {
"id": "uuid",
"name": "IT Ландшафт",
"color": "#c4623a"
},
"is_archived": false
}
],
"pagination": {
"page": 1,
"per_page": 10,
"total": 42,
"total_pages": 5
}
}
```
### 2.4. Endpoint для списка фильтров
Вспомогательный endpoint для заполнения dropdown-фильтров:
```
GET /api/v1/gantt-charts/filters
```
**Ответ:**
```json
{
"spaces": [
{ "id": "uuid", "name": "IT Ландшафт", "color": "#c4623a" },
{ "id": "uuid", "name": "DevOps", "color": "#27ae60" }
],
"creators": [
{ "id": "uuid", "display_name": "Антон Агальцов" },
{ "id": "uuid", "display_name": "Иван Петров" }
]
}
```
**Логика `creators`:** уникальные создатели всех диаграмм, доступных текущему пользователю. Получается через DISTINCT-запрос по `created_by` с JOIN на users.
### 2.5. Backend — изменения в Go
**Файлы для изменения:**
#### `internal/handlers/gantt_handler.go`
- Добавить новый handler `ListAllGanttCharts` — без привязки к space
- Зарегистрировать маршрут `GET /gantt-charts` (без `/spaces/:id/`)
- Парсить query-параметры: `space_id`, `created_by`, `search`, `sort`, `order`
- Старый маршрут `GET /spaces/:id/gantt-charts` — оставить для обратной совместимости (или удалить, если не используется)
#### `internal/service/gantt_service.go`
- Новый метод `ListAll(ctx, userId, filters)` — запрашивает все spaces пользователя (через `space_member` с `status=accepted`), затем выбирает все gantt_charts из этих spaces с фильтрами
#### `internal/repository/gantt_repository.go`
- Новый метод с динамическим SQL:
- Базовый JOIN: `gantt_charts``spaces``space_members`
- WHERE: `space_members.user_id = $1 AND space_members.status = 'accepted'`
- Опциональные фильтры: `AND space_id = $N`, `AND created_by = $N`, `AND (title ILIKE '%' || $N || '%' OR description ILIKE '%' || $N || '%')`
- Возвращать поле `space` (id, name, color) через JOIN
#### `internal/dto/gantt/`
- Новые DTO: `GanttChartListFilters`, `GanttChartWithSpaceResponse`, `GanttFiltersResponse`
**SQL (пример):**
```sql
SELECT gc.*, s.id as space_id, s.name as space_name, s.color as space_color,
u.display_name as created_by_name
FROM gantt_charts gc
JOIN spaces s ON s.id = gc.space_id
JOIN space_members sm ON sm.space_id = s.id
LEFT JOIN users u ON u.id = gc.created_by
WHERE sm.user_id = $1
AND sm.status = 'accepted'
AND ($2::uuid IS NULL OR gc.space_id = $2)
AND ($3::uuid IS NULL OR gc.created_by = $3)
AND ($4::text IS NULL OR gc.title ILIKE '%' || $4 || '%'
OR gc.description ILIKE '%' || $4 || '%')
ORDER BY
CASE WHEN $5 = 'title' THEN gc.title END,
CASE WHEN $5 = 'created_at' THEN gc.created_at::text END,
gc.updated_at DESC
LIMIT $6 OFFSET $7;
```
---
## 3. Изменения Frontend (Angular)
### 3.1. Toolbar фильтров
Добавить toolbar над списком диаграмм:
```
┌──────────────────────────────────────────────────────────────────┐
│ [🔍 Поиск... ] [▼ Пространство] │
│ [▼ Создатель] [▼ Сортировка] [Сбросить фильтры] │
└──────────────────────────────────────────────────────────────────┘
```
**Компоненты:**
- **Строка поиска** — input с debounce (300ms), поиск по назвению и описанию
- **Dropdown «Пространство»** — список из `/gantt-charts/filters`, с цветовой меткой
- **Dropdown «Создатель»** — список уникальных создателей
- **Dropdown «Сортировка»** — по дате обновления, дате создания, названию
- **Кнопка «Сбросить»** — очищает все фильтры
**Состояние фильтров в URL** (query params):
```
/gantt?search=миграция&space_id=xxx&sort=updated_at
```
Это позволяет делиться ссылкой с уже настроенными фильтрами.
### 3.2. Карточка диаграммы — бейдж пространства
На каждой карточке добавить бейдж с названием пространства:
```
┌─────────────────────────────────────┐
│ 🟠 IT Ландшафт │ ← бейдж с цветом пространства
│ Миграция backend │
│ Перевод PHP→Go, этап 2 │
│ Создал: Антон А. · 07.04.2026 │
└─────────────────────────────────────┘
```
### 3.3. Форма создания диаграммы — выбор пространства
В модальном окне создания:
- Dropdown со списком пространств, где пользователь имеет роль **editor** или выше
- Валидация: если пользователь не editor ни в одном пространстве — показать сообщение «Нет доступных пространств для создания»
**Запрос доступных пространств:** переиспользовать существующий `GET /api/v1/spaces` с фильтрацией на клиенте по `role ∈ [owner, admin, editor]`, либо добавить параметр `?min_role=editor` на backend.
### 3.4. Изменение routing
**Было:**
```
/gantt → список гантов текущего пространства
```
**Стало:**
```
/gantt → список всех доступных гантов (из всех пространств)
```
Убрать зависимость от `SpaceStorageService.getSelectedSpaceId()` в списке гантов.
### 3.5. Frontend — файлы для изменения
| Файл | Изменение |
|------|-----------|
| `pages/gantt/services/gantt.service.ts` | Новый метод `listAll(filters)`, `getFilters()` |
| `pages/gantt/gantt.component.ts` | Подключить toolbar, парсинг URL-параметров |
| `pages/gantt/gantt.component.html` | Добавить toolbar, бейджи на карточках |
| `pages/gantt/components/gantt-card/` | Добавить бейдж пространства |
| `pages/gantt/components/create-chart-modal/` | Dropdown выбора пространства |
| `core/models/` | Обновить модели: добавить `space` в GanttChart |
---
## 4. Обратная совместимость
| Endpoint | Статус |
|----------|--------|
| `GET /api/v1/spaces/{id}/gantt-charts` | Сохранить (для детального view внутри пространства) |
| `GET /api/v1/gantt-charts` | **Новый** — глобальный список |
| `GET /api/v1/gantt-charts/filters` | **Новый** — данные для dropdown'ов |
| `POST /api/v1/spaces/{id}/gantt-charts` | Сохранить (создание в контексте пространства) |
| Все CRUD для sections/tasks | Без изменений |
---
## 5. Критерии приёмки
- [ ] Endpoint `GET /api/v1/gantt-charts` возвращает все доступные диаграммы
- [ ] Фильтр по `space_id` — показывает только диаграммы конкретного пространства
- [ ] Фильтр по `created_by` — показывает только диаграммы выбранного создателя
- [ ] Поиск `search` — ищет по title и description (case-insensitive)
- [ ] Dropdown фильтров заполняется реальными данными из `/filters`
- [ ] Карточка диаграммы показывает бейдж пространства с цветом
- [ ] Форма создания предлагает только пространства с ролью ≥ editor
- [ ] Состояние фильтров сохраняется в URL (можно поделиться ссылкой)
- [ ] Старые endpoints (`/spaces/{id}/gantt-charts`) продолжают работать
- [ ] Swagger документация обновлена
---
## 6. Порядок реализации
1. Backend: DTO для новых запросов/ответов
2. Backend: Repository — SQL-запрос с фильтрами + JOIN на spaces
3. Backend: Service — `ListAll`, `GetFilters`
4. Backend: Handler — `ListAllGanttCharts`, `GetGanttFilters`, регистрация маршрутов
5. Backend: Swagger — обновить документацию
6. Frontend: Модели — обновить GanttChart, добавить Space в ответ
7. Frontend: Service — `listAll()`, `getFilters()`
8. Frontend: Toolbar компонент — поиск + dropdown'ы
9. Frontend: Карточка — бейдж пространства
10. Frontend: Модальное окно создания — dropdown пространства
11. Frontend: URL-параметры — синхронизация фильтров с router
12. Тестирование
@@ -0,0 +1,125 @@
---
title: "Leadera — Публичный доступ к диаграмме Ганта"
date: 2026-04-09
lastmod: 2026-04-09
tags: ["leadera", "gantt", "public-access", "sharing"]
weight: 130
---
# Публичный доступ к диаграмме Ганта (Share)
**Дата:** 09.04.2026
**Статус:** Требования
**Проект:** Leadera
---
## Общее описание
Пользователь может создать публичную ссылку на фрагмент диаграммы Ганта, доступную для просмотра без авторизации. Открывший ссылку видит диаграмму в read-only режиме с возможностью масштабирования таймлайна, но без каких-либо элементов управления (создание, редактирование, удаление задач/секций/вех).
---
## Пользовательский сценарий (создатель ссылки)
1. Пользователь открывает диаграмму Ганта проекта
2. Нажимает кнопку «Экспорт» / «Общий доступ»
3. Указывает диапазон дат, который должен отображаться на диаграмме
4. Система генерирует уникальную ссылку (UUID) и показывает её пользователю
5. Пользователь копирует ссылку и передаёт заинтересованным лицам
### Управление ссылками
- Вызывается через меню экспорта → модальное окно «Общий доступ»
- В модальном окне отображается список созданных ссылок с указанием диапазона дат
- Каждую ссылку можно **удалить** (полное удаление из БД, не soft delete)
- Ссылки **нельзя редактировать** — только создавать и удалять
- Ограничений на количество активных ссылок нет
- Статистика просмотров (сколько раз открывали) — **не нужна**
---
## Пользовательский сценарий (зритель по ссылке)
1. Зритель переходит по ссылке (без авторизации)
2. Открывается отдельная страница с диаграммой Ганта
3. Зритель видит:
- Задачи и вехи, попадающие в заданный диапазон
- Секции (заголовки группировок)
- Таймлайн с цветовой раскраской задач
4. Зритель может:
- Масштабировать таймлайн
- Переключать отображение «по ячейкам» / «по дням»
5. Зритель **не может**:
- Создавать, редактировать, удалять задачи / секции / вехи
- Видеть любые контролы управления (кнопки создания, контекстные меню, drag-and-drop и т.д.)
- Авторизоваться через эту страницу
---
## Данные и фильтрация
### Что попадает в представление
Задачи и вехи, у которых хотя бы одно из условий верно:
- Дата начала попадает в диапазон ссылки
- Дата конца попадает в диапазон ссылки
- Задача начинается до диапазона и заканчивается после (полностью перекрывает)
Секции включаются, если в них есть хотя бы одна попавшая задача.
### API возвращает
- Только задачи и вехи, попадающие в диапазон (не всю диаграмму)
- Информацию о секциях, содержащих попавшие задачи
- Минимальный набор полей, необходимый для отрисовки (без внутренних ID пользователей и т.п.)
---
## Технические требования
### Ссылки
- Формат: UUID v4 (каждый раз новый)
- Срок действия: бессрочно (пока не удалена)
- Удаление: **жёсткое** (hard delete из БД)
### Backend (Go API)
#### Таблица БД: `gantt_public_links`
- `id` (UUID, PK)
- `project_id` (FK → projects)
- `date_from` (date)
- `date_to` (date)
- `created_by` (FK → users)
- `created_at` (timestamp)
#### Endpoints
- `POST /api/v1/gantt/:projectId/public-links` — создать ссылку (авторизован, участник проекта)
- Body: `{ dateFrom, dateTo }`
- Response: `{ url: "https://leadera.pro/share/<uuid>" }`
- `GET /api/v1/gantt/:projectId/public-links` — список ссылок проекта (авторизован, участник)
- `DELETE /api/v1/gantt/public-links/:uuid` — удалить ссылку (авторизован, создатель)
- `GET /api/v1/public/gantt/:uuid` — получить данные диаграммы по ссылке (без авторизации)
- Response: задачи, вехи, секции в рамках диапазона
#### Rate limiting
- На публичный endpoint `GET /api/v1/public/gantt/:uuid` — rate limiting для защиты от перебора
- Рекомендация: 60 запросов в минуту на IP
### Frontend (Angular)
#### Модальное окно «Общий доступ»
- Вызывается из меню экспорта
- Содержит:
- Форму создания ссылки (диапазон дат + кнопка «Создать»)
- Список существующих ссылок (диапазон + URL + кнопка «Копировать» + кнопка «Удалить»)
#### Публичная страница `/share/:uuid`
- Отдельный route, **не требует авторизации**
- Отображает диаграмму Ганта в read-only режиме
- Доступны только: масштабирование таймлайна, переключение ячейки/дни
- Все контролы управления скрыты (удалены из шаблона, не просто disabled)
- Минимальный layout — без навигации проекта, без сайдбара
---
## Открытые вопросы
_(Нет)_
+329
View File
@@ -0,0 +1,329 @@
---
title: "Leadera: Вехи (Milestones) на диаграмме Ганта"
date: 2026-04-08
tags: ["leadera", "gantt", "angular", "go", "api", "ux"]
---
## Бизнес-требования
### BR-1: Сущность «Веха» (Milestone)
Веха — это маркер на таймлайне диаграммы Ганта, обозначающий важную дату в проекте. В отличие от задач, веха не имеет длительности и привязана к конкретной дате.
**Поля вехи:**
| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID | Идентификатор |
| gantt_chart_id | UUID | Принадлежность к диаграмме (не к секции!) |
| title | string | Название вехи (обязательное, 1–255 символов) |
| date | date | Дата вехи (без времени, обязательная) |
| color | string | Цвет маркера (HEX, по умолчанию `#dc3545` — красный) |
| created_by | UUID | Кто создал (не отображается в UI, хранится для аудита) |
| updated_by | UUID | Кто обновил (не отображается в UI) |
| created_at | timestamp | Дата создания |
| updated_at | timestamp | Дата обновления |
| deleted_at | timestamp | Soft delete |
**Права доступа:** Создание, редактирование и удаление вех доступно участникам с правами выше «только чтение» (аналогично задачам).
**Особенности:**
- Вех может быть несколько в проекте
- Несколько вех могут иметь одну и ту же дату
- Вехи сортируются строго по дате (по возрастанию)
- Вехи не привязаны к секциям — они отображаются **сквозь все секции**
---
### BR-2: Управление вехами (модальное окно)
**Вызов:** Кнопка «Вехи» рядом с кнопкой «Создать секцию» на странице диаграммы.
**Модальное окно содержит:**
- Заголовок: «Вехи проекта»
- Список всех вех текущей диаграммы (сортировка по дате по возрастанию)
- Каждая веха в списке показывает: цвет (индикатор), название, дату, кнопки «Редактировать» и «Удалить»
- Кнопка «Добавить веху»
**Форма добавления/редактирования вехи:**
- Название — текстовый ввод (обязательное, 1–255 символов)
- Дата — datepicker (обязательное)
- Цвет — выбор цвета (по аналогии с палитрой цветов в задачах)
- По умолчанию цвет = красный (`#dc3545`)
**Удаление:** с подтверждением (confirm dialog).
---
### BR-3: Отображение вех на таймлайне
**Визуальное представление:**
- **Маркер-флажок** (flag marker) на шкале таймлайна
- От маркера вниз идёт **пунктирная вертикальная линия** через все секции (на всю высоту диаграммы)
- Цвет линии и маркера = цвет вехи
**Позиционирование:**
- Веха ставится ровно на свою дату на шкале времени
- Если дата вехи выходит за рамки текущего диапазона задач — таймлайн **расширяется** (аналогично задачам)
- При изменении масштаба (days/weeks/months/quarters) позиция вехи пересчитывается
**Tooltip при наведении:**
- При наведении на маркер/линию — показывается tooltip с названием вехи
- Если на одну дату приходится несколько вех — в tooltip показываются **все** вехи этой даты с их названиями (каждая с индикацией своего цвета)
---
## Технические требования
### База данных
#### Новая таблица: gantt_milestones
```sql
CREATE TABLE gantt_milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gantt_chart_id UUID NOT NULL REFERENCES gantt_charts(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
date DATE NOT NULL,
color VARCHAR(20) NOT NULL DEFAULT '#dc3545',
created_by UUID NOT NULL REFERENCES users(id),
updated_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_gantt_milestones_chart ON gantt_milestones(gantt_chart_id);
CREATE INDEX idx_gantt_milestones_date ON gantt_milestones(date);
```
---
### Backend (Go API)
#### Этап 1: Миграция + Домен
**Промт для агента:**
> Добавь в проект новую таблицу `gantt_milestones`.
>
> 1. Создай миграцию в `cmd/migrate/`:
> - Новая таблица `gantt_milestones` с полями: id (UUID PK), gantt_chart_id (UUID FK → gantt_charts, ON DELETE CASCADE), title (VARCHAR 255 NOT NULL), date (DATE NOT NULL), color (VARCHAR 20 NOT NULL DEFAULT '#dc3545'), created_by (UUID FK → users), updated_by (UUID FK → users), created_at, updated_at, deleted_at. Индексы по gantt_chart_id и date.
>
> 2. Создай доменную модель `GanttMilestone` в `internal/domain/gantt.go`:
> ```go
> type GanttMilestone struct {
> ID UUID `json:"id"`
> GanttChartID UUID `json:"gantt_chart_id"`
> Title string `json:"title"`
> Date time.Time `json:"date"`
> Color string `json:"color"`
> CreatedBy UUID `json:"created_by"`
> UpdatedBy UUID `json:"updated_by"`
> CreatedAt time.Time `json:"created_at"`
> UpdatedAt time.Time `json:"updated_at"`
> DeletedAt *time.Time `json:"deleted_at,omitempty"`
> }
> ```
#### Этап 2: CRUD для GanttMilestones
**Промт для агента:**
> Реализуй полный CRUD для сущности `GanttMilestone` по аналогии с существующими handlers/services/repositories в проекте.
>
> **Роуты:**
> - `GET /api/gantt-charts/:chartId/milestones` — список вех диаграммы
> - `POST /api/gantt-charts/:chartId/milestones` — создать веху
> - `PUT /api/gantt-charts/:chartId/milestones/:milestoneId` — обновить веху
> - `DELETE /api/gantt-charts/:chartId/milestones/:milestoneId` — удалить веху (soft delete)
>
> **Repository:** `internal/repository/gantt_milestone_repository.go`
> - `GetByChartID(ctx, chartID) ([]domain.GanttMilestone, error)` — все неудалённые вехи диаграммы, отсортированные по date ASC
> - `GetByID(ctx, id) (*domain.GanttMilestone, error)`
> - `Create(ctx, milestone) error`
> - `Update(ctx, milestone) error`
> - `Delete(ctx, id) error` — soft delete (set deleted_at)
> - Все запросы — raw SQL через pgx, как в остальных репозиториях проекта
>
> **Service:** `internal/service/gantt_milestone_service.go`
> - Валидация: title (1–255 символов), date (обязательное), color (валидный HEX, default '#dc3545')
> - Проверка прав доступа: пользователь должен иметь права выше «только чтение» в пространстве (space) диаграммы
> - Проверка что chartId существует и не архивирован
>
> **Handler:** `internal/handler/gantt_milestone_handler.go`
> - Swagger-аннотации для всех эндпоинтов
> - Валидация входных данных
> - Ошибки в формате проекта
>
> **DTO:** `internal/dto/gantt/milestones_dto.go`
> ```go
> type CreateGanttMilestoneRequest struct {
> Title string `json:"title" binding:"required,min=1,max=255"`
> Date string `json:"date" binding:"required"`
> Color *string `json:"color" binding:"omitempty,max=20"`
> }
>
> type UpdateGanttMilestoneRequest struct {
> Title *string `json:"title" binding:"omitempty,min=1,max=255"`
> Date *string `json:"date" binding:"omitempty"`
> Color *string `json:"color" binding:"omitempty,max=20"`
> }
>
> type GanttMilestoneResponse struct {
> ID domain.UUID `json:"id"`
> GanttChartID domain.UUID `json:"gantt_chart_id"`
> Title string `json:"title"`
> Date string `json:"date"`
> Color string `json:"color"`
> CreatedByUser *CreatedByUpdatedUser `json:"created_by_user"`
> UpdatedByUser *CreatedByUpdatedUser `json:"updated_by_user"`
> CreatedAt time.Time `json:"created_at"`
> UpdatedAt time.Time `json:"updated_at"`
> }
>
> type GanttMilestonesListResponse struct {
> Milestones []GanttMilestoneResponse `json:"milestones"`
> }
> ```
>
> **Регистрация роутов:** Добавь группу milestone-роутов в роутер рядом с существующими роутами секций/задач. Проверка прав через существующий middleware.
#### Этап 3: Включение вех в ответ диаграммы
**Промт для агента:**
> При запросе данных диаграммы Ганта (полная загрузка) включай связанные вехи в ответ.
>
> 1. Добавь эндпоинт или расширь существующий `GET /api/gantt-charts/:chartId` — включить поле `milestones` с массивом `GanttMilestoneResponse`
> 2. Вехи с `deleted_at IS NOT NULL` не возвращаются
> 3. Вехи сортируются по `date` ASC
> 4. Вехи загружаются вместе с данными диаграммы (tasks, sections) — один запрос при открытии Ганта
---
### Frontend (Angular)
#### Этап FE-1: Модели + Сервис
**Промт для агента:**
> Добавь в проект поддержку вех (milestones) на диаграмме Ганта.
>
> 1. В `pages/gantt/models/` (или соответствующем файле моделей) добавь:
>
> ```typescript
> export interface GanttMilestone {
> id: string;
> gantt_chart_id: string;
> title: string;
> date: string;
> color: string;
> created_by_user?: { id: string; full_name: string };
> updated_by_user?: { id: string; full_name: string };
> created_at: string;
> updated_at: string;
> }
>
> export interface GanttMilestoneCreateRequest {
> title: string;
> date: string;
> color?: string;
> }
>
> export interface GanttMilestoneUpdateRequest {
> title?: string;
> date?: string;
> color?: string;
> }
> ```
>
> 2. Добавь `milestones: GanttMilestone[]` в интерфейс/модель полной загрузки данных диаграммы
>
> 3. Создай сервис `pages/gantt/services/gantt-milestone.service.ts`:
> - Inject HttpClient
> - Методы:
> - `getMilestones(chartId: string): Observable<GanttMilestone[]>`
> - `createMilestone(chartId: string, data: GanttMilestoneCreateRequest): Observable<GanttMilestone>`
> - `updateMilestone(chartId: string, milestoneId: string, data: GanttMilestoneUpdateRequest): Observable<GanttMilestone>`
> - `deleteMilestone(chartId: string, milestoneId: string): Observable<void>`
> - Все методы возвращают Observable с типизированными ответами (по аналогии с GanttChartService)
#### Этап FE-2: Модальное окно управления вехами
**Промт для агента:**
> Создай компонент модального окна для управления вехами диаграммы Ганта.
>
> 1. Создай компонент `pages/gantt/components/milestones-modal/`
>
> 2. **Кнопка вызова:** Добавь кнопку «Вехи» рядом с кнопкой «Создать секцию» в toolbar диаграммы. По клику — открытие модального окна через ng-bootstrap NgbModal.
>
> 3. **Модальное окно** (ng-bootstrap modal):
> - Заголовок: «Вехи проекта»
> - Список вех (сортировка по date ASC):
> - Каждый элемент: цветной кружок (color), название, дата (форматированная), кнопки ✏️ (редактировать) и 🗑️ (удалить с confirm)
> - Кнопка «Добавить веху» внизу списка
> - Если вех нет — сообщение «Вех пока нет. Добавьте первую!»
>
> 4. **Форма добавления/редактирования** (inline в модальном окне):
> - Название — текстовый ввод (обязательное)
> - Дата — ng-bootstrap datepicker (обязательное)
> - Цвет — палитра цветов (использовать тот же компонент/подход, что и для задач)
> - По умолчанию цвет = `#dc3545` (красный)
> - Кнопки «Сохранить» и «Отмена»
>
> 5. При сохранении — вызов соответствующего метода сервиса (create/update)
> 6. При удалении — confirm dialog → вызов deleteMilestone
> 7. После любого изменения — обновить список вех и обновить данные на канвасе (через сервис/GanttStore)
>
> 8. Инжектировать GanttMilestoneService и NgbModal
#### Этап FE-3: Отображение вех на таймлайне (Konva canvas)
**Промт для агента:**
> Отобрази вехи (milestones) на canvas-диаграмме Ганта.
>
> Проект использует Konva.js для рендеринга.
>
> 1. При загрузке данных диаграммы — извлечь массив `milestones`
>
> 2. Для каждой вехи нарисовать на canvas:
> - **Маркер-флажок** (flag marker) на позиции даты вехи на шкале времени
> - Стиль: маленький треугольник или флаг на верхней границе таймлайна
> - Цвет = milestone.color
> - **Пунктирная вертикальная линия** от маркера вниз через все секции (на всю высоту области секций)
> - Стиль: Konva.Line, dash: [5, 5], strokeWidth: 1.5, color = milestone.color, opacity: 0.7
>
> 3. **Позиционирование:**
> - Рассчитать X-позицию по дате вехи с использованием текущей шкалы времени (как для задач)
> - Если дата вехи выходит за текущий диапазон — расширить диапазон (влияет на общую ширину таймлайна)
> - При изменении масштаба (days/weeks/months/quarters) — пересчитать позиции всех вех
> - При скролле — вехи скроллятся вместе с задачами
>
> 4. **Tooltip при наведении:**
> - При наведении на маркер или пунктирную линию — показать Konva.Tooltip или HTML tooltip:
> - Если веха одна на эту дату — показать название
> - Если несколько вех на одну дату — показать список: цветной кружок + название для каждой
>
> 5. **Z-index:**
> - Линии вех рисуются **поверх** фона секций, но **под** задачами
> - Маркеры-флажки — поверх всего (самый высокий z-index)
>
> 6. **Реактивность:**
> - При добавлении/удалении/редактировании вехи — перерисовать только вехи (без полного ре рендера задач)
> - Подписаться на изменения массива milestones через сервис/store
---
## Порядок реализации
| # | Этап | Сторона | Зависимости |
|---|------|---------|-------------|
| 1 | Миграция + Домен | Backend | — |
| 2 | CRUD вех | Backend | Этап 1 |
| 3 | Включение вех в ответ диаграммы | Backend | Этап 2 |
| 4 | Модели + Сервис | Frontend | — (параллельно с backend) |
| 5 | Модальное окно управления вехами | Frontend | Этапы 4 + backend 2 |
| 6 | Отображение вех на таймлайне | Frontend | Этапы 4 + backend 3 |
@@ -0,0 +1,407 @@
---
title: "Leadera: Ограничения секций в диаграмме Ганта"
date: 2026-04-06
tags: ["leadera", "gantt", "angular", "go", "api", "ux"]
---
## Бизнес-требования
### BR-1: Лимит одновременных задач (Concurrency Limit)
У каждой секции появляется параметр **max_concurrent_tasks** (целое число ≥ 0, по умолчанию 0).
**Поведение:**
- Если `max_concurrent_tasks = 3` — в любой данный день в этой секции не может быть больше 3 активных задач одновременно
- Если `max_concurrent_tasks = 0` — ограничение отключено, лимита нет
- Пересечение считается по дате: задача занимает дни с `start_date` по `end_date` включительно. Две задачи «пересекаются», если у них есть хотя бы один общий день
- Ограничение работает **только в рамках одной секции**. Задачи из разных секций не влияют друг на друга
**Срабатывание при:**
1. Создании задачи — если добавление нарушает лимит → Alert
2. Перетаскивании задачи (drag) — если новая позиция нарушает лимит → Alert
3. Растягивании задачи (resize) — если новый размер нарушает лимит → Alert
4. Редактировании задачи (форма) — если изменённые даты нарушают лимит → Alert
**Важно:** Действие **НЕ отменяется**. Задача создаётся/перемещается в любом случае, но пользователь видит предупреждение.
**Текст Alert:** «В секции "[название секции]" на [дата/диапазон] превышен лимит одновременных задач (максимум: N)»
---
### BR-2: Блоки секций (Section Blocks)
У каждой секции появляется набор **блоков** — особый вид записей, отображаемых на таймлайне как заштрихованные области.
**Сущность GanttSectionBlock:**
| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID | Идентификатор |
| section_id | UUID | Принадлежность к секции |
| title | string | Название (например: «Отпуск», «Командировка») |
| start_date | date | Начало интервала (минимум 1 день) |
| end_date | date | Конец интервала |
| block_concurrency | boolean | Запрещать ли пересечение обычных задач с данным блоком |
**Валидация:**
- `end_date` не может быть раньше `start_date`
- Все поля обязательны
- Блок может быть в прошлом
**Визуальное отображение на Ганте:**
- Заштрихованная область на **всю высоту секции** (включая все строки задач в секции)
- Название блока отображается **поверх штриховки с подложкой** (для читаемости)
**Поведение block_concurrency:**
- Если `block_concurrency = true` — при пересечении задачи с данным блоком показывается Alert (аналогично BR-1), но задача **всё равно создаётся/перемещается**
- Если `block_concurrency = false` — блок отображается визуально, но не влияет на задачи
**Срабатывание при:**
1. Создании задачи — пересекает блок с `block_concurrency = true` → Alert
2. Перетаскивании задачи (drag) → Alert
3. Растягивании задачи (resize) → Alert
4. Редактировании задачи (форма) → Alert
**Текст Alert:** «В секции "[название секции]" задача пересекает блок "[название блока]" ([дата] — [дата])»
---
### BR-3: Система Alert-ов
Все предупреждения (от BR-1 и BR-2) выводятся в **единую область** над диаграммой Ганта.
**Поведение:**
- Alert-ы — Bootstrap Alert (warning), **постоянные**
- Пользователь **НЕ может** закрыть Alert вручную
- Alert исчезает **автоматически**, когда текущая дата прошла дату конфликта (конфликт больше не актуален)
- Проверка конфликтов — **только на фронтенде** (на основе загруженных данных: задачи секции, блоки, max_concurrent_tasks)
- Если Alert-ов много — область **сворачиваемая** с счётчиком в бейдже (например: «⚠️ 3 предупреждения»)
- По умолчанию — развёрнуто
- Сворачивание/разворачивание — клик по заголовку области
---
### BR-4: Настройки секции
- Настройки совмещены с **существующим модальным окном редактирования секции** (не отдельное окно)
**Окно создания секции** — добавляется:
- Поле «Макс. одновременных задач» (числовой ввод, 0 = без лимита, по умолчанию 0)
**Окно редактирования секции** — добавляется:
- Поле «Макс. одновременных задач» (числовой ввод, 0 = без лимита)
- Список блоков (таблица/список с CRUD: добавить, редактировать, удалить)
- **Сортировка:** по `start_date` по убыванию (будущее сверху)
- Каждый блок — форма: title, start_date, end_date, block_concurrency (чекбокс: «Запретить пересечение задач»)
- Валидация: end_date >= start_date, все поля заполнены
---
### BR-5: Наследование
- При появлении функционала копирования/дублирования диаграммы — настройки секций (лимит + блоки) наследуются
---
## Технические требования
### База данных
#### Новая таблица: gantt_section_blocks
```sql
CREATE TABLE gantt_section_blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
section_id UUID NOT NULL REFERENCES gantt_sections(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
block_concurrency BOOLEAN NOT NULL DEFAULT false,
created_by UUID NOT NULL REFERENCES users(id),
updated_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT chk_block_dates CHECK (end_date >= start_date)
);
CREATE INDEX idx_gantt_section_blocks_section ON gantt_section_blocks(section_id);
CREATE INDEX idx_gantt_section_blocks_dates ON gantt_section_blocks(start_date, end_date);
```
#### Изменение таблицы: gantt_sections
```sql
ALTER TABLE gantt_sections ADD COLUMN max_concurrent_tasks INTEGER NOT NULL DEFAULT 0;
```
---
### Backend (Go API)
#### Этап 1: Миграция + Домен
**Промт для агента:**
> Добавь в проект новую таблицу `gantt_section_blocks` и колонку `max_concurrent_tasks` в `gantt_sections`.
>
> 1. Создай миграцию в `cmd/migrate/`:
> - Новая таблица `gantt_section_blocks` с полями: id (UUID PK), section_id (UUID FK → gantt_sections, ON DELETE CASCADE), title (VARCHAR 255 NOT NULL), start_date (DATE NOT NULL), end_date (DATE NOT NULL), block_concurrency (BOOLEAN DEFAULT false), created_by (UUID FK → users), updated_by (UUID FK → users), created_at, updated_at, deleted_at. CHECK constraint: end_date >= start_date. Индексы по section_id и (start_date, end_date).
> - ALTER TABLE gantt_sections ADD COLUMN max_concurrent_tasks INTEGER NOT NULL DEFAULT 0.
> 2. Создай доменную модель `GanttSectionBlock` в `internal/domain/gantt.go` со всеми полями таблицы.
> 3. Добавь поле `MaxConcurrentTasks int` в структуру `GanttSection`.
> 4. Обнови маппинг в relevant repository файлах, чтобы новое поле `max_concurrent_tasks` читалось/писалось.
#### Этап 2: CRUD для GanttSectionBlocks
**Промт для агента:**
> Реализуй полный CRUD для сущности `GanttSectionBlock` по аналогии с существующими handlers/services/repositories в проекте.
>
> **Роуты (внутри группы секции):**
> - `GET /api/gantt-charts/:chartId/sections/:sectionId/blocks` — список блоков секции
> - `POST /api/gantt-charts/:chartId/sections/:sectionId/blocks` — создать блок
> - `PUT /api/gantt-charts/:chartId/sections/:sectionId/blocks/:blockId` — обновить блок
> - `DELETE /api/gantt-charts/:chartId/sections/:sectionId/blocks/:blockId` — удалить блок (soft delete)
>
> **Repository:** `internal/repository/gantt_section_block_repository.go`
> - `GetBySectionID(ctx, sectionID) ([]domain.GanttSectionBlock, error)`
> - `GetByID(ctx, id) (*domain.GanttSectionBlock, error)`
> - `Create(ctx, block) error`
> - `Update(ctx, block) error`
> - `Delete(ctx, id) error` — soft delete (set deleted_at)
> - Все запросы — raw SQL через pgx, как в остальных репозиториях проекта
>
> **Service:** `internal/service/gantt_section_block_service.go`
> - Бизнес-логика: валидация dates (end >= start), проверка прав доступа к секции
> - Inject repository
>
> **Handler:** `internal/handler/gantt_section_block_handler.go`
> - Swagger-аннотации для всех эндпоинтов
> - Валидация входных данных
> - Ошибки в формате проекта (GanttErrorResponse)
>
> **Модели запросов:**
> - Create: title (required), start_date (required), end_date (required), block_concurrency (bool, default false)
> - Update: title, start_date, end_date, block_concurrency — все опциональны
#### Этап 3: Обновление настроек секции
**Промт для агента:**
> Обнови CRUD секций (gantt_sections) для поддержки нового поля `max_concurrent_tasks`.
>
> 1. Добавь `max_concurrent_tasks` в `GanttSectionCreateRequest` (опциональное, int ≥ 0, default 0)
> 2. Добавь `max_concurrent_tasks` в `GanttSectionUpdateRequest` (опциональное, int ≥ 0)
> 3. Обнови SQL-запросы в repository: INSERT и UPDATE должны включать `max_concurrent_tasks`
> 4. При GET секции (или списке секций) — возвращать `max_concurrent_tasks` в ответе
> 5. Валидация: значение должно быть ≥ 0
#### Этап 4: Включение блоков в ответ секций
**Промт для агента:**
> При запросе списка секций или одной секции включай связанные блоки в ответ.
>
> 1. Обнови `GET /api/gantt-charts/:chartId/sections/:sectionId` — добавить поле `blocks` с массивом GanttSectionBlock
> 2. Обнови `GET /api/gantt-charts/:chartId/sections` — добавить поле `blocks` для каждой секции
> 3. Блоки с `deleted_at IS NOT NULL` не возвращаются
> 4. Блоки сортируются по start_date по убыванию (будущее сверху)
---
### Frontend (Angular)
#### Этап FE-1: Модели + Сервис
**Промт для агента:**
> Добавь в проект поддержку ограничений секций Ганта.
>
> 1. В `pages/gantt/models/gantt-chart.model.ts` добавь:
>
> ```typescript
> export interface GanttSectionBlock {
> id: string;
> section_id: string;
> title: string;
> start_date: string;
> end_date: string;
> block_concurrency: boolean;
> created_by_user: UserInfo;
> updated_by_user: UserInfo;
> created_at: string;
> updated_at: string;
> }
>
> export interface GanttSectionBlockCreateRequest {
> title: string;
> start_date: string;
> end_date: string;
> block_concurrency?: boolean;
> }
>
> export interface GanttSectionBlockUpdateRequest {
> title?: string;
> start_date?: string;
> end_date?: string;
> block_concurrency?: boolean;
> }
>
> export interface ConflictCheck {
> has_conflicts: boolean;
> conflicts: ConflictItem[];
> }
>
> export interface ConflictItem {
> type: 'concurrency' | 'block';
> section_id: string;
> section_title: string;
> block_title?: string;
> start_date?: string;
> end_date?: string;
> message: string;
> dates?: string[];
> }
> ```
>
> 2. Добавь `max_concurrent_tasks: number` в интерфейс `GanttSection`
> 3. Добавь `blocks: GanttSectionBlock[]` в интерфейс `GanttSection`
> 4. Добавь `max_concurrent_tasks?: number` в `GanttSectionCreateRequest` и `GanttSectionUpdateRequest`
>
> 5. Создай сервис `pages/gantt/services/gantt-section-block.service.ts`:
> - Inject HttpClient
> - Методы: getBlocks(chartId, sectionId), createBlock(chartId, sectionId, data), updateBlock(chartId, sectionId, blockId, data), deleteBlock(chartId, sectionId, blockId)
> - Все методы возвращают Observable с типизированными ответами (по аналогии с GanttChartService)
#### Этап FE-2: Интеграция в модальные окна секции
**Промт для агента:**
> Интегрируй настройки ограничений в существующие модальные окна создания и редактирования секции Ганта.
>
> 1. Найди существующие компоненты модальных окон секции (создание/редактирование).
>
> **Окно создания секции** — добавить:
> - Числовой ввод: «Максимум одновременных задач»
> - Подсказка: «0 = без ограничений»
> - Валидация: число ≥ 0
> - Значение по умолчанию: 0
> - Отправляется в `GanttSectionCreateRequest` как `max_concurrent_tasks`
>
> **Окно редактирования секции** — добавить:
> - Числовой ввод: «Максимум одновременных задач» (аналогично созданию)
> - Секция «Блоки (запретные интервалы)»:
> - Таблица/список существующих блоков с колонками: Название, Начало, Конец, Блокировка (чекбокс/иконка), Действия (редактировать/удалить)
> - **Сортировка:** по `start_date` по убыванию (будущее сверху)
> - Кнопка «Добавить блок» — inline-форма:
> - title (текстовый ввод, обязательный)
> - start_date (datepicker, обязательный)
> - end_date (datepicker, обязательный)
> - block_concurrency (чекбокс: «Запретить пересечение задач»)
> - Валидация: end_date >= start_date, все поля заполнены
> - Редактирование — inline или через ту же форму
> - Удаление — с подтверждением
>
> 2. При открытии окна редактирования — загрузить текущие настройки секции + блоки
> 3. При сохранении — обновить max_concurrent_tasks и сохранить изменения блоков
> 4. Инжектировать GanttChartService и GanttSectionBlockService
#### Этап FE-3: Визуализация блоков на Ганте
**Промт для агента:**
> Отобрази блоки секций (GanttSectionBlock) на canvas-диаграмме Ганта.
>
> Проект использует Konva.js для рендеринга. Основной компонент: `gantt-chart-canvas`.
>
> 1. При загрузке данных диаграммы — извлечь блоки из каждой секции
> 2. В рендерере секций (canvas/timeline-renderer.ts или аналогичный) — для каждого блока:
> - Нарисовать заштрихованную область (diagonal hatch pattern) на **всю высоту секции** (все строки задач в этой секции)
> - Область ограничена по горизонтали: от start_date до end_date (в пикселях по шкале времени)
> - Цвет штриховки: полупрозрачный серый или configurable
> - Поверх штриховки — текст с названием блока (block.title) на подложке (прямоугольник с фоном, чтобы текст читался)
> 3. Блоки рендерятся **до** задач (z-index ниже), чтобы задачи были поверх
> 4. Блоки с `block_concurrency = true` и `false` визуально не отличаются (штриховка одинаковая)
> 5. При скролле/зуме — блоки пересчитываются как обычные элементы таймлайна
#### Этап FE-4: Система Alert-ов
**Промт для агента:**
> Создай компонент для отображения Alert-ов о конфликтах на диаграмме Ганта.
>
> 1. Создай компонент `pages/gantt/components/gantt-conflict-alerts/`
>
> 2. Компонент размещается **над** canvas-диаграммой Ганта
>
> 3. Входные данные: массив `ConflictItem[]` (из модели)
>
> 4. Визуал:
> - Bootstrap Alert (warning) для каждого конфликта
> - Каждый Alert показывает message из ConflictItem
> - **Без кнопки закрытия** — Alert нельзя закрыть вручную
> - Alert исчезает автоматически, когда текущая дата прошла дату конфликта
> - Область **сворачиваемая**: заголовок «⚠️ Предупреждения (N)» — клик сворачивает/разворачивает список
> - По умолчанию — развёрнуто
> - При 0 конфликтов — область скрыта
>
> 5. Состояние хранится в сервисе или в компоненте диаграммы:
> - Добавление нового конфликта → area auto-expands
> - Alert удаляется из массива **автоматически**, когда текущая дата прошла дату конфликта
> - Проверка актуальности конфликтов — при каждой отрисовке / изменении данных
> - Не использовать toast — Alert-ы постоянные
#### Этап FE-5: Проверка конфликтов при операциях с задачами
**Промт для агента:**
> Интегрируй проверку конфликтов во все операции с задачами на диаграмме Ганта.
>
> Проект использует interaction-handler.ts для drag/resize и формы для создания/редактирования.
>
> **Важно:** Проверка конфликтов — **только на фронтенде**, без вызова API. Используются загруженные данные секции (задачи, блоки, max_concurrent_tasks).
>
> 1. Вынеси логику проверки конфликтов `checkConflicts()` в отдельный метод в gantt-canvas.service.ts:
> - Принимает: section (GanttSection — содержит max_concurrent_tasks и blocks), tasks (GanttTask[] — задачи секции), task_id (nullable — исключить текущую задачу), start_date, end_date
> - Логика:
> a. Если section.max_concurrent_tasks > 0: посчитать кол-во задач (исключая task_id), пересекающихся с [start_date, end_date] на каждый день. Если в любой день count > max_concurrent_tasks — добавить конфликт concurrency
> b. Для каждого блока секции с block_concurrency = true, пересекающегося с [start_date, end_date] — добавить конфликт block
> - Возвращает ConflictCheck
>
> 2. После **каждой** успешной операции с задачей (создание, обновление, drag, resize):
> - Вызвать checkConflicts с параметрами задачи
> - Если has_conflicts = true — передать конфликты в GanttConflictAlertsComponent
> - Alert-ы показываются, действие **НЕ отменяется**
>
> 3. Точки интеграции:
> - **Создание задачи** (форма): после успешного POST → checkConflicts → show alerts
> - **Редактирование задачи** (форма): после успешного PUT → checkConflicts → show alerts
> - **Drag задачи** (interaction-handler.ts): после успешного обновления позиции → checkConflicts → show alerts
> - **Resize задачи** (interaction-handler.ts): после успешного обновления размера → checkConflicts → show alerts
>
> 4. При переключении диаграммы или секции — очищать Alert-ы
> 5. Alert-ы автоматически удаляются, когда текущая дата прошла дату конфликта — область скрывается, когда конфликтов не осталось
---
## Порядок реализации
| # | Этап | Сторона | Зависимости |
|---|------|---------|-------------|
| 1 | Миграция + Домен | Backend | — |
| 2 | CRUD блоков | Backend | Этап 1 |
| 3 | Обновление настроек секции | Backend | Этап 1 |
| 4 | Включение блоков в ответ секций | Backend | Этап 2 |
| 5 | Модели + Сервис | Frontend | — (можно параллельно с backend) |
| 6 | Интеграция в модальные окна | Frontend | Этапы 5 + backend 2,3 |
| 7 | Визуализация блоков на Ганте | Frontend | Этапы 5 + backend 4 |
| 8 | Система Alert-ов | Frontend | Этап 5 |
| 9 | Проверка конфликтов при операциях | Frontend | Этапы 5,7,8 |
+746
View File
@@ -0,0 +1,746 @@
---
title: "Leadera — WebSocket для диаграмм Ганта"
date: 2026-04-07
lastmod: 2026-04-07
tags: ["leadera", "websocket", "realtime", "gantt", "architecture"]
weight: 110
---
# WebSocket для диаграмм Ганта — realtime-совместное редактирование
**Дата:** 07.04.2026
**Статус:** Планирование
**Проект:** Leadera (dev — sand.a2v.space)
---
## 1. Постановка задачи
Диаграммы Ганта должны обновляться в реальном времени при совместной работе: если два или более пользователей редактируют один Гант, изменения каждого мгновенно отображаются у остальных — без перезагрузки страницы и ручного обновления.
---
## 2. Архитектура
```
Angular frontend ←── WebSocket ──→ Go backend (Hub) ──→ PostgreSQL
│ │
│ HTTP (мутации) │ Broadcast
└──→ REST API ──→ Service ──→ DB ──→ Hub ──→ все клиенты комнаты
```
**Ключевой принцип:** мутации данных идут через HTTP (REST), WebSocket используется **только для доставки уведомлений**. Сервер — источник истины, клиент не может обойти проверки через WS.
### Комнаты
Каждый открытый Гант — это «комната» с идентификатором `chartId`. При подключении клиент подписывается на комнату. Hub рассылает события всем подписчикам комнаты.
---
## 3. Backend (Go)
### 3.1. Зависимости
```bash
go get github.com/gorilla/websocket
```
### 3.2. Новые файлы — `internal/ws/`
#### `message.go` — типы сообщений
```go
package ws
import "time"
// EventType — тип события
type EventType string
const (
// Данные
EventSectionCreated EventType = "section_created"
EventSectionUpdated EventType = "section_updated"
EventSectionDeleted EventType = "section_deleted"
EventSectionsReordered EventType = "sections_reordered"
EventTaskCreated EventType = "task_created"
EventTaskUpdated EventType = "task_updated"
EventTaskDeleted EventType = "task_deleted"
// Присутствие
EventUserJoined EventType = "user_joined"
EventUserLeft EventType = "user_left"
// Безопасность / доступ
EventAccessRevoked EventType = "access_revoked"
EventChartArchived EventType = "chart_archived"
EventRoleChanged EventType = "role_changed"
EventTokenExpired EventType = "token_expired"
)
// Event — WS-событие, отправляемое клиентам
type Event struct {
Type EventType `json:"type"`
ChartID string `json:"chartId"`
UserID string `json:"userId,omitempty"`
Payload interface{} `json:"payload,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// IncomingMessage — сообщение от клиента (для ping/pong)
type IncomingMessage struct {
Type string `json:"type"`
}
```
#### `hub.go` — центральный брокер
```go
package ws
import (
"sync"
)
// Hub управляет комнатами и рассылкой событий
type Hub struct {
mu sync.RWMutex
rooms map[string]map[*Client]bool // chartId → клиенты
}
func NewHub() *Hub {
return &Hub{
rooms: make(map[string]map[*Client]bool),
}
}
// Subscribe добавляет клиента в комнату
func (h *Hub) Subscribe(chartID string, client *Client) {
h.mu.Lock()
defer h.mu.Unlock()
if h.rooms[chartID] == nil {
h.rooms[chartID] = make(map[*Client]bool)
}
h.rooms[chartID][client] = true
// Уведомить остальных: user_joined
h.broadcastExcept(chartID, client, Event{
Type: EventUserJoined,
ChartID: chartID,
UserID: client.UserID,
Payload: map[string]string{"username": client.Username},
Timestamp: time.Now(),
})
}
// Unsubscribe удаляет клиента из комнаты
func (h *Hub) Unsubscribe(chartID string, client *Client) {
h.mu.Lock()
defer h.mu.Unlock()
if clients, ok := h.rooms[chartID]; ok {
delete(clients, client)
if len(clients) == 0 {
delete(h.rooms, chartID)
}
}
// Уведомить остальных: user_left
h.broadcastExcept(chartID, client, Event{
Type: EventUserLeft,
ChartID: chartID,
UserID: client.UserID,
Timestamp: time.Now(),
})
}
// Broadcast рассылает событие всем клиентам в комнате
func (h *Hub) Broadcast(chartID string, event Event) {
h.mu.RLock()
defer h.mu.RUnlock()
if clients, ok := h.rooms[chartID]; ok {
for client := range clients {
client.Send(event)
}
}
}
// SendToUser отправляет событие конкретному пользователю во всех его комнатах
func (h *Hub) SendToUser(userID string, event Event) {
h.mu.RLock()
defer h.mu.RUnlock()
for _, clients := range h.rooms {
for client := range clients {
if client.UserID == userID {
client.Send(event)
}
}
}
}
// broadcastExcept рассылает всем, кроме отправителя
func (h *Hub) broadcastExcept(chartID string, sender *Client, event Event) {
if clients, ok := h.rooms[chartID]; ok {
for client := range clients {
if client != sender {
client.Send(event)
}
}
}
}
```
#### `client.go` — обёртка WS-соединения
```go
package ws
import (
"encoding/json"
"sync"
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMessageSize = 1024
)
type Client struct {
Hub *Hub
Conn *websocket.Conn
UserID string
Username string
ChartID string
SendCh chan Event
mu sync.Mutex
}
func NewClient(hub *Hub, conn *websocket.Conn, userID, username, chartID string) *Client {
return &Client{
Hub: hub,
Conn: conn,
UserID: userID,
Username: username,
ChartID: chartID,
SendCh: make(chan Event, 64),
}
}
// ReadPump читает входящие сообщения (ping/pong)
func (c *Client) ReadPump() {
defer func() {
c.Hub.Unsubscribe(c.ChartID, c)
c.Conn.Close()
}()
c.Conn.SetReadLimit(maxMessageSize)
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
c.Conn.SetPongHandler(func(string) error {
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, message, err := c.Conn.ReadMessage()
if err != nil {
break
}
// Клиент может слать только ping, данные игнорируем
_ = message
}
}
// WritePump отправляет события клиенту
func (c *Client) WritePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case event, ok := <-c.SendCh:
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
data, _ := json.Marshal(event)
c.mu.Lock()
c.Conn.WriteMessage(websocket.TextMessage, data)
c.mu.Unlock()
case <-ticker.C:
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// Send ставит событие в очередь отправки
func (c *Client) Send(event Event) {
select {
case c.SendCh <- event:
default:
// Очередь полна — отключаем медленного клиента
close(c.SendCh)
}
}
```
### 3.3. WS endpoint — `internal/handlers/ws_handler.go`
```go
package handlers
import (
"net/http"
"time"
"app-leadera-api/internal/middleware"
"app-leadera-api/internal/ws"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true }, // nginx проксирует
}
type WSHandler struct {
hub *ws.Hub
ganttMW *middleware.GanttMiddleware
}
func NewWSHandler(hub *ws.Hub, ganttMW *middleware.GanttMiddleware) *WSHandler {
return &WSHandler{hub: hub, ganttMW: ganttMW}
}
func (h *WSHandler) HandleGanttWS(c *gin.Context) {
// 1. Получить пользователя из контекста (auth middleware уже отработал)
userID, exists := c.Get("userID")
if !exists {
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
return
}
// 2. Проверить доступ к chartId (gantt middleware)
chartID := c.Param("chartId")
if _, err := uuid.Parse(chartID); err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": "invalid chart id"})
return
}
// TODO: проверить через ganttMW что userID имеет доступ к этому chart
// 3. Upgrade до WebSocket
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
// 4. Создать клиент и подписать на комнату
client := ws.NewClient(
h.hub, conn,
userID.(string),
c.GetString("username"),
chartID,
)
h.hub.Subscribe(chartID, client)
// 5. Запустить pump'ы
go client.WritePump()
go client.ReadPump()
}
```
### 3.4. Регистрация маршрута
В `cmd/server/main.go` или где регистрируются routes:
```go
// После создания hub
wsHub := ws.NewHub()
// WS endpoint
wsHandler := handlers.NewWSHandler(wsHub, ganttMW)
api.GET("/ws/gantt/:chartId", authMW.RequireAuth(), wsHandler.HandleGanttWS)
// Передать hub в ganttHandler для broadcast'ов
ganttHandler := handlers.NewGanttHandler(ganttService, sectionService, taskService, blockService, wsHub)
```
### 3.5. Интеграция broadcast в handlers
В каждый write-handler — одна строка после успешной операции:
```go
// Пример: после успешного UpdateTask
h.hub.Broadcast(chartID, ws.Event{
Type: ws.EventTaskUpdated,
ChartID: chartID,
UserID: userID,
Payload: updatedTask,
Timestamp: time.Now(),
})
```
Точки интеграции в `gantt_handler.go`:
- `CreateGanttChart` → не нужен (пользователь только что создал, он один)
- `UpdateGanttChart` / `ArchiveGanttChart``chart_archived`
- `CreateSection``section_created`
- `UpdateSection``section_updated`
- `DeleteSection``section_deleted`
- `ReorderSections``sections_reordered`
- `CreateTask``task_created`
- `UpdateTask``task_updated`
- `DeleteTask``task_deleted`
### 3.6. Интеграция событий безопасности
В соответствующих service-методах:
```go
// При исключении из space (SpaceService.RemoveMember)
wsHub.SendToUser(removedUserID, ws.Event{
Type: ws.EventAccessRevoked,
Payload: map[string]string{"spaceId": spaceID},
})
// При архивации ганта
wsHub.Broadcast(chartID, ws.Event{
Type: ws.EventChartArchived,
ChartID: chartID,
})
// При изменении роли
wsHub.SendToUser(userID, ws.Event{
Type: ws.EventRoleChanged,
Payload: map[string]string{"role": newRole},
})
```
---
## 4. Frontend (Angular)
### 4.1. WebSocket сервис — `gantt-ws.service.ts`
```typescript
// src/app/pages/gantt/services/gantt-ws.service.ts
import { Injectable, inject, OnDestroy } from '@angular/core';
import { Subject, Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { SpaceStorageService } from '../../../core/services/space-storage.service';
import { AuthService } from '../../../core/auth/services/auth.service';
export interface WSEvent {
type: string;
chartId?: string;
userId?: string;
payload?: any;
timestamp?: string;
}
@Injectable({ providedIn: 'root' })
export class GanttWSService implements OnDestroy {
private spaceStorage = inject(SpaceStorageService);
private auth = inject(AuthService);
private socket: WebSocket | null = null;
private eventSubject = new Subject<WSEvent>();
private reconnectTimer: any;
private chartId: string | null = null;
/** Observable для подписки на события */
get events$(): Observable<WSEvent> {
return this.eventSubject.asObservable();
}
/** Подключиться к комнате ганта */
connect(chartId: string): void {
this.disconnect();
this.chartId = chartId;
const token = this.auth.getAccessToken();
const wsUrl = this.buildWSUrl(chartId, token);
this.socket = new WebSocket(wsUrl);
this.socket.onopen = () => {
console.log(`[WS] Connected to gantt ${chartId}`);
};
this.socket.onmessage = (msg) => {
try {
const event: WSEvent = JSON.parse(msg.data);
this.eventSubject.next(event);
} catch (e) {
console.error('[WS] Parse error', e);
}
};
this.socket.onclose = (event) => {
console.log(`[WS] Closed: ${event.code}`);
if (event.code !== 1000) {
this.scheduleReconnect();
}
};
this.socket.onerror = () => {
this.socket?.close();
};
}
/** Отключиться */
disconnect(): void {
clearTimeout(this.reconnectTimer);
if (this.socket) {
this.socket.close(1000, 'manual');
this.socket = null;
}
this.chartId = null;
}
ngOnDestroy(): void {
this.disconnect();
}
private buildWSUrl(chartId: string, token: string): string {
const base = environment.wsUrl ||
location.protocol.replace('http', 'ws') + '//' + location.host;
return `${base}/api/v1/ws/gantt/${chartId}?token=${token}`;
}
private scheduleReconnect(): void {
if (!this.chartId) return;
this.reconnectTimer = setTimeout(() => {
console.log('[WS] Reconnecting...');
if (this.chartId) this.connect(this.chartId);
}, 3000);
}
}
```
### 4.2. Интеграция в gantt-chart-canvas компонент
```typescript
// В gantt-chart-canvas.component.ts
export class GanttChartCanvasComponent implements OnInit, OnDestroy {
private ws = inject(GanttWSService);
private onlineUsers: Set<string> = new Set();
private wsSubscription: Subscription | null = null;
ngOnInit(): void {
// Подключиться к WS при загрузке ганта
this.ws.connect(this.chartId);
this.wsSubscription = this.ws.events$.subscribe(event => {
this.handleWSEvent(event);
});
}
ngOnDestroy(): void {
this.wsSubscription?.unsubscribe();
this.ws.disconnect();
}
private handleWSEvent(event: WSEvent): void {
// Если это событие от текущего пользователя — пропускаем
// (мы уже обновили UI оптимистично или после HTTP-ответа)
if (event.userId === this.currentUserId) return;
switch (event.type) {
case 'section_created':
this.addSectionLocal(event.payload);
this.redrawCanvas();
break;
case 'section_updated':
this.updateSectionLocal(event.payload);
this.redrawCanvas();
break;
case 'section_deleted':
this.removeSectionLocal(event.payload.id);
this.redrawCanvas();
break;
case 'sections_reordered':
this.reorderSectionsLocal(event.payload.ordered_ids);
this.redrawCanvas();
break;
case 'task_created':
this.addTaskLocal(event.payload);
this.redrawCanvas();
break;
case 'task_updated':
this.updateTaskLocal(event.payload);
this.redrawCanvas();
break;
case 'task_deleted':
this.removeTaskLocal(event.payload.id);
this.redrawCanvas();
break;
case 'user_joined':
this.onlineUsers.add(event.userId!);
this.showOnlineIndicator();
break;
case 'user_left':
this.onlineUsers.delete(event.userId!);
this.showOnlineIndicator();
break;
// === Безопасность ===
case 'access_revoked':
this.router.navigate(['/gantt'], {
state: { alert: 'Доступ к пространству был отозван' }
});
break;
case 'chart_archived':
this.router.navigate(['/gantt'], {
state: { alert: 'Диаграмма была заархивирована' }
});
break;
case 'role_changed':
this.currentRole = event.payload.role;
this.updateEditableState();
break;
case 'token_expired':
this.router.navigate(['/auth/login']);
break;
}
}
private redrawCanvas(): void {
// Перерисовать Konva canvas с обновлёнными данными
}
}
```
### 4.3. Индикатор онлайн-пользователей
Добавить в шаблон canvas-компонента:
```html
<!-- Онлайн-индикатор -->
<div class="online-indicator" *ngIf="onlineUsers.size > 0">
<span class="pulse-dot"></span>
{{ onlineUsers.size }} онлайн
</div>
```
---
## 5. Безопасность
### 5.1. Авторизация при подключении
| Этап | Проверка |
|------|----------|
| WS Handshake | JWT в query-параметре `?token=...` |
| Подписка на комнату | Проверка membership в space |
| Каждое broadcast-событие | Hub знает userId каждого клиента |
### 5.2. Сценарии отзыва доступа
| Событие | Триггер | Действие на клиенте |
|---------|---------|---------------------|
| `access_revoked` | Исключение из space | Редирект на список гантов |
| `chart_archived` | Архивация ганта | Редирект на список гантов |
| `role_changed` | Смена роли (editor→viewer) | Отключить редактирование |
| `token_expired` | Токен протух | Редирект на логин |
### 5.3. Защита от злоупотреблений
| Угроза | Мера |
|--------|------|
| Спам WS-сообщений | Rate limit: 30 msg/min на клиента, `ReadLimit` в gorilla/websocket |
| Подключение без прав | Проверка space membership при handshake |
| Истёкший токен | Ping/pong + периодическая проверка JWT (раз в 5 мин) |
| XSS через WS | Payload — JSON, sanitize имён на frontend |
| Мутации через WS | WS **только для чтения** (уведомления). Мутации — через HTTP API с полной валидацией |
### 5.4. Nginx конфигурация
В конфиг `sand.a2v.space` добавить:
```nginx
# WebSocket проксирование
location /api/v1/ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s; # 24h для long-lived WS
proxy_send_timeout 86400s;
}
```
---
## 6. Оценка трудозатрат
| Компонент | Строки кода | Время |
|-----------|-------------|-------|
| `internal/ws/` (hub, client, message) | ~200 | 2-3 часа |
| `ws_handler.go` (endpoint) | ~60 | 30 мин |
| Интеграция broadcast в handlers | ~20 (по строке на endpoint) | 30 мин |
| События безопасности в services | ~30 | 30 мин |
| `gantt-ws.service.ts` (Angular) | ~80 | 1 час |
| Интеграция в canvas-компонент | ~80 | 1-2 часа |
| Индикатор онлайн | ~30 | 30 мин |
| Nginx конфигурация | ~10 | 10 мин |
| Тестирование | — | 2-3 часа |
| **Итого** | **~510 строк** | **~1-2 дня** |
---
## 7. Порядок реализации
1. **Backend: `internal/ws/`** — hub, client, message types
2. **Backend: WS endpoint** — handler + route
3. **Backend: Broadcast в handlers** — интеграция в существующие CRUD-эндпоинты
4. **Backend: События безопасности** — access_revoked, chart_archived, role_changed
5. **Nginx: WS location** — проксирование WebSocket
6. **Frontend: WS service** — подключение, реконнект, парсинг событий
7. **Frontend: Интеграция в canvas** — обработка событий, обновление Konva
8. **Frontend: Индикатор онлайн** — визуальная обратная связь
9. **Тестирование** — многопользовательский сценарий, отзыв прав, обрывы связи
---
## 8. Дальнейшие улучшения (после MVP)
- **Cursor других пользователей** — показывать позицию курсора на таймлайне
- **Блокировка элемента** — «кто-то редактирует эту задачу» (optimistic locking через WS)
- **Чат в ганте** — комментарии к задачам прямо на канвасе
- **History/undo** — откат изменений с визуальной историей
- **Уведомления** — push-уведомления при @mention в комментариях к задачам