Files
docs.a2v.space/public/leadera/index.json
T
2026-04-26 17:09:31 +03:00

1 line
98 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
[{"content":"Аудит проекта Leadera Дата: 30.03.2026\nВерсия: pre-production, миграции 1–5\n1. Обзор проекта Leadera — SaaS-система управления IT-ландшафтом с диаграммами Ганта.\nСтек Компонент Технологии Backend Go 1.21+, Gin, PostgreSQL 16, pgx/v5 + sqlx, JWT (HMAC-SHA256) Frontend Angular 21.1, Bootstrap 5, NgBootstrap, RxJS, Signals Инфраструктура Docker (Colima), pm2, nginx reverse proxy, VPS reg.ru Доменная модель User ──┬── Space (через space_members с role) │ └── GanttChart (принадлежит Space) ├── GanttSection └── GanttTask Роли: admin, editor, viewer — назначаются на уровне Space через таблицу space_members.\nЧто реализовано (endpoints) Auth (5): register, login, refresh, change-password, logout\nSpaces (9): CRUD + members CRUD\nGantt Charts (9): CRUD + archive + members (добавлен, но удалён в миграции 5)\nGantt Sections (5): CRUD + reorder\nGantt Tasks (4): CRUD\nИтого: ~32 endpoint\n2. Архитектура и качество кода 2.1. Backend — структура cmd/server/main.go — точка входа internal/ domain/ — модели (User, Space, GanttChart, etc.) dto/ — request/response DTO (auth, space, gantt) handlers/ — HTTP handlers (auth, space, gantt) middleware/ — auth, cors, space, gantt, role, logging, recovery repository/ — слой доступа к данным service/ — бизнес-логика config/ — конфигурация (.env) utils/ — jwt, password, response, validation migrations/ — SQL миграции (1-5) Оценка: ✅ Чистая layered архитектура. Разделение ответственности чёткое.\n2.2. Frontend — структура src/app/ core/auth/ — auth service, interceptor core/models/ — app.model, space.model core/services/ — space.service, space-storage.service pages/auth/ — sign-in, sign-up, reset-pw, etc. pages/gantt/ — компоненты, модалки, сервисы, модели pages/dashboards/ — spaces list, dashboard layouts/ — navbar, topbar, main-layout Оценка: ✅ Стандартная Angular-структура. Signals для state management.\n2.3. Проблемы кода # Проблема Критичность Описание C1 \\r в service-файлах Medium gantt_service.go, gantt_section_service.go содержат CRLF C2 main.go:238 — мёртвый код getUserHandler Low Неиспользуемая функция C3 Swagger не сгенерирован Medium Аннотации есть, но api/ директории нет C4 RegisterProtectedRoutes не вызывается в main.go Critical GET/PUT/DELETE /spaces/:id не зарегистрированы 3. Права доступа — главный вопрос 3.1. Текущая реализация Права назначаются исключительно через Space:\nТаблица space_members — связка user ↔ space с role (admin/editor/viewer) SpaceMiddleware.RequireSpace() — проверяет членство и записывает ContextSpaceRole в context RoleMiddleware.RequireMinRole() — проверяет иерархию ролей (admin \u0026gt; editor \u0026gt; viewer) Диаграммы Ганта НЕ имеют собственной системы ролей. Таблица gantt_chart_members была создана (миграция 4) и тут же удалена (миграция 5). Все проверки доступа к диаграммам идут через space membership.\n3.2. Как это работает Запрос → AuthMiddleware (JWT → userID) → SpaceMiddleware.RequireSpace() (проверяет space_members → ContextSpaceRole) → GanttMiddleware.RequireGanttChart() (проверяет chart.space_id == spaceID) → RoleMiddleware.RequireMinRole(\u0026#34;editor\u0026#34;) (проверяет ContextSpaceRole) Это правильно. Любой член Space с ролью editor+ может создавать/редактировать диаграммы. Viewers — только читать.\n3.3. 🔴 Критическая проблема: Frontend не соответствует Backend Frontend MembersModalComponent пытается управлять участниками диаграммы:\nganttService.addMember(chartId, dto) → /api/v1/spaces/{spaceId}/gantt-charts/{chartId}/members ganttService.updateMemberRole(chartId, userId, dto) → .../members/{userId} ganttService.removeMember(chartId, userId) → .../members/{userId} Backend GanttHandler.RegisterRoutes() НЕ регистрирует маршруты для members диаграмм. После удаления gantt_chart_members (миграция 5) эти endpoints не существуют.\nРезультат: модальное окно «Участники диаграммы» на фронтенде не работает — все вызовы вернут 404.\n3.4. 🔴 Проблема: Role fallback В role_middleware.go:28-31:\nrole := c.GetString(ContextSpaceRole) if role == \u0026#34;\u0026#34; { role = c.GetString(ContextRole) // fallback на глобальную роль User } Если ContextSpaceRole не установлен, используется глобальная роль пользователя (из JWT). Это значит:\nПользователь с role=admin в таблице users получит admin-доступ к любому space, даже если он не член этого space Но SpaceMiddleware проверяет членство раньше, так что в цепочке RequireSpace() → RequireMinRole() это не эксплуатируется Однако если RequireMinRole() используется без RequireSpace() — это дыра Рекомендация: Удалить fallback на ContextRole. Если ContextSpaceRole пуст — отказывать.\n3.5. Рекомендации по правам доступа Удалить модалку участников диаграммы на фронтенде — она мертва Или добавить раздел «Участники Space» с возможностью управления ролями через /spaces/:space_id/members (эти endpoints работают) Удалить fallback на ContextRole в role_middleware.go Добавить проверку: editor не может повысить кого-то до admin (сейчас может — AddMember разрешает editor+ назначать любую роль) 4. Безопасность 4.1. Критические # Проблема Файл Описание S1 JWT Secret = test .env JWT_SECRET=test_secret_key_change_in_production — это прод-конфиг! S2 CORS: * .env CORS_ALLOWED_ORIGINS=* — любой домен может обращаться к API S3 Пароль БД в .env .env DB_PASSWORD=popoloka16 — creds в открытом виде S4 Refresh tokens: SHA256 utils/password.go SHA256 вместо bcrypt для refresh token hash — быстро брутфорсится 4.2. Высокие # Проблема Описание S5 Нет rate limiting Brute-force на /auth/login без ограничений S6 Нет CSRF protection API принимает POST/PUT/DELETE без CSRF-токена S7 Нет CSP заголовков X-Frame-Options, Content-Security-Policy не настроены S8 JWT в localStorage XSS может украсть access + refresh tokens 4.3. Средние # Проблема Описание S9 Нет password policy Минимальные требования к паролю не enforced S10 Нет audit log Действия с spaces/charts не логируются для аудита S11 Нет блокировки аккаунта Неограниченные попытки входа 5. Инфраструктура 5.1. Текущее состояние Компонент Статус Примечание Go API (pm2: leadera-api) ⚠️ Порт 8080, хост 0.0.0.0 Angular FE (pm2: leadera-fe) ⚠️ Порт 4200, хост 0.0.0.0 PostgreSQL (Docker) ✅ Порт 5432, контейнер app-leadera-db VPS reverse proxy ⚠️ sand.a2v.space → 8082/8083, но 502 если сервисы не запущены SSL ✅ Let\u0026rsquo;s Encrypt на VPS 5.2. Проблемы # Проблема Описание I1 Docker для БД, нативно для API Несогласованность — API и FE не в Docker I2 Нет health-check в pm2 API может упасть без перезапуска I3 Нет CI/CD Ручной деплой через make run + pm2 I4 Нет nginx на Mac Studio FE и API торчат наружу напрямую 6. Функциональная оценка 6.1. Что работает Функция Backend Frontend Статус Регистрация/логин ✅ ✅ Работает JWT auth + refresh ✅ ✅ Работает Spaces CRUD ⚠️ ✅ RegisterProtectedRoutes не вызван (C4) Space members ✅ ✅ Работает Gantt charts CRUD ✅ ✅ Работает Gantt sections ✅ ✅ Работает Gantt tasks ✅ ⚠️ Нет UI для задач внутри диаграммы Архивация диаграмм ✅ ✅ Работает Members диаграммы ❌ ✅ (мёртвый UI) Backend endpoints не существуют 6.2. Что НЕ работает или отсутствует Функция Статус Визуализация Ганта (таймлайн) ❌ Нет компонента отрисовки Drag \u0026amp; Drop задач ❌ Real-time collaboration ❌ Уведомления ❌ Поиск ❌ Экспорт (PDF/Excel) ❌ Email verification ❌ Password reset Backend есть, UI есть — не проверено 6.3. Оценка готовности Система на стадии каркаса. Backend API функционален для CRUD операций, но:\nВизуализация Ганта — основная фича — не реализована (нет canvas/SVG рендеринга) Управление участниками только на уровне Space Нет CI/CD, тестов, мониторинга 7. Приоритизированный план исправлений Неделя 1 — Критическое (безопасность) Приоритет Задача Трудоёмкость P0 Заменить JWT secret на криптостойкий 1 час P0 Настроить CORS (только sand.a2v.space) 1 час P0 bcrypt для refresh tokens вместо SHA256 2 часа P0 Удалить fallback на ContextRole в role_middleware 30 мин P0 Исправить RegisterProtectedRoutes — зарегистрировать маршруты 1 час P1 Rate limiting на auth endpoints 4 часа P1 Editor не может назначать admin роль 2 часа Неделя 2 — Архитектура прав Приоритет Задача Трудоёмкость P1 Удалить/переписать MembersModalComponent → Space members 4 часа P1 Добавить страницу управления участниками Space 8 часов P2 HttpOnly cookies для JWT (вместо localStorage) 8 часов P2 CSRF protection 4 часа Неделя 3 — Функциональность Приоритет Задача Трудоёмкость P2 Визуализация Ганта (canvas/d3) 40+ часов P2 Drag \u0026amp; Drop для задач 16 часов P3 Тесты (unit + integration) 24 часа P3 CI/CD pipeline 8 часов 8. Резюме Ключевые выводы Права доступа — архитектурно верно: роль назначается через Space, не через диаграмму. Но фронтенд ещё не обновлён после удаления gantt_chart_members. Безопасность — критические уязвимости в конфигурации (JWT secret, CORS, bcrypt). Требуют немедленного исправления до любого публичного деплоя. Функциональность — CRUD работает, но основная ценность (визуализация Ганта) не реализована. Код — чистый, хорошая архитектура, но есть мёртвый код и незарегистрированные маршруты. Общая оценка: 🔴 Pre-alpha Проект готов к внутренней демонстрации CRUD, но не готов к продакшену. Первоочередное — безопасность и исправление мёртвых endpoints.\n","section":"leadera","tags":["audit","leadera","security","architecture"],"title":"Аудит Leadera — полный отчёт","url":"/leadera/leadera-audit/"},{"content":"WebSocket для диаграмм Ганта — realtime-совместное редактирование Дата: 07.04.2026 Статус: Планирование Проект: Leadera (dev — sand.a2v.space)\n1. Постановка задачи Диаграммы Ганта должны обновляться в реальном времени при совместной работе: если два или более пользователей редактируют один Гант, изменения каждого мгновенно отображаются у остальных — без перезагрузки страницы и ручного обновления.\n2. Архитектура Angular frontend ←── WebSocket ──→ Go backend (Hub) ──→ PostgreSQL │ │ │ HTTP (мутации) │ Broadcast └──→ REST API ──→ Service ──→ DB ──→ Hub ──→ все клиенты комнаты Ключевой принцип: мутации данных идут через HTTP (REST), WebSocket используется только для доставки уведомлений. Сервер — источник истины, клиент не может обойти проверки через WS.\nКомнаты Каждый открытый Гант — это «комната» с идентификатором chartId. При подключении клиент подписывается на комнату. Hub рассылает события всем подписчикам комнаты.\n3. Backend (Go) 3.1. Зависимости go get github.com/gorilla/websocket 3.2. Новые файлы — internal/ws/ message.go — типы сообщений package ws import \u0026#34;time\u0026#34; // EventType — тип события type EventType string const ( // Данные EventSectionCreated EventType = \u0026#34;section_created\u0026#34; EventSectionUpdated EventType = \u0026#34;section_updated\u0026#34; EventSectionDeleted EventType = \u0026#34;section_deleted\u0026#34; EventSectionsReordered EventType = \u0026#34;sections_reordered\u0026#34; EventTaskCreated EventType = \u0026#34;task_created\u0026#34; EventTaskUpdated EventType = \u0026#34;task_updated\u0026#34; EventTaskDeleted EventType = \u0026#34;task_deleted\u0026#34; // Присутствие EventUserJoined EventType = \u0026#34;user_joined\u0026#34; EventUserLeft EventType = \u0026#34;user_left\u0026#34; // Безопасность / доступ EventAccessRevoked EventType = \u0026#34;access_revoked\u0026#34; EventChartArchived EventType = \u0026#34;chart_archived\u0026#34; EventRoleChanged EventType = \u0026#34;role_changed\u0026#34; EventTokenExpired EventType = \u0026#34;token_expired\u0026#34; ) // Event — WS-событие, отправляемое клиентам type Event struct { Type EventType `json:\u0026#34;type\u0026#34;` ChartID string `json:\u0026#34;chartId\u0026#34;` UserID string `json:\u0026#34;userId,omitempty\u0026#34;` Payload interface{} `json:\u0026#34;payload,omitempty\u0026#34;` Timestamp time.Time `json:\u0026#34;timestamp\u0026#34;` } // IncomingMessage — сообщение от клиента (для ping/pong) type IncomingMessage struct { Type string `json:\u0026#34;type\u0026#34;` } hub.go — центральный брокер package ws import ( \u0026#34;sync\u0026#34; ) // Hub управляет комнатами и рассылкой событий type Hub struct { mu sync.RWMutex rooms map[string]map[*Client]bool // chartId → клиенты } func NewHub() *Hub { return \u0026amp;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{\u0026#34;username\u0026#34;: 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-соединения package ws import ( \u0026#34;encoding/json\u0026#34; \u0026#34;sync\u0026#34; \u0026#34;time\u0026#34; \u0026#34;github.com/gorilla/websocket\u0026#34; ) 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 \u0026amp;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 := \u0026lt;-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 \u0026lt;-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 \u0026lt;- event: default: // Очередь полна — отключаем медленного клиента close(c.SendCh) } } 3.3. WS endpoint — internal/handlers/ws_handler.go package handlers import ( \u0026#34;net/http\u0026#34; \u0026#34;time\u0026#34; \u0026#34;app-leadera-api/internal/middleware\u0026#34; \u0026#34;app-leadera-api/internal/ws\u0026#34; \u0026#34;github.com/gin-gonic/gin\u0026#34; \u0026#34;github.com/google/uuid\u0026#34; \u0026#34;github.com/gorilla/websocket\u0026#34; ) 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 \u0026amp;WSHandler{hub: hub, ganttMW: ganttMW} } func (h *WSHandler) HandleGanttWS(c *gin.Context) { // 1. Получить пользователя из контекста (auth middleware уже отработал) userID, exists := c.Get(\u0026#34;userID\u0026#34;) if !exists { c.AbortWithStatusJSON(401, gin.H{\u0026#34;error\u0026#34;: \u0026#34;unauthorized\u0026#34;}) return } // 2. Проверить доступ к chartId (gantt middleware) chartID := c.Param(\u0026#34;chartId\u0026#34;) if _, err := uuid.Parse(chartID); err != nil { c.AbortWithStatusJSON(400, gin.H{\u0026#34;error\u0026#34;: \u0026#34;invalid chart id\u0026#34;}) 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(\u0026#34;username\u0026#34;), chartID, ) h.hub.Subscribe(chartID, client) // 5. Запустить pump\u0026#39;ы go client.WritePump() go client.ReadPump() } 3.4. Регистрация маршрута В cmd/server/main.go или где регистрируются routes:\n// После создания hub wsHub := ws.NewHub() // WS endpoint wsHandler := handlers.NewWSHandler(wsHub, ganttMW) api.GET(\u0026#34;/ws/gantt/:chartId\u0026#34;, authMW.RequireAuth(), wsHandler.HandleGanttWS) // Передать hub в ganttHandler для broadcast\u0026#39;ов ganttHandler := handlers.NewGanttHandler(ganttService, sectionService, taskService, blockService, wsHub) 3.5. Интеграция broadcast в handlers В каждый write-handler — одна строка после успешной операции:\n// Пример: после успешного UpdateTask h.hub.Broadcast(chartID, ws.Event{ Type: ws.EventTaskUpdated, ChartID: chartID, UserID: userID, Payload: updatedTask, Timestamp: time.Now(), }) Точки интеграции в gantt_handler.go:\nCreateGanttChart → не нужен (пользователь только что создал, он один) 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-методах:\n// При исключении из space (SpaceService.RemoveMember) wsHub.SendToUser(removedUserID, ws.Event{ Type: ws.EventAccessRevoked, Payload: map[string]string{\u0026#34;spaceId\u0026#34;: spaceID}, }) // При архивации ганта wsHub.Broadcast(chartID, ws.Event{ Type: ws.EventChartArchived, ChartID: chartID, }) // При изменении роли wsHub.SendToUser(userID, ws.Event{ Type: ws.EventRoleChanged, Payload: map[string]string{\u0026#34;role\u0026#34;: newRole}, }) 4. Frontend (Angular) 4.1. WebSocket сервис — gantt-ws.service.ts // src/app/pages/gantt/services/gantt-ws.service.ts import { Injectable, inject, OnDestroy } from \u0026#39;@angular/core\u0026#39;; import { Subject, Observable } from \u0026#39;rxjs\u0026#39;; import { environment } from \u0026#39;../../../environments/environment\u0026#39;; import { SpaceStorageService } from \u0026#39;../../../core/services/space-storage.service\u0026#39;; import { AuthService } from \u0026#39;../../../core/auth/services/auth.service\u0026#39;; export interface WSEvent { type: string; chartId?: string; userId?: string; payload?: any; timestamp?: string; } @Injectable({ providedIn: \u0026#39;root\u0026#39; }) export class GanttWSService implements OnDestroy { private spaceStorage = inject(SpaceStorageService); private auth = inject(AuthService); private socket: WebSocket | null = null; private eventSubject = new Subject\u0026lt;WSEvent\u0026gt;(); private reconnectTimer: any; private chartId: string | null = null; /** Observable для подписки на события */ get events$(): Observable\u0026lt;WSEvent\u0026gt; { 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 = () =\u0026gt; { console.log(`[WS] Connected to gantt ${chartId}`); }; this.socket.onmessage = (msg) =\u0026gt; { try { const event: WSEvent = JSON.parse(msg.data); this.eventSubject.next(event); } catch (e) { console.error(\u0026#39;[WS] Parse error\u0026#39;, e); } }; this.socket.onclose = (event) =\u0026gt; { console.log(`[WS] Closed: ${event.code}`); if (event.code !== 1000) { this.scheduleReconnect(); } }; this.socket.onerror = () =\u0026gt; { this.socket?.close(); }; } /** Отключиться */ disconnect(): void { clearTimeout(this.reconnectTimer); if (this.socket) { this.socket.close(1000, \u0026#39;manual\u0026#39;); this.socket = null; } this.chartId = null; } ngOnDestroy(): void { this.disconnect(); } private buildWSUrl(chartId: string, token: string): string { const base = environment.wsUrl || location.protocol.replace(\u0026#39;http\u0026#39;, \u0026#39;ws\u0026#39;) + \u0026#39;//\u0026#39; + location.host; return `${base}/api/v1/ws/gantt/${chartId}?token=${token}`; } private scheduleReconnect(): void { if (!this.chartId) return; this.reconnectTimer = setTimeout(() =\u0026gt; { console.log(\u0026#39;[WS] Reconnecting...\u0026#39;); if (this.chartId) this.connect(this.chartId); }, 3000); } } 4.2. Интеграция в gantt-chart-canvas компонент // В gantt-chart-canvas.component.ts export class GanttChartCanvasComponent implements OnInit, OnDestroy { private ws = inject(GanttWSService); private onlineUsers: Set\u0026lt;string\u0026gt; = new Set(); private wsSubscription: Subscription | null = null; ngOnInit(): void { // Подключиться к WS при загрузке ганта this.ws.connect(this.chartId); this.wsSubscription = this.ws.events$.subscribe(event =\u0026gt; { 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 \u0026#39;section_created\u0026#39;: this.addSectionLocal(event.payload); this.redrawCanvas(); break; case \u0026#39;section_updated\u0026#39;: this.updateSectionLocal(event.payload); this.redrawCanvas(); break; case \u0026#39;section_deleted\u0026#39;: this.removeSectionLocal(event.payload.id); this.redrawCanvas(); break; case \u0026#39;sections_reordered\u0026#39;: this.reorderSectionsLocal(event.payload.ordered_ids); this.redrawCanvas(); break; case \u0026#39;task_created\u0026#39;: this.addTaskLocal(event.payload); this.redrawCanvas(); break; case \u0026#39;task_updated\u0026#39;: this.updateTaskLocal(event.payload); this.redrawCanvas(); break; case \u0026#39;task_deleted\u0026#39;: this.removeTaskLocal(event.payload.id); this.redrawCanvas(); break; case \u0026#39;user_joined\u0026#39;: this.onlineUsers.add(event.userId!); this.showOnlineIndicator(); break; case \u0026#39;user_left\u0026#39;: this.onlineUsers.delete(event.userId!); this.showOnlineIndicator(); break; // === Безопасность === case \u0026#39;access_revoked\u0026#39;: this.router.navigate([\u0026#39;/gantt\u0026#39;], { state: { alert: \u0026#39;Доступ к пространству был отозван\u0026#39; } }); break; case \u0026#39;chart_archived\u0026#39;: this.router.navigate([\u0026#39;/gantt\u0026#39;], { state: { alert: \u0026#39;Диаграмма была заархивирована\u0026#39; } }); break; case \u0026#39;role_changed\u0026#39;: this.currentRole = event.payload.role; this.updateEditableState(); break; case \u0026#39;token_expired\u0026#39;: this.router.navigate([\u0026#39;/auth/login\u0026#39;]); break; } } private redrawCanvas(): void { // Перерисовать Konva canvas с обновлёнными данными } } 4.3. Индикатор онлайн-пользователей Добавить в шаблон canvas-компонента:\n\u0026lt;!-- Онлайн-индикатор --\u0026gt; \u0026lt;div class=\u0026#34;online-indicator\u0026#34; *ngIf=\u0026#34;onlineUsers.size \u0026gt; 0\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;pulse-dot\u0026#34;\u0026gt;\u0026lt;/span\u0026gt; {{ onlineUsers.size }} онлайн \u0026lt;/div\u0026gt; 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 добавить:\n# 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 \u0026#34;upgrade\u0026#34;; 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. Порядок реализации Backend: internal/ws/ — hub, client, message types Backend: WS endpoint — handler + route Backend: Broadcast в handlers — интеграция в существующие CRUD-эндпоинты Backend: События безопасности — access_revoked, chart_archived, role_changed Nginx: WS location — проксирование WebSocket Frontend: WS service — подключение, реконнект, парсинг событий Frontend: Интеграция в canvas — обработка событий, обновление Konva Frontend: Индикатор онлайн — визуальная обратная связь Тестирование — многопользовательский сценарий, отзыв прав, обрывы связи 8. Дальнейшие улучшения (после MVP) Cursor других пользователей — показывать позицию курсора на таймлайне Блокировка элемента — «кто-то редактирует эту задачу» (optimistic locking через WS) Чат в ганте — комментарии к задачам прямо на канвасе History/undo — откат изменений с визуальной историей Уведомления — push-уведомления при @mention в комментариях к задачам ","section":"leadera","tags":["leadera","websocket","realtime","gantt","architecture"],"title":"Leadera — WebSocket для диаграмм Ганта","url":"/leadera/leadera-websocket-gantt/"},{"content":"Экспорт диаграммы Ганта в Excel Дата: 09.04.2026 Статус: Требования Проект: Leadera\nОбщее описание Пользователь нажимает кнопку экспорта в интерфейсе диаграммы Ганта. Система запрашивает диапазон дат выгрузки (по умолчанию — текущий видимый диапазон из интерфейса). После указания диапазона скачивается Excel-файл с визуальным представлением диаграммы Ганта.\nВводные данные Источник: текущая диаграмма Ганта проекта (один проект = одна диаграмма) Диапазон дат: пользователь указывает начальную и конечную дату. По умолчанию подставляется видимый в момент запроса диапазон из интерфейса Масштаб таймлайна: берётся тот, который выбран пользователем в интерфейсе в момент запроса (не сохранённый в системе, а текущий). Варианты: по ячейкам или по дням Структура Excel-файла Шапка (первые строки) Название диаграммы (название проекта) Диапазон дат, в рамках которого сделана выгрузка Лист — один Вся диаграмма размещается на одном листе.\nСекции Секции отображаются как визуальная группировка строк (не отдельные листы) Название секции — строка-заголовок над группой задач Задачи Каждая задача — отдельная строка. Данные задачи:\nНазвание (текстовая колонка слева) Дата начала (текстовая колонка слева) Дата конца (текстовая колонка слева) Цвет — ячейки таймлайна, соответствующие задаче, закрашены в цвет задачи Таймлайн Таймлайн представлен колонками дат в верхней части (горизонтальная ось времени) Масштаб колонок соответствует выбранному пользователем в момент экспорта (по ячейкам / по дням) Задачи отображаются как merged cells по соответствующим колонкам дат — визуальная полоса диаграммы Ганта Вехи Отображаются отдельной строкой (как задача, но с маркировкой вехи) Позиционируются на соответствующей дате в таймлайне Зависимости Не отображаются (стрелочки/связи между задачами не нужны) Пользовательский сценарий Пользователь открывает диаграмму Ганта проекта Настраивает масштаб таймлайна (по ячейкам / по дням) Нажимает кнопку «Экспорт в Excel» Система показывает диалог с выбором диапазона дат (предзаполнен текущим видимым диапазоном) Пользователь подтверждает / корректирует диапазон Система генерирует 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\u0026rsquo;ами Предзаполнение диапазона из текущего видимого viewport\u0026rsquo;а Скачивание файла через Blob / saveAs Фильтрация данных Выгружаются все задачи и вехи, которые: Начинаются в диапазоне, ИЛИ Заканчиваются в диапазоне, ИЛИ Начинаются до диапазона и заканчиваются после (перекрывают целиком) Секции включаются, если в них есть хотя бы одна попавшая задача Открытые вопросы (Нет)\n","section":"leadera","tags":["leadera","gantt","excel","export"],"title":"Leadera — Экспорт диаграммы Ганта в Excel","url":"/leadera/leadera-gantt-excel-export/"},{"content":"Фильтры и реорганизация списка диаграмм Ганта Дата: 07.04.2026 Статус: Требования Проект: Leadera (dev — sand.a2v.space)\n1. Постановка задачи Список диаграмм Ганта должен показывать все доступные пользователю диаграммы из всех пространств (spaces), с возможностью фильтрации и поиска. Сейчас список привязан к одному пространству — нужно убрать эту зависимость и добавить удобный toolbar.\n2. Изменения API 2.1. Новый endpoint списка диаграмм Было:\nGET /api/v1/spaces/{spaceId}/gantt-charts?page=1\u0026amp;per_page=10 Стало:\nGET /api/v1/gantt-charts?page=1\u0026amp;per_page=10 Пользователь видит все диаграммы из всех пространств, где он является подтверждённым участником (статус accepted).\n2.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) Примеры:\nGET /api/v1/gantt-charts?search=миграция GET /api/v1/gantt-charts?space_id=0b334ded-...\u0026amp;created_by=a1b2c3d4-... GET /api/v1/gantt-charts?page=2\u0026amp;per_page=20\u0026amp;sort=title\u0026amp;order=asc 2.3. Изменение структуры ответа Добавить поле space в каждую диаграмму:\n{ \u0026#34;data\u0026#34;: [ { \u0026#34;id\u0026#34;: \u0026#34;uuid\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;Миграция backend\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;...\u0026#34;, \u0026#34;created_by\u0026#34;: \u0026#34;uuid\u0026#34;, \u0026#34;created_by_name\u0026#34;: \u0026#34;Антон Агальцов\u0026#34;, \u0026#34;created_at\u0026#34;: \u0026#34;2026-04-01T10:00:00Z\u0026#34;, \u0026#34;updated_at\u0026#34;: \u0026#34;2026-04-07T09:00:00Z\u0026#34;, \u0026#34;space\u0026#34;: { \u0026#34;id\u0026#34;: \u0026#34;uuid\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;IT Ландшафт\u0026#34;, \u0026#34;color\u0026#34;: \u0026#34;#c4623a\u0026#34; }, \u0026#34;is_archived\u0026#34;: false } ], \u0026#34;pagination\u0026#34;: { \u0026#34;page\u0026#34;: 1, \u0026#34;per_page\u0026#34;: 10, \u0026#34;total\u0026#34;: 42, \u0026#34;total_pages\u0026#34;: 5 } } 2.4. Endpoint для списка фильтров Вспомогательный endpoint для заполнения dropdown-фильтров:\nGET /api/v1/gantt-charts/filters Ответ:\n{ \u0026#34;spaces\u0026#34;: [ { \u0026#34;id\u0026#34;: \u0026#34;uuid\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;IT Ландшафт\u0026#34;, \u0026#34;color\u0026#34;: \u0026#34;#c4623a\u0026#34; }, { \u0026#34;id\u0026#34;: \u0026#34;uuid\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;DevOps\u0026#34;, \u0026#34;color\u0026#34;: \u0026#34;#27ae60\u0026#34; } ], \u0026#34;creators\u0026#34;: [ { \u0026#34;id\u0026#34;: \u0026#34;uuid\u0026#34;, \u0026#34;display_name\u0026#34;: \u0026#34;Антон Агальцов\u0026#34; }, { \u0026#34;id\u0026#34;: \u0026#34;uuid\u0026#34;, \u0026#34;display_name\u0026#34;: \u0026#34;Иван Петров\u0026#34; } ] } Логика creators: уникальные создатели всех диаграмм, доступных текущему пользователю. Получается через DISTINCT-запрос по created_by с JOIN на users.\n2.5. Backend — изменения в Go Файлы для изменения:\ninternal/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 (пример):\nSELECT 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 = \u0026#39;accepted\u0026#39; 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 \u0026#39;%\u0026#39; || $4 || \u0026#39;%\u0026#39; OR gc.description ILIKE \u0026#39;%\u0026#39; || $4 || \u0026#39;%\u0026#39;) ORDER BY CASE WHEN $5 = \u0026#39;title\u0026#39; THEN gc.title END, CASE WHEN $5 = \u0026#39;created_at\u0026#39; THEN gc.created_at::text END, gc.updated_at DESC LIMIT $6 OFFSET $7; 3. Изменения Frontend (Angular) 3.1. Toolbar фильтров Добавить toolbar над списком диаграмм:\n┌──────────────────────────────────────────────────────────────────┐ │ [🔍 Поиск... ] [▼ Пространство] │ │ [▼ Создатель] [▼ Сортировка] [Сбросить фильтры] │ └──────────────────────────────────────────────────────────────────┘ Компоненты:\nСтрока поиска — input с debounce (300ms), поиск по назвению и описанию Dropdown «Пространство» — список из /gantt-charts/filters, с цветовой меткой Dropdown «Создатель» — список уникальных создателей Dropdown «Сортировка» — по дате обновления, дате создания, названию Кнопка «Сбросить» — очищает все фильтры Состояние фильтров в URL (query params):\n/gantt?search=миграция\u0026amp;space_id=xxx\u0026amp;sort=updated_at Это позволяет делиться ссылкой с уже настроенными фильтрами.\n3.2. Карточка диаграммы — бейдж пространства На каждой карточке добавить бейдж с названием пространства:\n┌─────────────────────────────────────┐ │ 🟠 IT Ландшафт │ ← бейдж с цветом пространства │ Миграция backend │ │ Перевод PHP→Go, этап 2 │ │ Создал: Антон А. · 07.04.2026 │ └─────────────────────────────────────┘ 3.3. Форма создания диаграммы — выбор пространства В модальном окне создания:\nDropdown со списком пространств, где пользователь имеет роль editor или выше Валидация: если пользователь не editor ни в одном пространстве — показать сообщение «Нет доступных пространств для создания» Запрос доступных пространств: переиспользовать существующий GET /api/v1/spaces с фильтрацией на клиенте по role ∈ [owner, admin, editor], либо добавить параметр ?min_role=editor на backend.\n3.4. Изменение routing Было:\n/gantt → список гантов текущего пространства Стало:\n/gantt → список всех доступных гантов (из всех пространств) Убрать зависимость от SpaceStorageService.getSelectedSpaceId() в списке гантов.\n3.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\u0026rsquo;ов 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. Порядок реализации Backend: DTO для новых запросов/ответов Backend: Repository — SQL-запрос с фильтрами + JOIN на spaces Backend: Service — ListAll, GetFilters Backend: Handler — ListAllGanttCharts, GetGanttFilters, регистрация маршрутов Backend: Swagger — обновить документацию Frontend: Модели — обновить GanttChart, добавить Space в ответ Frontend: Service — listAll(), getFilters() Frontend: Toolbar компонент — поиск + dropdown\u0026rsquo;ы Frontend: Карточка — бейдж пространства Frontend: Модальное окно создания — dropdown пространства Frontend: URL-параметры — синхронизация фильтров с router Тестирование ","section":"leadera","tags":["leadera","gantt","api","frontend","requirements"],"title":"Leadera — Фильтры и реорганизация списка диаграмм Ганта","url":"/leadera/leadera-gantt-filters/"},{"content":"Публичный доступ к диаграмме Ганта (Share) Дата: 09.04.2026 Статус: Требования Проект: Leadera\nОбщее описание Пользователь может создать публичную ссылку на фрагмент диаграммы Ганта, доступную для просмотра без авторизации. Открывший ссылку видит диаграмму в read-only режиме с возможностью масштабирования таймлайна, но без каких-либо элементов управления (создание, редактирование, удаление задач/секций/вех).\nПользовательский сценарий (создатель ссылки) Пользователь открывает диаграмму Ганта проекта Нажимает кнопку «Экспорт» / «Общий доступ» Указывает диапазон дат, который должен отображаться на диаграмме Система генерирует уникальную ссылку (UUID) и показывает её пользователю Пользователь копирует ссылку и передаёт заинтересованным лицам Управление ссылками Вызывается через меню экспорта → модальное окно «Общий доступ» В модальном окне отображается список созданных ссылок с указанием диапазона дат Каждую ссылку можно удалить (полное удаление из БД, не soft delete) Ссылки нельзя редактировать — только создавать и удалять Ограничений на количество активных ссылок нет Статистика просмотров (сколько раз открывали) — не нужна Пользовательский сценарий (зритель по ссылке) Зритель переходит по ссылке (без авторизации) Открывается отдельная страница с диаграммой Ганта Зритель видит: Задачи и вехи, попадающие в заданный диапазон Секции (заголовки группировок) Таймлайн с цветовой раскраской задач Зритель может: Масштабировать таймлайн Переключать отображение «по ячейкам» / «по дням» Зритель не может: Создавать, редактировать, удалять задачи / секции / вехи Видеть любые контролы управления (кнопки создания, контекстные меню, drag-and-drop и т.д.) Авторизоваться через эту страницу Данные и фильтрация Что попадает в представление Задачи и вехи, у которых хотя бы одно из условий верно:\nДата начала попадает в диапазон ссылки Дата конца попадает в диапазон ссылки Задача начинается до диапазона и заканчивается после (полностью перекрывает) Секции включаются, если в них есть хотя бы одна попавшая задача.\nAPI возвращает Только задачи и вехи, попадающие в диапазон (не всю диаграмму) Информацию о секциях, содержащих попавшие задачи Минимальный набор полей, необходимый для отрисовки (без внутренних 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: \u0026quot;https://leadera.pro/share/\u0026lt;uuid\u0026gt;\u0026quot; } 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 — без навигации проекта, без сайдбара Открытые вопросы (Нет)\n","section":"leadera","tags":["leadera","gantt","public-access","sharing"],"title":"Leadera — Публичный доступ к диаграмме Ганта","url":"/leadera/leadera-gantt-public-access/"},{"content":"Бизнес-требования BR-1: Сущность «Веха» (Milestone) Веха — это маркер на таймлайне диаграммы Ганта, обозначающий важную дату в проекте. В отличие от задач, веха не имеет длительности и привязана к конкретной дате.\nПоля вехи:\nПоле Тип Описание 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 Права доступа: Создание, редактирование и удаление вех доступно участникам с правами выше «только чтение» (аналогично задачам).\nОсобенности:\nВех может быть несколько в проекте Несколько вех могут иметь одну и ту же дату Вехи сортируются строго по дате (по возрастанию) Вехи не привязаны к секциям — они отображаются сквозь все секции BR-2: Управление вехами (модальное окно) Вызов: Кнопка «Вехи» рядом с кнопкой «Создать секцию» на странице диаграммы.\nМодальное окно содержит:\nЗаголовок: «Вехи проекта» Список всех вех текущей диаграммы (сортировка по дате по возрастанию) Каждая веха в списке показывает: цвет (индикатор), название, дату, кнопки «Редактировать» и «Удалить» Кнопка «Добавить веху» Форма добавления/редактирования вехи:\nНазвание — текстовый ввод (обязательное, 1–255 символов) Дата — datepicker (обязательное) Цвет — выбор цвета (по аналогии с палитрой цветов в задачах) По умолчанию цвет = красный (#dc3545) Удаление: с подтверждением (confirm dialog).\nBR-3: Отображение вех на таймлайне Визуальное представление:\nМаркер-флажок (flag marker) на шкале таймлайна От маркера вниз идёт пунктирная вертикальная линия через все секции (на всю высоту диаграммы) Цвет линии и маркера = цвет вехи Позиционирование:\nВеха ставится ровно на свою дату на шкале времени Если дата вехи выходит за рамки текущего диапазона задач — таймлайн расширяется (аналогично задачам) При изменении масштаба (days/weeks/months/quarters) позиция вехи пересчитывается Tooltip при наведении:\nПри наведении на маркер/линию — показывается tooltip с названием вехи Если на одну дату приходится несколько вех — в tooltip показываются все вехи этой даты с их названиями (каждая с индикацией своего цвета) Технические требования База данных Новая таблица: gantt_milestones 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 \u0026#39;#dc3545\u0026#39;, 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: Миграция + Домен Промт для агента:\nДобавь в проект новую таблицу gantt_milestones.\nСоздай миграцию в cmd/migrate/:\nНовая таблица 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 \u0026lsquo;#dc3545\u0026rsquo;), created_by (UUID FK → users), updated_by (UUID FK → users), created_at, updated_at, deleted_at. Индексы по gantt_chart_id и date. Создай доменную модель GanttMilestone в internal/domain/gantt.go:\ntype GanttMilestone struct { ID UUID `json:\u0026#34;id\u0026#34;` GanttChartID UUID `json:\u0026#34;gantt_chart_id\u0026#34;` Title string `json:\u0026#34;title\u0026#34;` Date time.Time `json:\u0026#34;date\u0026#34;` Color string `json:\u0026#34;color\u0026#34;` CreatedBy UUID `json:\u0026#34;created_by\u0026#34;` UpdatedBy UUID `json:\u0026#34;updated_by\u0026#34;` CreatedAt time.Time `json:\u0026#34;created_at\u0026#34;` UpdatedAt time.Time `json:\u0026#34;updated_at\u0026#34;` DeletedAt *time.Time `json:\u0026#34;deleted_at,omitempty\u0026#34;` } Этап 2: CRUD для GanttMilestones Промт для агента:\nРеализуй полный CRUD для сущности GanttMilestone по аналогии с существующими handlers/services/repositories в проекте.\nРоуты:\nGET /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\nGetByChartID(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\nВалидация: title (1–255 символов), date (обязательное), color (валидный HEX, default \u0026lsquo;#dc3545\u0026rsquo;) Проверка прав доступа: пользователь должен иметь права выше «только чтение» в пространстве (space) диаграммы Проверка что chartId существует и не архивирован Handler: internal/handler/gantt_milestone_handler.go\nSwagger-аннотации для всех эндпоинтов Валидация входных данных Ошибки в формате проекта DTO: internal/dto/gantt/milestones_dto.go\ntype CreateGanttMilestoneRequest struct { Title string `json:\u0026#34;title\u0026#34; binding:\u0026#34;required,min=1,max=255\u0026#34;` Date string `json:\u0026#34;date\u0026#34; binding:\u0026#34;required\u0026#34;` Color *string `json:\u0026#34;color\u0026#34; binding:\u0026#34;omitempty,max=20\u0026#34;` } type UpdateGanttMilestoneRequest struct { Title *string `json:\u0026#34;title\u0026#34; binding:\u0026#34;omitempty,min=1,max=255\u0026#34;` Date *string `json:\u0026#34;date\u0026#34; binding:\u0026#34;omitempty\u0026#34;` Color *string `json:\u0026#34;color\u0026#34; binding:\u0026#34;omitempty,max=20\u0026#34;` } type GanttMilestoneResponse struct { ID domain.UUID `json:\u0026#34;id\u0026#34;` GanttChartID domain.UUID `json:\u0026#34;gantt_chart_id\u0026#34;` Title string `json:\u0026#34;title\u0026#34;` Date string `json:\u0026#34;date\u0026#34;` Color string `json:\u0026#34;color\u0026#34;` CreatedByUser *CreatedByUpdatedUser `json:\u0026#34;created_by_user\u0026#34;` UpdatedByUser *CreatedByUpdatedUser `json:\u0026#34;updated_by_user\u0026#34;` CreatedAt time.Time `json:\u0026#34;created_at\u0026#34;` UpdatedAt time.Time `json:\u0026#34;updated_at\u0026#34;` } type GanttMilestonesListResponse struct { Milestones []GanttMilestoneResponse `json:\u0026#34;milestones\u0026#34;` } Регистрация роутов: Добавь группу milestone-роутов в роутер рядом с существующими роутами секций/задач. Проверка прав через существующий middleware.\nЭтап 3: Включение вех в ответ диаграммы Промт для агента:\nПри запросе данных диаграммы Ганта (полная загрузка) включай связанные вехи в ответ.\nДобавь эндпоинт или расширь существующий GET /api/gantt-charts/:chartId — включить поле milestones с массивом GanttMilestoneResponse Вехи с deleted_at IS NOT NULL не возвращаются Вехи сортируются по date ASC Вехи загружаются вместе с данными диаграммы (tasks, sections) — один запрос при открытии Ганта Frontend (Angular) Этап FE-1: Модели + Сервис Промт для агента:\nДобавь в проект поддержку вех (milestones) на диаграмме Ганта.\nВ pages/gantt/models/ (или соответствующем файле моделей) добавь: 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; } Добавь milestones: GanttMilestone[] в интерфейс/модель полной загрузки данных диаграммы\nСоздай сервис pages/gantt/services/gantt-milestone.service.ts:\nInject HttpClient Методы: getMilestones(chartId: string): Observable\u0026lt;GanttMilestone[]\u0026gt; createMilestone(chartId: string, data: GanttMilestoneCreateRequest): Observable\u0026lt;GanttMilestone\u0026gt; updateMilestone(chartId: string, milestoneId: string, data: GanttMilestoneUpdateRequest): Observable\u0026lt;GanttMilestone\u0026gt; deleteMilestone(chartId: string, milestoneId: string): Observable\u0026lt;void\u0026gt; Все методы возвращают Observable с типизированными ответами (по аналогии с GanttChartService) Этап FE-2: Модальное окно управления вехами Промт для агента:\nСоздай компонент модального окна для управления вехами диаграммы Ганта.\nСоздай компонент pages/gantt/components/milestones-modal/\nКнопка вызова: Добавь кнопку «Вехи» рядом с кнопкой «Создать секцию» в toolbar диаграммы. По клику — открытие модального окна через ng-bootstrap NgbModal.\nМодальное окно (ng-bootstrap modal):\nЗаголовок: «Вехи проекта» Список вех (сортировка по date ASC): Каждый элемент: цветной кружок (color), название, дата (форматированная), кнопки ✏️ (редактировать) и 🗑️ (удалить с confirm) Кнопка «Добавить веху» внизу списка Если вех нет — сообщение «Вех пока нет. Добавьте первую!» Форма добавления/редактирования (inline в модальном окне):\nНазвание — текстовый ввод (обязательное) Дата — ng-bootstrap datepicker (обязательное) Цвет — палитра цветов (использовать тот же компонент/подход, что и для задач) По умолчанию цвет = #dc3545 (красный) Кнопки «Сохранить» и «Отмена» При сохранении — вызов соответствующего метода сервиса (create/update)\nПри удалении — confirm dialog → вызов deleteMilestone\nПосле любого изменения — обновить список вех и обновить данные на канвасе (через сервис/GanttStore)\nИнжектировать GanttMilestoneService и NgbModal\nЭтап FE-3: Отображение вех на таймлайне (Konva canvas) Промт для агента:\nОтобрази вехи (milestones) на canvas-диаграмме Ганта.\nПроект использует Konva.js для рендеринга.\nПри загрузке данных диаграммы — извлечь массив milestones\nДля каждой вехи нарисовать на canvas:\nМаркер-флажок (flag marker) на позиции даты вехи на шкале времени Стиль: маленький треугольник или флаг на верхней границе таймлайна Цвет = milestone.color Пунктирная вертикальная линия от маркера вниз через все секции (на всю высоту области секций) Стиль: Konva.Line, dash: [5, 5], strokeWidth: 1.5, color = milestone.color, opacity: 0.7 Позиционирование:\nРассчитать X-позицию по дате вехи с использованием текущей шкалы времени (как для задач) Если дата вехи выходит за текущий диапазон — расширить диапазон (влияет на общую ширину таймлайна) При изменении масштаба (days/weeks/months/quarters) — пересчитать позиции всех вех При скролле — вехи скроллятся вместе с задачами Tooltip при наведении:\nПри наведении на маркер или пунктирную линию — показать Konva.Tooltip или HTML tooltip: Если веха одна на эту дату — показать название Если несколько вех на одну дату — показать список: цветной кружок + название для каждой Z-index:\nЛинии вех рисуются поверх фона секций, но под задачами Маркеры-флажки — поверх всего (самый высокий z-index) Реактивность:\nПри добавлении/удалении/редактировании вехи — перерисовать только вехи (без полного ре рендера задач) Подписаться на изменения массива milestones через сервис/store Порядок реализации # Этап Сторона Зависимости 1 Миграция + Домен Backend — 2 CRUD вех Backend Этап 1 3 Включение вех в ответ диаграммы Backend Этап 2 4 Модели + Сервис Frontend — (параллельно с backend) 5 Модальное окно управления вехами Frontend Этапы 4 + backend 2 6 Отображение вех на таймлайне Frontend Этапы 4 + backend 3 ","section":"leadera","tags":["leadera","gantt","angular","go","api","ux"],"title":"Leadera: Вехи (Milestones) на диаграмме Ганта","url":"/leadera/leadera-milestones/"},{"content":"Бизнес-требования BR-1: Лимит одновременных задач (Concurrency Limit) У каждой секции появляется параметр max_concurrent_tasks (целое число ≥ 0, по умолчанию 0).\nПоведение:\nЕсли max_concurrent_tasks = 3 — в любой данный день в этой секции не может быть больше 3 активных задач одновременно Если max_concurrent_tasks = 0 — ограничение отключено, лимита нет Пересечение считается по дате: задача занимает дни с start_date по end_date включительно. Две задачи «пересекаются», если у них есть хотя бы один общий день Ограничение работает только в рамках одной секции. Задачи из разных секций не влияют друг на друга Срабатывание при:\nСоздании задачи — если добавление нарушает лимит → Alert Перетаскивании задачи (drag) — если новая позиция нарушает лимит → Alert Растягивании задачи (resize) — если новый размер нарушает лимит → Alert Редактировании задачи (форма) — если изменённые даты нарушают лимит → Alert Важно: Действие НЕ отменяется. Задача создаётся/перемещается в любом случае, но пользователь видит предупреждение.\nТекст Alert: «В секции \u0026ldquo;[название секции]\u0026rdquo; на [дата/диапазон] превышен лимит одновременных задач (максимум: N)»\nBR-2: Блоки секций (Section Blocks) У каждой секции появляется набор блоков — особый вид записей, отображаемых на таймлайне как заштрихованные области.\nСущность GanttSectionBlock:\nПоле Тип Описание id UUID Идентификатор section_id UUID Принадлежность к секции title string Название (например: «Отпуск», «Командировка») start_date date Начало интервала (минимум 1 день) end_date date Конец интервала block_concurrency boolean Запрещать ли пересечение обычных задач с данным блоком Валидация:\nend_date не может быть раньше start_date Все поля обязательны Блок может быть в прошлом Визуальное отображение на Ганте:\nЗаштрихованная область на всю высоту секции (включая все строки задач в секции) Название блока отображается поверх штриховки с подложкой (для читаемости) Поведение block_concurrency:\nЕсли block_concurrency = true — при пересечении задачи с данным блоком показывается Alert (аналогично BR-1), но задача всё равно создаётся/перемещается Если block_concurrency = false — блок отображается визуально, но не влияет на задачи Срабатывание при:\nСоздании задачи — пересекает блок с block_concurrency = true → Alert Перетаскивании задачи (drag) → Alert Растягивании задачи (resize) → Alert Редактировании задачи (форма) → Alert Текст Alert: «В секции \u0026ldquo;[название секции]\u0026rdquo; задача пересекает блок \u0026ldquo;[название блока]\u0026rdquo; ([дата] — [дата])»\nBR-3: Система Alert-ов Все предупреждения (от BR-1 и BR-2) выводятся в единую область над диаграммой Ганта.\nПоведение:\nAlert-ы — Bootstrap Alert (warning), постоянные Пользователь НЕ может закрыть Alert вручную Alert исчезает автоматически, когда текущая дата прошла дату конфликта (конфликт больше не актуален) Проверка конфликтов — только на фронтенде (на основе загруженных данных: задачи секции, блоки, max_concurrent_tasks) Если Alert-ов много — область сворачиваемая с счётчиком в бейдже (например: «⚠️ 3 предупреждения») По умолчанию — развёрнуто Сворачивание/разворачивание — клик по заголовку области BR-4: Настройки секции Настройки совмещены с существующим модальным окном редактирования секции (не отдельное окно) Окно создания секции — добавляется:\nПоле «Макс. одновременных задач» (числовой ввод, 0 = без лимита, по умолчанию 0) Окно редактирования секции — добавляется:\nПоле «Макс. одновременных задач» (числовой ввод, 0 = без лимита) Список блоков (таблица/список с CRUD: добавить, редактировать, удалить) Сортировка: по start_date по убыванию (будущее сверху) Каждый блок — форма: title, start_date, end_date, block_concurrency (чекбокс: «Запретить пересечение задач») Валидация: end_date \u0026gt;= start_date, все поля заполнены BR-5: Наследование При появлении функционала копирования/дублирования диаграммы — настройки секций (лимит + блоки) наследуются Технические требования База данных Новая таблица: gantt_section_blocks 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 \u0026gt;= 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 ALTER TABLE gantt_sections ADD COLUMN max_concurrent_tasks INTEGER NOT NULL DEFAULT 0; Backend (Go API) Этап 1: Миграция + Домен Промт для агента:\nДобавь в проект новую таблицу gantt_section_blocks и колонку max_concurrent_tasks в gantt_sections.\nСоздай миграцию в 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 \u0026gt;= start_date. Индексы по section_id и (start_date, end_date). ALTER TABLE gantt_sections ADD COLUMN max_concurrent_tasks INTEGER NOT NULL DEFAULT 0. Создай доменную модель GanttSectionBlock в internal/domain/gantt.go со всеми полями таблицы. Добавь поле MaxConcurrentTasks int в структуру GanttSection. Обнови маппинг в relevant repository файлах, чтобы новое поле max_concurrent_tasks читалось/писалось. Этап 2: CRUD для GanttSectionBlocks Промт для агента:\nРеализуй полный CRUD для сущности GanttSectionBlock по аналогии с существующими handlers/services/repositories в проекте.\nРоуты (внутри группы секции):\nGET /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\nGetBySectionID(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\nБизнес-логика: валидация dates (end \u0026gt;= start), проверка прав доступа к секции Inject repository Handler: internal/handler/gantt_section_block_handler.go\nSwagger-аннотации для всех эндпоинтов Валидация входных данных Ошибки в формате проекта (GanttErrorResponse) Модели запросов:\nCreate: title (required), start_date (required), end_date (required), block_concurrency (bool, default false) Update: title, start_date, end_date, block_concurrency — все опциональны Этап 3: Обновление настроек секции Промт для агента:\nОбнови CRUD секций (gantt_sections) для поддержки нового поля max_concurrent_tasks.\nДобавь max_concurrent_tasks в GanttSectionCreateRequest (опциональное, int ≥ 0, default 0) Добавь max_concurrent_tasks в GanttSectionUpdateRequest (опциональное, int ≥ 0) Обнови SQL-запросы в repository: INSERT и UPDATE должны включать max_concurrent_tasks При GET секции (или списке секций) — возвращать max_concurrent_tasks в ответе Валидация: значение должно быть ≥ 0 Этап 4: Включение блоков в ответ секций Промт для агента:\nПри запросе списка секций или одной секции включай связанные блоки в ответ.\nОбнови GET /api/gantt-charts/:chartId/sections/:sectionId — добавить поле blocks с массивом GanttSectionBlock Обнови GET /api/gantt-charts/:chartId/sections — добавить поле blocks для каждой секции Блоки с deleted_at IS NOT NULL не возвращаются Блоки сортируются по start_date по убыванию (будущее сверху) Frontend (Angular) Этап FE-1: Модели + Сервис Промт для агента:\nДобавь в проект поддержку ограничений секций Ганта.\nВ pages/gantt/models/gantt-chart.model.ts добавь: 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: \u0026#39;concurrency\u0026#39; | \u0026#39;block\u0026#39;; section_id: string; section_title: string; block_title?: string; start_date?: string; end_date?: string; message: string; dates?: string[]; } Добавь max_concurrent_tasks: number в интерфейс GanttSection\nДобавь blocks: GanttSectionBlock[] в интерфейс GanttSection\nДобавь max_concurrent_tasks?: number в GanttSectionCreateRequest и GanttSectionUpdateRequest\nСоздай сервис pages/gantt/services/gantt-section-block.service.ts:\nInject HttpClient Методы: getBlocks(chartId, sectionId), createBlock(chartId, sectionId, data), updateBlock(chartId, sectionId, blockId, data), deleteBlock(chartId, sectionId, blockId) Все методы возвращают Observable с типизированными ответами (по аналогии с GanttChartService) Этап FE-2: Интеграция в модальные окна секции Промт для агента:\nИнтегрируй настройки ограничений в существующие модальные окна создания и редактирования секции Ганта.\nНайди существующие компоненты модальных окон секции (создание/редактирование). Окно создания секции — добавить:\nЧисловой ввод: «Максимум одновременных задач» Подсказка: «0 = без ограничений» Валидация: число ≥ 0 Значение по умолчанию: 0 Отправляется в GanttSectionCreateRequest как max_concurrent_tasks Окно редактирования секции — добавить:\nЧисловой ввод: «Максимум одновременных задач» (аналогично созданию) Секция «Блоки (запретные интервалы)»: Таблица/список существующих блоков с колонками: Название, Начало, Конец, Блокировка (чекбокс/иконка), Действия (редактировать/удалить) Сортировка: по start_date по убыванию (будущее сверху) Кнопка «Добавить блок» — inline-форма: title (текстовый ввод, обязательный) start_date (datepicker, обязательный) end_date (datepicker, обязательный) block_concurrency (чекбокс: «Запретить пересечение задач») Валидация: end_date \u0026gt;= start_date, все поля заполнены Редактирование — inline или через ту же форму Удаление — с подтверждением При открытии окна редактирования — загрузить текущие настройки секции + блоки При сохранении — обновить max_concurrent_tasks и сохранить изменения блоков Инжектировать GanttChartService и GanttSectionBlockService Этап FE-3: Визуализация блоков на Ганте Промт для агента:\nОтобрази блоки секций (GanttSectionBlock) на canvas-диаграмме Ганта.\nПроект использует Konva.js для рендеринга. Основной компонент: gantt-chart-canvas.\nПри загрузке данных диаграммы — извлечь блоки из каждой секции В рендерере секций (canvas/timeline-renderer.ts или аналогичный) — для каждого блока: Нарисовать заштрихованную область (diagonal hatch pattern) на всю высоту секции (все строки задач в этой секции) Область ограничена по горизонтали: от start_date до end_date (в пикселях по шкале времени) Цвет штриховки: полупрозрачный серый или configurable Поверх штриховки — текст с названием блока (block.title) на подложке (прямоугольник с фоном, чтобы текст читался) Блоки рендерятся до задач (z-index ниже), чтобы задачи были поверх Блоки с block_concurrency = true и false визуально не отличаются (штриховка одинаковая) При скролле/зуме — блоки пересчитываются как обычные элементы таймлайна Этап FE-4: Система Alert-ов Промт для агента:\nСоздай компонент для отображения Alert-ов о конфликтах на диаграмме Ганта.\nСоздай компонент pages/gantt/components/gantt-conflict-alerts/\nКомпонент размещается над canvas-диаграммой Ганта\nВходные данные: массив ConflictItem[] (из модели)\nВизуал:\nBootstrap Alert (warning) для каждого конфликта Каждый Alert показывает message из ConflictItem Без кнопки закрытия — Alert нельзя закрыть вручную Alert исчезает автоматически, когда текущая дата прошла дату конфликта Область сворачиваемая: заголовок «⚠️ Предупреждения (N)» — клик сворачивает/разворачивает список По умолчанию — развёрнуто При 0 конфликтов — область скрыта Состояние хранится в сервисе или в компоненте диаграммы:\nДобавление нового конфликта → area auto-expands Alert удаляется из массива автоматически, когда текущая дата прошла дату конфликта Проверка актуальности конфликтов — при каждой отрисовке / изменении данных Не использовать toast — Alert-ы постоянные Этап FE-5: Проверка конфликтов при операциях с задачами Промт для агента:\nИнтегрируй проверку конфликтов во все операции с задачами на диаграмме Ганта.\nПроект использует interaction-handler.ts для drag/resize и формы для создания/редактирования.\nВажно: Проверка конфликтов — только на фронтенде, без вызова API. Используются загруженные данные секции (задачи, блоки, max_concurrent_tasks).\nВынеси логику проверки конфликтов checkConflicts() в отдельный метод в gantt-canvas.service.ts:\nПринимает: section (GanttSection — содержит max_concurrent_tasks и blocks), tasks (GanttTask[] — задачи секции), task_id (nullable — исключить текущую задачу), start_date, end_date Логика: a. Если section.max_concurrent_tasks \u0026gt; 0: посчитать кол-во задач (исключая task_id), пересекающихся с [start_date, end_date] на каждый день. Если в любой день count \u0026gt; max_concurrent_tasks — добавить конфликт concurrency b. Для каждого блока секции с block_concurrency = true, пересекающегося с [start_date, end_date] — добавить конфликт block Возвращает ConflictCheck После каждой успешной операции с задачей (создание, обновление, drag, resize):\nВызвать checkConflicts с параметрами задачи Если has_conflicts = true — передать конфликты в GanttConflictAlertsComponent Alert-ы показываются, действие НЕ отменяется Точки интеграции:\nСоздание задачи (форма): после успешного POST → checkConflicts → show alerts Редактирование задачи (форма): после успешного PUT → checkConflicts → show alerts Drag задачи (interaction-handler.ts): после успешного обновления позиции → checkConflicts → show alerts Resize задачи (interaction-handler.ts): после успешного обновления размера → checkConflicts → show alerts При переключении диаграммы или секции — очищать Alert-ы\nAlert-ы автоматически удаляются, когда текущая дата прошла дату конфликта — область скрывается, когда конфликтов не осталось\nПорядок реализации # Этап Сторона Зависимости 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 ","section":"leadera","tags":["leadera","gantt","angular","go","api","ux"],"title":"Leadera: Ограничения секций в диаграмме Ганта","url":"/leadera/leadera-section-constraints/"}]