add ai functions
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Some checks reported errors
continuous-integration/drone/push Build encountered an error
This commit is contained in:
955
ARCHITECTURE.md
955
ARCHITECTURE.md
File diff suppressed because it is too large
Load Diff
90
CONTEXT.md
90
CONTEXT.md
@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
## Текущий статус
|
## Текущий статус
|
||||||
|
|
||||||
**Этап:** Фаза 2 завершена ✅, E2E тесты готовы ✅, далее Фаза 3 (AI-интеграция)
|
**Этап:** Фаза 3.1 завершена ✅ | Новые требования (Фазы 4-8) запланированы 📋
|
||||||
**Фаза MVP:** Базовый функционал + авторизация + расширенный функционал готовы
|
**Фаза MVP:** Базовый функционал + авторизация + расширенный функционал + AI-оценка + мини-ТЗ + история ТЗ готовы
|
||||||
|
**Следующий этап:** Фаза 4 — Права доступа
|
||||||
**Последнее обновление:** 2026-01-15
|
**Последнее обновление:** 2026-01-15
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -58,6 +59,28 @@
|
|||||||
| 2026-01-15 | **Testing:** Рефакторинг тестов на data-testid — стабильные селекторы вместо tbody/tr/.nth() |
|
| 2026-01-15 | **Testing:** Рефакторинг тестов на data-testid — стабильные селекторы вместо tbody/tr/.nth() |
|
||||||
| 2026-01-15 | **Testing:** Добавлены data-testid во все компоненты фронтенда (IdeasTable, TeamPage, CommentsPanel и др.) |
|
| 2026-01-15 | **Testing:** Добавлены data-testid во все компоненты фронтенда (IdeasTable, TeamPage, CommentsPanel и др.) |
|
||||||
| 2026-01-15 | **Docs:** Создан E2E_TESTING.md — гайд по написанию e2e тестов, соглашения по data-testid |
|
| 2026-01-15 | **Docs:** Создан E2E_TESTING.md — гайд по написанию e2e тестов, соглашения по data-testid |
|
||||||
|
| 2026-01-15 | **Фаза 3:** Backend AI модуль (ai.service.ts, ai.controller.ts, POST /api/ai/estimate) |
|
||||||
|
| 2026-01-15 | **Фаза 3:** Миграция AddAiEstimateFields — поля estimatedHours, complexity, estimateDetails, estimatedAt в Idea |
|
||||||
|
| 2026-01-15 | **Фаза 3:** Frontend AI сервис (services/ai.ts, hooks/useAi.ts) |
|
||||||
|
| 2026-01-15 | **Фаза 3:** Frontend AiEstimateModal — модалка с результатом оценки (часы, сложность, разбивка по ролям, рекомендации) |
|
||||||
|
| 2026-01-15 | **Фаза 3:** Кнопка AI-оценки в таблице идей (AutoAwesome icon) + колонка "Оценка" |
|
||||||
|
| 2026-01-15 | **Infra:** Добавлены AI_PROXY_BASE_URL, AI_PROXY_API_KEY в k8s/backend-deployment.yaml |
|
||||||
|
| 2026-01-15 | **Testing:** E2E тесты Фазы 3 (Playwright) — 11 тестов покрывают AI-оценку (модалка, загрузка, результат, разбивка, просмотр) |
|
||||||
|
| 2026-01-15 | **Фаза 3:** Просмотр сохранённых результатов AI-оценки — клик по ячейке "Оценка" открывает модалку с деталями |
|
||||||
|
| 2026-01-15 | **Фаза 3.1:** Backend миграция для полей specification, specificationGeneratedAt |
|
||||||
|
| 2026-01-15 | **Фаза 3.1:** Backend POST /api/ai/generate-specification endpoint + buildSpecificationPrompt |
|
||||||
|
| 2026-01-15 | **Фаза 3.1:** Backend обновлён buildPrompt() — включает ТЗ в AI-оценку для лучшей точности |
|
||||||
|
| 2026-01-15 | **Фаза 3.1:** Frontend SpecificationModal компонент (генерация/просмотр/редактирование ТЗ) |
|
||||||
|
| 2026-01-15 | **Фаза 3.1:** Frontend кнопка ТЗ в таблице (Description icon) — серая если нет ТЗ, синяя если есть |
|
||||||
|
| 2026-01-15 | **Фаза 3.1:** Frontend интеграция useGenerateSpecification hook + сохранение редактированного ТЗ |
|
||||||
|
| 2026-01-15 | **Testing:** E2E тесты Фазы 3.1 (Playwright) — 9 тестов покрывают генерацию, просмотр, редактирование ТЗ |
|
||||||
|
| 2026-01-15 | **Фаза 3.1:** Markdown-рендеринг ТЗ в режиме просмотра (react-markdown), raw markdown в режиме редактирования |
|
||||||
|
| 2026-01-15 | **Фаза 3.1:** История ТЗ — SpecificationHistory entity, миграция, GET/DELETE/POST restore endpoints |
|
||||||
|
| 2026-01-15 | **Фаза 3.1:** Frontend история ТЗ — табы (Текущее ТЗ / История), просмотр/восстановление/удаление версий |
|
||||||
|
| 2026-01-15 | **Фаза 3.1:** При перегенерации ТЗ старая версия автоматически сохраняется в историю |
|
||||||
|
| 2026-01-15 | **Фаза 3.1:** AI-промпты (ТЗ и оценка) теперь учитывают комментарии к идее |
|
||||||
|
| 2026-01-15 | **Планирование:** Добавлены новые требования — права доступа, аудит, WebSocket, темная тема, экспорт |
|
||||||
|
| 2026-01-15 | **Документация:** Обновлены REQUIREMENTS.md, ARCHITECTURE.md, ROADMAP.md — добавлены Фазы 4-8 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -65,7 +88,33 @@
|
|||||||
|
|
||||||
> Смотри [ROADMAP.md](ROADMAP.md) для полного плана разработки
|
> Смотри [ROADMAP.md](ROADMAP.md) для полного плана разработки
|
||||||
|
|
||||||
**Сейчас:** Фаза 2 завершена ✅ — далее Фаза 3 (AI-интеграция)
|
**Готово:** Фазы 0-3.1 завершены ✅
|
||||||
|
**Следующий шаг:** Фаза 4 — Права доступа 📋
|
||||||
|
|
||||||
|
### Новые требования (Фазы 4-8):
|
||||||
|
|
||||||
|
**Фаза 4: Права доступа**
|
||||||
|
- [ ] Гранулярные права (18 различных прав)
|
||||||
|
- [ ] Панель администратора
|
||||||
|
- [ ] Автор идеи (readonly)
|
||||||
|
- [ ] Admin определяется через K8s Secret
|
||||||
|
|
||||||
|
**Фаза 5: Аудит и история**
|
||||||
|
- [ ] Логирование всех действий
|
||||||
|
- [ ] Восстановление удалённых данных
|
||||||
|
- [ ] Настраиваемый срок хранения (по умолчанию 30 дней)
|
||||||
|
|
||||||
|
**Фаза 6: Real-time и WebSocket**
|
||||||
|
- [ ] Многопользовательская работа
|
||||||
|
- [ ] Индикаторы присутствия
|
||||||
|
- [ ] Конкурентное редактирование
|
||||||
|
|
||||||
|
**Фаза 7: Темная тема**
|
||||||
|
- [ ] Переключатель светлая/тёмная
|
||||||
|
- [ ] Автоопределение системной темы
|
||||||
|
|
||||||
|
**Фаза 8: Экспорт**
|
||||||
|
- [ ] Экспорт идеи в DOCX
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -94,24 +143,32 @@ team-planner/
|
|||||||
├── tests/
|
├── tests/
|
||||||
│ ├── package.json # Зависимости для тестов
|
│ ├── package.json # Зависимости для тестов
|
||||||
│ ├── playwright.config.ts # Конфигурация Playwright
|
│ ├── playwright.config.ts # Конфигурация Playwright
|
||||||
│ └── e2e/ # Playwright E2E тесты (54 теста) ✅
|
│ └── e2e/ # Playwright E2E тесты ✅
|
||||||
│ ├── auth.setup.ts # Авторизация для тестов (Keycloak)
|
│ ├── auth.setup.ts # Авторизация для тестов (Keycloak)
|
||||||
│ ├── phase1.spec.ts # Тесты Фазы 1 (17 тестов)
|
│ ├── phase1.spec.ts # Тесты Фазы 1 (17 тестов)
|
||||||
│ └── phase2.spec.ts # Тесты Фазы 2 (37 тестов — D&D, цвета, комментарии, команда)
|
│ ├── phase2.spec.ts # Тесты Фазы 2 (37 тестов — D&D, цвета, комментарии, команда)
|
||||||
|
│ └── phase3.spec.ts # Тесты Фазы 3 (20 тестов — AI-оценка + мини-ТЗ)
|
||||||
├── backend/ # NestJS API
|
├── backend/ # NestJS API
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── auth/ # Модуль авторизации ✅
|
│ │ ├── auth/ # Модуль авторизации ✅
|
||||||
│ │ │ ├── jwt.strategy.ts # JWT валидация через JWKS
|
│ │ │ ├── jwt.strategy.ts # JWT валидация через JWKS
|
||||||
│ │ │ ├── jwt-auth.guard.ts # Глобальный guard
|
│ │ │ ├── jwt-auth.guard.ts # Глобальный guard
|
||||||
│ │ │ └── decorators/public.decorator.ts # @Public() для открытых endpoints
|
│ │ │ └── decorators/public.decorator.ts # @Public() для открытых endpoints
|
||||||
│ │ ├── ideas/ # Модуль идей (готов + reorder)
|
│ │ ├── ideas/ # Модуль идей (готов + reorder + history)
|
||||||
|
│ │ │ ├── entities/
|
||||||
|
│ │ │ │ ├── idea.entity.ts # Idea + specification поля
|
||||||
|
│ │ │ │ └── specification-history.entity.ts # История ТЗ ✅
|
||||||
│ │ │ ├── dto/
|
│ │ │ ├── dto/
|
||||||
│ │ │ │ └── reorder-ideas.dto.ts # DTO для изменения порядка
|
│ │ │ │ └── reorder-ideas.dto.ts # DTO для изменения порядка
|
||||||
│ │ │ ├── ideas.controller.ts # PATCH /ideas/reorder
|
│ │ │ ├── ideas.controller.ts # PATCH /ideas/reorder
|
||||||
│ │ │ └── ideas.service.ts # reorder() с транзакцией
|
│ │ │ └── ideas.service.ts # reorder() с транзакцией
|
||||||
│ │ ├── team/ # Модуль команды (Фаза 2) — TeamMember + Role entities
|
│ │ ├── team/ # Модуль команды (Фаза 2) — TeamMember + Role entities
|
||||||
│ │ ├── comments/ # Модуль комментариев (Фаза 2)
|
│ │ ├── comments/ # Модуль комментариев (Фаза 2)
|
||||||
│ │ └── ai/ # AI-оценка (Фаза 3)
|
│ │ └── ai/ # AI-оценка + мини-ТЗ + история (Фаза 3 + 3.1) ✅
|
||||||
|
│ │ ├── ai.module.ts
|
||||||
|
│ │ ├── ai.service.ts # estimateIdea + generateSpecification + history + комментарии в промптах
|
||||||
|
│ │ ├── ai.controller.ts # /estimate, /generate-specification, /specification-history/*
|
||||||
|
│ │ └── dto/
|
||||||
│ └── ...
|
│ └── ...
|
||||||
└── frontend/ # React приложение
|
└── frontend/ # React приложение
|
||||||
├── src/
|
├── src/
|
||||||
@ -129,16 +186,20 @@ team-planner/
|
|||||||
│ │ │ ├── TeamPage.tsx # Табы: Участники / Роли
|
│ │ │ ├── TeamPage.tsx # Табы: Участники / Роли
|
||||||
│ │ │ ├── TeamMemberModal.tsx # Модалка участника
|
│ │ │ ├── TeamMemberModal.tsx # Модалка участника
|
||||||
│ │ │ └── RolesManager.tsx # Управление ролями
|
│ │ │ └── RolesManager.tsx # Управление ролями
|
||||||
│ │ └── CommentsPanel/ # Комментарии к идеям
|
│ │ ├── CommentsPanel/ # Комментарии к идеям
|
||||||
|
│ │ ├── AiEstimateModal/ # Модалка AI-оценки (Фаза 3) ✅
|
||||||
|
│ │ └── SpecificationModal/ # Модалка мини-ТЗ (Фаза 3.1) ✅
|
||||||
│ ├── hooks/
|
│ ├── hooks/
|
||||||
│ │ └── useIdeas.ts # React Query хуки + useReorderIdeas
|
│ │ ├── useIdeas.ts # React Query хуки + useReorderIdeas
|
||||||
|
│ │ └── useAi.ts # useEstimateIdea + useGenerateSpecification + history hooks ✅
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ ├── api.ts # Axios + auth interceptors
|
│ │ ├── api.ts # Axios + auth interceptors
|
||||||
│ │ ├── keycloak.ts # Keycloak instance ✅
|
│ │ ├── keycloak.ts # Keycloak instance ✅
|
||||||
│ │ ├── ideas.ts # API методы + reorder()
|
│ │ ├── ideas.ts # API методы + reorder()
|
||||||
│ │ ├── team.ts # API команды
|
│ │ ├── team.ts # API команды
|
||||||
│ │ ├── roles.ts # API ролей
|
│ │ ├── roles.ts # API ролей
|
||||||
│ │ └── comments.ts # API комментариев
|
│ │ ├── comments.ts # API комментариев
|
||||||
|
│ │ └── ai.ts # AI Proxy API (Фаза 3 + 3.1) ✅
|
||||||
│ ├── store/
|
│ ├── store/
|
||||||
│ │ └── ideas.ts # Zustand store
|
│ │ └── ideas.ts # Zustand store
|
||||||
│ └── types/
|
│ └── types/
|
||||||
@ -176,8 +237,10 @@ team-planner/
|
|||||||
|
|
||||||
- **Интерфейс на русском языке** — все тексты, лейблы, placeholder'ы должны быть на русском
|
- **Интерфейс на русском языке** — все тексты, лейблы, placeholder'ы должны быть на русском
|
||||||
- AI-интеграция через ai-proxy: `/Users/vigdorov/dev/gptunnel-service/INTEGRATION.md`
|
- AI-интеграция через ai-proxy: `/Users/vigdorov/dev/gptunnel-service/INTEGRATION.md`
|
||||||
- Многопользовательский режим НЕ нужен
|
- **Многопользовательский режим НУЖЕН** — WebSocket, real-time обновления (Фаза 6)
|
||||||
- Экспорт и интеграции НЕ нужны
|
- **Экспорт НУЖЕН** — экспорт идеи в DOCX (Фаза 8)
|
||||||
|
- **Права доступа НУЖНЫ** — гранулярная система прав, панель админа (Фаза 4)
|
||||||
|
- **Аудит НУЖЕН** — история действий с восстановлением (Фаза 5)
|
||||||
- Warning о React Compiler и TanStack Table можно игнорировать
|
- Warning о React Compiler и TanStack Table можно игнорировать
|
||||||
- **Drag & Drop:** dnd-kit с useSortable + @dnd-kit/modifiers (restrictToVerticalAxis), DragHandle через React Context, CSS.Translate для совместимости с таблицами, reorder через транзакцию
|
- **Drag & Drop:** dnd-kit с useSortable + @dnd-kit/modifiers (restrictToVerticalAxis), DragHandle через React Context, CSS.Translate для совместимости с таблицами, reorder через транзакцию
|
||||||
- **Keycloak:** auth.vigdorov.ru, realm `team-planner`, client `team-planner-frontend`
|
- **Keycloak:** auth.vigdorov.ru, realm `team-planner`, client `team-planner-frontend`
|
||||||
@ -185,3 +248,6 @@ team-planner/
|
|||||||
- **Production URL:** https://team-planner.vigdorov.ru (добавлен в Valid redirect URIs и Web origins клиента Keycloak)
|
- **Production URL:** https://team-planner.vigdorov.ru (добавлен в Valid redirect URIs и Web origins клиента Keycloak)
|
||||||
- **CI/CD:** Drone CI (.drone.yml) — сборка backend/frontend/keycloak-theme, деплой в K8s namespace `team-planner`
|
- **CI/CD:** Drone CI (.drone.yml) — сборка backend/frontend/keycloak-theme, деплой в K8s namespace `team-planner`
|
||||||
- **E2E тесты:** Все компоненты имеют `data-testid` для стабильных селекторов. Перед написанием тестов читай E2E_TESTING.md!
|
- **E2E тесты:** Все компоненты имеют `data-testid` для стабильных селекторов. Перед написанием тестов читай E2E_TESTING.md!
|
||||||
|
- **AI Proxy:** Интеграция с ai-proxy-service (K8s namespace `ai-proxy`), модель `claude-3.7-sonnet`, env: AI_PROXY_BASE_URL, AI_PROXY_API_KEY
|
||||||
|
- **История ТЗ:** При перегенерации старая версия сохраняется в `specification_history`, можно просмотреть/восстановить/удалить
|
||||||
|
- **Комментарии в AI:** Промпты для генерации ТЗ и оценки трудозатрат теперь включают комментарии к идее для лучшей точности
|
||||||
|
|||||||
@ -17,11 +17,12 @@
|
|||||||
## Локальное окружение
|
## Локальное окружение
|
||||||
|
|
||||||
### Порты
|
### Порты
|
||||||
| Сервис | Порт |
|
| Сервис | Порт | Описание |
|
||||||
|--------|------|
|
|--------|------|----------|
|
||||||
| Frontend (React) | 4000 |
|
| Frontend (React) | 4000 | Vite dev server |
|
||||||
| Backend (NestJS) | 4001 |
|
| Backend (NestJS) | 4001 | NestJS API |
|
||||||
| PostgreSQL | 5432 |
|
| PostgreSQL | 5432 | Docker container |
|
||||||
|
| AI Proxy (туннель) | 3000 | SSH туннель к K8s |
|
||||||
|
|
||||||
### База данных
|
### База данных
|
||||||
PostgreSQL поднимается в Docker. Файл `docker-compose.yml` в корне проекта.
|
PostgreSQL поднимается в Docker. Файл `docker-compose.yml` в корне проекта.
|
||||||
@ -31,6 +32,63 @@ PostgreSQL поднимается в Docker. Файл `docker-compose.yml` в к
|
|||||||
docker-compose up -d postgres
|
docker-compose up -d postgres
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Настройка Backend
|
||||||
|
|
||||||
|
Создай файл `backend/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USERNAME=teamplanner
|
||||||
|
DB_PASSWORD=teamplanner
|
||||||
|
DB_DATABASE=teamplanner
|
||||||
|
|
||||||
|
# Keycloak
|
||||||
|
KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner
|
||||||
|
|
||||||
|
# AI Proxy (для Фазы 3)
|
||||||
|
AI_PROXY_BASE_URL=http://localhost:3000
|
||||||
|
AI_PROXY_API_KEY=<your-ai-proxy-api-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI Proxy — port-forward
|
||||||
|
|
||||||
|
Для локальной работы с AI Proxy нужен port-forward:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск port-forward (в отдельном терминале или в фоне)
|
||||||
|
kubectl port-forward svc/ai-proxy-service 3000:3000 -n ai-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
# {"status":"ok","service":"ai-proxy-service","version":"0.0.1",...}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примечание:** kubectl настроен для доступа к production кластеру.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Работа с Production кластером
|
||||||
|
|
||||||
|
kubectl настроен для доступа к production кластеру:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка статуса приложения
|
||||||
|
kubectl get pods -n team-planner
|
||||||
|
|
||||||
|
# Просмотр логов
|
||||||
|
kubectl logs -f deployment/team-planner-backend -n team-planner
|
||||||
|
|
||||||
|
# Проверка AI Proxy
|
||||||
|
kubectl get pods -n ai-proxy
|
||||||
|
kubectl logs -f deployment/ai-proxy-service -n ai-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Внимание:** Будьте осторожны при работе с production окружением!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Правила работы
|
## Правила работы
|
||||||
|
|||||||
148
REQUIREMENTS.md
148
REQUIREMENTS.md
@ -27,21 +27,33 @@
|
|||||||
| Цвет | Цветовая маркировка строки | Color |
|
| Цвет | Цветовая маркировка строки | Color |
|
||||||
| Оценка времени | AI-генерируемая оценка трудозатрат | Calculated |
|
| Оценка времени | AI-генерируемая оценка трудозатрат | Calculated |
|
||||||
|
|
||||||
#### 1.2 Редактирование идей
|
#### 1.2 Автор идеи
|
||||||
|
- При создании идеи автоматически сохраняется автор (текущий пользователь)
|
||||||
|
- Автора идеи изменить нельзя (поле readonly)
|
||||||
|
- Отображение автора в таблице и детальном просмотре
|
||||||
|
|
||||||
|
#### 1.3 Редактирование идей
|
||||||
- **Inline-редактирование**: возможность редактировать любое поле прямо в таблице двойным кликом
|
- **Inline-редактирование**: возможность редактировать любое поле прямо в таблице двойным кликом
|
||||||
- **Быстрое изменение статуса и приоритета** через dropdown
|
- **Быстрое изменение статуса и приоритета** через dropdown
|
||||||
- **Автосохранение** изменений
|
- **Автосохранение** изменений
|
||||||
|
|
||||||
#### 1.3 Drag & Drop
|
#### 1.4 Drag & Drop
|
||||||
- Перемещение идей в списке для ручной сортировки
|
- Перемещение идей в списке для ручной сортировки
|
||||||
- Визуальная индикация при перетаскивании
|
- Визуальная индикация при перетаскивании
|
||||||
- Сохранение порядка после перемещения
|
- Сохранение порядка после перемещения
|
||||||
|
|
||||||
#### 1.4 Цветовая маркировка
|
#### 1.5 Цветовая маркировка
|
||||||
- Возможность назначить цвет строке для визуального выделения
|
- Возможность назначить цвет строке для визуального выделения
|
||||||
- Предустановленная палитра цветов
|
- Предустановленная палитра цветов
|
||||||
- Фильтрация по цвету
|
- Фильтрация по цвету
|
||||||
|
|
||||||
|
#### 1.6 Экспорт идеи
|
||||||
|
- Экспорт отдельной идеи в формате DOCX
|
||||||
|
- Включает: название, описание, статус, приоритет, модуль, целевую аудиторию, боль, роль AI, способ проверки
|
||||||
|
- Если есть AI-оценка — включается в документ (общее время, сложность, разбивка по ролям, рекомендации)
|
||||||
|
- Если есть ТЗ — включается в документ (markdown рендерится как форматированный текст)
|
||||||
|
- Комментарии к идее включаются в документ (автор, дата, текст)
|
||||||
|
|
||||||
### 2. Сортировка и фильтрация
|
### 2. Сортировка и фильтрация
|
||||||
|
|
||||||
#### 2.1 Сортировка
|
#### 2.1 Сортировка
|
||||||
@ -89,6 +101,14 @@
|
|||||||
- Расчёт общего времени с учётом состава команды
|
- Расчёт общего времени с учётом состава команды
|
||||||
- Рекомендации по оптимизации
|
- Рекомендации по оптимизации
|
||||||
|
|
||||||
|
#### 3.4 Генерация мини-ТЗ
|
||||||
|
- **Генерация ТЗ**: создание структурированного технического задания на основе описания идеи
|
||||||
|
- **Структура ТЗ**: цель, функциональные требования, технические требования, критерии приёмки, зависимости и риски
|
||||||
|
- **Сохранение**: ТЗ сохраняется в базе данных для повторного использования
|
||||||
|
- **Просмотр**: возможность просмотреть сохранённое ТЗ по клику на кнопку
|
||||||
|
- **Редактирование**: возможность изменить сгенерированное ТЗ вручную
|
||||||
|
- **Интеграция с оценкой**: AI-оценка времени учитывает ТЗ для более точного расчёта
|
||||||
|
|
||||||
### 4. Комментарии
|
### 4. Комментарии
|
||||||
|
|
||||||
- Добавление комментариев к идее
|
- Добавление комментариев к идее
|
||||||
@ -96,6 +116,101 @@
|
|||||||
- Упоминание участников (@mention)
|
- Упоминание участников (@mention)
|
||||||
- История комментариев
|
- История комментариев
|
||||||
|
|
||||||
|
### 5. Система прав доступа
|
||||||
|
|
||||||
|
#### 5.1 Роли пользователей
|
||||||
|
- **Администратор** — единственный пользователь с полными правами, логин задаётся в секретах кластера (K8s Secret)
|
||||||
|
- **Обычный пользователь** — новый пользователь после первого входа получает только права на просмотр
|
||||||
|
- Администратор может изменять права любого пользователя (кроме себя)
|
||||||
|
|
||||||
|
#### 5.2 Гранулярные права доступа
|
||||||
|
Каждое право настраивается отдельно:
|
||||||
|
|
||||||
|
| Право | Описание |
|
||||||
|
|-------|----------|
|
||||||
|
| `view_ideas` | Просмотр списка идей (по умолчанию: ✅) |
|
||||||
|
| `create_ideas` | Создание новых идей |
|
||||||
|
| `edit_own_ideas` | Редактирование своих идей |
|
||||||
|
| `edit_any_ideas` | Редактирование чужих идей |
|
||||||
|
| `delete_own_ideas` | Удаление своих идей |
|
||||||
|
| `delete_any_ideas` | Удаление чужих идей |
|
||||||
|
| `reorder_ideas` | Изменение порядка идей (drag & drop) |
|
||||||
|
| `add_comments` | Добавление комментариев |
|
||||||
|
| `delete_own_comments` | Удаление своих комментариев |
|
||||||
|
| `delete_any_comments` | Удаление чужих комментариев |
|
||||||
|
| `request_ai_estimate` | Запрос AI-оценки трудозатрат |
|
||||||
|
| `request_ai_specification` | Запрос AI-генерации ТЗ |
|
||||||
|
| `edit_specification` | Редактирование ТЗ |
|
||||||
|
| `delete_ai_generations` | Удаление AI-генераций (оценки, ТЗ) |
|
||||||
|
| `manage_team` | Управление командой (добавление/удаление участников) |
|
||||||
|
| `manage_roles` | Управление ролями команды |
|
||||||
|
| `export_ideas` | Экспорт идей в документы |
|
||||||
|
| `view_audit_log` | Просмотр истории действий |
|
||||||
|
|
||||||
|
#### 5.3 Панель администратора
|
||||||
|
- Доступна только администратору
|
||||||
|
- Таблица пользователей с их правами
|
||||||
|
- Чекбоксы для включения/выключения каждого права
|
||||||
|
- Применение изменений сохраняется немедленно
|
||||||
|
|
||||||
|
### 6. История действий (Аудит)
|
||||||
|
|
||||||
|
#### 6.1 Логирование действий
|
||||||
|
- Любые манипуляции с данными фиксируются: создание, редактирование, удаление идей, генерации AI, комментарии
|
||||||
|
- Сохраняется: кто сделал, что сделал, когда, старое значение, новое значение
|
||||||
|
|
||||||
|
#### 6.2 Формат записи аудита
|
||||||
|
| Поле | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| id | Уникальный идентификатор записи |
|
||||||
|
| userId | ID пользователя |
|
||||||
|
| userName | Имя пользователя |
|
||||||
|
| action | Тип действия (create, update, delete, generate, restore) |
|
||||||
|
| entityType | Тип сущности (idea, comment, specification, estimate, team_member) |
|
||||||
|
| entityId | ID сущности |
|
||||||
|
| oldValue | Значение до изменения (JSON) |
|
||||||
|
| newValue | Значение после изменения (JSON) |
|
||||||
|
| timestamp | Дата и время действия |
|
||||||
|
|
||||||
|
#### 6.3 Просмотр истории
|
||||||
|
- Страница истории действий (только для админа или пользователей с правом `view_audit_log`)
|
||||||
|
- Фильтрация по пользователю, типу действия, типу сущности, дате
|
||||||
|
- Возможность просмотра diff (что изменилось)
|
||||||
|
- Восстановление удалённых данных из аудита
|
||||||
|
|
||||||
|
#### 6.4 Настройки хранения
|
||||||
|
- Срок хранения истории настраивается администратором
|
||||||
|
- По умолчанию: 30 дней
|
||||||
|
- Автоматическая очистка старых записей по cron job
|
||||||
|
|
||||||
|
### 7. Многопользовательская работа
|
||||||
|
|
||||||
|
#### 7.1 Real-time обновления (WebSocket)
|
||||||
|
- Автоматическое обновление данных у всех пользователей при изменениях
|
||||||
|
- События: создание/редактирование/удаление идей, новые комментарии, изменение порядка
|
||||||
|
- Визуальная индикация изменений другими пользователями
|
||||||
|
|
||||||
|
#### 7.2 Конкурентное редактирование
|
||||||
|
- При попытке редактировать идею, которую редактирует другой пользователь — предупреждение
|
||||||
|
- Показ кто сейчас редактирует запись
|
||||||
|
- Оптимистичная блокировка с version/updatedAt
|
||||||
|
|
||||||
|
#### 7.3 Присутствие пользователей
|
||||||
|
- Показ онлайн пользователей
|
||||||
|
- Аватары/иконки пользователей, работающих с приложением
|
||||||
|
|
||||||
|
### 8. Темная тема
|
||||||
|
|
||||||
|
#### 8.1 Переключение темы
|
||||||
|
- Переключатель светлая/тёмная тема в header
|
||||||
|
- Автоопределение системной темы (prefers-color-scheme)
|
||||||
|
- Сохранение выбора в localStorage
|
||||||
|
|
||||||
|
#### 8.2 Цветовая схема
|
||||||
|
- Все компоненты поддерживают обе темы
|
||||||
|
- Цвета статусов, приоритетов и маркировки адаптированы для тёмной темы
|
||||||
|
- MUI theme provider с dark mode
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Технические требования
|
## Технические требования
|
||||||
@ -108,7 +223,10 @@
|
|||||||
- **Database**: PostgreSQL
|
- **Database**: PostgreSQL
|
||||||
- **ORM**: TypeORM
|
- **ORM**: TypeORM
|
||||||
- **API**: REST + WebSocket (для real-time обновлений)
|
- **API**: REST + WebSocket (для real-time обновлений)
|
||||||
|
- **WebSocket**: @nestjs/websockets + Socket.io
|
||||||
- **AI Integration**: ai-proxy service тут лежит гайд по интеграции /Users/vigdorov/dev/gptunnel-service/INTEGRATION.md
|
- **AI Integration**: ai-proxy service тут лежит гайд по интеграции /Users/vigdorov/dev/gptunnel-service/INTEGRATION.md
|
||||||
|
- **Document Generation**: docx (для экспорта)
|
||||||
|
- **Cron Jobs**: @nestjs/schedule (для очистки аудита)
|
||||||
|
|
||||||
### Frontend (React + TypeScript)
|
### Frontend (React + TypeScript)
|
||||||
|
|
||||||
@ -137,25 +255,31 @@
|
|||||||
### Безопасность
|
### Безопасность
|
||||||
- Валидация входных данных
|
- Валидация входных данных
|
||||||
- Rate limiting для AI-запросов
|
- Rate limiting для AI-запросов
|
||||||
|
- Проверка прав доступа на каждом endpoint
|
||||||
|
- Защита от конкурентных изменений (оптимистичная блокировка)
|
||||||
|
|
||||||
### Авторизация
|
### Авторизация и авторизация
|
||||||
- **Keycloak** (auth.vigdorov.ru) — внешний Identity Provider
|
- **Keycloak** (auth.vigdorov.ru) — внешний Identity Provider
|
||||||
- Авторизация через редиректы на стандартную форму Keycloak
|
- Авторизация через редиректы на стандартную форму Keycloak
|
||||||
- Authorization Code Flow + PKCE
|
- Authorization Code Flow + PKCE
|
||||||
- JWT токены с валидацией через JWKS
|
- JWT токены с валидацией через JWKS
|
||||||
- Автоматическое обновление токенов
|
- Автоматическое обновление токенов
|
||||||
- Защита всех API endpoints (кроме /health)
|
- Защита всех API endpoints (кроме /health)
|
||||||
- Роли и права доступа НЕ требуются — просто аутентификация
|
- **Гранулярные права доступа** — см. раздел 5
|
||||||
|
- **Администратор** определяется через K8s Secret `ADMIN_EMAIL`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Открытые вопросы
|
## Решённые вопросы
|
||||||
|
|
||||||
1. Нужна ли многопользовательская работа и разграничение прав?
|
1. Нужна ли многопользовательская работа и разграничение прав?
|
||||||
НЕТ
|
**ДА** — см. разделы 5 (Права доступа) и 7 (Многопользовательская работа)
|
||||||
|
|
||||||
2. Требуется ли история изменений (audit log)?
|
2. Требуется ли история изменений (audit log)?
|
||||||
НЕТ
|
**ДА** — см. раздел 6 (История действий)
|
||||||
4. Нужен ли экспорт данных (CSV, Excel)?
|
|
||||||
НЕТ
|
3. Нужен ли экспорт данных?
|
||||||
5. Интеграция с внешними системами (Jira, Trello)?
|
**ДА** — экспорт отдельной идеи в DOCX (см. раздел 1.6)
|
||||||
НЕТ
|
|
||||||
|
4. Интеграция с внешними системами (Jira, Trello)?
|
||||||
|
**НЕТ** — не требуется
|
||||||
|
|||||||
282
ROADMAP.md
282
ROADMAP.md
@ -13,7 +13,13 @@
|
|||||||
| 1 | Базовый функционал | ✅ Завершена | CRUD идей, таблица, редактирование |
|
| 1 | Базовый функционал | ✅ Завершена | CRUD идей, таблица, редактирование |
|
||||||
| 1.5 | Авторизация | ✅ Завершена | Keycloak, JWT, защита API |
|
| 1.5 | Авторизация | ✅ Завершена | Keycloak, JWT, защита API |
|
||||||
| 2 | Расширенный функционал | ✅ Завершена | Drag&Drop, цвета, комментарии, команда |
|
| 2 | Расширенный функционал | ✅ Завершена | Drag&Drop, цвета, комментарии, команда |
|
||||||
| 3 | AI-интеграция | ⏸️ Ожидает | Оценка времени, рекомендации |
|
| 3 | AI-интеграция | ✅ Завершена | Оценка времени, рекомендации |
|
||||||
|
| 3.1 | Генерация мини-ТЗ | ✅ Завершена | Генерация, редактирование, история ТЗ |
|
||||||
|
| 4 | Права доступа | 📋 Планируется | Гранулярные права, панель админа |
|
||||||
|
| 5 | Аудит и история | 📋 Планируется | Логирование действий, восстановление |
|
||||||
|
| 6 | Real-time и WebSocket | 📋 Планируется | Многопользовательская работа |
|
||||||
|
| 7 | Темная тема | 📋 Планируется | Переключение светлая/тёмная |
|
||||||
|
| 8 | Экспорт | 📋 Планируется | Экспорт идеи в DOCX |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -161,37 +167,271 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Фаза 3: AI-интеграция ⏸️
|
## Фаза 3: AI-интеграция ✅
|
||||||
|
|
||||||
### Backend — Модуль AI
|
### Backend — Модуль AI
|
||||||
- [ ] Интегрировать ai-proxy service
|
- [x] Интегрировать ai-proxy service
|
||||||
- [ ] POST /api/ai/estimate
|
- [x] POST /api/ai/estimate
|
||||||
- [ ] Получить идею и состав команды
|
- [x] Получить идею и состав команды
|
||||||
- [ ] Сформировать промпт
|
- [x] Сформировать промпт
|
||||||
- [ ] Отправить запрос в AI
|
- [x] Отправить запрос в AI
|
||||||
- [ ] Распарсить ответ
|
- [x] Распарсить ответ
|
||||||
- [ ] Сохранить оценку
|
- [x] Сохранить оценку
|
||||||
- [ ] Rate limiting для AI-запросов
|
- [ ] Rate limiting для AI-запросов (опционально)
|
||||||
|
|
||||||
### Frontend — AI-оценка
|
### Frontend — AI-оценка
|
||||||
- [ ] Кнопка "Оценить AI" в строке/детали идеи
|
- [x] Кнопка "Оценить AI" в строке/детали идеи
|
||||||
- [ ] Модалка с результатом оценки
|
- [x] Модалка с результатом оценки
|
||||||
- [ ] Общее время
|
- [x] Общее время
|
||||||
- [ ] Сложность
|
- [x] Сложность
|
||||||
- [ ] Разбивка по ролям
|
- [x] Разбивка по ролям
|
||||||
- [ ] Рекомендации
|
- [x] Рекомендации
|
||||||
- [ ] Отображение оценки в таблице
|
- [x] Отображение оценки в таблице
|
||||||
- [ ] Loading state для AI-запросов
|
- [x] Loading state для AI-запросов
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backlog (после MVP)
|
## Фаза 3.1: Генерация мини-ТЗ ✅
|
||||||
|
|
||||||
|
> **Генерация технического задания с помощью AI + история версий**
|
||||||
|
|
||||||
|
### Backend — Расширение модуля AI
|
||||||
|
- [x] Добавить поля в Idea entity (specification, specificationGeneratedAt)
|
||||||
|
- [x] Миграция для новых полей
|
||||||
|
- [x] POST /api/ai/generate-specification
|
||||||
|
- [x] Получить идею
|
||||||
|
- [x] Сформировать промпт для генерации ТЗ
|
||||||
|
- [x] Отправить запрос в AI
|
||||||
|
- [x] Сохранить результат
|
||||||
|
- [x] Обновить POST /api/ai/estimate — учитывать ТЗ в промпте
|
||||||
|
- [x] Добавить specification в UpdateIdeaDto
|
||||||
|
|
||||||
|
### Backend — История ТЗ
|
||||||
|
- [x] SpecificationHistory entity
|
||||||
|
- [x] Миграция для specification_history таблицы
|
||||||
|
- [x] GET /api/ai/specification-history/:ideaId
|
||||||
|
- [x] DELETE /api/ai/specification-history/:historyId
|
||||||
|
- [x] POST /api/ai/specification-history/:historyId/restore
|
||||||
|
- [x] Автосохранение старого ТЗ в историю при перегенерации
|
||||||
|
|
||||||
|
### Backend — Комментарии в AI-промптах
|
||||||
|
- [x] Включить комментарии к идее в промпт генерации ТЗ
|
||||||
|
- [x] Включить комментарии к идее в промпт оценки трудозатрат
|
||||||
|
|
||||||
|
### Frontend — Модалка ТЗ
|
||||||
|
- [x] Новый компонент SpecificationModal
|
||||||
|
- [x] Режим генерации (loading → результат)
|
||||||
|
- [x] Режим просмотра
|
||||||
|
- [x] Режим редактирования
|
||||||
|
- [x] Markdown-рендеринг (react-markdown)
|
||||||
|
- [x] Кнопка ТЗ в колонке actions
|
||||||
|
- [x] Серая — ТЗ нет
|
||||||
|
- [x] Синяя — ТЗ есть
|
||||||
|
- [x] Spinner — генерация
|
||||||
|
- [x] Хук useGenerateSpecification
|
||||||
|
- [x] API метод generateSpecification
|
||||||
|
|
||||||
|
### Frontend — История ТЗ
|
||||||
|
- [x] Табы "Текущее ТЗ" / "История" (при наличии истории)
|
||||||
|
- [x] Список исторических версий с датами
|
||||||
|
- [x] Просмотр исторической версии
|
||||||
|
- [x] Восстановление версии из истории
|
||||||
|
- [x] Удаление версии из истории
|
||||||
|
- [x] Хуки useSpecificationHistory, useDeleteSpecificationHistoryItem, useRestoreSpecificationFromHistory
|
||||||
|
|
||||||
|
### E2E тестирование
|
||||||
|
- [x] Генерация ТЗ для идеи
|
||||||
|
- [x] Просмотр существующего ТЗ
|
||||||
|
- [x] Редактирование и сохранение ТЗ
|
||||||
|
- [x] data-testid для новых компонентов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 4: Права доступа 📋
|
||||||
|
|
||||||
|
> **Гранулярная система прав доступа и панель администратора**
|
||||||
|
|
||||||
|
### Backend — Модуль Permissions
|
||||||
|
- [ ] User entity (userId, email, name, lastLogin)
|
||||||
|
- [ ] UserPermissions entity (связь с User, все права как boolean поля)
|
||||||
|
- [ ] Миграции для users и user_permissions
|
||||||
|
- [ ] PermissionsService (getMyPermissions, getUsersWithPermissions, updateUserPermissions)
|
||||||
|
- [ ] PermissionsController
|
||||||
|
- [ ] GET /api/permissions/me
|
||||||
|
- [ ] GET /api/permissions/users (admin only)
|
||||||
|
- [ ] PATCH /api/permissions/:userId (admin only)
|
||||||
|
- [ ] PermissionsGuard (проверка прав на endpoints)
|
||||||
|
- [ ] @RequirePermission() декоратор
|
||||||
|
- [ ] Env: ADMIN_EMAIL из K8s Secret
|
||||||
|
- [ ] Middleware: создание User при первом входе (только view_ideas)
|
||||||
|
|
||||||
|
### Backend — Защита существующих endpoints
|
||||||
|
- [ ] IdeasController — проверка create_ideas, edit_own/any_ideas, delete_own/any_ideas
|
||||||
|
- [ ] CommentsController — проверка add_comments, delete_own/any_comments
|
||||||
|
- [ ] AiController — проверка request_ai_estimate, request_ai_specification
|
||||||
|
- [ ] TeamController — проверка manage_team, manage_roles
|
||||||
|
|
||||||
|
### Frontend — Панель администратора
|
||||||
|
- [ ] AdminPage компонент
|
||||||
|
- [ ] PermissionsTable — таблица пользователей с чекбоксами прав
|
||||||
|
- [ ] usePermissions хуки (useMyPermissions, useUsersPermissions, useUpdatePermissions)
|
||||||
|
- [ ] Скрытие/отключение кнопок на основе прав
|
||||||
|
- [ ] Роутинг: /admin (только для админа)
|
||||||
|
|
||||||
|
### Backend — Автор идеи
|
||||||
|
- [ ] Добавить поле authorId, authorName в Idea entity
|
||||||
|
- [ ] Миграция для новых полей
|
||||||
|
- [ ] Автозаполнение при создании идеи
|
||||||
|
- [ ] Запрет изменения автора в UpdateIdeaDto
|
||||||
|
|
||||||
|
### Frontend — Отображение автора
|
||||||
|
- [ ] Колонка "Автор" в таблице идей
|
||||||
|
- [ ] Отображение автора в деталях идеи
|
||||||
|
|
||||||
|
### E2E тестирование
|
||||||
|
- [ ] Тесты прав доступа
|
||||||
|
- [ ] Тесты панели администратора
|
||||||
|
- [ ] Тесты автора идеи
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 5: Аудит и история 📋
|
||||||
|
|
||||||
|
> **Логирование всех действий с возможностью восстановления**
|
||||||
|
|
||||||
|
### Backend — Модуль Audit
|
||||||
|
- [ ] AuditLog entity (userId, userName, action, entityType, entityId, oldValue, newValue, timestamp)
|
||||||
|
- [ ] Миграция для audit_log таблицы
|
||||||
|
- [ ] AuditService
|
||||||
|
- [ ] log(action, entityType, entityId, oldValue, newValue)
|
||||||
|
- [ ] getAuditLog(filters, pagination)
|
||||||
|
- [ ] restore(auditId)
|
||||||
|
- [ ] cleanup(olderThanDays)
|
||||||
|
- [ ] AuditController
|
||||||
|
- [ ] GET /api/audit
|
||||||
|
- [ ] POST /api/audit/:id/restore
|
||||||
|
- [ ] GET /api/audit/settings
|
||||||
|
- [ ] PATCH /api/audit/settings
|
||||||
|
- [ ] Интеграция AuditService во все сервисы (Ideas, Comments, Team, AI)
|
||||||
|
- [ ] Cron job для очистки старых записей (@nestjs/schedule)
|
||||||
|
- [ ] Env: AUDIT_RETENTION_DAYS
|
||||||
|
|
||||||
|
### Frontend — Страница истории
|
||||||
|
- [ ] AuditPage компонент
|
||||||
|
- [ ] AuditLogTable с фильтрами
|
||||||
|
- [ ] AuditDetailModal (просмотр diff)
|
||||||
|
- [ ] Кнопка "Восстановить" для удалённых сущностей
|
||||||
|
- [ ] useAudit хуки
|
||||||
|
|
||||||
|
### Frontend — Настройки аудита (в админ-панели)
|
||||||
|
- [ ] Поле "Срок хранения истории" в AdminPage
|
||||||
|
- [ ] useAuditSettings хук
|
||||||
|
|
||||||
|
### E2E тестирование
|
||||||
|
- [ ] Тесты просмотра истории
|
||||||
|
- [ ] Тесты восстановления
|
||||||
|
- [ ] Тесты настроек аудита
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 6: Real-time и WebSocket 📋
|
||||||
|
|
||||||
|
> **Многопользовательская работа с real-time обновлениями**
|
||||||
|
|
||||||
|
### Backend — WebSocket Gateway
|
||||||
|
- [ ] Установить @nestjs/websockets, socket.io
|
||||||
|
- [ ] EventsGateway (handleConnection, handleDisconnect)
|
||||||
|
- [ ] JWT валидация в WebSocket handshake
|
||||||
|
- [ ] События: idea:created, idea:updated, idea:deleted, ideas:reordered
|
||||||
|
- [ ] События: comment:created, comment:deleted
|
||||||
|
- [ ] События: specification:generated, estimate:generated
|
||||||
|
- [ ] События присутствия: users:online, user:joined, user:left
|
||||||
|
- [ ] События редактирования: idea:editing, idea:stopEditing
|
||||||
|
- [ ] Интеграция emit во все сервисы
|
||||||
|
|
||||||
|
### Frontend — WebSocket Provider
|
||||||
|
- [ ] WebSocketProvider компонент (socket.io-client)
|
||||||
|
- [ ] useWebSocket хук
|
||||||
|
- [ ] Автоматическая синхронизация React Query при получении событий
|
||||||
|
- [ ] Reconnect логика
|
||||||
|
|
||||||
|
### Frontend — Индикаторы
|
||||||
|
- [ ] OnlineUsers компонент (список онлайн пользователей)
|
||||||
|
- [ ] EditingIndicator (кто редактирует идею)
|
||||||
|
- [ ] Визуальная подсветка изменённых строк
|
||||||
|
|
||||||
|
### Frontend — Конкурентное редактирование
|
||||||
|
- [ ] Предупреждение при попытке редактировать занятую идею
|
||||||
|
- [ ] Optimistic locking (проверка version/updatedAt)
|
||||||
|
- [ ] Разрешение конфликтов
|
||||||
|
|
||||||
|
### E2E тестирование
|
||||||
|
- [ ] Тесты real-time обновлений (2 браузера)
|
||||||
|
- [ ] Тесты присутствия
|
||||||
|
- [ ] Тесты конкурентного редактирования
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 7: Темная тема 📋
|
||||||
|
|
||||||
|
> **Поддержка светлой и тёмной темы интерфейса**
|
||||||
|
|
||||||
|
### Frontend — Theme Provider
|
||||||
|
- [ ] ThemeStore (Zustand) — текущая тема, автоопределение
|
||||||
|
- [ ] ThemeProvider (MUI createTheme с dark/light mode)
|
||||||
|
- [ ] Сохранение выбора в localStorage
|
||||||
|
- [ ] Автоопределение системной темы (prefers-color-scheme)
|
||||||
|
|
||||||
|
### Frontend — Цветовые схемы
|
||||||
|
- [ ] Палитра для тёмной темы (см. ARCHITECTURE.md 5.1)
|
||||||
|
- [ ] Адаптация цветов статусов и приоритетов
|
||||||
|
- [ ] Адаптация цветов маркировки строк
|
||||||
|
- [ ] Адаптация всех компонентов
|
||||||
|
|
||||||
|
### Frontend — UI
|
||||||
|
- [ ] ThemeToggle компонент в header
|
||||||
|
- [ ] Иконки ☀️/🌙 для переключения
|
||||||
|
|
||||||
|
### E2E тестирование
|
||||||
|
- [ ] Тест переключения темы
|
||||||
|
- [ ] Визуальный тест тёмной темы
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 8: Экспорт 📋
|
||||||
|
|
||||||
|
> **Экспорт идеи в документ DOCX**
|
||||||
|
|
||||||
|
### Backend — Модуль Export
|
||||||
|
- [ ] Установить docx библиотеку
|
||||||
|
- [ ] ExportService
|
||||||
|
- [ ] generateIdeaDocx(ideaId) — генерация DOCX
|
||||||
|
- [ ] Включение: название, описание, статус, приоритет, модуль
|
||||||
|
- [ ] Включение: целевая аудитория, боль, роль AI, способ проверки
|
||||||
|
- [ ] Включение: AI-оценка (если есть)
|
||||||
|
- [ ] Включение: ТЗ в markdown → форматированный текст (если есть)
|
||||||
|
- [ ] Включение: комментарии (автор, дата, текст)
|
||||||
|
- [ ] ExportController
|
||||||
|
- [ ] GET /api/export/idea/:id
|
||||||
|
|
||||||
|
### Frontend — Кнопка экспорта
|
||||||
|
- [ ] Кнопка экспорта в строке таблицы (⬇️ иконка)
|
||||||
|
- [ ] useExportIdea хук
|
||||||
|
- [ ] Скачивание файла через blob
|
||||||
|
|
||||||
|
### E2E тестирование
|
||||||
|
- [ ] Тест экспорта идеи
|
||||||
|
- [ ] Проверка содержимого DOCX
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backlog (после фаз 4-8)
|
||||||
|
|
||||||
- [ ] WebSocket для real-time обновлений
|
|
||||||
- [ ] Виртуализация списка (1000+ идей)
|
- [ ] Виртуализация списка (1000+ идей)
|
||||||
- [ ] Keyboard shortcuts
|
- [ ] Keyboard shortcuts
|
||||||
- [ ] Сохранение пресетов фильтров
|
- [ ] Сохранение пресетов фильтров
|
||||||
- [ ] Темная тема
|
- [ ] Уведомления (email/push при упоминании)
|
||||||
|
- [ ] Интеграция с Jira/Trello (опционально)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
41
backend/src/ai/ai.controller.ts
Normal file
41
backend/src/ai/ai.controller.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Controller, Post, Get, Delete, Body, Param } from '@nestjs/common';
|
||||||
|
import { AiService, EstimateResult, SpecificationResult, SpecificationHistoryItem } from './ai.service';
|
||||||
|
import { EstimateIdeaDto, GenerateSpecificationDto } from './dto';
|
||||||
|
|
||||||
|
@Controller('ai')
|
||||||
|
export class AiController {
|
||||||
|
constructor(private readonly aiService: AiService) {}
|
||||||
|
|
||||||
|
@Post('estimate')
|
||||||
|
async estimateIdea(@Body() dto: EstimateIdeaDto): Promise<EstimateResult> {
|
||||||
|
return this.aiService.estimateIdea(dto.ideaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('generate-specification')
|
||||||
|
async generateSpecification(
|
||||||
|
@Body() dto: GenerateSpecificationDto,
|
||||||
|
): Promise<SpecificationResult> {
|
||||||
|
return this.aiService.generateSpecification(dto.ideaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('specification-history/:ideaId')
|
||||||
|
async getSpecificationHistory(
|
||||||
|
@Param('ideaId') ideaId: string,
|
||||||
|
): Promise<SpecificationHistoryItem[]> {
|
||||||
|
return this.aiService.getSpecificationHistory(ideaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('specification-history/:historyId')
|
||||||
|
async deleteSpecificationHistoryItem(
|
||||||
|
@Param('historyId') historyId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.aiService.deleteSpecificationHistoryItem(historyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('specification-history/:historyId/restore')
|
||||||
|
async restoreSpecificationFromHistory(
|
||||||
|
@Param('historyId') historyId: string,
|
||||||
|
): Promise<SpecificationResult> {
|
||||||
|
return this.aiService.restoreSpecificationFromHistory(historyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/ai/ai.module.ts
Normal file
16
backend/src/ai/ai.module.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AiController } from './ai.controller';
|
||||||
|
import { AiService } from './ai.service';
|
||||||
|
import { Idea } from '../ideas/entities/idea.entity';
|
||||||
|
import { TeamMember } from '../team/entities/team-member.entity';
|
||||||
|
import { SpecificationHistory } from '../ideas/entities/specification-history.entity';
|
||||||
|
import { Comment } from '../comments/entities/comment.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Idea, TeamMember, SpecificationHistory, Comment])],
|
||||||
|
controllers: [AiController],
|
||||||
|
providers: [AiService],
|
||||||
|
exports: [AiService],
|
||||||
|
})
|
||||||
|
export class AiModule {}
|
||||||
398
backend/src/ai/ai.service.ts
Normal file
398
backend/src/ai/ai.service.ts
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Idea } from '../ideas/entities/idea.entity';
|
||||||
|
import { TeamMember } from '../team/entities/team-member.entity';
|
||||||
|
import { SpecificationHistory } from '../ideas/entities/specification-history.entity';
|
||||||
|
import { Comment } from '../comments/entities/comment.entity';
|
||||||
|
|
||||||
|
export interface RoleEstimate {
|
||||||
|
role: string;
|
||||||
|
hours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateResult {
|
||||||
|
ideaId: string;
|
||||||
|
ideaTitle: string;
|
||||||
|
totalHours: number;
|
||||||
|
complexity: 'trivial' | 'simple' | 'medium' | 'complex' | 'veryComplex';
|
||||||
|
breakdown: RoleEstimate[];
|
||||||
|
recommendations: string[];
|
||||||
|
estimatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecificationResult {
|
||||||
|
ideaId: string;
|
||||||
|
ideaTitle: string;
|
||||||
|
specification: string;
|
||||||
|
generatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecificationHistoryItem {
|
||||||
|
id: string;
|
||||||
|
specification: string;
|
||||||
|
ideaDescriptionSnapshot: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiService {
|
||||||
|
private readonly logger = new Logger(AiService.name);
|
||||||
|
private readonly aiProxyBaseUrl: string;
|
||||||
|
private readonly aiProxyApiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
@InjectRepository(Idea)
|
||||||
|
private ideaRepository: Repository<Idea>,
|
||||||
|
@InjectRepository(TeamMember)
|
||||||
|
private teamMemberRepository: Repository<TeamMember>,
|
||||||
|
@InjectRepository(SpecificationHistory)
|
||||||
|
private specificationHistoryRepository: Repository<SpecificationHistory>,
|
||||||
|
@InjectRepository(Comment)
|
||||||
|
private commentRepository: Repository<Comment>,
|
||||||
|
) {
|
||||||
|
this.aiProxyBaseUrl = this.configService.get<string>(
|
||||||
|
'AI_PROXY_BASE_URL',
|
||||||
|
'http://ai-proxy-service.ai-proxy.svc.cluster.local:3000',
|
||||||
|
);
|
||||||
|
this.aiProxyApiKey = this.configService.get<string>('AI_PROXY_API_KEY', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateSpecification(ideaId: string): Promise<SpecificationResult> {
|
||||||
|
// Загружаем идею
|
||||||
|
const idea = await this.ideaRepository.findOne({ where: { id: ideaId } });
|
||||||
|
if (!idea) {
|
||||||
|
throw new HttpException('Идея не найдена', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем комментарии к идее
|
||||||
|
const comments = await this.commentRepository.find({
|
||||||
|
where: { ideaId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если уже есть ТЗ — сохраняем в историю
|
||||||
|
if (idea.specification) {
|
||||||
|
await this.specificationHistoryRepository.save({
|
||||||
|
ideaId: idea.id,
|
||||||
|
specification: idea.specification,
|
||||||
|
ideaDescriptionSnapshot: idea.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем промпт для генерации ТЗ
|
||||||
|
const prompt = this.buildSpecificationPrompt(idea, comments);
|
||||||
|
|
||||||
|
// Отправляем запрос к AI
|
||||||
|
const specification = await this.callAiProxy(prompt);
|
||||||
|
|
||||||
|
// Сохраняем ТЗ в идею
|
||||||
|
const generatedAt = new Date();
|
||||||
|
await this.ideaRepository.update(ideaId, {
|
||||||
|
specification,
|
||||||
|
specificationGeneratedAt: generatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ideaId: idea.id,
|
||||||
|
ideaTitle: idea.title,
|
||||||
|
specification,
|
||||||
|
generatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSpecificationHistory(ideaId: string): Promise<SpecificationHistoryItem[]> {
|
||||||
|
const history = await this.specificationHistoryRepository.find({
|
||||||
|
where: { ideaId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return history.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
specification: item.specification,
|
||||||
|
ideaDescriptionSnapshot: item.ideaDescriptionSnapshot,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSpecificationHistoryItem(historyId: string): Promise<void> {
|
||||||
|
const result = await this.specificationHistoryRepository.delete(historyId);
|
||||||
|
if (result.affected === 0) {
|
||||||
|
throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreSpecificationFromHistory(historyId: string): Promise<SpecificationResult> {
|
||||||
|
const historyItem = await this.specificationHistoryRepository.findOne({
|
||||||
|
where: { id: historyId },
|
||||||
|
relations: ['idea'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!historyItem) {
|
||||||
|
throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idea = historyItem.idea;
|
||||||
|
|
||||||
|
// Сохраняем текущее ТЗ в историю (если есть)
|
||||||
|
if (idea.specification) {
|
||||||
|
await this.specificationHistoryRepository.save({
|
||||||
|
ideaId: idea.id,
|
||||||
|
specification: idea.specification,
|
||||||
|
ideaDescriptionSnapshot: idea.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Восстанавливаем ТЗ из истории
|
||||||
|
const generatedAt = new Date();
|
||||||
|
await this.ideaRepository.update(idea.id, {
|
||||||
|
specification: historyItem.specification,
|
||||||
|
specificationGeneratedAt: generatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Удаляем восстановленную запись из истории
|
||||||
|
await this.specificationHistoryRepository.delete(historyId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ideaId: idea.id,
|
||||||
|
ideaTitle: idea.title,
|
||||||
|
specification: historyItem.specification,
|
||||||
|
generatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async estimateIdea(ideaId: string): Promise<EstimateResult> {
|
||||||
|
// Загружаем идею
|
||||||
|
const idea = await this.ideaRepository.findOne({ where: { id: ideaId } });
|
||||||
|
if (!idea) {
|
||||||
|
throw new HttpException('Идея не найдена', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем комментарии к идее
|
||||||
|
const comments = await this.commentRepository.find({
|
||||||
|
where: { ideaId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Загружаем состав команды
|
||||||
|
const teamMembers = await this.teamMemberRepository.find({
|
||||||
|
relations: ['role'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Формируем промпт
|
||||||
|
const prompt = this.buildPrompt(idea, teamMembers, comments);
|
||||||
|
|
||||||
|
// Отправляем запрос к AI
|
||||||
|
const aiResponse = await this.callAiProxy(prompt);
|
||||||
|
|
||||||
|
// Парсим ответ
|
||||||
|
const result = this.parseAiResponse(aiResponse, idea);
|
||||||
|
|
||||||
|
// Сохраняем оценку в идею
|
||||||
|
await this.ideaRepository.update(ideaId, {
|
||||||
|
estimatedHours: result.totalHours,
|
||||||
|
complexity: result.complexity,
|
||||||
|
estimateDetails: { breakdown: result.breakdown, recommendations: result.recommendations },
|
||||||
|
estimatedAt: result.estimatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPrompt(idea: Idea, teamMembers: TeamMember[], comments: Comment[]): string {
|
||||||
|
const teamInfo = teamMembers
|
||||||
|
.map((m) => {
|
||||||
|
const prod = m.productivity;
|
||||||
|
return `- ${m.name} (${m.role.name}): производительность — trivial: ${prod.trivial}ч, simple: ${prod.simple}ч, medium: ${prod.medium}ч, complex: ${prod.complex}ч, veryComplex: ${prod.veryComplex}ч`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const rolesSummary = this.getRolesSummary(teamMembers);
|
||||||
|
|
||||||
|
const commentsSection = comments.length > 0
|
||||||
|
? `## Комментарии к идее
|
||||||
|
${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')}
|
||||||
|
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `Ты — эксперт по оценке трудозатрат разработки программного обеспечения.
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
Оцени трудозатраты на реализацию следующей идеи с учётом состава команды.
|
||||||
|
|
||||||
|
## Идея
|
||||||
|
- **Название:** ${idea.title}
|
||||||
|
- **Описание:** ${idea.description || 'Не указано'}
|
||||||
|
- **Модуль:** ${idea.module || 'Не указан'}
|
||||||
|
- **Целевая аудитория:** ${idea.targetAudience || 'Не указана'}
|
||||||
|
- **Боль/Проблема:** ${idea.pain || 'Не указана'}
|
||||||
|
- **Роль AI:** ${idea.aiRole || 'Не указана'}
|
||||||
|
- **Способ проверки:** ${idea.verificationMethod || 'Не указан'}
|
||||||
|
- **Приоритет:** ${idea.priority}
|
||||||
|
|
||||||
|
## Техническое задание (ТЗ)
|
||||||
|
${idea.specification || 'Не указано'}
|
||||||
|
|
||||||
|
${commentsSection}## Состав команды
|
||||||
|
${teamInfo || 'Команда не указана'}
|
||||||
|
|
||||||
|
## Роли в команде
|
||||||
|
${rolesSummary}
|
||||||
|
|
||||||
|
## Требуемый формат ответа (СТРОГО JSON)
|
||||||
|
Верни ТОЛЬКО JSON без markdown-разметки:
|
||||||
|
{
|
||||||
|
"totalHours": <число — общее количество часов>,
|
||||||
|
"complexity": "<одно из: trivial, simple, medium, complex, veryComplex>",
|
||||||
|
"breakdown": [
|
||||||
|
{"role": "<название роли>", "hours": <число>}
|
||||||
|
],
|
||||||
|
"recommendations": ["<рекомендация 1>", "<рекомендация 2>"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Учитывай реальную производительность каждого члена команды при оценке. Обязательно учти информацию из комментариев — там могут быть важные уточнения и особенности.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRolesSummary(teamMembers: TeamMember[]): string {
|
||||||
|
const rolesMap = new Map<string, number>();
|
||||||
|
for (const member of teamMembers) {
|
||||||
|
const roleName = member.role.name;
|
||||||
|
rolesMap.set(roleName, (rolesMap.get(roleName) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(rolesMap.entries())
|
||||||
|
.map(([role, count]) => `- ${role}: ${count} чел.`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSpecificationPrompt(idea: Idea, comments: Comment[]): string {
|
||||||
|
const commentsSection = comments.length > 0
|
||||||
|
? `## Комментарии к идее
|
||||||
|
${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')}
|
||||||
|
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `Ты — опытный бизнес-аналитик и технический писатель.
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
Составь краткое техническое задание (мини-ТЗ) для следующей идеи. ТЗ должно быть достаточно детальным для оценки трудозатрат и понимания scope работ.
|
||||||
|
|
||||||
|
## Идея
|
||||||
|
- **Название:** ${idea.title}
|
||||||
|
- **Описание:** ${idea.description || 'Не указано'}
|
||||||
|
- **Модуль:** ${idea.module || 'Не указан'}
|
||||||
|
- **Целевая аудитория:** ${idea.targetAudience || 'Не указана'}
|
||||||
|
- **Боль/Проблема:** ${idea.pain || 'Не указана'}
|
||||||
|
- **Роль AI:** ${idea.aiRole || 'Не указана'}
|
||||||
|
- **Способ проверки:** ${idea.verificationMethod || 'Не указан'}
|
||||||
|
- **Приоритет:** ${idea.priority}
|
||||||
|
|
||||||
|
${commentsSection}## Требования к ТЗ
|
||||||
|
Мини-ТЗ должно содержать:
|
||||||
|
1. **Цель** — что должно быть достигнуто
|
||||||
|
2. **Функциональные требования** — основные функции (3-7 пунктов)
|
||||||
|
3. **Нефункциональные требования** — если применимо (производительность, безопасность)
|
||||||
|
4. **Критерии приёмки** — как понять что задача выполнена
|
||||||
|
5. **Ограничения и допущения** — что не входит в scope
|
||||||
|
|
||||||
|
**Важно:** Обязательно учти информацию из комментариев при составлении ТЗ — там могут быть важные уточнения, требования и особенности реализации.
|
||||||
|
|
||||||
|
## Формат ответа
|
||||||
|
Напиши ТЗ в формате Markdown. Будь конкретен, избегай общих фраз. Объём: 200-400 слов.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callAiProxy(prompt: string): Promise<string> {
|
||||||
|
if (!this.aiProxyApiKey) {
|
||||||
|
throw new HttpException(
|
||||||
|
'AI_PROXY_API_KEY не настроен',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.aiProxyBaseUrl}/api/v1/chat/completions`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': this.aiProxyApiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'claude-3.7-sonnet',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 1000,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
this.logger.error(`AI Proxy error: ${response.status} - ${errorText}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Ошибка при запросе к AI сервису',
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.choices[0].message.content;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.error(`AI Proxy call failed: ${error.message}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Не удалось подключиться к AI сервису',
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAiResponse(aiResponse: string, idea: Idea): EstimateResult {
|
||||||
|
try {
|
||||||
|
// Удаляем возможную markdown-разметку
|
||||||
|
let cleanJson = aiResponse.trim();
|
||||||
|
if (cleanJson.startsWith('```json')) {
|
||||||
|
cleanJson = cleanJson.slice(7);
|
||||||
|
}
|
||||||
|
if (cleanJson.startsWith('```')) {
|
||||||
|
cleanJson = cleanJson.slice(3);
|
||||||
|
}
|
||||||
|
if (cleanJson.endsWith('```')) {
|
||||||
|
cleanJson = cleanJson.slice(0, -3);
|
||||||
|
}
|
||||||
|
cleanJson = cleanJson.trim();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(cleanJson);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ideaId: idea.id,
|
||||||
|
ideaTitle: idea.title,
|
||||||
|
totalHours: Number(parsed.totalHours) || 0,
|
||||||
|
complexity: parsed.complexity || 'medium',
|
||||||
|
breakdown: Array.isArray(parsed.breakdown) ? parsed.breakdown : [],
|
||||||
|
recommendations: Array.isArray(parsed.recommendations)
|
||||||
|
? parsed.recommendations
|
||||||
|
: [],
|
||||||
|
estimatedAt: new Date(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to parse AI response: ${aiResponse}`);
|
||||||
|
throw new HttpException(
|
||||||
|
'Не удалось разобрать ответ AI',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
backend/src/ai/dto/estimate-idea.dto.ts
Normal file
6
backend/src/ai/dto/estimate-idea.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class EstimateIdeaDto {
|
||||||
|
@IsUUID()
|
||||||
|
ideaId: string;
|
||||||
|
}
|
||||||
6
backend/src/ai/dto/generate-specification.dto.ts
Normal file
6
backend/src/ai/dto/generate-specification.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class GenerateSpecificationDto {
|
||||||
|
@IsUUID()
|
||||||
|
ideaId: string;
|
||||||
|
}
|
||||||
2
backend/src/ai/dto/index.ts
Normal file
2
backend/src/ai/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './estimate-idea.dto';
|
||||||
|
export * from './generate-specification.dto';
|
||||||
@ -8,6 +8,7 @@ import { IdeasModule } from './ideas/ideas.module';
|
|||||||
import { CommentsModule } from './comments/comments.module';
|
import { CommentsModule } from './comments/comments.module';
|
||||||
import { TeamModule } from './team/team.module';
|
import { TeamModule } from './team/team.module';
|
||||||
import { AuthModule, JwtAuthGuard } from './auth';
|
import { AuthModule, JwtAuthGuard } from './auth';
|
||||||
|
import { AiModule } from './ai/ai.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -34,6 +35,7 @@ import { AuthModule, JwtAuthGuard } from './auth';
|
|||||||
IdeasModule,
|
IdeasModule,
|
||||||
CommentsModule,
|
CommentsModule,
|
||||||
TeamModule,
|
TeamModule,
|
||||||
|
AiModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
import { IsOptional, IsInt, Min } from 'class-validator';
|
import { IsOptional, IsInt, Min, IsString } from 'class-validator';
|
||||||
import { CreateIdeaDto } from './create-idea.dto';
|
import { CreateIdeaDto } from './create-idea.dto';
|
||||||
|
|
||||||
export class UpdateIdeaDto extends PartialType(CreateIdeaDto) {
|
export class UpdateIdeaDto extends PartialType(CreateIdeaDto) {
|
||||||
@ -7,4 +7,8 @@ export class UpdateIdeaDto extends PartialType(CreateIdeaDto) {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
order?: number;
|
order?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
specification?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,6 +72,26 @@ export class Idea {
|
|||||||
@Column({ type: 'int', default: 0 })
|
@Column({ type: 'int', default: 0 })
|
||||||
order: number;
|
order: number;
|
||||||
|
|
||||||
|
// AI-оценка
|
||||||
|
@Column({ name: 'estimated_hours', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
|
estimatedHours: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
complexity: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'estimate_details', type: 'jsonb', nullable: true })
|
||||||
|
estimateDetails: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@Column({ name: 'estimated_at', type: 'timestamp', nullable: true })
|
||||||
|
estimatedAt: Date | null;
|
||||||
|
|
||||||
|
// Мини-ТЗ
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
specification: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'specification_generated_at', type: 'timestamp', nullable: true })
|
||||||
|
specificationGeneratedAt: Date | null;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
31
backend/src/ideas/entities/specification-history.entity.ts
Normal file
31
backend/src/ideas/entities/specification-history.entity.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Idea } from './idea.entity';
|
||||||
|
|
||||||
|
@Entity('specification_history')
|
||||||
|
export class SpecificationHistory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'idea_id' })
|
||||||
|
ideaId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Idea, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'idea_id' })
|
||||||
|
idea: Idea;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
specification: string;
|
||||||
|
|
||||||
|
@Column({ name: 'idea_description_snapshot', type: 'text', nullable: true })
|
||||||
|
ideaDescriptionSnapshot: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { IdeasService } from './ideas.service';
|
import { IdeasService } from './ideas.service';
|
||||||
import { IdeasController } from './ideas.controller';
|
import { IdeasController } from './ideas.controller';
|
||||||
import { Idea } from './entities/idea.entity';
|
import { Idea } from './entities/idea.entity';
|
||||||
|
import { SpecificationHistory } from './entities/specification-history.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Idea])],
|
imports: [TypeOrmModule.forFeature([Idea, SpecificationHistory])],
|
||||||
controllers: [IdeasController],
|
controllers: [IdeasController],
|
||||||
providers: [IdeasService],
|
providers: [IdeasService],
|
||||||
exports: [IdeasService],
|
exports: [IdeasService, TypeOrmModule],
|
||||||
})
|
})
|
||||||
export class IdeasModule {}
|
export class IdeasModule {}
|
||||||
|
|||||||
25
backend/src/migrations/1736899500000-AddAiEstimateFields.ts
Normal file
25
backend/src/migrations/1736899500000-AddAiEstimateFields.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddAiEstimateFields1736899500000 implements MigrationInterface {
|
||||||
|
name = 'AddAiEstimateFields1736899500000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "ideas"
|
||||||
|
ADD COLUMN "estimated_hours" DECIMAL(10, 2),
|
||||||
|
ADD COLUMN "complexity" VARCHAR(20),
|
||||||
|
ADD COLUMN "estimate_details" JSONB,
|
||||||
|
ADD COLUMN "estimated_at" TIMESTAMP
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "ideas"
|
||||||
|
DROP COLUMN "estimated_at",
|
||||||
|
DROP COLUMN "estimate_details",
|
||||||
|
DROP COLUMN "complexity",
|
||||||
|
DROP COLUMN "estimated_hours"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddSpecificationField1736942400000 implements MigrationInterface {
|
||||||
|
name = 'AddSpecificationField1736942400000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "ideas"
|
||||||
|
ADD COLUMN "specification" TEXT,
|
||||||
|
ADD COLUMN "specification_generated_at" TIMESTAMP
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "ideas"
|
||||||
|
DROP COLUMN "specification_generated_at",
|
||||||
|
DROP COLUMN "specification"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddSpecificationHistory1736943000000 implements MigrationInterface {
|
||||||
|
name = 'AddSpecificationHistory1736943000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "specification_history" (
|
||||||
|
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
"idea_id" uuid NOT NULL,
|
||||||
|
"specification" text NOT NULL,
|
||||||
|
"idea_description_snapshot" text,
|
||||||
|
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "PK_specification_history" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "FK_specification_history_idea" FOREIGN KEY ("idea_id")
|
||||||
|
REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE NO ACTION
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX "IDX_specification_history_idea_id" ON "specification_history" ("idea_id")
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_specification_history_idea_id"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "specification_history"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@
|
|||||||
"keycloak-js": "^26.2.2",
|
"keycloak-js": "^26.2.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
204
frontend/src/components/AiEstimateModal/AiEstimateModal.tsx
Normal file
204
frontend/src/components/AiEstimateModal/AiEstimateModal.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
LinearProgress,
|
||||||
|
Alert,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
AccessTime,
|
||||||
|
TrendingUp,
|
||||||
|
Lightbulb,
|
||||||
|
CheckCircle,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import type { EstimateResult } from '../../services/ai';
|
||||||
|
import type { IdeaComplexity } from '../../types/idea';
|
||||||
|
|
||||||
|
interface AiEstimateModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
result: EstimateResult | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const complexityLabels: Record<IdeaComplexity, string> = {
|
||||||
|
trivial: 'Тривиальная',
|
||||||
|
simple: 'Простая',
|
||||||
|
medium: 'Средняя',
|
||||||
|
complex: 'Сложная',
|
||||||
|
veryComplex: 'Очень сложная',
|
||||||
|
};
|
||||||
|
|
||||||
|
const complexityColors: Record<
|
||||||
|
IdeaComplexity,
|
||||||
|
'success' | 'info' | 'warning' | 'error' | 'default'
|
||||||
|
> = {
|
||||||
|
trivial: 'success',
|
||||||
|
simple: 'success',
|
||||||
|
medium: 'info',
|
||||||
|
complex: 'warning',
|
||||||
|
veryComplex: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatHours(hours: number): string {
|
||||||
|
if (hours < 8) {
|
||||||
|
return `${hours} ч`;
|
||||||
|
}
|
||||||
|
const days = Math.floor(hours / 8);
|
||||||
|
const remainingHours = hours % 8;
|
||||||
|
if (remainingHours === 0) {
|
||||||
|
return `${days} д`;
|
||||||
|
}
|
||||||
|
return `${days} д ${remainingHours} ч`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiEstimateModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
result,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
}: AiEstimateModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
data-testid="ai-estimate-modal"
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
AI-оценка трудозатрат
|
||||||
|
{result && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{result.ideaTitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
{isLoading && (
|
||||||
|
<Box sx={{ py: 4 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
Анализируем идею и состав команды...
|
||||||
|
</Typography>
|
||||||
|
<LinearProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ my: 2 }}>
|
||||||
|
{error.message || 'Не удалось получить оценку'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !isLoading && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{/* Общая оценка */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 3,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
py: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<AccessTime color="primary" />
|
||||||
|
<Typography variant="h4" component="span">
|
||||||
|
{formatHours(result.totalHours)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Общее время
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<TrendingUp color="primary" />
|
||||||
|
<Chip
|
||||||
|
label={complexityLabels[result.complexity]}
|
||||||
|
color={complexityColors[result.complexity]}
|
||||||
|
size="medium"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Сложность
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Разбивка по ролям */}
|
||||||
|
{result.breakdown.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
Разбивка по ролям
|
||||||
|
</Typography>
|
||||||
|
<Paper variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Роль</TableCell>
|
||||||
|
<TableCell align="right">Время</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{result.breakdown.map((item, index) => (
|
||||||
|
<TableRow key={index} data-testid={`estimate-breakdown-row-${index}`}>
|
||||||
|
<TableCell>{item.role}</TableCell>
|
||||||
|
<TableCell align="right">{formatHours(item.hours)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Рекомендации */}
|
||||||
|
{result.recommendations.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Lightbulb fontSize="small" color="warning" />
|
||||||
|
Рекомендации
|
||||||
|
</Typography>
|
||||||
|
<List dense disablePadding>
|
||||||
|
{result.recommendations.map((rec, index) => (
|
||||||
|
<ListItem key={index} disableGutters data-testid={`estimate-recommendation-${index}`}>
|
||||||
|
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||||
|
<CheckCircle fontSize="small" color="success" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={rec} />
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} data-testid="close-estimate-modal-button">
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/components/AiEstimateModal/index.ts
Normal file
1
frontend/src/components/AiEstimateModal/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AiEstimateModal } from './AiEstimateModal';
|
||||||
@ -40,18 +40,35 @@ import {
|
|||||||
useIdeasQuery,
|
useIdeasQuery,
|
||||||
useDeleteIdea,
|
useDeleteIdea,
|
||||||
useReorderIdeas,
|
useReorderIdeas,
|
||||||
|
useUpdateIdea,
|
||||||
} from '../../hooks/useIdeas';
|
} from '../../hooks/useIdeas';
|
||||||
|
import {
|
||||||
|
useEstimateIdea,
|
||||||
|
useGenerateSpecification,
|
||||||
|
useSpecificationHistory,
|
||||||
|
useDeleteSpecificationHistoryItem,
|
||||||
|
useRestoreSpecificationFromHistory,
|
||||||
|
} from '../../hooks/useAi';
|
||||||
import { useIdeasStore } from '../../store/ideas';
|
import { useIdeasStore } from '../../store/ideas';
|
||||||
import { createColumns } from './columns';
|
import { createColumns } from './columns';
|
||||||
import { DraggableRow } from './DraggableRow';
|
import { DraggableRow } from './DraggableRow';
|
||||||
import { CommentsPanel } from '../CommentsPanel';
|
import { CommentsPanel } from '../CommentsPanel';
|
||||||
|
import { AiEstimateModal } from '../AiEstimateModal';
|
||||||
|
import { SpecificationModal } from '../SpecificationModal';
|
||||||
|
import type { EstimateResult } from '../../services/ai';
|
||||||
|
import type { Idea } from '../../types/idea';
|
||||||
|
|
||||||
const SKELETON_COLUMNS_COUNT = 9;
|
const SKELETON_COLUMNS_COUNT = 10;
|
||||||
|
|
||||||
export function IdeasTable() {
|
export function IdeasTable() {
|
||||||
const { data, isLoading, isError } = useIdeasQuery();
|
const { data, isLoading, isError } = useIdeasQuery();
|
||||||
const deleteIdea = useDeleteIdea();
|
const deleteIdea = useDeleteIdea();
|
||||||
const reorderIdeas = useReorderIdeas();
|
const reorderIdeas = useReorderIdeas();
|
||||||
|
const updateIdea = useUpdateIdea();
|
||||||
|
const estimateIdea = useEstimateIdea();
|
||||||
|
const generateSpecification = useGenerateSpecification();
|
||||||
|
const deleteSpecificationHistoryItem = useDeleteSpecificationHistoryItem();
|
||||||
|
const restoreSpecificationFromHistory = useRestoreSpecificationFromHistory();
|
||||||
const { sorting, setSorting, pagination, setPage, setLimit } =
|
const { sorting, setSorting, pagination, setPage, setLimit } =
|
||||||
useIdeasStore();
|
useIdeasStore();
|
||||||
|
|
||||||
@ -59,19 +76,140 @@ export function IdeasTable() {
|
|||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
// ID идеи с раскрытыми комментариями
|
// ID идеи с раскрытыми комментариями
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
// AI-оценка
|
||||||
|
const [estimatingId, setEstimatingId] = useState<string | null>(null);
|
||||||
|
const [estimateModalOpen, setEstimateModalOpen] = useState(false);
|
||||||
|
const [estimateResult, setEstimateResult] = useState<EstimateResult | null>(null);
|
||||||
|
// ТЗ (спецификация)
|
||||||
|
const [specificationModalOpen, setSpecificationModalOpen] = useState(false);
|
||||||
|
const [specificationIdea, setSpecificationIdea] = useState<Idea | null>(null);
|
||||||
|
const [generatedSpecification, setGeneratedSpecification] = useState<string | null>(null);
|
||||||
|
const [generatingSpecificationId, setGeneratingSpecificationId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// История ТЗ
|
||||||
|
const specificationHistory = useSpecificationHistory(specificationIdea?.id ?? null);
|
||||||
|
|
||||||
const handleToggleComments = (id: string) => {
|
const handleToggleComments = (id: string) => {
|
||||||
setExpandedId((prev) => (prev === id ? null : id));
|
setExpandedId((prev) => (prev === id ? null : id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEstimate = (id: string) => {
|
||||||
|
setEstimatingId(id);
|
||||||
|
setEstimateModalOpen(true);
|
||||||
|
setEstimateResult(null);
|
||||||
|
estimateIdea.mutate(id, {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setEstimateResult(result);
|
||||||
|
setEstimatingId(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setEstimatingId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseEstimateModal = () => {
|
||||||
|
setEstimateModalOpen(false);
|
||||||
|
setEstimateResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewEstimate = (idea: Idea) => {
|
||||||
|
if (!idea.estimatedHours || !idea.estimateDetails) return;
|
||||||
|
|
||||||
|
// Показываем сохранённые результаты оценки
|
||||||
|
setEstimateResult({
|
||||||
|
ideaId: idea.id,
|
||||||
|
ideaTitle: idea.title,
|
||||||
|
totalHours: idea.estimatedHours,
|
||||||
|
complexity: idea.complexity!,
|
||||||
|
breakdown: idea.estimateDetails.breakdown,
|
||||||
|
recommendations: idea.estimateDetails.recommendations,
|
||||||
|
estimatedAt: idea.estimatedAt!,
|
||||||
|
});
|
||||||
|
setEstimateModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSpecification = (idea: Idea) => {
|
||||||
|
setSpecificationIdea(idea);
|
||||||
|
setSpecificationModalOpen(true);
|
||||||
|
|
||||||
|
// Если ТЗ уже есть — показываем его
|
||||||
|
if (idea.specification) {
|
||||||
|
setGeneratedSpecification(idea.specification);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иначе генерируем
|
||||||
|
setGeneratedSpecification(null);
|
||||||
|
setGeneratingSpecificationId(idea.id);
|
||||||
|
generateSpecification.mutate(idea.id, {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setGeneratedSpecification(result.specification);
|
||||||
|
setGeneratingSpecificationId(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setGeneratingSpecificationId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSpecificationModal = () => {
|
||||||
|
setSpecificationModalOpen(false);
|
||||||
|
setSpecificationIdea(null);
|
||||||
|
setGeneratedSpecification(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSpecification = (specification: string) => {
|
||||||
|
if (!specificationIdea) return;
|
||||||
|
updateIdea.mutate(
|
||||||
|
{ id: specificationIdea.id, data: { specification } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setGeneratedSpecification(specification);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerateSpecification = () => {
|
||||||
|
if (!specificationIdea) return;
|
||||||
|
setGeneratingSpecificationId(specificationIdea.id);
|
||||||
|
generateSpecification.mutate(specificationIdea.id, {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setGeneratedSpecification(result.specification);
|
||||||
|
setGeneratingSpecificationId(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setGeneratingSpecificationId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteHistoryItem = (historyId: string) => {
|
||||||
|
deleteSpecificationHistoryItem.mutate(historyId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestoreFromHistory = (historyId: string) => {
|
||||||
|
restoreSpecificationFromHistory.mutate(historyId, {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setGeneratedSpecification(result.specification);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createColumns({
|
createColumns({
|
||||||
onDelete: (id) => deleteIdea.mutate(id),
|
onDelete: (id) => deleteIdea.mutate(id),
|
||||||
onToggleComments: handleToggleComments,
|
onToggleComments: handleToggleComments,
|
||||||
|
onEstimate: handleEstimate,
|
||||||
|
onViewEstimate: handleViewEstimate,
|
||||||
|
onSpecification: handleSpecification,
|
||||||
expandedId,
|
expandedId,
|
||||||
|
estimatingId,
|
||||||
|
generatingSpecificationId,
|
||||||
}),
|
}),
|
||||||
[deleteIdea, expandedId],
|
[deleteIdea, expandedId, estimatingId, generatingSpecificationId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
@ -307,6 +445,29 @@ export function IdeasTable() {
|
|||||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<AiEstimateModal
|
||||||
|
open={estimateModalOpen}
|
||||||
|
onClose={handleCloseEstimateModal}
|
||||||
|
result={estimateResult}
|
||||||
|
isLoading={estimateIdea.isPending && !estimateResult}
|
||||||
|
error={estimateIdea.error}
|
||||||
|
/>
|
||||||
|
<SpecificationModal
|
||||||
|
open={specificationModalOpen}
|
||||||
|
onClose={handleCloseSpecificationModal}
|
||||||
|
idea={specificationIdea}
|
||||||
|
specification={generatedSpecification}
|
||||||
|
isLoading={generateSpecification.isPending && !generatedSpecification}
|
||||||
|
error={generateSpecification.error}
|
||||||
|
onSave={handleSaveSpecification}
|
||||||
|
isSaving={updateIdea.isPending}
|
||||||
|
onRegenerate={handleRegenerateSpecification}
|
||||||
|
history={specificationHistory.data ?? []}
|
||||||
|
isHistoryLoading={specificationHistory.isLoading}
|
||||||
|
onDeleteHistoryItem={handleDeleteHistoryItem}
|
||||||
|
onRestoreFromHistory={handleRestoreFromHistory}
|
||||||
|
isRestoring={restoreSpecificationFromHistory.isPending}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { createColumnHelper } from '@tanstack/react-table';
|
import { createColumnHelper } from '@tanstack/react-table';
|
||||||
import { Chip, Box, IconButton, Tooltip } from '@mui/material';
|
import { Chip, Box, IconButton, Tooltip, CircularProgress, Typography } from '@mui/material';
|
||||||
import { Delete, Comment, ExpandLess } from '@mui/icons-material';
|
import { Delete, Comment, ExpandLess, AutoAwesome, AccessTime, Description } from '@mui/icons-material';
|
||||||
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
|
import type { Idea, IdeaStatus, IdeaPriority, IdeaComplexity } from '../../types/idea';
|
||||||
import { EditableCell } from './EditableCell';
|
import { EditableCell } from './EditableCell';
|
||||||
import { ColorPickerCell } from './ColorPickerCell';
|
import { ColorPickerCell } from './ColorPickerCell';
|
||||||
import { statusOptions, priorityOptions } from './constants';
|
import { statusOptions, priorityOptions } from './constants';
|
||||||
@ -30,13 +30,45 @@ const priorityColors: Record<
|
|||||||
critical: 'error',
|
critical: 'error',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const complexityLabels: Record<IdeaComplexity, string> = {
|
||||||
|
trivial: 'Триви.',
|
||||||
|
simple: 'Прост.',
|
||||||
|
medium: 'Сред.',
|
||||||
|
complex: 'Сложн.',
|
||||||
|
veryComplex: 'Оч.сложн.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const complexityColors: Record<
|
||||||
|
IdeaComplexity,
|
||||||
|
'success' | 'info' | 'warning' | 'error' | 'default'
|
||||||
|
> = {
|
||||||
|
trivial: 'success',
|
||||||
|
simple: 'success',
|
||||||
|
medium: 'info',
|
||||||
|
complex: 'warning',
|
||||||
|
veryComplex: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatHoursShort(hours: number): string {
|
||||||
|
if (hours < 8) {
|
||||||
|
return `${hours}ч`;
|
||||||
|
}
|
||||||
|
const days = Math.floor(hours / 8);
|
||||||
|
return `${days}д`;
|
||||||
|
}
|
||||||
|
|
||||||
interface ColumnsConfig {
|
interface ColumnsConfig {
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onToggleComments: (id: string) => void;
|
onToggleComments: (id: string) => void;
|
||||||
|
onEstimate: (id: string) => void;
|
||||||
|
onViewEstimate: (idea: Idea) => void;
|
||||||
|
onSpecification: (idea: Idea) => void;
|
||||||
expandedId: string | null;
|
expandedId: string | null;
|
||||||
|
estimatingId: string | null;
|
||||||
|
generatingSpecificationId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createColumns = ({ onDelete, onToggleComments, expandedId }: ColumnsConfig) => [
|
export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEstimate, onSpecification, expandedId, estimatingId, generatingSpecificationId }: ColumnsConfig) => [
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id: 'drag',
|
id: 'drag',
|
||||||
header: '',
|
header: '',
|
||||||
@ -153,14 +185,103 @@ export const createColumns = ({ onDelete, onToggleComments, expandedId }: Column
|
|||||||
},
|
},
|
||||||
size: 200,
|
size: 200,
|
||||||
}),
|
}),
|
||||||
|
columnHelper.accessor('estimatedHours', {
|
||||||
|
header: 'Оценка',
|
||||||
|
cell: (info) => {
|
||||||
|
const idea = info.row.original;
|
||||||
|
if (!idea.estimatedHours) {
|
||||||
|
return (
|
||||||
|
<Typography variant="body2" color="text.disabled">
|
||||||
|
—
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip title="Нажмите, чтобы посмотреть детали оценки">
|
||||||
|
<Box
|
||||||
|
onClick={() => onViewEstimate(idea)}
|
||||||
|
data-testid="view-estimate-button"
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 0.5,
|
||||||
|
py: 0.25,
|
||||||
|
mx: -0.5,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccessTime fontSize="small" color="action" />
|
||||||
|
<Typography variant="body2">
|
||||||
|
{formatHoursShort(idea.estimatedHours)}
|
||||||
|
</Typography>
|
||||||
|
{idea.complexity && (
|
||||||
|
<Chip
|
||||||
|
label={complexityLabels[idea.complexity]}
|
||||||
|
color={complexityColors[idea.complexity]}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 130,
|
||||||
|
enableSorting: false,
|
||||||
|
}),
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const ideaId = info.row.original.id;
|
const idea = info.row.original;
|
||||||
|
const ideaId = idea.id;
|
||||||
const isExpanded = expandedId === ideaId;
|
const isExpanded = expandedId === ideaId;
|
||||||
|
const isEstimating = estimatingId === ideaId;
|
||||||
|
const isGeneratingSpec = generatingSpecificationId === ideaId;
|
||||||
|
const hasSpecification = !!idea.specification;
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
<Tooltip title={hasSpecification ? 'Просмотр ТЗ' : 'Сгенерировать ТЗ'}>
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onSpecification(idea)}
|
||||||
|
disabled={isGeneratingSpec}
|
||||||
|
color={hasSpecification ? 'primary' : 'default'}
|
||||||
|
data-testid="specification-button"
|
||||||
|
sx={{ opacity: hasSpecification ? 0.9 : 0.5, '&:hover': { opacity: 1 } }}
|
||||||
|
>
|
||||||
|
{isGeneratingSpec ? (
|
||||||
|
<CircularProgress size={18} />
|
||||||
|
) : (
|
||||||
|
<Description fontSize="small" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="AI-оценка">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onEstimate(ideaId)}
|
||||||
|
disabled={isEstimating}
|
||||||
|
color="primary"
|
||||||
|
data-testid="estimate-idea-button"
|
||||||
|
sx={{ opacity: 0.7, '&:hover': { opacity: 1 } }}
|
||||||
|
>
|
||||||
|
{isEstimating ? (
|
||||||
|
<CircularProgress size={18} />
|
||||||
|
) : (
|
||||||
|
<AutoAwesome fontSize="small" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title="Комментарии">
|
<Tooltip title="Комментарии">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@ -183,6 +304,6 @@ export const createColumns = ({ onDelete, onToggleComments, expandedId }: Column
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
size: 90,
|
size: 150,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -0,0 +1,464 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
LinearProgress,
|
||||||
|
Alert,
|
||||||
|
TextField,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
Divider,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Edit,
|
||||||
|
Save,
|
||||||
|
Close,
|
||||||
|
Refresh,
|
||||||
|
Delete,
|
||||||
|
Restore,
|
||||||
|
Visibility,
|
||||||
|
History,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import Markdown from 'react-markdown';
|
||||||
|
import type { Idea, SpecificationHistoryItem } from '../../types/idea';
|
||||||
|
|
||||||
|
interface SpecificationModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
idea: Idea | null;
|
||||||
|
specification: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
onSave: (specification: string) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
onRegenerate: () => void;
|
||||||
|
history: SpecificationHistoryItem[];
|
||||||
|
isHistoryLoading: boolean;
|
||||||
|
onDeleteHistoryItem: (historyId: string) => void;
|
||||||
|
onRestoreFromHistory: (historyId: string) => void;
|
||||||
|
isRestoring: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabPanelProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
index: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabPanel({ children, value, index }: TabPanelProps) {
|
||||||
|
return (
|
||||||
|
<div role="tabpanel" hidden={value !== index}>
|
||||||
|
{value === index && children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecificationModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
idea,
|
||||||
|
specification,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
onRegenerate,
|
||||||
|
history,
|
||||||
|
isHistoryLoading,
|
||||||
|
onDeleteHistoryItem,
|
||||||
|
onRestoreFromHistory,
|
||||||
|
isRestoring,
|
||||||
|
}: SpecificationModalProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedText, setEditedText] = useState('');
|
||||||
|
const [tabIndex, setTabIndex] = useState(0);
|
||||||
|
const [viewingHistoryItem, setViewingHistoryItem] = useState<SpecificationHistoryItem | null>(null);
|
||||||
|
|
||||||
|
// Сбрасываем состояние при открытии/закрытии
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && specification) {
|
||||||
|
setEditedText(specification);
|
||||||
|
setIsEditing(false);
|
||||||
|
setTabIndex(0);
|
||||||
|
setViewingHistoryItem(null);
|
||||||
|
}
|
||||||
|
}, [open, specification]);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setEditedText(specification || '');
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditedText(specification || '');
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(editedText);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = () => {
|
||||||
|
setViewingHistoryItem(null);
|
||||||
|
setTabIndex(0);
|
||||||
|
onRegenerate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewHistoryItem = (item: SpecificationHistoryItem) => {
|
||||||
|
setViewingHistoryItem(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseHistoryView = () => {
|
||||||
|
setViewingHistoryItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestoreFromHistory = (historyId: string) => {
|
||||||
|
onRestoreFromHistory(historyId);
|
||||||
|
setViewingHistoryItem(null);
|
||||||
|
setTabIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string | null) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
return new Date(dateString).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasHistory = history.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
data-testid="specification-modal"
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" component="span">
|
||||||
|
Техническое задание
|
||||||
|
</Typography>
|
||||||
|
{idea && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{idea.title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
{specification && !isLoading && !isEditing && !viewingHistoryItem && (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Перегенерировать ТЗ">
|
||||||
|
<IconButton
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
data-testid="specification-regenerate-button"
|
||||||
|
>
|
||||||
|
<Refresh />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Редактировать">
|
||||||
|
<IconButton
|
||||||
|
onClick={handleEdit}
|
||||||
|
size="small"
|
||||||
|
data-testid="specification-edit-button"
|
||||||
|
>
|
||||||
|
<Edit />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
{/* Табы появляются только если есть история */}
|
||||||
|
{hasHistory && !isEditing && !viewingHistoryItem && (
|
||||||
|
<Tabs
|
||||||
|
value={tabIndex}
|
||||||
|
onChange={(_, newValue) => setTabIndex(newValue)}
|
||||||
|
sx={{ px: 3, borderBottom: 1, borderColor: 'divider' }}
|
||||||
|
>
|
||||||
|
<Tab label="Текущее ТЗ" data-testid="specification-tab-current" />
|
||||||
|
<Tab
|
||||||
|
label={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<History fontSize="small" />
|
||||||
|
История ({history.length})
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
data-testid="specification-tab-history"
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogContent dividers>
|
||||||
|
{/* Просмотр исторического ТЗ */}
|
||||||
|
{viewingHistoryItem && (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<IconButton size="small" onClick={handleCloseHistoryView}>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="subtitle2">
|
||||||
|
Версия от {formatDate(viewingHistoryItem.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="Восстановить эту версию">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => handleRestoreFromHistory(viewingHistoryItem.id)}
|
||||||
|
disabled={isRestoring}
|
||||||
|
data-testid="specification-restore-button"
|
||||||
|
>
|
||||||
|
<Restore />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
{viewingHistoryItem.ideaDescriptionSnapshot && (
|
||||||
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="caption">
|
||||||
|
Описание идеи на момент генерации: {viewingHistoryItem.ideaDescriptionSnapshot}
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
data-testid="specification-history-content"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'grey.100',
|
||||||
|
borderRadius: 1,
|
||||||
|
maxHeight: '50vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
'& h1, & h2, & h3, & h4, & h5, & h6': { mt: 2, mb: 1, '&:first-of-type': { mt: 0 } },
|
||||||
|
'& h1': { fontSize: '1.5rem', fontWeight: 600 },
|
||||||
|
'& h2': { fontSize: '1.25rem', fontWeight: 600 },
|
||||||
|
'& h3': { fontSize: '1.1rem', fontWeight: 600 },
|
||||||
|
'& p': { mb: 1.5, lineHeight: 1.6 },
|
||||||
|
'& ul, & ol': { pl: 3, mb: 1.5 },
|
||||||
|
'& li': { mb: 0.5 },
|
||||||
|
'& strong': { fontWeight: 600 },
|
||||||
|
'& code': { bgcolor: 'grey.200', px: 0.5, py: 0.25, borderRadius: 0.5, fontFamily: 'monospace', fontSize: '0.875em' },
|
||||||
|
'& pre': { bgcolor: 'grey.200', p: 1.5, borderRadius: 1, overflow: 'auto', '& code': { bgcolor: 'transparent', p: 0 } },
|
||||||
|
'& blockquote': { borderLeft: 3, borderColor: 'primary.main', pl: 2, ml: 0, fontStyle: 'italic', color: 'text.secondary' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Markdown>{viewingHistoryItem.specification}</Markdown>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Основной контент (не историческая версия) */}
|
||||||
|
{!viewingHistoryItem && (
|
||||||
|
<>
|
||||||
|
<TabPanel value={tabIndex} index={0}>
|
||||||
|
{isLoading && (
|
||||||
|
<Box sx={{ py: 4 }} data-testid="specification-loading">
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
Генерируем техническое задание...
|
||||||
|
</Typography>
|
||||||
|
<LinearProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ my: 2 }} data-testid="specification-error">
|
||||||
|
{error.message || 'Не удалось сгенерировать ТЗ'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && isEditing && (
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
minRows={15}
|
||||||
|
maxRows={25}
|
||||||
|
value={editedText}
|
||||||
|
onChange={(e) => setEditedText(e.target.value)}
|
||||||
|
placeholder="Введите техническое задание..."
|
||||||
|
data-testid="specification-textarea"
|
||||||
|
sx={{
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && !isEditing && specification && (
|
||||||
|
<Box>
|
||||||
|
{idea?.specificationGeneratedAt && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||||
|
Сгенерировано: {formatDate(idea.specificationGeneratedAt)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
data-testid="specification-content"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'grey.50',
|
||||||
|
borderRadius: 1,
|
||||||
|
maxHeight: '55vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
'& h1, & h2, & h3, & h4, & h5, & h6': { mt: 2, mb: 1, '&:first-of-type': { mt: 0 } },
|
||||||
|
'& h1': { fontSize: '1.5rem', fontWeight: 600 },
|
||||||
|
'& h2': { fontSize: '1.25rem', fontWeight: 600 },
|
||||||
|
'& h3': { fontSize: '1.1rem', fontWeight: 600 },
|
||||||
|
'& p': { mb: 1.5, lineHeight: 1.6 },
|
||||||
|
'& ul, & ol': { pl: 3, mb: 1.5 },
|
||||||
|
'& li': { mb: 0.5 },
|
||||||
|
'& strong': { fontWeight: 600 },
|
||||||
|
'& code': { bgcolor: 'grey.200', px: 0.5, py: 0.25, borderRadius: 0.5, fontFamily: 'monospace', fontSize: '0.875em' },
|
||||||
|
'& pre': { bgcolor: 'grey.200', p: 1.5, borderRadius: 1, overflow: 'auto', '& code': { bgcolor: 'transparent', p: 0 } },
|
||||||
|
'& blockquote': { borderLeft: 3, borderColor: 'primary.main', pl: 2, ml: 0, fontStyle: 'italic', color: 'text.secondary' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Markdown>{specification}</Markdown>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value={tabIndex} index={1}>
|
||||||
|
{isHistoryLoading ? (
|
||||||
|
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
<LinearProgress />
|
||||||
|
</Box>
|
||||||
|
) : history.length === 0 ? (
|
||||||
|
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
<Typography color="text.secondary">История пуста</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<List data-testid="specification-history-list">
|
||||||
|
{history.map((item, index) => (
|
||||||
|
<Box key={item.id}>
|
||||||
|
{index > 0 && <Divider />}
|
||||||
|
<ListItem
|
||||||
|
data-testid={`specification-history-item-${index}`}
|
||||||
|
sx={{ pr: 16 }}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{formatDate(item.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
{item.ideaDescriptionSnapshot && (
|
||||||
|
<Chip
|
||||||
|
label="Описание изменилось"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="info"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.specification.slice(0, 150)}...
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<Tooltip title="Просмотреть">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleViewHistoryItem(item)}
|
||||||
|
data-testid={`specification-history-view-${index}`}
|
||||||
|
>
|
||||||
|
<Visibility fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Восстановить">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => handleRestoreFromHistory(item.id)}
|
||||||
|
disabled={isRestoring}
|
||||||
|
data-testid={`specification-history-restore-${index}`}
|
||||||
|
>
|
||||||
|
<Restore fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Удалить">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => onDeleteHistoryItem(item.id)}
|
||||||
|
data-testid={`specification-history-delete-${index}`}
|
||||||
|
>
|
||||||
|
<Delete fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button onClick={handleCancel} disabled={isSaving}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
startIcon={<Save />}
|
||||||
|
data-testid="specification-save-button"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : viewingHistoryItem ? (
|
||||||
|
<Button onClick={handleCloseHistoryView}>
|
||||||
|
Назад к текущему ТЗ
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
startIcon={<Close />}
|
||||||
|
data-testid="specification-close-button"
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/components/SpecificationModal/index.ts
Normal file
1
frontend/src/components/SpecificationModal/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './SpecificationModal';
|
||||||
63
frontend/src/hooks/useAi.ts
Normal file
63
frontend/src/hooks/useAi.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { aiApi } from '../services/ai';
|
||||||
|
|
||||||
|
const IDEAS_QUERY_KEY = 'ideas';
|
||||||
|
const SPECIFICATION_HISTORY_KEY = 'specification-history';
|
||||||
|
|
||||||
|
export function useEstimateIdea() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (ideaId: string) => aiApi.estimateIdea(ideaId),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Инвалидируем кэш идей чтобы обновить данные с новой оценкой
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerateSpecification() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (ideaId: string) => aiApi.generateSpecification(ideaId),
|
||||||
|
onSuccess: (_, ideaId) => {
|
||||||
|
// Инвалидируем кэш идей и историю
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY, ideaId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpecificationHistory(ideaId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [SPECIFICATION_HISTORY_KEY, ideaId],
|
||||||
|
queryFn: () => aiApi.getSpecificationHistory(ideaId!),
|
||||||
|
enabled: !!ideaId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteSpecificationHistoryItem() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (historyId: string) => aiApi.deleteSpecificationHistoryItem(historyId),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Инвалидируем все запросы истории
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestoreSpecificationFromHistory() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (historyId: string) => aiApi.restoreSpecificationFromHistory(historyId),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Инвалидируем кэш идей и историю
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
38
frontend/src/services/ai.ts
Normal file
38
frontend/src/services/ai.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type { IdeaComplexity, RoleEstimate, SpecificationResult, SpecificationHistoryItem } from '../types/idea';
|
||||||
|
|
||||||
|
export interface EstimateResult {
|
||||||
|
ideaId: string;
|
||||||
|
ideaTitle: string;
|
||||||
|
totalHours: number;
|
||||||
|
complexity: IdeaComplexity;
|
||||||
|
breakdown: RoleEstimate[];
|
||||||
|
recommendations: string[];
|
||||||
|
estimatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiApi = {
|
||||||
|
estimateIdea: async (ideaId: string): Promise<EstimateResult> => {
|
||||||
|
const { data } = await api.post<EstimateResult>('/ai/estimate', { ideaId });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
generateSpecification: async (ideaId: string): Promise<SpecificationResult> => {
|
||||||
|
const { data } = await api.post<SpecificationResult>('/ai/generate-specification', { ideaId });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSpecificationHistory: async (ideaId: string): Promise<SpecificationHistoryItem[]> => {
|
||||||
|
const { data } = await api.get<SpecificationHistoryItem[]>(`/ai/specification-history/${ideaId}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSpecificationHistoryItem: async (historyId: string): Promise<void> => {
|
||||||
|
await api.delete(`/ai/specification-history/${historyId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreSpecificationFromHistory: async (historyId: string): Promise<SpecificationResult> => {
|
||||||
|
const { data } = await api.post<SpecificationResult>(`/ai/specification-history/${historyId}/restore`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -6,6 +6,18 @@ export type IdeaStatus =
|
|||||||
| 'cancelled';
|
| 'cancelled';
|
||||||
export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical';
|
export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
export type IdeaComplexity = 'trivial' | 'simple' | 'medium' | 'complex' | 'veryComplex';
|
||||||
|
|
||||||
|
export interface RoleEstimate {
|
||||||
|
role: string;
|
||||||
|
hours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateDetails {
|
||||||
|
breakdown: RoleEstimate[];
|
||||||
|
recommendations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Idea {
|
export interface Idea {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -19,6 +31,14 @@ export interface Idea {
|
|||||||
verificationMethod: string | null;
|
verificationMethod: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
order: number;
|
order: number;
|
||||||
|
// AI-оценка
|
||||||
|
estimatedHours: number | null;
|
||||||
|
complexity: IdeaComplexity | null;
|
||||||
|
estimateDetails: EstimateDetails | null;
|
||||||
|
estimatedAt: string | null;
|
||||||
|
// Мини-ТЗ
|
||||||
|
specification: string | null;
|
||||||
|
specificationGeneratedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@ -39,4 +59,19 @@ export interface CreateIdeaDto {
|
|||||||
export interface UpdateIdeaDto extends Omit<Partial<CreateIdeaDto>, 'color'> {
|
export interface UpdateIdeaDto extends Omit<Partial<CreateIdeaDto>, 'color'> {
|
||||||
order?: number;
|
order?: number;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
|
specification?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecificationResult {
|
||||||
|
ideaId: string;
|
||||||
|
ideaTitle: string;
|
||||||
|
specification: string;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecificationHistoryItem {
|
||||||
|
id: string;
|
||||||
|
specification: string;
|
||||||
|
ideaDescriptionSnapshot: string | null;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,13 @@ spec:
|
|||||||
key: db-password
|
key: db-password
|
||||||
- name: KEYCLOAK_REALM_URL
|
- name: KEYCLOAK_REALM_URL
|
||||||
value: "https://auth.vigdorov.ru/realms/team-planner"
|
value: "https://auth.vigdorov.ru/realms/team-planner"
|
||||||
|
- name: AI_PROXY_BASE_URL
|
||||||
|
value: "http://ai-proxy-service.ai-proxy.svc.cluster.local:3000"
|
||||||
|
- name: AI_PROXY_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: team-planner-secrets
|
||||||
|
key: ai-proxy-api-key
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "256Mi"
|
memory: "256Mi"
|
||||||
|
|||||||
1216
package-lock.json
generated
1216
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
463
tests/e2e/phase3.spec.ts
Normal file
463
tests/e2e/phase3.spec.ts
Normal file
@ -0,0 +1,463 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E тесты для Фазы 3 Team Planner
|
||||||
|
* - AI-оценка трудозатрат
|
||||||
|
*
|
||||||
|
* Используем data-testid для стабильных селекторов
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe('Фаза 3: AI-оценка', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('[data-testid="ideas-table"]', { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Кнопка AI-оценки присутствует в каждой строке', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
if (hasData) {
|
||||||
|
const estimateButtons = page.locator('[data-testid="estimate-idea-button"]');
|
||||||
|
const buttonCount = await estimateButtons.count();
|
||||||
|
expect(buttonCount).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Клик на кнопку AI-оценки открывает модалку', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
// Кликаем на кнопку AI-оценки первой идеи
|
||||||
|
const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first();
|
||||||
|
await estimateButton.click();
|
||||||
|
|
||||||
|
// Проверяем что модалка открылась
|
||||||
|
const modal = page.locator('[data-testid="ai-estimate-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Модалка AI-оценки показывает загрузку', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first();
|
||||||
|
await estimateButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="ai-estimate-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Должен быть либо индикатор загрузки, либо результат, либо ошибка
|
||||||
|
const hasContent = await modal.locator('text=Анализируем').isVisible().catch(() => false) ||
|
||||||
|
await modal.locator('text=Общее время').isVisible().catch(() => false) ||
|
||||||
|
await modal.locator('text=Не удалось').isVisible().catch(() => false);
|
||||||
|
|
||||||
|
expect(hasContent).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI-оценка возвращает результат с часами и сложностью', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first();
|
||||||
|
await estimateButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="ai-estimate-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ждём результат (до 30 секунд - AI может отвечать долго)
|
||||||
|
const totalTimeLabel = modal.locator('text=Общее время');
|
||||||
|
await expect(totalTimeLabel).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
|
// Проверяем наличие сложности
|
||||||
|
const complexityLabel = modal.locator('text=Сложность');
|
||||||
|
await expect(complexityLabel).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI-оценка показывает разбивку по ролям', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first();
|
||||||
|
await estimateButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="ai-estimate-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ждём результат
|
||||||
|
await expect(modal.locator('text=Общее время')).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
|
// Проверяем наличие таблицы разбивки по ролям
|
||||||
|
const breakdownLabel = modal.locator('text=Разбивка по ролям');
|
||||||
|
// Разбивка опциональна (может не быть если команда не указана)
|
||||||
|
const hasBreakdown = await breakdownLabel.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (hasBreakdown) {
|
||||||
|
const breakdownRows = modal.locator('[data-testid^="estimate-breakdown-row-"]');
|
||||||
|
const rowCount = await breakdownRows.count();
|
||||||
|
expect(rowCount).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Кнопка закрытия модалки работает', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first();
|
||||||
|
await estimateButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="ai-estimate-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Закрываем модалку
|
||||||
|
const closeButton = page.locator('[data-testid="close-estimate-modal-button"]');
|
||||||
|
await closeButton.click();
|
||||||
|
|
||||||
|
// Модалка должна закрыться
|
||||||
|
await expect(modal).not.toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('После оценки результат сохраняется в строке таблицы', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
// Запоминаем первую строку
|
||||||
|
const firstRow = page.locator('[data-testid^="idea-row-"]').first();
|
||||||
|
|
||||||
|
// Кликаем на оценку
|
||||||
|
const estimateButton = firstRow.locator('[data-testid="estimate-idea-button"]');
|
||||||
|
await estimateButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="ai-estimate-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ждём результат
|
||||||
|
await expect(modal.locator('text=Общее время')).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
|
// Закрываем модалку
|
||||||
|
await page.locator('[data-testid="close-estimate-modal-button"]').click();
|
||||||
|
await expect(modal).not.toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Проверяем что в строке появилась оценка (часы или дни)
|
||||||
|
// Ищем текст типа "8ч" или "2д" в строке
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Колонка "Оценка" должна содержать данные
|
||||||
|
const rowText = await firstRow.textContent();
|
||||||
|
const hasEstimate = rowText?.match(/\d+[чд]/) !== null;
|
||||||
|
|
||||||
|
expect(hasEstimate).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Колонка "Оценка" отображается в таблице', async ({ page }) => {
|
||||||
|
const table = page.locator('[data-testid="ideas-table"]');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// Проверяем наличие заголовка колонки
|
||||||
|
const header = table.locator('th', { hasText: 'Оценка' });
|
||||||
|
await expect(header).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Клик по оценке открывает модалку с деталями', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
// Ищем строку с оценкой (кнопка view-estimate-button появляется только если есть оценка)
|
||||||
|
const viewEstimateButton = page.locator('[data-testid="view-estimate-button"]').first();
|
||||||
|
const hasEstimate = await viewEstimateButton.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
test.skip(!hasEstimate, 'Нет идей с оценкой для тестирования');
|
||||||
|
|
||||||
|
// Кликаем по оценке
|
||||||
|
await viewEstimateButton.click();
|
||||||
|
|
||||||
|
// Модалка должна открыться с деталями
|
||||||
|
const modal = page.locator('[data-testid="ai-estimate-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Должны быть видны результаты (без загрузки)
|
||||||
|
await expect(modal.locator('text=Общее время')).toBeVisible();
|
||||||
|
await expect(modal.locator('text=Сложность')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Фаза 3: AI-оценка - создание данных для теста', () => {
|
||||||
|
test('Создание идеи и запуск AI-оценки', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('[data-testid="ideas-table"]', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Проверяем есть ли кнопка создания идеи
|
||||||
|
const createButton = page.locator('[data-testid="create-idea-button"]');
|
||||||
|
const hasCreateButton = await createButton.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (hasCreateButton) {
|
||||||
|
// Создаём идею
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="create-idea-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Заполняем форму
|
||||||
|
await page.locator('[data-testid="idea-title-input"] input').fill('Тестовая идея для AI-оценки');
|
||||||
|
await page.locator('[data-testid="idea-description-input"] textarea').first().fill(
|
||||||
|
'Реализовать систему уведомлений. Нужны email и push-уведомления для важных событий.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Сохраняем
|
||||||
|
await page.locator('[data-testid="submit-create-idea"]').click();
|
||||||
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ждём появления новой строки
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Теперь проверяем AI-оценку
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Не удалось создать данные для тестирования');
|
||||||
|
|
||||||
|
// Запускаем AI-оценку
|
||||||
|
const estimateButton = page.locator('[data-testid="estimate-idea-button"]').first();
|
||||||
|
await estimateButton.click();
|
||||||
|
|
||||||
|
const estimateModal = page.locator('[data-testid="ai-estimate-modal"]');
|
||||||
|
await expect(estimateModal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ждём результат (до 60 секунд - AI может отвечать долго)
|
||||||
|
// Или ошибку (текст "Не удалось" из компонента)
|
||||||
|
const resultOrError = estimateModal.locator('text=/Общее время|Не удалось/');
|
||||||
|
await expect(resultOrError).toBeVisible({ timeout: 60000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты для генерации мини-ТЗ (Phase 3.1)
|
||||||
|
*/
|
||||||
|
test.describe('Фаза 3.1: Генерация мини-ТЗ', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('[data-testid="ideas-table"]', { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Кнопка ТЗ присутствует в каждой строке', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
if (hasData) {
|
||||||
|
const specButtons = page.locator('[data-testid="specification-button"]');
|
||||||
|
const buttonCount = await specButtons.count();
|
||||||
|
expect(buttonCount).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Клик на кнопку ТЗ открывает модалку', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
// Кликаем на кнопку ТЗ первой идеи
|
||||||
|
const specButton = page.locator('[data-testid="specification-button"]').first();
|
||||||
|
await specButton.click();
|
||||||
|
|
||||||
|
// Проверяем что модалка открылась
|
||||||
|
const modal = page.locator('[data-testid="specification-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Модалка ТЗ показывает загрузку при генерации', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
// Ищем строку без ТЗ (кнопка не подсвечена синим)
|
||||||
|
const specButton = page.locator('[data-testid="specification-button"]').first();
|
||||||
|
await specButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="specification-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Должен быть либо индикатор загрузки, либо контент, либо ошибка
|
||||||
|
const hasContent = await modal.locator('[data-testid="specification-loading"]').isVisible().catch(() => false) ||
|
||||||
|
await modal.locator('[data-testid="specification-content"]').isVisible().catch(() => false) ||
|
||||||
|
await modal.locator('[data-testid="specification-error"]').isVisible().catch(() => false);
|
||||||
|
|
||||||
|
expect(hasContent).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Генерация ТЗ возвращает результат', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
const specButton = page.locator('[data-testid="specification-button"]').first();
|
||||||
|
await specButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="specification-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ждём результат (до 60 секунд - AI может отвечать долго)
|
||||||
|
const content = modal.locator('[data-testid="specification-content"]');
|
||||||
|
const error = modal.locator('[data-testid="specification-error"]');
|
||||||
|
|
||||||
|
// Ожидаем либо контент, либо ошибку
|
||||||
|
await expect(content.or(error)).toBeVisible({ timeout: 60000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Кнопка закрытия модалки ТЗ работает', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
const specButton = page.locator('[data-testid="specification-button"]').first();
|
||||||
|
await specButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="specification-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ждём пока загрузится контент или ошибка
|
||||||
|
const content = modal.locator('[data-testid="specification-content"]');
|
||||||
|
const error = modal.locator('[data-testid="specification-error"]');
|
||||||
|
await expect(content.or(error)).toBeVisible({ timeout: 60000 });
|
||||||
|
|
||||||
|
// Закрываем модалку
|
||||||
|
const closeButton = page.locator('[data-testid="specification-close-button"]');
|
||||||
|
await closeButton.click();
|
||||||
|
|
||||||
|
// Модалка должна закрыться
|
||||||
|
await expect(modal).not.toBeVisible({ timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Кнопка редактирования ТЗ появляется после генерации', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
const specButton = page.locator('[data-testid="specification-button"]').first();
|
||||||
|
await specButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="specification-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ждём контент
|
||||||
|
const content = modal.locator('[data-testid="specification-content"]');
|
||||||
|
const hasContent = await content.isVisible({ timeout: 60000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (hasContent) {
|
||||||
|
// Проверяем наличие кнопки редактирования
|
||||||
|
const editButton = modal.locator('[data-testid="specification-edit-button"]');
|
||||||
|
await expect(editButton).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Редактирование ТЗ открывает textarea', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
const specButton = page.locator('[data-testid="specification-button"]').first();
|
||||||
|
await specButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="specification-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ждём контент
|
||||||
|
const content = modal.locator('[data-testid="specification-content"]');
|
||||||
|
const hasContent = await content.isVisible({ timeout: 60000 }).catch(() => false);
|
||||||
|
|
||||||
|
test.skip(!hasContent, 'Не удалось сгенерировать ТЗ');
|
||||||
|
|
||||||
|
// Кликаем редактировать
|
||||||
|
const editButton = modal.locator('[data-testid="specification-edit-button"]');
|
||||||
|
await editButton.click();
|
||||||
|
|
||||||
|
// Должен появиться textarea
|
||||||
|
const textarea = modal.locator('[data-testid="specification-textarea"]');
|
||||||
|
await expect(textarea).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Сохранение отредактированного ТЗ', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
const specButton = page.locator('[data-testid="specification-button"]').first();
|
||||||
|
await specButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="specification-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Ждём контент
|
||||||
|
const content = modal.locator('[data-testid="specification-content"]');
|
||||||
|
const hasContent = await content.isVisible({ timeout: 60000 }).catch(() => false);
|
||||||
|
|
||||||
|
test.skip(!hasContent, 'Не удалось сгенерировать ТЗ');
|
||||||
|
|
||||||
|
// Кликаем редактировать
|
||||||
|
const editButton = modal.locator('[data-testid="specification-edit-button"]');
|
||||||
|
await editButton.click();
|
||||||
|
|
||||||
|
// Редактируем текст
|
||||||
|
const textarea = modal.locator('[data-testid="specification-textarea"] textarea');
|
||||||
|
const testText = '\n\n## Дополнительно\nТестовая правка ' + Date.now();
|
||||||
|
await textarea.fill(await textarea.inputValue() + testText);
|
||||||
|
|
||||||
|
// Сохраняем
|
||||||
|
const saveButton = modal.locator('[data-testid="specification-save-button"]');
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
// Должен вернуться режим просмотра
|
||||||
|
await expect(content).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Проверяем что изменения сохранились
|
||||||
|
const contentText = await content.textContent();
|
||||||
|
expect(contentText).toContain('Дополнительно');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Повторное открытие показывает сохранённое ТЗ', async ({ page }) => {
|
||||||
|
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
|
||||||
|
const hasData = !(await emptyState.isVisible().catch(() => false));
|
||||||
|
|
||||||
|
test.skip(!hasData, 'Нет данных для тестирования');
|
||||||
|
|
||||||
|
// Ищем идею с уже сгенерированным ТЗ (кнопка синяя)
|
||||||
|
const blueSpecButton = page.locator('[data-testid="specification-button"][class*="primary"]').first();
|
||||||
|
const hasExistingSpec = await blueSpecButton.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
test.skip(!hasExistingSpec, 'Нет идей с ТЗ для тестирования');
|
||||||
|
|
||||||
|
await blueSpecButton.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="specification-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Контент должен появиться сразу (без загрузки)
|
||||||
|
const content = modal.locator('[data-testid="specification-content"]');
|
||||||
|
await expect(content).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
// Не должно быть индикатора загрузки
|
||||||
|
const loading = modal.locator('[data-testid="specification-loading"]');
|
||||||
|
await expect(loading).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,7 +2,7 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "AUTH_SESSION_ID",
|
"name": "AUTH_SESSION_ID",
|
||||||
"value": "aDcxV2VydUZQUVNSUHM0S290YzZtdVV2LlJBd2xHOXUyNWh6a1o2Qkc0V3pxRkpkNng5MVkza2o2REE0eTYyN21jWTJ6TS1WbC01Yk16UWZjZFRHcFNjWDRpMWJNTlhQZUZkZ3MxeW9WcHd4dnBn.keycloak-keycloakx-0-40655",
|
"value": "c2hWNkNfa0JhY1hOc1ZWTnhLdk1tOTllLnNBM2ZQTk5yRlBKek5lS3FoR093OFloU1ZyU3E1QzFadzVIU1Jta2lMRllqbXJxLW9QSEMxOFkzZWZDZDl3UHVKZUVaU0VvWWJTOVRNTHJJSUpZc1hB.keycloak-keycloakx-0-40655",
|
||||||
"domain": "auth.vigdorov.ru",
|
"domain": "auth.vigdorov.ru",
|
||||||
"path": "/realms/team-planner/",
|
"path": "/realms/team-planner/",
|
||||||
"expires": -1,
|
"expires": -1,
|
||||||
@ -12,17 +12,17 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "KC_AUTH_SESSION_HASH",
|
"name": "KC_AUTH_SESSION_HASH",
|
||||||
"value": "on0q6coyrWw3ypD0a99QFRAKTjOKY9lwC5JUXEZd+1M",
|
"value": "\"gFqhBG3DVcCfpsSCaidKwK+Ziy23r6ddJ/rdb/jKDs8\"",
|
||||||
"domain": "auth.vigdorov.ru",
|
"domain": "auth.vigdorov.ru",
|
||||||
"path": "/realms/team-planner/",
|
"path": "/realms/team-planner/",
|
||||||
"expires": 1768425176.234387,
|
"expires": 1768427781.187379,
|
||||||
"httpOnly": false,
|
"httpOnly": false,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "None"
|
"sameSite": "None"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "KEYCLOAK_IDENTITY",
|
"name": "KEYCLOAK_IDENTITY",
|
||||||
"value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2ZDRjMWU2My1hNTllLTQ0NjAtYThkYy05YTRiZjFjMTRjMDMifQ.eyJleHAiOjE3Njg0NjExMTcsImlhdCI6MTc2ODQyNTExNywianRpIjoiMjMxZmU5ZmQtM2QzMC1hODE4LWJiZTItNjhjMDRhMTNlMTk1IiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnZpZ2Rvcm92LnJ1L3JlYWxtcy90ZWFtLXBsYW5uZXIiLCJzdWIiOiIyZDJiOTRmMC0xZWQ1LTQ0MTUtYmM4MC1jZTRlZWMxNDQ1NGQiLCJ0eXAiOiJTZXJpYWxpemVkLUlEIiwic2lkIjoiaDcxV2VydUZQUVNSUHM0S290YzZtdVV2Iiwic3RhdGVfY2hlY2tlciI6IjZjSXJIcFBVX09FSnpkNUpWWHRPMUVveS1aaVN1RS1jVGNpQVRyX01WVWsifQ.B4IGHS3mMLHkLMJlfyU8xJK_Xz8wtTeOEtSm57qbKHdnUYdXaavWNdPwIZ1rrPprPiypqn0_Ddj28dQVdNkClQ",
|
"value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2ZDRjMWU2My1hNTllLTQ0NjAtYThkYy05YTRiZjFjMTRjMDMifQ.eyJleHAiOjE3Njg0NjM3MjMsImlhdCI6MTc2ODQyNzcyMywianRpIjoiNGRmN2U5MzQtY2Q4Mi1hYTYwLTViNTUtMWFhZjVlMWViODJjIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnZpZ2Rvcm92LnJ1L3JlYWxtcy90ZWFtLXBsYW5uZXIiLCJzdWIiOiIyZDJiOTRmMC0xZWQ1LTQ0MTUtYmM4MC1jZTRlZWMxNDQ1NGQiLCJ0eXAiOiJTZXJpYWxpemVkLUlEIiwic2lkIjoic2hWNkNfa0JhY1hOc1ZWTnhLdk1tOTllIiwic3RhdGVfY2hlY2tlciI6Im9Ic2R0czlWR0RvV19EcjcxbG4tM2FjWDR1SmJuMWtzdHRCcVpzRnlPbDQifQ.Nbi8YdiZddWqY4rsS7b_hin9cbTedp2bOQ11I25tLdTH6VGGJaCP1T59pYd3OlqyDYPoD97uOBiobKTues1rwg",
|
||||||
"domain": "auth.vigdorov.ru",
|
"domain": "auth.vigdorov.ru",
|
||||||
"path": "/realms/team-planner/",
|
"path": "/realms/team-planner/",
|
||||||
"expires": -1,
|
"expires": -1,
|
||||||
@ -32,10 +32,10 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "KEYCLOAK_SESSION",
|
"name": "KEYCLOAK_SESSION",
|
||||||
"value": "on0q6coyrWw3ypD0a99QFRAKTjOKY9lwC5JUXEZd-1M",
|
"value": "gFqhBG3DVcCfpsSCaidKwK-Ziy23r6ddJ_rdb_jKDs8",
|
||||||
"domain": "auth.vigdorov.ru",
|
"domain": "auth.vigdorov.ru",
|
||||||
"path": "/realms/team-planner/",
|
"path": "/realms/team-planner/",
|
||||||
"expires": 1768461118.031888,
|
"expires": 1768463723.271756,
|
||||||
"httpOnly": false,
|
"httpOnly": false,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "None"
|
"sameSite": "None"
|
||||||
|
|||||||
Reference in New Issue
Block a user