1 line
98 KiB
JSON
1 line
98 KiB
JSON
[{"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/"}] |