Compare commits

...

22 Commits

Author SHA1 Message Date
1556ff9a29 chore: migrate to @vigdorov/* shared configs (eslint, prettier, typescript, vite)
All checks were successful
continuous-integration/drone/push Build is passing
Replace project-local ESLint, Prettier, TypeScript, and Vite configs
with shared packages from dev-configs monorepo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:57:41 +03:00
5ec631f229 manual ci
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone Build is passing
2026-02-08 15:03:28 +03:00
593c573985 migrate to ci-templates
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-08 14:11:40 +03:00
75015c1c85 fix db ci
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-08 11:28:27 +03:00
990a6fe918 change db
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-08 01:53:04 +03:00
9d34deb77d fix nats path
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-08 01:05:40 +03:00
4d80480d0f add broker
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-07 20:24:31 +03:00
b270345e77 fix ci
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 12:26:40 +03:00
1b95fd9e55 fix CI for template
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 12:20:29 +03:00
7421f33de8 fix bus phase 3/2
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 12:05:57 +03:00
684e416588 add view any columns and view mode for ideas
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 11:41:01 +03:00
890d6de92e fix login error on keycloak template
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 11:16:56 +03:00
2e46cc41a1 fix lint
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-15 02:36:24 +03:00
dea0676169 add ai functions
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2026-01-15 01:59:16 +03:00
739a7d172d end fase 2 2026-01-15 00:18:35 +03:00
85e7966c97 update docs
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-14 02:02:44 +03:00
5366347bcc fix template 2
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-14 01:57:37 +03:00
9e43ad65c5 fix background theme
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-14 01:50:14 +03:00
8f9fa581eb fix deploy theme
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-14 01:44:18 +03:00
61b856254b add custom theme
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-14 01:38:39 +03:00
2953a97a46 fix tests
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-14 01:20:27 +03:00
2ce092aa59 add auth 2026-01-14 01:10:01 +03:00
163 changed files with 18480 additions and 5773 deletions

View File

@ -1,266 +1,127 @@
--- ## Universal .drone.yml for all project types
## Configure your project via service.yaml (see ci-templates/docs/requirements.md)
kind: pipeline kind: pipeline
type: kubernetes type: kubernetes
name: main-pipeline name: ci
# Триггер: запускать при изменениях в backend, frontend или .drone.yml
trigger:
branch:
- main
- master
event:
- push
steps: steps:
# ============================================================ - name: prepare
# СБОРКА ОБРАЗОВ (параллельно) image: alpine:3.19
# ============================================================ environment:
GITEA_TOKEN:
from_secret: GITEA_TOKEN
commands:
- apk add --no-cache git bash yq
- git clone --depth 1 https://token:$GITEA_TOKEN@git.vigdorov.ru/vigdorov/ci-templates.git .ci
- chmod +x .ci/scripts/*.sh
- bash .ci/scripts/prepare.sh
# --- Сборка Backend образа --- - name: build
- name: build-backend image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: plugins/kaniko depends_on: [prepare]
when: environment:
changeset: HARBOR_USER:
includes: from_secret: HARBOR_USER
- backend/** HARBOR_PASSWORD:
- .drone.yml from_secret: HARBOR_PASSWORD
excludes: commands:
- backend/README.md - /busybox/sh .ci/scripts/build.sh
- backend/**/*.md
settings:
registry: registry.vigdorov.ru
repo: registry.vigdorov.ru/library/team-planner-backend
dockerfile: backend/Dockerfile
context: backend
tags:
- ${DRONE_COMMIT_SHA:0:7}
- latest
cache: true
cache_repo: registry.vigdorov.ru/library/team-planner-backend-cache
username:
from_secret: HARBOR_USER
password:
from_secret: HARBOR_PASSWORD
no_push_metadata: true
# --- Сборка Frontend образа (параллельно с backend) --- - name: deploy
- name: build-frontend image: alpine:3.19
image: plugins/kaniko depends_on: [build]
when: environment:
changeset: KUBE_CONFIG:
includes: from_secret: KUBE_CONFIG
- frontend/** commands:
- .drone.yml - apk add --no-cache bash yq kubectl helm
excludes: - bash .ci/scripts/deploy.sh
- frontend/README.md
- frontend/**/*.md
settings:
registry: registry.vigdorov.ru
repo: registry.vigdorov.ru/library/team-planner-frontend
dockerfile: frontend/Dockerfile
context: frontend
tags:
- ${DRONE_COMMIT_SHA:0:7}
- latest
cache: true
cache_repo: registry.vigdorov.ru/library/team-planner-frontend-cache
username:
from_secret: HARBOR_USER
password:
from_secret: HARBOR_PASSWORD
no_push_metadata: true
# ============================================================ trigger:
# ДЕПЛОЙ (только после завершения ОБЕИХ сборок) branch: [main, master]
# ============================================================ event: [push, custom]
# --- Развертывание Backend в PROD ---
- name: deploy-backend
image: alpine/k8s:1.28.2
depends_on:
- build-backend
- build-frontend
when:
changeset:
includes:
- backend/**
- .drone.yml
excludes:
- backend/README.md
- backend/**/*.md
environment:
KUBE_CONFIG_CONTENT:
from_secret: KUBE_CONFIG
commands:
- mkdir -p ~/.kube
- echo "$KUBE_CONFIG_CONTENT" > ~/.kube/config
- chmod 600 ~/.kube/config
- sed -i "s|https://127.0.0.1:6443|https://10.10.10.100:6443|g" ~/.kube/config
- export APP_NAMESPACE="team-planner"
- export IMAGE_TAG="${DRONE_COMMIT_SHA:0:7}"
- export BACKEND_IMAGE="registry.vigdorov.ru/library/team-planner-backend"
- kubectl cluster-info
- sed -e "s|__BACKEND_IMAGE__|$BACKEND_IMAGE:$IMAGE_TAG|g" k8s/backend-deployment.yaml | kubectl apply -n $APP_NAMESPACE -f -
- kubectl apply -n $APP_NAMESPACE -f k8s/backend-service.yaml
- echo "📋 Waiting for rollout..."
- echo "=== CURRENT PODS STATE (before rollout) ==="
- kubectl get pods -n $APP_NAMESPACE -l app=team-planner-backend -o wide
- |
if ! kubectl rollout status deployment/team-planner-backend -n $APP_NAMESPACE --timeout=120s; then
echo "❌ Rollout failed! Collecting diagnostics..."
echo ""
echo "=== DEPLOYMENT STATUS ==="
kubectl get deployment team-planner-backend -n $APP_NAMESPACE -o wide
echo ""
echo "=== PODS STATUS ==="
kubectl get pods -n $APP_NAMESPACE -l app=team-planner-backend -o wide
echo ""
echo "=== DESCRIBE DEPLOYMENT ==="
kubectl describe deployment team-planner-backend -n $APP_NAMESPACE
echo ""
echo "=== RECENT EVENTS ==="
kubectl get events -n $APP_NAMESPACE --sort-by='.lastTimestamp' | tail -30
echo ""
echo "=== POD LOGS (last 100 lines) ==="
POD_NAME=$(kubectl get pods -n $APP_NAMESPACE -l app=team-planner-backend -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
if [ -n "$POD_NAME" ]; then
kubectl logs $POD_NAME -n $APP_NAMESPACE --tail=100 2>/dev/null || echo "No logs available"
echo ""
echo "=== DESCRIBE POD ==="
kubectl describe pod $POD_NAME -n $APP_NAMESPACE
else
echo "No pods found"
fi
exit 1
fi
- echo "✅ Backend deployed to PROD (image:$IMAGE_TAG)"
# --- Развертывание Frontend в PROD ---
- name: deploy-frontend
image: alpine/k8s:1.28.2
depends_on:
- build-backend
- build-frontend
when:
changeset:
includes:
- frontend/**
- .drone.yml
excludes:
- frontend/README.md
- frontend/**/*.md
environment:
KUBE_CONFIG_CONTENT:
from_secret: KUBE_CONFIG
commands:
- mkdir -p ~/.kube
- echo "$KUBE_CONFIG_CONTENT" > ~/.kube/config
- chmod 600 ~/.kube/config
- sed -i "s|https://127.0.0.1:6443|https://10.10.10.100:6443|g" ~/.kube/config
- export APP_NAMESPACE="team-planner"
- export IMAGE_TAG="${DRONE_COMMIT_SHA:0:7}"
- export FRONTEND_IMAGE="registry.vigdorov.ru/library/team-planner-frontend"
- kubectl cluster-info
- sed -e "s|__FRONTEND_IMAGE__|$FRONTEND_IMAGE:$IMAGE_TAG|g" k8s/frontend-deployment.yaml | kubectl apply -n $APP_NAMESPACE -f -
- kubectl apply -n $APP_NAMESPACE -f k8s/frontend-service.yaml
- echo "📋 Waiting for rollout..."
- |
if ! kubectl rollout status deployment/team-planner-frontend -n $APP_NAMESPACE --timeout=300s; then
echo "❌ Rollout failed! Collecting diagnostics..."
echo ""
echo "=== DEPLOYMENT STATUS ==="
kubectl get deployment team-planner-frontend -n $APP_NAMESPACE -o wide
echo ""
echo "=== PODS STATUS ==="
kubectl get pods -n $APP_NAMESPACE -l app=team-planner-frontend -o wide
echo ""
echo "=== DESCRIBE DEPLOYMENT ==="
kubectl describe deployment team-planner-frontend -n $APP_NAMESPACE
echo ""
echo "=== RECENT EVENTS ==="
kubectl get events -n $APP_NAMESPACE --sort-by='.lastTimestamp' | tail -30
echo ""
echo "=== POD LOGS (last 100 lines) ==="
POD_NAME=$(kubectl get pods -n $APP_NAMESPACE -l app=team-planner-frontend -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
if [ -n "$POD_NAME" ]; then
kubectl logs $POD_NAME -n $APP_NAMESPACE --tail=100 2>/dev/null || echo "No logs available"
echo ""
echo "=== DESCRIBE POD ==="
kubectl describe pod $POD_NAME -n $APP_NAMESPACE
else
echo "No pods found"
fi
exit 1
fi
- echo "✅ Frontend deployed to PROD (image:$IMAGE_TAG)"
--- ---
kind: pipeline kind: pipeline
type: kubernetes type: kubernetes
name: infra-pipeline name: keycloak-theme
# Триггер: запускать только при изменениях в k8s конфигах volumes:
trigger: - name: shared
branch: temp: {}
- main
- master
event:
- push
paths:
include:
- k8s/**
steps: steps:
# --- Создание секретов (УДАЛИТЬ ПОСЛЕ ПЕРВОГО ДЕПЛОЯ) --- - name: check-changes
- name: create-secrets image: alpine/git
image: alpine/k8s:1.28.2 volumes:
environment: - name: shared
KUBE_CONFIG_CONTENT: path: /shared
from_secret: KUBE_CONFIG commands:
DB_NAME: - |
from_secret: DB_NAME CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- keycloak-theme/ 2>/dev/null | grep -v '\.md$' || true)
DB_USER: if [ -z "$CHANGED_FILES" ]; then
from_secret: DB_USER echo "No changes in keycloak-theme/ - skipping"
DB_PASSWORD: touch /shared/.skip
from_secret: DB_PASSWORD else
commands: echo "Changed files:"
- mkdir -p ~/.kube echo "$CHANGED_FILES"
- echo "$KUBE_CONFIG_CONTENT" > ~/.kube/config fi
- chmod 600 ~/.kube/config
- sed -i "s|https://127.0.0.1:6443|https://10.10.10.100:6443|g" ~/.kube/config
- export APP_NAMESPACE="team-planner"
- kubectl create namespace $APP_NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
- |
kubectl create secret generic team-planner-secrets \
--from-literal=db-name="$DB_NAME" \
--from-literal=db-user="$DB_USER" \
--from-literal=db-password="$DB_PASSWORD" \
--namespace=$APP_NAMESPACE \
--dry-run=client -o yaml | kubectl apply -f -
- echo "✅ Secrets created/updated"
# --- Развертывание инфраструктуры (PostgreSQL, Services, Ingress) --- - name: build-keycloak-theme
- name: deploy-infra image: gcr.io/kaniko-project/executor:debug
image: alpine/k8s:1.28.2 depends_on: [check-changes]
depends_on: volumes:
- create-secrets - name: shared
environment: path: /shared
KUBE_CONFIG_CONTENT: environment:
from_secret: KUBE_CONFIG HARBOR_USER:
commands: from_secret: HARBOR_USER
- mkdir -p ~/.kube HARBOR_PASSWORD:
- echo "$KUBE_CONFIG_CONTENT" > ~/.kube/config from_secret: HARBOR_PASSWORD
- chmod 600 ~/.kube/config commands:
- sed -i "s|https://127.0.0.1:6443|https://10.10.10.100:6443|g" ~/.kube/config - |
- export APP_NAMESPACE="team-planner" if [ -f /shared/.skip ]; then
- export HOSTNAME="team-planner.vigdorov.ru" echo "Skipping build"
- export SECRET_NAME="wildcard-cert" exit 0
- kubectl cluster-info fi
- kubectl create namespace $APP_NAMESPACE --dry-run=client -o yaml | kubectl apply -f - - |
- kubectl apply -n $APP_NAMESPACE -f k8s/postgres-pvc.yaml export IMAGE_TAG=$(echo $DRONE_COMMIT_SHA | cut -c1-7)
- kubectl apply -n $APP_NAMESPACE -f k8s/postgres-statefulset.yaml export REGISTRY="registry.vigdorov.ru"
- kubectl apply -n $APP_NAMESPACE -f k8s/postgres-service.yaml export REPO="$REGISTRY/library/keycloak-team-planner"
- kubectl apply -n $APP_NAMESPACE -f k8s/backend-service.yaml mkdir -p /kaniko/.docker
- kubectl apply -n $APP_NAMESPACE -f k8s/frontend-service.yaml echo "{\"auths\":{\"$REGISTRY\":{\"username\":\"$HARBOR_USER\",\"password\":\"$HARBOR_PASSWORD\"}}}" > /kaniko/.docker/config.json
- sed -e "s|__HOSTNAME__|$HOSTNAME|g" -e "s|__SECRET_NAME__|$SECRET_NAME|g" k8s/ingress.yaml | kubectl apply -n $APP_NAMESPACE -f - /kaniko/executor \
- echo "✅ Infrastructure updated" --dockerfile=keycloak-theme/Dockerfile \
--context=dir:///drone/src/keycloak-theme \
--destination=$REPO:$IMAGE_TAG \
--destination=$REPO:latest \
--cache=false
- name: deploy-keycloak-theme
image: alpine/k8s:1.28.2
depends_on: [build-keycloak-theme]
volumes:
- name: shared
path: /shared
environment:
KUBE_CONFIG_CONTENT:
from_secret: KUBE_CONFIG
commands:
- |
if [ -f /shared/.skip ]; then
echo "Skipping deploy"
exit 0
fi
- |
mkdir -p ~/.kube
echo "$KUBE_CONFIG_CONTENT" > ~/.kube/config
chmod 600 ~/.kube/config
sed -i "s|https://127.0.0.1:6443|https://10.10.10.100:6443|g" ~/.kube/config
export IMAGE_TAG=$(echo $DRONE_COMMIT_SHA | cut -c1-7)
kubectl set image statefulset/keycloak-keycloakx keycloak=registry.vigdorov.ru/library/keycloak-team-planner:$IMAGE_TAG -n auth
kubectl rollout status statefulset/keycloak-keycloakx -n auth --timeout=180s
trigger:
branch: [main, master]
event: [push, custom]

2
.gitignore vendored
View File

@ -35,3 +35,5 @@ Thumbs.db
# Kubernetes secrets # Kubernetes secrets
k8s/secrets.yaml k8s/secrets.yaml
.playwright-mcp

1
.npmrc Normal file
View File

@ -0,0 +1 @@
@vigdorov:registry=https://git.vigdorov.ru/api/packages/vigdorov/npm/

1
.prettierrc Normal file
View File

@ -0,0 +1 @@
"@vigdorov/prettier-config"

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
2. CONTEXT.md — текущий статус 2. CONTEXT.md — текущий статус
3. ROADMAP.md — план и задачи 3. ROADMAP.md — план и задачи
4. REQUIREMENTS.md / ARCHITECTURE.md — по необходимости 4. REQUIREMENTS.md / ARCHITECTURE.md — по необходимости
5. E2E_TESTING.md — **перед написанием тестов!**
После работы обнови CONTEXT.md. После работы обнови CONTEXT.md.
@ -23,13 +24,15 @@
- [ROADMAP.md](ROADMAP.md) — план разработки, задачи по фазам - [ROADMAP.md](ROADMAP.md) — план разработки, задачи по фазам
- [REQUIREMENTS.md](REQUIREMENTS.md) — требования к продукту - [REQUIREMENTS.md](REQUIREMENTS.md) — требования к продукту
- [ARCHITECTURE.md](ARCHITECTURE.md) — C4, sequence diagrams, API контракты, UI прототипы - [ARCHITECTURE.md](ARCHITECTURE.md) — C4, sequence diagrams, API контракты, UI прототипы
- [E2E_TESTING.md](E2E_TESTING.md) — **читай перед написанием тестов!** Гайд по e2e тестированию
## Структура проекта ## Структура проекта
``` ```
team-planner/ team-planner/
├── backend/ # NestJS API ├── backend/ # NestJS API
── frontend/ # React + TypeScript ── frontend/ # React + TypeScript
└── tests/ # E2E тесты (Playwright)
``` ```
## Ключевые сущности ## Ключевые сущности
@ -51,3 +54,18 @@ team-planner/
Используется ai-proxy service для оценки трудозатрат. Используется ai-proxy service для оценки трудозатрат.
Гайд: `/Users/vigdorov/dev/gptunnel-service/INTEGRATION.md` Гайд: `/Users/vigdorov/dev/gptunnel-service/INTEGRATION.md`
## E2E Тестирование
**Перед написанием тестов обязательно прочитай [E2E_TESTING.md](E2E_TESTING.md)!**
Ключевые правила:
- Тесты следуют требованиям из ROADMAP.md, а не адаптируются под код
- Используй `data-testid` для стабильных селекторов (не `tbody tr`, `.nth()`, CSS классы)
- При добавлении новых компонентов сразу добавляй `data-testid`
- Группируй тесты по фичам/сценариям, а не по компонентам
```bash
# Запуск тестов
cd tests && npx playwright test
```

View File

@ -6,9 +6,10 @@
## Текущий статус ## Текущий статус
**Этап:** Фаза 1 (Frontend) завершена **Этап:** Фаза 3.2 завершена
**Фаза MVP:** Готов к тестированию базового функционала **Фаза MVP:** Базовый функционал + авторизация + расширенный функционал + AI-оценка + мини-ТЗ + история ТЗ + полный просмотр идеи готовы
**Последнее обновление:** 2025-12-31 **Следующий этап:** Фаза 4 — Права доступа
**Последнее обновление:** 2026-01-15
--- ---
@ -16,6 +17,7 @@
| Дата | Что сделано | | Дата | Что сделано |
|------|-------------| |------|-------------|
| 2026-02-08 | **Инфра:** Миграция CI/CD на ci-templates (service.yaml + .drone.yml), удалены Dockerfile/k8s/nginx/docker-compose |
| 2025-12-29 | Созданы REQUIREMENTS.md, CLAUDE.md, CONTEXT.md | | 2025-12-29 | Созданы REQUIREMENTS.md, CLAUDE.md, CONTEXT.md |
| 2025-12-29 | Создан ARCHITECTURE.md (C4, sequences, API, UI prototypes, спецификация) | | 2025-12-29 | Создан ARCHITECTURE.md (C4, sequences, API, UI prototypes, спецификация) |
| 2025-12-29 | Создан ROADMAP.md — план разработки по фазам | | 2025-12-29 | Создан ROADMAP.md — план разработки по фазам |
@ -35,6 +37,59 @@
| 2025-12-29 | **Фаза 1:** Frontend — Удаление идей | | 2025-12-29 | **Фаза 1:** Frontend — Удаление идей |
| 2025-12-31 | Исправлен баг: Select в inline-редактировании закрывался при клике (MenuProps.disablePortal) | | 2025-12-31 | Исправлен баг: Select в inline-редактировании закрывался при клике (MenuProps.disablePortal) |
| 2025-12-31 | Локализация интерфейса на русский язык | | 2025-12-31 | Локализация интерфейса на русский язык |
| 2026-01-13 | **Фаза 2:** Backend — PATCH /api/ideas/reorder endpoint (ReorderIdeasDto, транзакция) |
| 2026-01-13 | **Фаза 2:** Frontend — Drag & Drop с dnd-kit (DraggableRow, drag handle) |
| 2026-01-13 | **Фаза 2:** Исправлены баги D&D: setNodeRef, сортировка по order, оптимистичные обновления, DragOverlay |
| 2026-01-13 | Добавлены Selenium E2E тесты (tests/e2e/) — Фаза 1 ✅, Фаза 2 частично |
| 2026-01-13 | **Авторизация:** Backend Auth модуль (JWT + Keycloak JWKS) |
| 2026-01-13 | **Авторизация:** Frontend AuthProvider (keycloak-js, auto token refresh) |
| 2026-01-14 | E2E тесты переписаны с Selenium на Playwright (tests/e2e/*.spec.ts) |
| 2026-01-14 | **Фаза 2:** Улучшен Drag & Drop — добавлен @dnd-kit/modifiers, исправлен race condition с drag handle, restrictToVerticalAxis |
| 2026-01-14 | **Production:** Настроен Keycloak для production (team-planner.vigdorov.ru), обновлён Dockerfile с Keycloak переменными |
| 2026-01-14 | **UI:** Страница логина (LoginPage) — кнопка "Войти", описание приложения, контакт для получения доступа |
| 2026-01-14 | **UI:** Кнопка выхода на главной странице (IconButton с Logout) |
| 2026-01-14 | **Infra:** Добавлен KEYCLOAK_REALM_URL в k8s/backend-deployment.yaml |
| 2026-01-14 | **Keycloak Theme:** Кастомная тема для Keycloak (MUI стиль) — keycloak-theme/ |
| 2026-01-14 | **CI/CD:** Добавлены steps build-keycloak-theme и deploy-keycloak-theme в .drone.yml |
| 2026-01-14 | **Фаза 2:** Цветовая маркировка — ColorPickerCell, цветной фон строки, фильтр по цвету |
| 2026-01-14 | **Фаза 2:** Комментарии — backend модуль (entity, service, controller, миграция), frontend (CommentsPanel, раскрывающаяся панель) |
| 2026-01-14 | **UX:** Хук useAuth для данных пользователя, имя в header, автор комментариев из Keycloak |
| 2026-01-14 | **Фаза 2:** Управление командой — backend (TeamMember entity, CRUD, summary), frontend (TeamPage, табы навигации) |
| 2026-01-14 | **Фаза 2:** Динамические роли — Role entity вместо enum, CRUD API (/api/roles), RolesManager UI, миграция данных |
| 2026-01-15 | **Testing:** E2E тесты Фазы 2 (Playwright) — 54 теста покрывают D&D, цвета, комментарии, команду |
| 2026-01-15 | **Testing:** Рефакторинг тестов на data-testid — стабильные селекторы вместо tbody/tr/.nth() |
| 2026-01-15 | **Testing:** Добавлены data-testid во все компоненты фронтенда (IdeasTable, TeamPage, CommentsPanel и др.) |
| 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 |
| 2026-01-15 | **Планирование:** Добавлена Фаза 3.2 — Полный просмотр идеи (все поля доступны для просмотра и редактирования) |
| 2026-01-15 | **Фаза 3.2:** Добавлены колонки pain, aiRole, verificationMethod в таблицу идей |
| 2026-01-15 | **Фаза 3.2:** ColumnVisibility компонент — управление видимостью колонок (Settings icon), сохранение в localStorage |
| 2026-01-15 | **Фаза 3.2:** IdeaDetailModal компонент — просмотр всех полей идеи, режим редактирования, интеграция с ТЗ и AI-оценкой |
| 2026-01-15 | **Фаза 3.2:** Кнопка "Подробнее" (Visibility icon) в actions колонке для открытия детального просмотра |
| 2026-01-15 | **Фаза 3.2:** Исправлен баг — статус ТЗ сохраняется при редактировании идеи в модалке |
| 2026-01-15 | **Testing:** E2E тесты Фазы 3.2 (Playwright) — 15 тестов покрывают детальный просмотр, редактирование, column visibility |
| 2026-01-15 | **CI/CD:** Keycloak theme вынесен в отдельный pipeline с проверкой изменений через git diff |
--- ---
@ -42,7 +97,48 @@
> Смотри [ROADMAP.md](ROADMAP.md) для полного плана разработки > Смотри [ROADMAP.md](ROADMAP.md) для полного плана разработки
**Сейчас:** Тестирование Фазы 1, затем Фаза 2 (Drag&Drop, цвета, комментарии) **Готово:** Фазы 0-3.2 завершены ✅
**Следующий шаг:** Фаза 4 — Права доступа 📋
### Фаза 3.2: Полный просмотр идеи ✅
**Колонки в таблице:**
- [x] Колонки pain, aiRole, verificationMethod
- [x] Column visibility (скрытие/показ колонок, localStorage)
**Модалка IdeaDetailModal:**
- [x] Режим просмотра (readonly по умолчанию)
- [x] Режим редактирования (кнопка "Редактировать")
- [x] Кнопки "Сохранить" / "Отмена"
- [x] Быстрый доступ к ТЗ и AI-оценке
**E2E тесты:**
- [x] Column visibility, модалка, редактирование, сохранение (15 тестов)
### Новые требования (Фазы 4-8):
**Фаза 4: Права доступа**
- [ ] Гранулярные права (18 различных прав)
- [ ] Панель администратора
- [ ] Автор идеи (readonly)
- [ ] Admin определяется через K8s Secret
**Фаза 5: Аудит и история**
- [ ] Логирование всех действий
- [ ] Восстановление удалённых данных
- [ ] Настраиваемый срок хранения (по умолчанию 30 дней)
**Фаза 6: Real-time и WebSocket**
- [ ] Многопользовательская работа
- [ ] Индикаторы присутствия
- [ ] Конкурентное редактирование
**Фаза 7: Темная тема**
- [ ] Переключатель светлая/тёмная
- [ ] Автоопределение системной темы
**Фаза 8: Экспорт**
- [ ] Экспорт идеи в DOCX
--- ---
@ -56,25 +152,81 @@ team-planner/
├── REQUIREMENTS.md # Требования к продукту ├── REQUIREMENTS.md # Требования к продукту
├── ARCHITECTURE.md # Архитектура, API, UI ├── ARCHITECTURE.md # Архитектура, API, UI
├── ROADMAP.md # План разработки ├── ROADMAP.md # План разработки
├── E2E_TESTING.md # Гайд по E2E тестированию ✅
├── docker-compose.yml # PostgreSQL и сервисы ├── docker-compose.yml # PostgreSQL и сервисы
├── .drone.yml # CI/CD pipeline (Drone CI)
├── keycloak-theme/ # Кастомная тема Keycloak ✅
│ ├── Dockerfile # Образ keycloak-team-planner
│ └── team-planner/
│ └── login/ # Тема страницы логина (MUI стиль)
│ ├── template.ftl
│ ├── login.ftl
│ ├── theme.properties
│ ├── resources/css/login.css
│ └── messages/messages_ru.properties
├── tests/
│ ├── package.json # Зависимости для тестов
│ ├── playwright.config.ts # Конфигурация Playwright
│ └── e2e/ # Playwright E2E тесты ✅
│ ├── auth.setup.ts # Авторизация для тестов (Keycloak)
│ ├── phase1.spec.ts # Тесты Фазы 1 (17 тестов)
│ ├── phase2.spec.ts # Тесты Фазы 2 (37 тестов — D&D, цвета, комментарии, команда)
│ ├── phase3.spec.ts # Тесты Фазы 3 (20 тестов — AI-оценка + мини-ТЗ)
│ └── phase3.2.spec.ts # Тесты Фазы 3.2 (15 тестов — детальный просмотр, column visibility) ✅
├── backend/ # NestJS API ├── backend/ # NestJS API
│ ├── src/ │ ├── src/
│ │ ├── ideas/ # Модуль идей (готов) │ │ ├── auth/ # Модуль авторизации ✅
│ │ ├── team/ # Модуль команды (Фаза 2) │ │ │ ├── jwt.strategy.ts # JWT валидация через JWKS
│ │ │ ├── jwt-auth.guard.ts # Глобальный guard
│ │ │ └── decorators/public.decorator.ts # @Public() для открытых endpoints
│ │ ├── ideas/ # Модуль идей (готов + reorder + history)
│ │ │ ├── entities/
│ │ │ │ ├── idea.entity.ts # Idea + specification поля
│ │ │ │ └── specification-history.entity.ts # История ТЗ
│ │ │ ├── dto/
│ │ │ │ └── reorder-ideas.dto.ts # DTO для изменения порядка
│ │ │ ├── ideas.controller.ts # PATCH /ideas/reorder
│ │ │ └── ideas.service.ts # reorder() с транзакцией
│ │ ├── 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/
│ ├── components/ │ ├── components/
│ │ ├── IdeasTable/ # Таблица идей с inline-редактированием │ │ ├── AuthProvider/ # Keycloak авторизация ✅
│ │ ├── LoginPage/ # Страница логина ✅
│ │ ├── IdeasTable/
│ │ │ ├── IdeasTable.tsx # Таблица с DndContext
│ │ │ ├── DraggableRow.tsx # Сортируемая строка (useSortable)
│ │ │ ├── columns.tsx # Колонки + drag handle (13 колонок)
│ │ │ ├── ColumnVisibility.tsx # Управление видимостью колонок ✅
│ │ │ └── ...
│ │ ├── IdeasFilters/ # Фильтры │ │ ├── IdeasFilters/ # Фильтры
│ │ ── CreateIdeaModal/ # Модалка создания │ │ ── CreateIdeaModal/ # Модалка создания
│ │ ├── TeamPage/ # Страница команды (Фаза 2)
│ │ │ ├── TeamPage.tsx # Табы: Участники / Роли
│ │ │ ├── TeamMemberModal.tsx # Модалка участника
│ │ │ └── RolesManager.tsx # Управление ролями
│ │ ├── CommentsPanel/ # Комментарии к идеям
│ │ ├── AiEstimateModal/ # Модалка AI-оценки (Фаза 3) ✅
│ │ ├── SpecificationModal/ # Модалка мини-ТЗ (Фаза 3.1) ✅
│ │ └── IdeaDetailModal/ # Модалка детального просмотра (Фаза 3.2) ✅
│ ├── hooks/ │ ├── hooks/
│ │ ── useIdeas.ts # React Query хуки │ │ ── useIdeas.ts # React Query хуки + useReorderIdeas
│ │ └── useAi.ts # useEstimateIdea + useGenerateSpecification + history hooks ✅
│ ├── services/ │ ├── services/
│ │ ├── api.ts # Axios instance │ │ ├── api.ts # Axios + auth interceptors
│ │ ── ideas.ts # API методы для идей │ │ ── keycloak.ts # Keycloak instance ✅
│ │ ├── ideas.ts # API методы + reorder()
│ │ ├── team.ts # API команды
│ │ ├── roles.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/
@ -95,6 +247,10 @@ team-planner/
| Drag & Drop | dnd-kit | Современный, хорошая поддержка | | Drag & Drop | dnd-kit | Современный, хорошая поддержка |
| Data Fetching | React Query | Кэширование, оптимистичные обновления | | Data Fetching | React Query | Кэширование, оптимистичные обновления |
| Язык интерфейса | Русский | Требование проекта | | Язык интерфейса | Русский | Требование проекта |
| Авторизация | Keycloak | Внешний IdP, OIDC, редиректы |
| Keycloak Theme | Custom FreeMarker | Единый стиль с приложением (MUI) |
| E2E тесты | Playwright | Быстрее Selenium, лучше API, auto-wait |
| CI/CD | Drone CI | Kubernetes pipeline, автодеплой |
--- ---
@ -108,6 +264,18 @@ 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 через транзакцию
- **Keycloak:** auth.vigdorov.ru, realm `team-planner`, client `team-planner-frontend`
- **Keycloak Theme:** Кастомная тема `team-planner` в стиле MUI, образ `registry.vigdorov.ru/library/keycloak-team-planner`
- **Production URL:** https://team-planner.vigdorov.ru (добавлен в Valid redirect URIs и Web origins клиента Keycloak)
- **CI/CD:** Drone CI (.drone.yml) — 3 pipeline'а: main-pipeline (backend/frontend), infra-pipeline (k8s), keycloak-theme-pipeline (отдельный с git diff проверкой)
- **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:** Промпты для генерации ТЗ и оценки трудозатрат теперь включают комментарии к идее для лучшей точности
- **Keycloak Theme CI:** Отдельный pipeline проверяет `git diff HEAD~1 HEAD -- keycloak-theme/` и пропускает сборку/деплой если нет изменений (экономия ресурсов, нет влияния на Keycloak)

View File

@ -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 окружением!
--- ---
## Правила работы ## Правила работы

298
E2E_TESTING.md Normal file
View File

@ -0,0 +1,298 @@
# E2E Testing Guide
Руководство по написанию e2e тестов для Team Planner.
## Принципы
### 1. Тесты следуют требованиям, а не коду
Тесты должны проверять **пользовательские сценарии** из требований, а не адаптироваться под текущую реализацию.
```
❌ Плохо: "Проверить что кнопка имеет класс .MuiButton-contained"
✅ Хорошо: "Проверить что пользователь может создать новую идею"
```
**Порядок работы:**
1. Прочитать требования к фазе/фиче в `ROADMAP.md` и `REQUIREMENTS.md`
2. Выделить пользовательские сценарии
3. Написать тесты для каждого сценария
4. Убедиться что тесты проверяют бизнес-логику, а не детали реализации
### 2. Стабильные селекторы через data-testid
**Никогда не использовать:**
- Позиционные селекторы: `tbody tr`, `.nth(2)`, `:first-child`
- CSS классы MUI: `.MuiButton-root`, `.MuiTableCell-body`
- Структурные селекторы: `table > tbody > tr > td`
**Всегда использовать:**
- `data-testid` для уникальной идентификации элементов
- `[role="..."]` только для стандартных ARIA ролей (tab, dialog, listbox)
- Текстовые селекторы только для статичного контента
```typescript
// ❌ Плохо - сломается при изменении структуры
const row = page.locator('tbody tr').nth(2);
const button = page.locator('.MuiIconButton-root').first();
// ✅ Хорошо - стабильно при рефакторинге
const row = page.locator('[data-testid="idea-row-123"]');
const button = page.locator('[data-testid="delete-idea-button"]');
```
## Соглашения по data-testid
### Именование
| Паттерн | Пример | Использование |
|---------|--------|---------------|
| `{component}-{element}` | `ideas-table` | Основные элементы |
| `{component}-{element}-{id}` | `idea-row-123` | Динамические элементы |
| `{action}-{target}-button` | `delete-idea-button` | Кнопки действий |
| `{name}-input` | `member-name-input` | Поля ввода |
| `{name}-modal` | `team-member-modal` | Модальные окна |
| `filter-{name}` | `filter-status` | Фильтры |
### Обязательные data-testid по компонентам
#### Таблицы
```
{name}-table - сам table элемент
{name}-table-container - обёртка таблицы
{name}-empty-state - состояние "нет данных"
{item}-row-{id} - строка с данными
```
#### Формы и модалки
```
{name}-modal - Dialog компонент
{name}-form - form элемент
{field}-input - поля ввода (TextField)
{field}-select - выпадающие списки (FormControl)
submit-{action}-button - кнопка отправки
cancel-{action}-button - кнопка отмены
```
#### Действия в строках
```
edit-{item}-button - редактирование
delete-{item}-button - удаление
toggle-{feature}-button - переключение
```
## Работа с MUI компонентами
### Popover / Menu
MUI Popover рендерится через Portal в `<body>`. Для добавления `data-testid` используй `slotProps`:
```tsx
<Popover
slotProps={{
paper: {
'data-testid': 'color-picker-popover',
} as React.HTMLAttributes<HTMLDivElement>,
}}
>
```
### Dialog
Dialog также использует Portal. Добавляй `data-testid` напрямую:
```tsx
<Dialog data-testid="team-member-modal">
```
### Select / Combobox
Для работы с MUI Select:
```typescript
// Открыть dropdown
await page.locator('[data-testid="filter-status"] [role="combobox"]').click();
// Выбрать опцию из listbox
const listbox = page.locator('[role="listbox"]');
await listbox.locator('[role="option"]').filter({ hasText: 'Бэклог' }).click();
```
### TextField
TextField в MUI оборачивает input в несколько div. Для доступа к самому input:
```typescript
// data-testid на TextField
<TextField data-testid="member-name-input" />
// В тесте - добавляем input селектор
const input = page.locator('[data-testid="member-name-input"] input');
await input.fill('Имя');
```
## Структура тестов
### Файловая организация
```
tests/
├── e2e/
│ ├── auth.setup.ts # Аутентификация (запускается первой)
│ ├── phase1.spec.ts # Тесты фазы 1
│ ├── phase2.spec.ts # Тесты фазы 2
│ └── phase3.spec.ts # Тесты фазы 3
└── playwright.config.ts
```
### Шаблон тестового файла
```typescript
import { test, expect } from '@playwright/test';
/**
* E2E тесты для Фазы N Team Planner
* - Фича 1
* - Фича 2
*
* Используем data-testid для стабильных селекторов
*/
test.describe('Фаза N: Название фичи', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Ждём загрузки основного элемента
await page.waitForSelector('[data-testid="main-element"]', { timeout: 10000 });
});
test('Описание сценария', async ({ page }) => {
// Arrange - подготовка
const element = page.locator('[data-testid="element"]');
// Act - действие
await element.click();
// Assert - проверка
await expect(element).toBeVisible();
});
});
```
### Группировка тестов
Группируй тесты по фичам/сценариям, а не по компонентам:
```typescript
// ❌ Плохо - группировка по компонентам
test.describe('Button tests', () => { ... });
test.describe('Modal tests', () => { ... });
// ✅ Хорошо - группировка по фичам
test.describe('Фаза 2: Управление командой - CRUD участников', () => { ... });
test.describe('Фаза 2: Управление командой - Вкладка Роли', () => { ... });
```
## Обработка edge cases
### Проверка наличия данных
```typescript
test('Тест с данными', async ({ page }) => {
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
const hasData = !(await emptyState.isVisible().catch(() => false));
// Пропускаем тест если нет данных
test.skip(!hasData, 'Нет данных для тестирования');
// Продолжаем тест...
});
```
### Работа с динамическими ID
```typescript
// Для элементов с динамическими ID используй prefix-селектор
const ideaRows = page.locator('[data-testid^="idea-row-"]');
const rowCount = await ideaRows.count();
```
### Ожидание после действий
```typescript
// После клика, который вызывает API запрос
await button.click();
await page.waitForTimeout(500); // Даём время на запрос
// Лучше - ждать конкретный результат
await expect(newElement).toBeVisible({ timeout: 5000 });
```
## Чеклист перед написанием тестов
- [ ] Прочитаны требования к фиче в ROADMAP.md
- [ ] Определены пользовательские сценарии
- [ ] Проверено наличие data-testid в компонентах
- [ ] Если data-testid отсутствуют - добавить их в компоненты
- [ ] Тесты не зависят от порядка/позиции элементов в DOM
- [ ] Тесты корректно обрабатывают случай отсутствия данных
## Добавление data-testid в компоненты
При добавлении новых компонентов или фич, сразу добавляй data-testid:
```tsx
// Таблица
<Table data-testid="ideas-table">
<TableBody>
{items.map(item => (
<TableRow key={item.id} data-testid={`idea-row-${item.id}`}>
<TableCell>
<IconButton data-testid="delete-idea-button">
<Delete />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
// Модалка с формой
<Dialog data-testid="create-idea-modal">
<form data-testid="create-idea-form">
<TextField data-testid="idea-title-input" />
<Button data-testid="submit-create-idea">Создать</Button>
<Button data-testid="cancel-create-idea">Отмена</Button>
</form>
</Dialog>
```
## Запуск тестов
```bash
# Все тесты (из корня проекта)
npm run test
# Конкретный файл
npx playwright test e2e/phase2.spec.ts
# Конкретный тест по имени
npx playwright test -g "Drag handle имеет правильный курсор"
# С UI режимом для отладки
npx playwright test --ui
# Только упавшие тесты
npx playwright test --last-failed
```
## Правила исправления тестов
**ВАЖНО:** При исправлении сломанных тестов:
1. **НЕ запускай полный прогон** после каждого исправления
2. **Запускай только сломанный тест** для проверки исправления:
```bash
npx playwright test -g "Название теста"
```
3. **Полный прогон** делай только когда все сломанные тесты исправлены
4. Это экономит время и ресурсы при отладке

View File

@ -27,21 +27,41 @@
| Цвет | Цветовая маркировка строки | Color | | Цвет | Цветовая маркировка строки | Color |
| Оценка времени | AI-генерируемая оценка трудозатрат | Calculated | | Оценка времени | AI-генерируемая оценка трудозатрат | Calculated |
#### 1.2 Редактирование идей #### 1.2 Автор идеи
- **Inline-редактирование**: возможность редактировать любое поле прямо в таблице двойным кликом - При создании идеи автоматически сохраняется автор (текущий пользователь)
- **Быстрое изменение статуса и приоритета** через dropdown - Автора идеи изменить нельзя (поле readonly)
- **Автосохранение** изменений - Отображение автора в таблице и детальном просмотре
#### 1.3 Drag & Drop #### 1.3 Редактирование идей
- **Полный просмотр**: пользователь может просмотреть ВСЕ поля идеи (включая pain, aiRole, verificationMethod)
- **Полное редактирование**: пользователь может отредактировать ВСЕ редактируемые поля идеи
- **Inline-редактирование**: возможность редактировать любое поле прямо в таблице двойным кликом
- **Детальный просмотр**: модалка с полной информацией об идее
- Открывается в **режиме просмотра** (readonly)
- Кнопка "Редактировать" переводит в **режим редактирования**
- Кнопка "Сохранить" сохраняет изменения
- Кнопка "Отмена" отменяет изменения
- **Column visibility**: возможность скрыть/показать колонки таблицы
- **Быстрое изменение статуса и приоритета** через dropdown
- **Автосохранение** изменений (для inline-редактирования)
#### 1.4 Drag & Drop
- Перемещение идей в списке для ручной сортировки - Перемещение идей в списке для ручной сортировки
- Визуальная индикация при перетаскивании - Визуальная индикация при перетаскивании
- Сохранение порядка после перемещения - Сохранение порядка после перемещения
#### 1.4 Цветовая маркировка #### 1.5 Цветовая маркировка
- Возможность назначить цвет строке для визуального выделения - Возможность назначить цвет строке для визуального выделения
- Предустановленная палитра цветов - Предустановленная палитра цветов
- Фильтрация по цвету - Фильтрация по цвету
#### 1.6 Экспорт идеи
- Экспорт отдельной идеи в формате DOCX
- Включает: название, описание, статус, приоритет, модуль, целевую аудиторию, боль, роль AI, способ проверки
- Если есть AI-оценка — включается в документ (общее время, сложность, разбивка по ролям, рекомендации)
- Если есть ТЗ — включается в документ (markdown рендерится как форматированный текст)
- Комментарии к идее включаются в документ (автор, дата, текст)
### 2. Сортировка и фильтрация ### 2. Сортировка и фильтрация
#### 2.1 Сортировка #### 2.1 Сортировка
@ -89,6 +109,14 @@
- Расчёт общего времени с учётом состава команды - Расчёт общего времени с учётом состава команды
- Рекомендации по оптимизации - Рекомендации по оптимизации
#### 3.4 Генерация мини-ТЗ
- **Генерация ТЗ**: создание структурированного технического задания на основе описания идеи
- **Структура ТЗ**: цель, функциональные требования, технические требования, критерии приёмки, зависимости и риски
- **Сохранение**: ТЗ сохраняется в базе данных для повторного использования
- **Просмотр**: возможность просмотреть сохранённое ТЗ по клику на кнопку
- **Редактирование**: возможность изменить сгенерированное ТЗ вручную
- **Интеграция с оценкой**: AI-оценка времени учитывает ТЗ для более точного расчёта
### 4. Комментарии ### 4. Комментарии
- Добавление комментариев к идее - Добавление комментариев к идее
@ -96,6 +124,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 +231,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,16 +263,31 @@
### Безопасность ### Безопасность
- Валидация входных данных - Валидация входных данных
- Rate limiting для AI-запросов - Rate limiting для AI-запросов
- Проверка прав доступа на каждом endpoint
- Защита от конкурентных изменений (оптимистичная блокировка)
### Авторизация и авторизация
- **Keycloak** (auth.vigdorov.ru) внешний Identity Provider
- Авторизация через редиректы на стандартную форму Keycloak
- Authorization Code Flow + PKCE
- JWT токены с валидацией через JWKS
- Автоматическое обновление токенов
- Защита всех 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)?
**НЕТ** не требуется

View File

@ -9,147 +9,478 @@
| Фаза | Название | Статус | Описание | | Фаза | Название | Статус | Описание |
|------|----------|--------|----------| |------|----------|--------|----------|
| 0 | Инициализация | В процессе | Настройка проектов, инфраструктура | | 0 | Инициализация | ✅ Завершена | Настройка проектов, инфраструктура |
| 1 | Базовый функционал | ⏸️ Ожидает | CRUD идей, таблица, редактирование | | 1 | Базовый функционал | ✅ Завершена | CRUD идей, таблица, редактирование |
| 2 | Расширенный функционал | ⏸️ Ожидает | Drag&Drop, цвета, комментарии, команда | | 1.5 | Авторизация | ✅ Завершена | Keycloak, JWT, защита API |
| 3 | AI-интеграция | ⏸️ Ожидает | Оценка времени, рекомендации | | 2 | Расширенный функционал | ✅ Завершена | Drag&Drop, цвета, комментарии, команда |
| 3 | AI-интеграция | ✅ Завершена | Оценка времени, рекомендации |
| 3.1 | Генерация мини-ТЗ | ✅ Завершена | Генерация, редактирование, история ТЗ |
| 3.2 | Полный просмотр идеи | ✅ Завершена | Просмотр и редактирование всех полей |
| 4 | Права доступа | 📋 Планируется | Гранулярные права, панель админа |
| 5 | Аудит и история | 📋 Планируется | Логирование действий, восстановление |
| 6 | Real-time и WebSocket | 📋 Планируется | Многопользовательская работа |
| 7 | Темная тема | 📋 Планируется | Переключение светлая/тёмная |
| 8 | Экспорт | 📋 Планируется | Экспорт идеи в DOCX |
--- ---
## Фаза 0: Инициализация ## Фаза 0: Инициализация
### Backend ### Backend
- [ ] Создать NestJS проект (`nest new backend`) - [x] Создать NestJS проект (`nest new backend`)
- [ ] Настроить TypeORM + PostgreSQL - [x] Настроить TypeORM + PostgreSQL
- [ ] Создать docker-compose для PostgreSQL - [x] Создать docker-compose для PostgreSQL
- [ ] Настроить базовую структуру модулей - [x] Настроить базовую структуру модулей
- [ ] Добавить глобальную валидацию (class-validator) - [x] Добавить глобальную валидацию (class-validator)
- [ ] Настроить CORS - [x] Настроить CORS
### Frontend ### Frontend
- [ ] Создать React проект (Vite + TypeScript) - [x] Создать React проект (Vite + TypeScript)
- [ ] Установить и настроить MUI - [x] Установить и настроить MUI
- [ ] Установить Zustand - [x] Установить Zustand
- [ ] Установить TanStack Table - [x] Установить TanStack Table
- [ ] Установить dnd-kit - [x] Установить dnd-kit
- [ ] Настроить Axios + React Query - [x] Настроить Axios + React Query
- [ ] Создать базовую структуру папок - [x] Создать базовую структуру папок
### Инфраструктура ### Инфраструктура
- [x] Создать общий docker-compose
- [ ] Настроить ESLint + Prettier для обоих проектов - [ ] Настроить ESLint + Prettier для обоих проектов
- [ ] Создать общий docker-compose
--- ---
## Фаза 1: Базовый функционал ## Фаза 1: Базовый функционал
### Backend — Модуль Ideas ### Backend — Модуль Ideas
- [ ] Создать сущность Idea (entity) - [x] Создать сущность Idea (entity)
- [ ] Создать DTO (CreateIdeaDto, UpdateIdeaDto, QueryIdeasDto) - [x] Создать DTO (CreateIdeaDto, UpdateIdeaDto, QueryIdeasDto)
- [ ] Реализовать IdeasService - [x] Реализовать IdeasService
- [ ] Реализовать IdeasController - [x] Реализовать IdeasController
- [ ] GET /api/ideas (с пагинацией, фильтрами, сортировкой) - [x] GET /api/ideas (с пагинацией, фильтрами, сортировкой)
- [ ] POST /api/ideas - [x] POST /api/ideas
- [ ] PATCH /api/ideas/:id - [x] PATCH /api/ideas/:id
- [ ] DELETE /api/ideas/:id - [x] DELETE /api/ideas/:id
- [ ] Добавить валидацию - [x] Добавить валидацию
- [ ] Написать тесты - [x] E2E тесты (Playwright)
### Frontend — Таблица идей ### Frontend — Таблица идей
- [ ] Создать типы (types/idea.ts) - [x] Создать типы (types/idea.ts)
- [ ] Создать API-сервис (services/ideas.ts) - [x] Создать API-сервис (services/ideas.ts)
- [ ] Создать Zustand store (store/ideas.ts) - [x] Создать Zustand store (store/ideas.ts)
- [ ] Создать компонент IdeasTable - [x] Создать компонент IdeasTable
- [ ] Отображение колонок - [x] Отображение колонок
- [ ] Пагинация - [x] Пагинация
- [ ] Сортировка (клик по заголовку) - [x] Сортировка (клик по заголовку)
- [ ] Создать компоненты фильтров - [x] Создать компоненты фильтров
- [ ] Фильтр по статусу - [x] Фильтр по статусу
- [ ] Фильтр по приоритету - [x] Фильтр по приоритету
- [ ] Фильтр по модулю - [x] Фильтр по модулю
- [ ] Текстовый поиск - [x] Текстовый поиск
- [ ] Inline-редактирование ячеек - [x] Inline-редактирование ячеек
- [ ] Double-click для редактирования - [x] Double-click для редактирования
- [ ] Автосохранение при blur/Enter - [x] Автосохранение при blur/Enter
- [ ] Оптимистичные обновления - [x] Оптимистичные обновления
- [ ] Создать модалку создания идеи - [x] Создать модалку создания идеи
- [ ] Добавить skeleton loader - [x] Добавить skeleton loader
- [ ] Добавить empty state - [x] Добавить empty state
- [x] Удаление идей
- [x] Локализация интерфейса на русский язык
--- ---
## Фаза 2: Расширенный функционал ## Фаза 1.5: Авторизация ✅
> **Keycloak интеграция для защиты приложения**
### Настройка Keycloak (auth.vigdorov.ru)
- [x] Создать realm `team-planner`
- [x] Создать client `team-planner-frontend` (public, PKCE)
- [x] Настроить Valid Redirect URIs
- [x] Создать тестового пользователя
### Backend — Auth модуль
- [x] Установить passport, passport-jwt, jwks-rsa
- [x] Создать JwtStrategy (валидация через JWKS)
- [x] Создать JwtAuthGuard (глобальный guard)
- [x] Создать @Public() декоратор
- [x] Добавить env переменные (KEYCLOAK_REALM_URL)
- [x] Защитить все endpoints (кроме /health)
### Frontend — AuthProvider
- [x] Установить keycloak-js
- [x] Создать keycloak.ts сервис
- [x] Создать AuthProvider компонент
- [x] onLoad: 'login-required'
- [x] PKCE (S256)
- [x] Автообновление токена
- [x] Добавить interceptors в api.ts
- [x] Authorization header
- [x] Обработка 401
- [x] Обернуть App в AuthProvider
---
## Фаза 2: Расширенный функционал ✅
### Backend — Дополнения ### Backend — Дополнения
- [ ] PATCH /api/ideas/reorder (изменение порядка) - [x] PATCH /api/ideas/reorder (изменение порядка)
- [ ] Модуль Comments - [x] Модуль Comments
- [ ] Сущность Comment - [x] Сущность Comment
- [ ] GET /api/ideas/:id/comments - [x] GET /api/ideas/:id/comments
- [ ] POST /api/ideas/:id/comments - [x] POST /api/ideas/:id/comments
- [ ] DELETE /api/comments/:id - [x] DELETE /api/comments/:id
- [ ] Модуль Team - [x] Модуль Team
- [ ] Сущность TeamMember - [x] Сущность TeamMember
- [ ] CRUD endpoints - [x] CRUD endpoints
- [ ] GET /api/team/summary - [x] GET /api/team/summary
### Frontend — Drag & Drop ### Frontend — Drag & Drop
- [ ] Интегрировать dnd-kit в таблицу - [x] Интегрировать dnd-kit в таблицу
- [ ] Drag handle в первой колонке - [x] Drag handle в первой колонке
- [ ] Визуальная индикация при перетаскивании - [x] Визуальная индикация при перетаскивании (DragOverlay)
- [ ] Сохранение порядка на сервер - [x] Сохранение порядка на сервер (оптимистичные обновления)
- [x] Сортировка по order по умолчанию
### Frontend — Цветовая маркировка ### Frontend — Цветовая маркировка
- [ ] Добавить поле color в таблицу - [x] Добавить поле color в таблицу
- [ ] Цветовой фон строки - [x] Цветовой фон строки
- [ ] Picker для выбора цвета - [x] Picker для выбора цвета
- [ ] Фильтр по цвету - [x] Фильтр по цвету
### Frontend — Комментарии ### Frontend — Комментарии
- [ ] Раскрывающаяся панель под строкой - [x] Раскрывающаяся панель под строкой
- [ ] Список комментариев с тредами - [x] Список комментариев
- [ ] Форма добавления комментария - [x] Форма добавления комментария
- [ ] Ответы на комментарии - [x] Удаление комментариев
### Frontend — Управление командой ### Frontend — Управление командой
- [ ] Страница /team - [x] Страница /team (табы навигации)
- [ ] Сводка по ролям - [x] Сводка по ролям
- [ ] Таблица участников - [x] Таблица участников
- [ ] Модалка добавления/редактирования - [x] Модалка добавления/редактирования
- [ ] Матрица производительности (время на задачи по сложности) - [x] Матрица производительности (время на задачи по сложности)
### E2E тестирование ✅
- [x] Playwright тесты для Фазы 1 (17 тестов)
- [x] Playwright тесты для Фазы 2 (37 тестов)
- [x] data-testid во всех компонентах
- [x] Гайд E2E_TESTING.md
--- ---
## Фаза 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 для новых компонентов
---
## Фаза 3.2: Полный просмотр идеи ✅
> **Просмотр и редактирование ВСЕХ полей идеи**
### Проблема (решена)
Ранее в таблице отображались не все поля идеи. Поля `pain`, `aiRole`, `verificationMethod` было невозможно ни посмотреть, ни отредактировать.
### Frontend — Дополнительные колонки в таблице
- [x] Добавить колонку "Боль" (pain) с inline-редактированием
- [x] Добавить колонку "Роль AI" (aiRole) с inline-редактированием
- [x] Добавить колонку "Способ проверки" (verificationMethod) с inline-редактированием
- [x] Column visibility — возможность скрыть/показать колонки
- [x] Кнопка настройки колонок (⚙️) в header таблицы
- [x] Dropdown с чекбоксами для каждой колонки
- [x] Сохранение настроек в localStorage
- [x] data-testid для новых колонок
### Frontend — Модалка детального просмотра
- [x] IdeaDetailModal компонент
- [x] Открытие по кнопке "Подробнее" (👁️ Visibility icon)
- [x] **Режим просмотра** (по умолчанию):
- [x] Все поля отображаются как readonly текст
- [x] Кнопка "Редактировать" для перехода в режим редактирования
- [x] **Режим редактирования**:
- [x] Все редактируемые поля становятся input/textarea/select
- [x] Кнопка "Сохранить" — сохраняет изменения и возвращает в режим просмотра
- [x] Кнопка "Отмена" — отменяет изменения и возвращает в режим просмотра
- [x] Поля для редактирования: title, description, status, priority, module, targetAudience, pain, aiRole, verificationMethod
- [x] Readonly поля (только просмотр): estimatedHours, complexity, createdAt, updatedAt
- [x] Быстрый доступ: кнопки "Открыть ТЗ" и "AI-оценка"
- [x] Кнопка "Подробнее" в колонке actions
- [x] data-testid для всех элементов модалки
### Исправлен баг
- [x] Статус ТЗ сохраняется при редактировании идеи в модалке (обновляются только отправленные поля)
### E2E тестирование (15 тестов)
- [x] Column visibility — скрытие/показ колонок
- [x] Открытие модалки детального просмотра
- [x] Просмотр всех полей в режиме readonly
- [x] Переход в режим редактирования
- [x] Редактирование полей pain, aiRole, verificationMethod
- [x] Сохранение изменений
- [x] Отмена редактирования
- [x] Регрессионный тест на сохранение статуса ТЗ
---
## Фаза 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 (опционально)
--- ---
@ -157,5 +488,6 @@
1. **Вертикальная разработка** — делаем полный flow (BE → FE) для каждой фичи 1. **Вертикальная разработка** — делаем полный flow (BE → FE) для каждой фичи
2. **Инкрементальность** — сначала базовое, потом улучшаем 2. **Инкрементальность** — сначала базовое, потом улучшаем
3. **Тестирование** — покрываем критичный функционал 3. **Тестирование** — покрываем критичный функционал E2E тестами (см. [E2E_TESTING.md](E2E_TESTING.md))
4. **Документирование** — обновляем CONTEXT.md после значимых изменений 4. **Документирование** — обновляем CONTEXT.md после значимых изменений
5. **data-testid** — все новые компоненты сразу получают data-testid для тестов

View File

@ -1,9 +1,16 @@
# Database # Database (Shared dev instance on server)
DB_HOST=localhost DB_HOST=10.10.10.100
DB_PORT=5432 DB_PORT=30432
DB_USERNAME=teamplanner DB_USERNAME=teamplanner
DB_PASSWORD=teamplanner DB_PASSWORD=teamplanner
DB_DATABASE=teamplanner DB_DATABASE=teamplanner
# App # App
PORT=4001 PORT=4001
# Keycloak
KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner
KEYCLOAK_CLIENT_ID=team-planner-frontend
# NATS
NATS_URL=nats://10.10.10.100:30422

View File

@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@ -1,48 +0,0 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install ALL dependencies (including devDependencies for build)
RUN npm install --include=dev
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Install only production dependencies
COPY package*.json ./
RUN npm install --only=production && npm cache clean --force
# Copy built application from builder
COPY --from=builder /app/dist ./dist
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001
# Change ownership
RUN chown -R nestjs:nodejs /app
# Switch to non-root user
USER nestjs
# Expose port
EXPOSE 4001
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
CMD node -e "require('http').get('http://localhost:4001/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the application
CMD ["node", "dist/main"]

View File

@ -1,39 +1,3 @@
// @ts-check import {node} from '@vigdorov/eslint-config';
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config( export default node();
{
ignores: ['eslint.config.mjs', 'coverage'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

View File

@ -30,32 +30,34 @@
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"jwks-rsa": "^3.2.0",
"nats": "^2.29.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.28" "typeorm": "^0.3.28"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@vigdorov/eslint-config": "^1.0.1",
"@vigdorov/typescript-config": "^1.1.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0", "jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",

View File

@ -0,0 +1,46 @@
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);
}
}

View File

@ -0,0 +1,18 @@
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 {}

View File

@ -0,0 +1,446 @@
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;
}
interface AiProxyResponse {
choices: {
message: {
content: string;
};
}[];
}
interface ParsedEstimate {
totalHours?: number;
complexity?: string;
breakdown?: RoleEstimate[];
recommendations?: string[];
}
@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()) as AiProxyResponse;
return data.choices[0].message.content;
} catch (error: unknown) {
if (error instanceof HttpException) {
throw error;
}
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`AI Proxy call failed: ${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) as ParsedEstimate;
const validComplexities = [
'trivial',
'simple',
'medium',
'complex',
'veryComplex',
] as const;
const complexity = validComplexities.includes(
parsed.complexity as (typeof validComplexities)[number],
)
? (parsed.complexity as EstimateResult['complexity'])
: 'medium';
return {
ideaId: idea.id,
ideaTitle: idea.title,
totalHours: Number(parsed.totalHours) || 0,
complexity,
breakdown: Array.isArray(parsed.breakdown) ? parsed.breakdown : [],
recommendations: Array.isArray(parsed.recommendations)
? parsed.recommendations
: [],
estimatedAt: new Date(),
};
} catch {
this.logger.error(`Failed to parse AI response: ${aiResponse}`);
throw new HttpException(
'Не удалось разобрать ответ AI',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class EstimateIdeaDto {
@IsUUID()
ideaId: string;
}

View File

@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class GenerateSpecificationDto {
@IsUUID()
ideaId: string;
}

View File

@ -0,0 +1,2 @@
export * from './estimate-idea.dto';
export * from './generate-specification.dto';

View File

@ -1,16 +1,19 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { Public } from './auth';
@Controller() @Controller()
export class AppController { export class AppController {
constructor(private readonly appService: AppService) {} constructor(private readonly appService: AppService) {}
@Get() @Get()
@Public()
getHello(): string { getHello(): string {
return this.appService.getHello(); return this.appService.getHello();
} }
@Get('health') @Get('health')
@Public()
health(): { status: string } { health(): { status: string } {
return { status: 'ok' }; return { status: 'ok' };
} }

View File

@ -1,9 +1,16 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { IdeasModule } from './ideas/ideas.module'; import { IdeasModule } from './ideas/ideas.module';
import { CommentsModule } from './comments/comments.module';
import { TeamModule } from './team/team.module';
import { AuthModule, JwtAuthGuard } from './auth';
import { AiModule } from './ai/ai.module';
import { SettingsModule } from './settings/settings.module';
import { NatsModule } from './nats/nats.module';
@Module({ @Module({
imports: [ imports: [
@ -26,9 +33,21 @@ import { IdeasModule } from './ideas/ideas.module';
synchronize: false, synchronize: false,
}), }),
}), }),
AuthModule,
IdeasModule, IdeasModule,
CommentsModule,
TeamModule,
AiModule,
SettingsModule,
NatsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [
AppService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
}) })
export class AppModule {} export class AppModule {}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
import { JwtAuthGuard } from './jwt-auth.guard';
@Module({
imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
providers: [JwtStrategy, JwtAuthGuard],
exports: [JwtAuthGuard],
})
export class AuthModule {}

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,4 @@
export * from './auth.module';
export * from './jwt-auth.guard';
export * from './jwt.strategy';
export * from './decorators/public.decorator';

View File

@ -0,0 +1,24 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from './decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

View File

@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
import { ConfigService } from '@nestjs/config';
export interface JwtPayload {
sub: string;
preferred_username: string;
email: string;
given_name?: string;
family_name?: string;
}
export interface AuthUser {
userId: string;
username: string;
email: string;
firstName?: string;
lastName?: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
const realmUrl = configService.get<string>('KEYCLOAK_REALM_URL');
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
issuer: realmUrl,
algorithms: ['RS256'],
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${realmUrl}/protocol/openid-connect/certs`,
}),
});
}
validate(payload: JwtPayload): AuthUser {
return {
userId: payload.sub,
username: payload.preferred_username,
email: payload.email,
firstName: payload.given_name,
lastName: payload.family_name,
};
}
}

View File

@ -0,0 +1,37 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto';
@Controller('api')
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Get('ideas/:ideaId/comments')
findByIdeaId(@Param('ideaId', ParseUUIDPipe) ideaId: string) {
return this.commentsService.findByIdeaId(ideaId);
}
@Post('ideas/:ideaId/comments')
create(
@Param('ideaId', ParseUUIDPipe) ideaId: string,
@Body() createCommentDto: CreateCommentDto,
) {
return this.commentsService.create(ideaId, createCommentDto);
}
@Delete('comments/:id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.commentsService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Comment } from './entities/comment.entity';
import { CommentsService } from './comments.service';
import { CommentsController } from './comments.controller';
@Module({
imports: [TypeOrmModule.forFeature([Comment])],
controllers: [CommentsController],
providers: [CommentsService],
exports: [CommentsService],
})
export class CommentsModule {}

View File

@ -0,0 +1,39 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Comment } from './entities/comment.entity';
import { CreateCommentDto } from './dto';
@Injectable()
export class CommentsService {
constructor(
@InjectRepository(Comment)
private readonly commentsRepository: Repository<Comment>,
) {}
async findByIdeaId(ideaId: string): Promise<Comment[]> {
return this.commentsRepository.find({
where: { ideaId },
order: { createdAt: 'ASC' },
});
}
async create(
ideaId: string,
createCommentDto: CreateCommentDto,
): Promise<Comment> {
const comment = this.commentsRepository.create({
...createCommentDto,
ideaId,
});
return this.commentsRepository.save(comment);
}
async remove(id: string): Promise<void> {
const comment = await this.commentsRepository.findOne({ where: { id } });
if (!comment) {
throw new NotFoundException(`Comment with ID "${id}" not found`);
}
await this.commentsRepository.remove(comment);
}
}

View File

@ -0,0 +1,12 @@
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
export class CreateCommentDto {
@IsString()
@IsNotEmpty()
text: string;
@IsOptional()
@IsString()
@MaxLength(255)
author?: string;
}

View File

@ -0,0 +1 @@
export * from './create-comment.dto';

View File

@ -0,0 +1,35 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Idea } from '../../ideas/entities/idea.entity';
@Entity('comments')
export class Comment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'text' })
text: string;
@Column({ type: 'varchar', length: 255, nullable: true })
author: string | null;
@Column({ name: 'idea_id', type: 'uuid' })
ideaId: string;
@ManyToOne(() => Idea, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'idea_id' })
idea: Idea;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,5 @@
export * from './comments.module';
export * from './comments.service';
export * from './comments.controller';
export * from './entities/comment.entity';
export * from './dto';

View File

@ -1,3 +1,4 @@
export * from './create-idea.dto'; export * from './create-idea.dto';
export * from './update-idea.dto'; export * from './update-idea.dto';
export * from './query-ideas.dto'; export * from './query-ideas.dto';
export * from './reorder-ideas.dto';

View File

@ -18,6 +18,10 @@ export class QueryIdeasDto {
@IsString() @IsString()
search?: string; search?: string;
@IsOptional()
@IsString()
color?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
sortBy?: string; sortBy?: string;

View File

@ -0,0 +1,26 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsInt,
IsUUID,
Min,
ValidateNested,
ArrayMinSize,
} from 'class-validator';
export class ReorderItemDto {
@IsUUID()
id: string;
@IsInt()
@Min(0)
order: number;
}
export class ReorderIdeasDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => ReorderItemDto)
items: ReorderItemDto[];
}

View File

@ -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;
} }

View File

@ -72,6 +72,36 @@ 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;

View 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;
}

View File

@ -0,0 +1,68 @@
import { Injectable, Logger } from '@nestjs/common';
import { IdeasService } from './ideas.service';
import { IdeaStatus } from './entities/idea.entity';
import {
HypothesisLinkedEvent,
HypothesisCompletedEvent,
} from '../nats/events';
@Injectable()
export class IdeaEventsHandler {
private readonly logger = new Logger(IdeaEventsHandler.name);
constructor(private ideasService: IdeasService) {}
async handleHypothesisLinked(payload: HypothesisLinkedEvent) {
try {
const idea = await this.ideasService.findOne(payload.hypothesisId);
if (
idea.status === IdeaStatus.BACKLOG ||
idea.status === IdeaStatus.TODO
) {
await this.ideasService.update(idea.id, {
status: IdeaStatus.IN_PROGRESS,
});
this.logger.log(
`Idea ${idea.id} status changed to in_progress (was ${idea.status})`,
);
} else {
this.logger.log(
`Idea ${idea.id} already in status ${idea.status}, skipping`,
);
}
} catch (error) {
this.logger.error(
`Failed to handle hypothesis.linked for ${payload.hypothesisId}`,
(error as Error).stack,
);
}
}
async handleHypothesisCompleted(payload: HypothesisCompletedEvent) {
try {
const idea = await this.ideasService.findOne(payload.hypothesisId);
if (
idea.status !== IdeaStatus.DONE &&
idea.status !== IdeaStatus.CANCELLED
) {
await this.ideasService.update(idea.id, {
status: IdeaStatus.DONE,
});
this.logger.log(
`Idea ${idea.id} status changed to done (was ${idea.status})`,
);
} else {
this.logger.log(
`Idea ${idea.id} already in status ${idea.status}, skipping`,
);
}
} catch (error) {
this.logger.error(
`Failed to handle hypothesis.completed for ${payload.hypothesisId}`,
(error as Error).stack,
);
}
}
}

View File

@ -10,7 +10,12 @@ import {
ParseUUIDPipe, ParseUUIDPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { IdeasService } from './ideas.service'; import { IdeasService } from './ideas.service';
import { CreateIdeaDto, UpdateIdeaDto, QueryIdeasDto } from './dto'; import {
CreateIdeaDto,
UpdateIdeaDto,
QueryIdeasDto,
ReorderIdeasDto,
} from './dto';
@Controller('ideas') @Controller('ideas')
export class IdeasController { export class IdeasController {
@ -31,6 +36,11 @@ export class IdeasController {
return this.ideasService.getModules(); return this.ideasService.getModules();
} }
@Patch('reorder')
reorder(@Body() reorderIdeasDto: ReorderIdeasDto) {
return this.ideasService.reorder(reorderIdeasDto.items);
}
@Get(':id') @Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) { findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.ideasService.findOne(id); return this.ideasService.findOne(id);

View File

@ -3,11 +3,13 @@ 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';
import { IdeaEventsHandler } from './idea-events.handler';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Idea])], imports: [TypeOrmModule.forFeature([Idea, SpecificationHistory])],
controllers: [IdeasController], controllers: [IdeasController],
providers: [IdeasService], providers: [IdeasService, IdeaEventsHandler],
exports: [IdeasService], exports: [IdeasService, IdeaEventsHandler, TypeOrmModule],
}) })
export class IdeasModule {} export class IdeasModule {}

View File

@ -26,6 +26,7 @@ export class IdeasService {
priority, priority,
module, module,
search, search,
color,
sortBy = 'order', sortBy = 'order',
sortOrder = 'ASC', sortOrder = 'ASC',
page = 1, page = 1,
@ -60,6 +61,10 @@ export class IdeasService {
queryBuilder.andWhere('idea.module = :module', { module }); queryBuilder.andWhere('idea.module = :module', { module });
} }
if (color) {
queryBuilder.andWhere('idea.color = :color', { color });
}
if (search) { if (search) {
queryBuilder.andWhere( queryBuilder.andWhere(
'(idea.title ILIKE :search OR idea.description ILIKE :search)', '(idea.title ILIKE :search OR idea.description ILIKE :search)',
@ -123,4 +128,12 @@ export class IdeasService {
return result.map((r) => r.module).filter(Boolean); return result.map((r) => r.module).filter(Boolean);
} }
async reorder(items: { id: string; order: number }[]): Promise<void> {
await this.ideasRepository.manager.transaction(async (manager) => {
for (const item of items) {
await manager.update(Idea, item.id, { order: item.order });
}
});
}
} }

View File

@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateCommentsTable1736899200000 implements MigrationInterface {
name = 'CreateCommentsTable1736899200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "comments" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"text" text NOT NULL,
"author" character varying(255),
"idea_id" uuid NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_comments_id" PRIMARY KEY ("id"),
CONSTRAINT "FK_comments_idea_id" FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE INDEX "IDX_comments_idea_id" ON "comments" ("idea_id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_comments_idea_id"`);
await queryRunner.query(`DROP TABLE "comments"`);
}
}

View File

@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTeamMembersTable1736899300000 implements MigrationInterface {
name = 'CreateTeamMembersTable1736899300000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "public"."team_members_role_enum" AS ENUM('backend', 'frontend', 'ai_ml', 'devops', 'qa', 'ui_ux', 'pm')`,
);
await queryRunner.query(`
CREATE TABLE "team_members" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying(255) NOT NULL,
"role" "public"."team_members_role_enum" NOT NULL,
"productivity" jsonb NOT NULL DEFAULT '{"trivial": 1, "simple": 4, "medium": 12, "complex": 32, "veryComplex": 60}',
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_team_members_id" PRIMARY KEY ("id")
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "team_members"`);
await queryRunner.query(`DROP TYPE "public"."team_members_role_enum"`);
}
}

View File

@ -0,0 +1,95 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateRolesTable1736899400000 implements MigrationInterface {
name = 'CreateRolesTable1736899400000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Создаём таблицу roles
await queryRunner.query(`
CREATE TABLE "roles" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying(100) NOT NULL,
"label" character varying(255) NOT NULL,
"sortOrder" integer NOT NULL DEFAULT 0,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_roles_id" PRIMARY KEY ("id"),
CONSTRAINT "UQ_roles_name" UNIQUE ("name")
)
`);
// 2. Добавляем начальные роли (из старого enum)
await queryRunner.query(`
INSERT INTO "roles" ("name", "label", "sortOrder") VALUES
('backend', 'Backend-разработчик', 0),
('frontend', 'Frontend-разработчик', 1),
('ai_ml', 'AI/ML-инженер', 2),
('devops', 'DevOps-инженер', 3),
('qa', 'QA-инженер', 4),
('ui_ux', 'UI/UX-дизайнер', 5),
('pm', 'Project Manager', 6)
`);
// 3. Добавляем колонку role_id в team_members (nullable сначала)
await queryRunner.query(`
ALTER TABLE "team_members" ADD COLUMN "role_id" uuid
`);
// 4. Мигрируем данные: связываем team_members с roles по name
await queryRunner.query(`
UPDATE "team_members" tm
SET "role_id" = r."id"
FROM "roles" r
WHERE tm."role"::text = r."name"
`);
// 5. Делаем role_id NOT NULL
await queryRunner.query(`
ALTER TABLE "team_members" ALTER COLUMN "role_id" SET NOT NULL
`);
// 6. Добавляем foreign key
await queryRunner.query(`
ALTER TABLE "team_members"
ADD CONSTRAINT "FK_team_members_role" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE RESTRICT
`);
// 7. Удаляем старую колонку role и enum
await queryRunner.query(`ALTER TABLE "team_members" DROP COLUMN "role"`);
await queryRunner.query(`DROP TYPE "public"."team_members_role_enum"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 1. Восстанавливаем enum
await queryRunner.query(
`CREATE TYPE "public"."team_members_role_enum" AS ENUM('backend', 'frontend', 'ai_ml', 'devops', 'qa', 'ui_ux', 'pm')`,
);
// 2. Добавляем колонку role
await queryRunner.query(`
ALTER TABLE "team_members" ADD COLUMN "role" "public"."team_members_role_enum"
`);
// 3. Мигрируем данные обратно
await queryRunner.query(`
UPDATE "team_members" tm
SET "role" = r."name"::"public"."team_members_role_enum"
FROM "roles" r
WHERE tm."role_id" = r."id"
`);
// 4. Делаем role NOT NULL
await queryRunner.query(`
ALTER TABLE "team_members" ALTER COLUMN "role" SET NOT NULL
`);
// 5. Удаляем foreign key и role_id
await queryRunner.query(
`ALTER TABLE "team_members" DROP CONSTRAINT "FK_team_members_role"`,
);
await queryRunner.query(`ALTER TABLE "team_members" DROP COLUMN "role_id"`);
// 6. Удаляем таблицу roles
await queryRunner.query(`DROP TABLE "roles"`);
}
}

View 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"
`);
}
}

View File

@ -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"
`);
}
}

View File

@ -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"`);
}
}

View File

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UserSettings1770500000000 implements MigrationInterface {
name = 'UserSettings1770500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "user_settings" (
"user_id" VARCHAR(255) NOT NULL,
"settings" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_user_settings" PRIMARY KEY ("user_id")
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "user_settings"`);
}
}

View File

@ -0,0 +1,18 @@
export const HYPOTHESIS_STREAM = 'HYPOTHESIS_EVENTS';
export const HypothesisSubjects = {
LINKED: 'hypothesis.linked',
COMPLETED: 'hypothesis.completed',
} as const;
export interface HypothesisLinkedEvent {
hypothesisId: string;
targetType: 'goal' | 'track';
targetId: string;
userId: string;
}
export interface HypothesisCompletedEvent {
hypothesisId: string;
userId: string;
}

View File

@ -0,0 +1,165 @@
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
connect,
NatsConnection,
JetStreamClient,
JetStreamManager,
JetStreamPullSubscription,
StringCodec,
AckPolicy,
DeliverPolicy,
RetentionPolicy,
StorageType,
consumerOpts,
} from 'nats';
import {
HYPOTHESIS_STREAM,
HypothesisSubjects,
HypothesisLinkedEvent,
HypothesisCompletedEvent,
} from './events';
import { IdeaEventsHandler } from '../ideas/idea-events.handler';
const CONSUMER_NAME = 'team-planner';
const PULL_BATCH = 10;
const PULL_INTERVAL_MS = 1000;
@Injectable()
export class NatsConsumerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(NatsConsumerService.name);
private nc: NatsConnection | null = null;
private sc = StringCodec();
private sub: JetStreamPullSubscription | null = null;
private pullTimer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(
private configService: ConfigService,
private ideaEventsHandler: IdeaEventsHandler,
) {}
async onModuleInit() {
const url = this.configService.get<string>('NATS_URL');
if (!url) {
this.logger.warn('NATS_URL not configured, NATS consuming disabled');
return;
}
try {
this.nc = await connect({ servers: url });
this.logger.log(`Connected to NATS at ${url}`);
const jsm: JetStreamManager = await this.nc.jetstreamManager();
await this.ensureStream(jsm);
await this.ensureConsumer(jsm);
const js: JetStreamClient = this.nc.jetstream();
await this.startConsuming(js);
} catch (error) {
this.logger.error('Failed to connect to NATS', (error as Error).stack);
}
}
async onModuleDestroy() {
this.running = false;
if (this.pullTimer) {
clearInterval(this.pullTimer);
}
if (this.sub) {
this.sub.unsubscribe();
}
if (this.nc) {
await this.nc.drain();
this.logger.log('NATS connection drained');
}
}
private async ensureStream(jsm: JetStreamManager) {
try {
await jsm.streams.info(HYPOTHESIS_STREAM);
this.logger.log(`Stream ${HYPOTHESIS_STREAM} already exists`);
} catch {
await jsm.streams.add({
name: HYPOTHESIS_STREAM,
subjects: ['hypothesis.>'],
retention: RetentionPolicy.Limits,
storage: StorageType.File,
max_age: 7 * 24 * 60 * 60 * 1_000_000_000,
});
this.logger.log(`Stream ${HYPOTHESIS_STREAM} created`);
}
}
private async ensureConsumer(jsm: JetStreamManager) {
try {
await jsm.consumers.info(HYPOTHESIS_STREAM, CONSUMER_NAME);
this.logger.log(`Consumer ${CONSUMER_NAME} already exists`);
} catch {
await jsm.consumers.add(HYPOTHESIS_STREAM, {
durable_name: CONSUMER_NAME,
ack_policy: AckPolicy.Explicit,
deliver_policy: DeliverPolicy.All,
});
this.logger.log(`Consumer ${CONSUMER_NAME} created`);
}
}
private async startConsuming(js: JetStreamClient) {
const opts = consumerOpts();
opts.bind(HYPOTHESIS_STREAM, CONSUMER_NAME);
this.sub = await js.pullSubscribe('hypothesis.>', opts);
this.running = true;
void this.processMessages();
this.pullTimer = setInterval(() => {
if (this.running && this.sub) {
this.sub.pull({ batch: PULL_BATCH, expires: 5000 });
}
}, PULL_INTERVAL_MS);
// initial pull
this.sub.pull({ batch: PULL_BATCH, expires: 5000 });
this.logger.log('Started consuming from HYPOTHESIS_EVENTS stream');
}
private async processMessages() {
if (!this.sub) return;
for await (const msg of this.sub) {
if (!this.running) break;
try {
const raw = this.sc.decode(msg.data);
const subject = msg.subject;
this.logger.log(`Received ${subject}: ${raw}`);
if (subject === HypothesisSubjects.LINKED) {
const data: HypothesisLinkedEvent = JSON.parse(
raw,
) as HypothesisLinkedEvent;
await this.ideaEventsHandler.handleHypothesisLinked(data);
} else if (subject === HypothesisSubjects.COMPLETED) {
const data: HypothesisCompletedEvent = JSON.parse(
raw,
) as HypothesisCompletedEvent;
await this.ideaEventsHandler.handleHypothesisCompleted(data);
}
msg.ack();
} catch (error) {
this.logger.error('Error processing message', (error as Error).stack);
msg.nak();
}
}
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { IdeasModule } from '../ideas/ideas.module';
import { NatsConsumerService } from './nats-consumer.service';
@Module({
imports: [IdeasModule],
providers: [NatsConsumerService],
})
export class NatsModule {}

View File

@ -0,0 +1,24 @@
import { Controller, Get, Put, Body, Req } from '@nestjs/common';
import { SettingsService } from './settings.service';
interface RequestWithUser {
user: { userId: string };
}
@Controller('settings')
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get()
async get(@Req() req: RequestWithUser) {
return this.settingsService.get(req.user.userId);
}
@Put()
async update(
@Req() req: RequestWithUser,
@Body() body: Record<string, unknown>,
) {
return this.settingsService.update(req.user.userId, body);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserSettings } from './user-settings.entity';
import { SettingsService } from './settings.service';
import { SettingsController } from './settings.controller';
@Module({
imports: [TypeOrmModule.forFeature([UserSettings])],
controllers: [SettingsController],
providers: [SettingsService],
})
export class SettingsModule {}

View File

@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserSettings } from './user-settings.entity';
@Injectable()
export class SettingsService {
constructor(
@InjectRepository(UserSettings)
private readonly repo: Repository<UserSettings>,
) {}
async get(userId: string): Promise<Record<string, unknown>> {
const row = await this.repo.findOne({ where: { userId } });
return row?.settings ?? {};
}
async update(
userId: string,
patch: Record<string, unknown>,
): Promise<Record<string, unknown>> {
let row = await this.repo.findOne({ where: { userId } });
if (!row) {
row = this.repo.create({ userId, settings: {} });
}
row.settings = { ...row.settings, ...patch };
await this.repo.save(row);
return row.settings;
}
}

View File

@ -0,0 +1,22 @@
import {
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('user_settings')
export class UserSettings {
@PrimaryColumn({ name: 'user_id', type: 'varchar', length: 255 })
userId: string;
@Column({ type: 'jsonb', default: {} })
settings: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,16 @@
import { IsString, IsNotEmpty, IsOptional, IsInt, Min } from 'class-validator';
export class CreateRoleDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
label: string;
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
}

View File

@ -0,0 +1,48 @@
import {
IsString,
IsNotEmpty,
IsUUID,
IsOptional,
IsObject,
IsNumber,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
class ProductivityMatrixDto {
@IsNumber()
@Min(0)
trivial: number;
@IsNumber()
@Min(0)
simple: number;
@IsNumber()
@Min(0)
medium: number;
@IsNumber()
@Min(0)
complex: number;
@IsNumber()
@Min(0)
veryComplex: number;
}
export class CreateTeamMemberDto {
@IsString()
@IsNotEmpty()
name: string;
@IsUUID()
roleId: string;
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => ProductivityMatrixDto)
productivity?: ProductivityMatrixDto;
}

View File

@ -0,0 +1,4 @@
export * from './create-team-member.dto';
export * from './update-team-member.dto';
export * from './create-role.dto';
export * from './update-role.dto';

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateRoleDto } from './create-role.dto';
export class UpdateRoleDto extends PartialType(CreateRoleDto) {}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTeamMemberDto } from './create-team-member.dto';
export class UpdateTeamMemberDto extends PartialType(CreateTeamMemberDto) {}

View File

@ -0,0 +1,33 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { TeamMember } from './team-member.entity';
@Entity('roles')
export class Role {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100, unique: true })
name: string;
@Column({ type: 'varchar', length: 255 })
label: string;
@Column({ type: 'int', default: 0 })
sortOrder: number;
@OneToMany(() => TeamMember, (member) => member.role)
members: TeamMember[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,53 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Role } from './role.entity';
// Матрица производительности: время в часах на задачи разной сложности
export interface ProductivityMatrix {
trivial: number; // < 1 часа
simple: number; // 1-4 часа
medium: number; // 4-16 часов
complex: number; // 16-40 часов
veryComplex: number; // > 40 часов
}
@Entity('team_members')
export class TeamMember {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@ManyToOne(() => Role, (role) => role.members, { eager: true })
@JoinColumn({ name: 'role_id' })
role: Role;
@Column({ name: 'role_id', type: 'uuid' })
roleId: string;
@Column({
type: 'jsonb',
default: {
trivial: 1,
simple: 4,
medium: 12,
complex: 32,
veryComplex: 60,
},
})
productivity: ProductivityMatrix;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,5 @@
export * from './team.module';
export * from './team.service';
export * from './team.controller';
export * from './entities/team-member.entity';
export * from './dto';

View File

@ -0,0 +1,49 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { RolesService } from './roles.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
@Controller('api/roles')
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get()
findAll() {
return this.rolesService.findAll();
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.rolesService.findOne(id);
}
@Post()
create(@Body() createDto: CreateRoleDto) {
return this.rolesService.create(createDto);
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateDto: UpdateRoleDto,
) {
return this.rolesService.update(id, updateDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.rolesService.remove(id);
}
}

View File

@ -0,0 +1,77 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Role } from './entities/role.entity';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
@Injectable()
export class RolesService {
constructor(
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
) {}
async findAll(): Promise<Role[]> {
return this.roleRepository.find({
order: { sortOrder: 'ASC', name: 'ASC' },
});
}
async findOne(id: string): Promise<Role> {
const role = await this.roleRepository.findOne({ where: { id } });
if (!role) {
throw new NotFoundException(`Role with ID "${id}" not found`);
}
return role;
}
async create(createDto: CreateRoleDto): Promise<Role> {
const existing = await this.roleRepository.findOne({
where: { name: createDto.name },
});
if (existing) {
throw new ConflictException(
`Role with name "${createDto.name}" already exists`,
);
}
const maxSortOrder = await this.roleRepository
.createQueryBuilder('role')
.select('MAX(role.sortOrder)', 'max')
.getRawOne<{ max: number | null }>();
const role = this.roleRepository.create({
...createDto,
sortOrder: createDto.sortOrder ?? (maxSortOrder?.max ?? -1) + 1,
});
return this.roleRepository.save(role);
}
async update(id: string, updateDto: UpdateRoleDto): Promise<Role> {
const role = await this.findOne(id);
if (updateDto.name && updateDto.name !== role.name) {
const existing = await this.roleRepository.findOne({
where: { name: updateDto.name },
});
if (existing) {
throw new ConflictException(
`Role with name "${updateDto.name}" already exists`,
);
}
}
Object.assign(role, updateDto);
return this.roleRepository.save(role);
}
async remove(id: string): Promise<void> {
const role = await this.findOne(id);
await this.roleRepository.remove(role);
}
}

View File

@ -0,0 +1,53 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { TeamService } from './team.service';
import { CreateTeamMemberDto, UpdateTeamMemberDto } from './dto';
@Controller('api/team')
export class TeamController {
constructor(private readonly teamService: TeamService) {}
@Get()
findAll() {
return this.teamService.findAll();
}
@Get('summary')
getSummary() {
return this.teamService.getSummary();
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.teamService.findOne(id);
}
@Post()
create(@Body() createDto: CreateTeamMemberDto) {
return this.teamService.create(createDto);
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateDto: UpdateTeamMemberDto,
) {
return this.teamService.update(id, updateDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.teamService.remove(id);
}
}

View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TeamMember } from './entities/team-member.entity';
import { Role } from './entities/role.entity';
import { TeamService } from './team.service';
import { TeamController } from './team.controller';
import { RolesService } from './roles.service';
import { RolesController } from './roles.controller';
@Module({
imports: [TypeOrmModule.forFeature([TeamMember, Role])],
controllers: [TeamController, RolesController],
providers: [TeamService, RolesService],
exports: [TeamService, RolesService],
})
export class TeamModule {}

View File

@ -0,0 +1,105 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TeamMember } from './entities/team-member.entity';
import { Role } from './entities/role.entity';
import { CreateTeamMemberDto } from './dto/create-team-member.dto';
import { UpdateTeamMemberDto } from './dto/update-team-member.dto';
@Injectable()
export class TeamService {
constructor(
@InjectRepository(TeamMember)
private readonly teamRepository: Repository<TeamMember>,
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
) {}
async findAll(): Promise<TeamMember[]> {
return this.teamRepository.find({
order: { role: { sortOrder: 'ASC' }, name: 'ASC' },
relations: ['role'],
});
}
async findOne(id: string): Promise<TeamMember> {
const member = await this.teamRepository.findOne({
where: { id },
relations: ['role'],
});
if (!member) {
throw new NotFoundException(`Team member with ID "${id}" not found`);
}
return member;
}
async create(createDto: CreateTeamMemberDto): Promise<TeamMember> {
// Проверяем что роль существует
const role = await this.roleRepository.findOne({
where: { id: createDto.roleId },
});
if (!role) {
throw new NotFoundException(
`Role with ID "${createDto.roleId}" not found`,
);
}
const member = this.teamRepository.create(createDto);
const saved = await this.teamRepository.save(member);
return this.findOne(saved.id);
}
async update(
id: string,
updateDto: UpdateTeamMemberDto,
): Promise<TeamMember> {
const member = await this.findOne(id);
if (updateDto.roleId) {
const role = await this.roleRepository.findOne({
where: { id: updateDto.roleId },
});
if (!role) {
throw new NotFoundException(
`Role with ID "${updateDto.roleId}" not found`,
);
}
}
Object.assign(member, updateDto);
await this.teamRepository.save(member);
return this.findOne(id);
}
async remove(id: string): Promise<void> {
const member = await this.findOne(id);
await this.teamRepository.remove(member);
}
async getSummary(): Promise<
{ roleId: string; label: string; count: number }[]
> {
// Получаем все роли
const roles = await this.roleRepository.find({
order: { sortOrder: 'ASC' },
});
// Получаем количество участников по ролям
const result = await this.teamRepository
.createQueryBuilder('member')
.select('member.role_id', 'roleId')
.addSelect('COUNT(*)', 'count')
.groupBy('member.role_id')
.getRawMany<{ roleId: string; count: string }>();
// Возвращаем все роли с количеством
return roles.map((role) => ({
roleId: role.id,
label: role.label,
count: parseInt(
result.find((r) => r.roleId === role.id)?.count ?? '0',
10,
),
}));
}
}

View File

@ -1,25 +1,16 @@
{ {
"extends": "@vigdorov/typescript-config/node",
"compilerOptions": { "compilerOptions": {
"module": "nodenext", "module": "nodenext",
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"resolvePackageJsonExports": true, "resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023", "target": "ES2023",
"sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./", "baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true, "strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false, "noImplicitAny": false,
"strictBindCallApply": false, "strictBindCallApply": false,
"strictPropertyInitialization": false,
"noFallthroughCasesInSwitch": false "noFallthroughCasesInSwitch": false
} }
} }

View File

@ -1,22 +0,0 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: team-planner-db
environment:
POSTGRES_USER: teamplanner
POSTGRES_PASSWORD: teamplanner
POSTGRES_DB: teamplanner
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U teamplanner"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:

4
frontend/.env.example Normal file
View File

@ -0,0 +1,4 @@
# Keycloak
VITE_KEYCLOAK_URL=https://auth.vigdorov.ru
VITE_KEYCLOAK_REALM=team-planner
VITE_KEYCLOAK_CLIENT_ID=team-planner-frontend

View File

@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@ -1,40 +0,0 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build argument for API URL (optional, defaults to empty for production)
# Empty value means use relative paths, which works with nginx proxy
ARG VITE_API_URL=""
ENV VITE_API_URL=$VITE_API_URL
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,41 +1,3 @@
import js from '@eslint/js' import {react} from '@vigdorov/eslint-config';
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([ export default react();
globalIgnores(['dist', 'coverage', '*.config.ts', '*.config.js']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: {
attributes: false,
},
},
],
'react-hooks/set-state-in-effect': 'warn',
},
},
])

View File

@ -1,62 +0,0 @@
events {}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Health check endpoint for k8s
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://team-planner-backend-service:4001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback - all routes go to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Static assets with caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

View File

@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@ -22,23 +23,21 @@
"@tanstack/react-query": "^5.90.14", "@tanstack/react-query": "^5.90.14",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.13.2", "axios": "^1.13.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": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vigdorov/eslint-config": "^1.0.1",
"@vigdorov/typescript-config": "^1.1.0",
"@vigdorov/vite-config": "^1.0.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.4.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4" "vite": "^7.2.4"
} }
} }

View File

@ -1,18 +1,43 @@
import { Container, Typography, Box, Button } from '@mui/material'; import { useState } from 'react';
import { Add } from '@mui/icons-material'; import {
Container,
Typography,
Box,
Button,
IconButton,
Tooltip,
Chip,
Avatar,
Tabs,
Tab,
} from '@mui/material';
import {
Add,
Logout,
Person,
Lightbulb,
Group,
Settings,
} from '@mui/icons-material';
import { IdeasTable } from './components/IdeasTable'; import { IdeasTable } from './components/IdeasTable';
import { IdeasFilters } from './components/IdeasFilters'; import { IdeasFilters } from './components/IdeasFilters';
import { CreateIdeaModal } from './components/CreateIdeaModal'; import { CreateIdeaModal } from './components/CreateIdeaModal';
import { TeamPage } from './components/TeamPage';
import { SettingsPage } from './components/SettingsPage';
import { useIdeasStore } from './store/ideas'; import { useIdeasStore } from './store/ideas';
import { useAuth } from './hooks/useAuth';
function App() { function App() {
const { setCreateModalOpen } = useIdeasStore(); const { setCreateModalOpen } = useIdeasStore();
const { user, logout } = useAuth();
const [tab, setTab] = useState(0);
return ( return (
<Container maxWidth="xl" sx={{ py: 4 }}> <Container maxWidth="xl" sx={{ py: 4 }}>
{/* Header */}
<Box <Box
sx={{ sx={{
mb: 4, mb: 3,
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
@ -26,22 +51,61 @@ function App() {
Управление бэклогом идей команды Управление бэклогом идей команды
</Typography> </Typography>
</Box> </Box>
<Button <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
variant="contained" <Chip
startIcon={<Add />} avatar={
onClick={() => setCreateModalOpen(true)} <Avatar sx={{ bgcolor: 'primary.main' }}>
> <Person sx={{ fontSize: 16 }} />
Новая идея </Avatar>
</Button> }
label={user?.name ?? 'Пользователь'}
variant="outlined"
/>
<Tooltip title="Выйти">
<IconButton onClick={logout} color="default" size="small">
<Logout />
</IconButton>
</Tooltip>
</Box>
</Box> </Box>
<Box sx={{ mb: 3 }}> {/* Tabs */}
<IdeasFilters /> <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={tab} onChange={(_, v: number) => setTab(v)}>
<Tab icon={<Lightbulb />} iconPosition="start" label="Идеи" />
<Tab icon={<Group />} iconPosition="start" label="Команда" />
<Tab icon={<Settings />} iconPosition="start" label="Настройки" />
</Tabs>
</Box> </Box>
<IdeasTable /> {/* Content */}
{tab === 0 && (
<>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<IdeasFilters />
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setCreateModalOpen(true)}
>
Новая идея
</Button>
</Box>
<IdeasTable />
<CreateIdeaModal />
</>
)}
<CreateIdeaModal /> {tab === 1 && <TeamPage />}
{tab === 2 && <SettingsPage />}
</Container> </Container>
); );
} }

View File

@ -0,0 +1,220 @@
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';
import {
formatEstimate,
type EstimateConfig,
DEFAULT_ESTIMATE_CONFIG,
} from '../../utils/estimate';
interface AiEstimateModalProps {
open: boolean;
onClose: () => void;
result: EstimateResult | null;
isLoading: boolean;
error: Error | null;
estimateConfig?: EstimateConfig;
}
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',
};
export function AiEstimateModal({
open,
onClose,
result,
isLoading,
error,
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
}: 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">
{formatEstimate(result.totalHours, estimateConfig)}
</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-${String(index)}`}
>
<TableCell>{item.role}</TableCell>
<TableCell align="right">
{formatEstimate(item.hours, estimateConfig)}
</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-${String(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>
);
}

View File

@ -0,0 +1 @@
export { AiEstimateModal } from './AiEstimateModal';

View File

@ -0,0 +1,83 @@
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { Box, CircularProgress, Typography } from '@mui/material';
import keycloak from '../../services/keycloak';
import { LoginPage } from '../LoginPage';
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const didInit = useRef(false);
useEffect(() => {
// Предотвращаем двойную инициализацию в StrictMode
if (didInit.current) {
return;
}
didInit.current = true;
let refreshInterval: ReturnType<typeof setInterval> | null = null;
const initKeycloak = async () => {
try {
const authenticated = await keycloak.init({
onLoad: 'check-sso',
checkLoginIframe: false,
pkceMethod: 'S256',
});
setIsAuthenticated(authenticated);
if (authenticated) {
// Автоматическое обновление токена
refreshInterval = setInterval(() => {
keycloak.updateToken(30).catch(() => {
console.error('Failed to refresh token');
void keycloak.login();
});
}, 10000);
}
} catch (error) {
console.error('Keycloak init failed:', error);
setIsAuthenticated(false);
} finally {
setIsLoading(false);
}
};
void initKeycloak();
return () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
};
}, []);
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
gap: 2,
}}
>
<CircularProgress />
<Typography>Авторизация...</Typography>
</Box>
);
}
if (!isAuthenticated) {
return <LoginPage />;
}
return <>{children}</>;
}

View File

@ -0,0 +1 @@
export { AuthProvider } from './AuthProvider';

View File

@ -0,0 +1,153 @@
import { useState } from 'react';
import {
Box,
TextField,
Button,
Typography,
IconButton,
CircularProgress,
Paper,
} from '@mui/material';
import { Delete, Send } from '@mui/icons-material';
import {
useCommentsQuery,
useCreateComment,
useDeleteComment,
} from '../../hooks/useComments';
import { useAuth } from '../../hooks/useAuth';
interface CommentsPanelProps {
ideaId: string;
}
export function CommentsPanel({ ideaId }: CommentsPanelProps) {
const { data: comments = [], isLoading } = useCommentsQuery(ideaId);
const createComment = useCreateComment();
const deleteComment = useDeleteComment();
const { user } = useAuth();
const [newComment, setNewComment] = useState('');
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!newComment.trim() || createComment.isPending) return;
await createComment.mutateAsync({
ideaId,
dto: { text: newComment.trim(), author: user?.name },
});
setNewComment('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void handleSubmit();
}
};
const handleDelete = (commentId: string) => {
deleteComment.mutate({ id: commentId, ideaId });
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Box sx={{ p: 2, backgroundColor: 'grey.50' }} data-testid="comments-panel">
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Комментарии ({comments.length})
</Typography>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : comments.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2 }}
data-testid="comments-empty"
>
Пока нет комментариев
</Typography>
) : (
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 2 }}
data-testid="comments-list"
>
{comments.map((comment) => (
<Paper
key={comment.id}
variant="outlined"
data-testid={`comment-${comment.id}`}
sx={{ p: 1.5, display: 'flex', alignItems: 'flex-start', gap: 1 }}
>
<Box sx={{ flex: 1 }}>
<Typography
variant="body2"
sx={{ whiteSpace: 'pre-wrap' }}
data-testid="comment-text"
>
{comment.text}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(comment.createdAt)}
{comment.author && `${comment.author}`}
</Typography>
</Box>
<IconButton
size="small"
onClick={() => handleDelete(comment.id)}
data-testid="delete-comment-button"
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<Delete fontSize="small" />
</IconButton>
</Paper>
))}
</Box>
)}
<Box
component="form"
onSubmit={handleSubmit}
sx={{ display: 'flex', gap: 1 }}
data-testid="comment-form"
>
<TextField
size="small"
placeholder="Добавить комментарий... (Ctrl+Enter)"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={handleKeyDown}
fullWidth
multiline
maxRows={3}
slotProps={{ htmlInput: { 'data-testid': 'comment-input' } }}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!newComment.trim() || createComment.isPending}
data-testid="submit-comment-button"
sx={{ minWidth: 'auto', px: 2 }}
>
{createComment.isPending ? (
<CircularProgress size={20} color="inherit" />
) : (
<Send fontSize="small" />
)}
</Button>
</Box>
</Box>
);
}

View File

@ -0,0 +1 @@
export { CommentsPanel } from './CommentsPanel';

View File

@ -74,8 +74,9 @@ export function CreateIdeaModal() {
onClose={handleClose} onClose={handleClose}
maxWidth="sm" maxWidth="sm"
fullWidth fullWidth
data-testid="create-idea-modal"
> >
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} data-testid="create-idea-form">
<DialogTitle>Новая идея</DialogTitle> <DialogTitle>Новая идея</DialogTitle>
<DialogContent> <DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
@ -91,6 +92,7 @@ export function CreateIdeaModal() {
onChange={(e) => handleChange('title', e.target.value)} onChange={(e) => handleChange('title', e.target.value)}
required required
autoFocus autoFocus
data-testid="idea-title-input"
/> />
<TextField <TextField
@ -178,11 +180,14 @@ export function CreateIdeaModal() {
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleClose}>Отмена</Button> <Button onClick={handleClose} data-testid="cancel-create-idea">
Отмена
</Button>
<Button <Button
type="submit" type="submit"
variant="contained" variant="contained"
disabled={!formData.title || createIdea.isPending} disabled={!formData.title || createIdea.isPending}
data-testid="submit-create-idea"
> >
{createIdea.isPending ? 'Создание...' : 'Создать'} {createIdea.isPending ? 'Создание...' : 'Создать'}
</Button> </Button>

View File

@ -0,0 +1,489 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Select,
MenuItem,
FormControl,
Box,
Typography,
Chip,
Divider,
Grid,
IconButton,
} from '@mui/material';
import { Close, Edit, Description, AutoAwesome } from '@mui/icons-material';
import type {
Idea,
IdeaStatus,
IdeaPriority,
UpdateIdeaDto,
} from '../../types/idea';
import { statusOptions, priorityOptions } from '../IdeasTable/constants';
import {
formatEstimate,
type EstimateConfig,
DEFAULT_ESTIMATE_CONFIG,
} from '../../utils/estimate';
interface IdeaDetailModalProps {
open: boolean;
onClose: () => void;
idea: Idea | null;
onSave: (id: string, dto: UpdateIdeaDto) => void;
isSaving: boolean;
onOpenSpecification: (idea: Idea) => void;
onOpenEstimate: (idea: Idea) => void;
estimateConfig?: EstimateConfig;
}
const statusColors: Record<
IdeaStatus,
'default' | 'primary' | 'secondary' | 'success' | 'error'
> = {
backlog: 'default',
todo: 'primary',
in_progress: 'secondary',
done: 'success',
cancelled: 'error',
};
const priorityColors: Record<
IdeaPriority,
'default' | 'info' | 'warning' | 'error'
> = {
low: 'default',
medium: 'info',
high: 'warning',
critical: 'error',
};
function formatDate(dateString: string | null): string {
if (!dateString) return '—';
return new Date(dateString).toLocaleString('ru-RU');
}
export function IdeaDetailModal({
open,
onClose,
idea,
onSave,
isSaving,
onOpenSpecification,
onOpenEstimate,
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
}: IdeaDetailModalProps) {
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState<UpdateIdeaDto>({});
// Сброс при открытии/закрытии или смене идеи
useEffect(() => {
if (idea) {
setFormData({
title: idea.title,
description: idea.description ?? '',
status: idea.status,
priority: idea.priority,
module: idea.module ?? '',
targetAudience: idea.targetAudience ?? '',
pain: idea.pain ?? '',
aiRole: idea.aiRole ?? '',
verificationMethod: idea.verificationMethod ?? '',
});
}
setIsEditing(false);
}, [idea, open]);
if (!idea) return null;
const handleChange = (field: keyof UpdateIdeaDto, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = () => {
onSave(idea.id, formData);
setIsEditing(false);
};
const handleCancel = () => {
setFormData({
title: idea.title,
description: idea.description ?? '',
status: idea.status,
priority: idea.priority,
module: idea.module ?? '',
targetAudience: idea.targetAudience ?? '',
pain: idea.pain ?? '',
aiRole: idea.aiRole ?? '',
verificationMethod: idea.verificationMethod ?? '',
});
setIsEditing(false);
};
const handleClose = () => {
setIsEditing(false);
onClose();
};
const statusLabel =
statusOptions.find((s) => s.value === idea.status)?.label ?? idea.status;
const priorityLabel =
priorityOptions.find((p) => p.value === idea.priority)?.label ??
idea.priority;
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
data-testid="idea-detail-modal"
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ flex: 1 }}>
{isEditing ? (
<TextField
fullWidth
value={formData.title ?? ''}
onChange={(e) => handleChange('title', e.target.value)}
variant="standard"
slotProps={{
htmlInput: { 'data-testid': 'idea-detail-title-input' },
}}
sx={{ '& input': { fontSize: '1.25rem', fontWeight: 500 } }}
/>
) : (
<Typography variant="h6" data-testid="idea-detail-title">
{idea.title}
</Typography>
)}
</Box>
<IconButton onClick={handleClose} size="small">
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Grid container spacing={3}>
{/* Статус и приоритет */}
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Статус
</Typography>
{isEditing ? (
<FormControl fullWidth size="small">
<Select
value={formData.status ?? idea.status}
onChange={(e) => handleChange('status', e.target.value)}
data-testid="idea-detail-status-select"
>
{statusOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
) : (
<Chip
label={statusLabel}
color={statusColors[idea.status]}
size="small"
data-testid="idea-detail-status"
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Приоритет
</Typography>
{isEditing ? (
<FormControl fullWidth size="small">
<Select
value={formData.priority ?? idea.priority}
onChange={(e) => handleChange('priority', e.target.value)}
data-testid="idea-detail-priority-select"
>
{priorityOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
) : (
<Chip
label={priorityLabel}
color={priorityColors[idea.priority]}
size="small"
variant="outlined"
data-testid="idea-detail-priority"
/>
)}
</Grid>
{/* Модуль */}
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Модуль
</Typography>
{isEditing ? (
<TextField
fullWidth
size="small"
value={formData.module ?? ''}
onChange={(e) => handleChange('module', e.target.value)}
slotProps={{
htmlInput: { 'data-testid': 'idea-detail-module-input' },
}}
/>
) : (
<Typography data-testid="idea-detail-module">
{idea.module ?? '—'}
</Typography>
)}
</Grid>
{/* Целевая аудитория */}
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Целевая аудитория
</Typography>
{isEditing ? (
<TextField
fullWidth
size="small"
value={formData.targetAudience ?? ''}
onChange={(e) => handleChange('targetAudience', e.target.value)}
slotProps={{
htmlInput: {
'data-testid': 'idea-detail-target-audience-input',
},
}}
/>
) : (
<Typography data-testid="idea-detail-target-audience">
{idea.targetAudience ?? '—'}
</Typography>
)}
</Grid>
{/* Описание */}
<Grid size={12}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Описание
</Typography>
{isEditing ? (
<TextField
fullWidth
multiline
rows={3}
value={formData.description ?? ''}
onChange={(e) => handleChange('description', e.target.value)}
slotProps={{
htmlInput: { 'data-testid': 'idea-detail-description-input' },
}}
/>
) : (
<Typography
data-testid="idea-detail-description"
sx={{ whiteSpace: 'pre-wrap' }}
>
{idea.description ?? '—'}
</Typography>
)}
</Grid>
{/* Боль */}
<Grid size={12}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Какую боль решает
</Typography>
{isEditing ? (
<TextField
fullWidth
multiline
rows={2}
value={formData.pain ?? ''}
onChange={(e) => handleChange('pain', e.target.value)}
slotProps={{
htmlInput: { 'data-testid': 'idea-detail-pain-input' },
}}
/>
) : (
<Typography
data-testid="idea-detail-pain"
sx={{ whiteSpace: 'pre-wrap' }}
>
{idea.pain ?? '—'}
</Typography>
)}
</Grid>
{/* Роль AI */}
<Grid size={12}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Роль AI
</Typography>
{isEditing ? (
<TextField
fullWidth
multiline
rows={2}
value={formData.aiRole ?? ''}
onChange={(e) => handleChange('aiRole', e.target.value)}
slotProps={{
htmlInput: { 'data-testid': 'idea-detail-ai-role-input' },
}}
/>
) : (
<Typography
data-testid="idea-detail-ai-role"
sx={{ whiteSpace: 'pre-wrap' }}
>
{idea.aiRole ?? '—'}
</Typography>
)}
</Grid>
{/* Способ проверки */}
<Grid size={12}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Способ проверки
</Typography>
{isEditing ? (
<TextField
fullWidth
multiline
rows={2}
value={formData.verificationMethod ?? ''}
onChange={(e) =>
handleChange('verificationMethod', e.target.value)
}
slotProps={{
htmlInput: {
'data-testid': 'idea-detail-verification-method-input',
},
}}
/>
) : (
<Typography
data-testid="idea-detail-verification-method"
sx={{ whiteSpace: 'pre-wrap' }}
>
{idea.verificationMethod ?? '—'}
</Typography>
)}
</Grid>
<Grid size={12}>
<Divider sx={{ my: 1 }} />
</Grid>
{/* AI-оценка и ТЗ */}
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
AI-оценка
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography data-testid="idea-detail-estimate">
{formatEstimate(idea.estimatedHours, estimateConfig)}
</Typography>
{idea.complexity && (
<Chip label={idea.complexity} size="small" variant="outlined" />
)}
<Button
size="small"
startIcon={<AutoAwesome />}
onClick={() => onOpenEstimate(idea)}
data-testid="idea-detail-open-estimate-button"
>
{idea.estimatedHours ? 'Подробнее' : 'Оценить'}
</Button>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Техническое задание
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography data-testid="idea-detail-specification-status">
{idea.specification ? 'Есть' : 'Нет'}
</Typography>
<Button
size="small"
startIcon={<Description />}
onClick={() => onOpenSpecification(idea)}
data-testid="idea-detail-open-specification-button"
>
{idea.specification ? 'Открыть' : 'Создать'}
</Button>
</Box>
</Grid>
<Grid size={12}>
<Divider sx={{ my: 1 }} />
</Grid>
{/* Метаданные */}
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Создано
</Typography>
<Typography variant="body2" data-testid="idea-detail-created-at">
{formatDate(idea.createdAt)}
</Typography>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Обновлено
</Typography>
<Typography variant="body2" data-testid="idea-detail-updated-at">
{formatDate(idea.updatedAt)}
</Typography>
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
{isEditing ? (
<>
<Button
onClick={handleCancel}
data-testid="idea-detail-cancel-button"
>
Отмена
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving}
data-testid="idea-detail-save-button"
>
{isSaving ? 'Сохранение...' : 'Сохранить'}
</Button>
</>
) : (
<>
<Button onClick={handleClose}>Закрыть</Button>
<Button
variant="contained"
startIcon={<Edit />}
onClick={() => setIsEditing(true)}
data-testid="idea-detail-edit-button"
>
Редактировать
</Button>
</>
)}
</DialogActions>
</Dialog>
);
}

View File

@ -0,0 +1 @@
export { IdeaDetailModal } from './IdeaDetailModal';

View File

@ -9,11 +9,22 @@ import {
Button, Button,
InputAdornment, InputAdornment,
} from '@mui/material'; } from '@mui/material';
import { Search, Clear } from '@mui/icons-material'; import { Search, Clear, Circle } from '@mui/icons-material';
import { useIdeasStore } from '../../store/ideas'; import { useIdeasStore } from '../../store/ideas';
import { useModulesQuery } from '../../hooks/useIdeas'; import { useModulesQuery } from '../../hooks/useIdeas';
import type { IdeaStatus, IdeaPriority } from '../../types/idea'; import type { IdeaStatus, IdeaPriority } from '../../types/idea';
const colorOptions = [
{ value: '#ef5350', label: 'Красный' },
{ value: '#ff7043', label: 'Оранжевый' },
{ value: '#ffca28', label: 'Жёлтый' },
{ value: '#66bb6a', label: 'Зелёный' },
{ value: '#42a5f5', label: 'Синий' },
{ value: '#ab47bc', label: 'Фиолетовый' },
{ value: '#8d6e63', label: 'Коричневый' },
{ value: '#78909c', label: 'Серый' },
];
const statusOptions: { value: IdeaStatus; label: string }[] = [ const statusOptions: { value: IdeaStatus; label: string }[] = [
{ value: 'backlog', label: 'Бэклог' }, { value: 'backlog', label: 'Бэклог' },
{ value: 'todo', label: 'К выполнению' }, { value: 'todo', label: 'К выполнению' },
@ -43,12 +54,17 @@ export function IdeasFilters() {
}, [searchValue, setFilter]); }, [searchValue, setFilter]);
const hasFilters = Boolean( const hasFilters = Boolean(
filters.status ?? filters.priority ?? filters.module ?? filters.search, filters.status ??
filters.priority ??
filters.module ??
filters.search ??
filters.color,
); );
return ( return (
<Box <Box
sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }} sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}
data-testid="ideas-filters"
> >
<TextField <TextField
size="small" size="small"
@ -56,6 +72,7 @@ export function IdeasFilters() {
value={searchValue} value={searchValue}
onChange={(e) => setSearchValue(e.target.value)} onChange={(e) => setSearchValue(e.target.value)}
sx={{ minWidth: 200 }} sx={{ minWidth: 200 }}
data-testid="search-input"
slotProps={{ slotProps={{
input: { input: {
startAdornment: ( startAdornment: (
@ -67,7 +84,11 @@ export function IdeasFilters() {
}} }}
/> />
<FormControl size="small" sx={{ minWidth: 120 }}> <FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-status"
>
<InputLabel>Статус</InputLabel> <InputLabel>Статус</InputLabel>
<Select<IdeaStatus | ''> <Select<IdeaStatus | ''>
value={filters.status ?? ''} value={filters.status ?? ''}
@ -86,7 +107,11 @@ export function IdeasFilters() {
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 120 }}> <FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-priority"
>
<InputLabel>Приоритет</InputLabel> <InputLabel>Приоритет</InputLabel>
<Select<IdeaPriority | ''> <Select<IdeaPriority | ''>
value={filters.priority ?? ''} value={filters.priority ?? ''}
@ -105,7 +130,11 @@ export function IdeasFilters() {
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 120 }}> <FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-module"
>
<InputLabel>Модуль</InputLabel> <InputLabel>Модуль</InputLabel>
<Select <Select
value={filters.module ?? ''} value={filters.module ?? ''}
@ -121,6 +150,39 @@ export function IdeasFilters() {
</Select> </Select>
</FormControl> </FormControl>
<FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-color"
>
<InputLabel>Цвет</InputLabel>
<Select
value={filters.color ?? ''}
label="Цвет"
onChange={(e) => setFilter('color', e.target.value || undefined)}
renderValue={(value) => {
if (!value) return 'Все';
const opt = colorOptions.find((o) => o.value === value);
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Circle sx={{ color: value, fontSize: 16 }} />
{opt?.label}
</Box>
);
}}
>
<MenuItem value="">Все</MenuItem>
{colorOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Circle sx={{ color: opt.value, fontSize: 16 }} />
{opt.label}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
{hasFilters && ( {hasFilters && (
<Button <Button
size="small" size="small"
@ -129,6 +191,7 @@ export function IdeasFilters() {
clearFilters(); clearFilters();
setSearchValue(''); setSearchValue('');
}} }}
data-testid="clear-filters-button"
> >
Сбросить Сбросить
</Button> </Button>

View File

@ -0,0 +1,126 @@
import { useState } from 'react';
import { Box, Popover, IconButton, Tooltip } from '@mui/material';
import { Circle, Clear } from '@mui/icons-material';
import type { Idea } from '../../types/idea';
import { useUpdateIdea } from '../../hooks/useIdeas';
// Предустановленные цвета
const COLORS = [
'#ef5350', // красный
'#ff7043', // оранжевый
'#ffca28', // жёлтый
'#66bb6a', // зелёный
'#42a5f5', // синий
'#ab47bc', // фиолетовый
'#8d6e63', // коричневый
'#78909c', // серый
];
interface ColorPickerCellProps {
idea: Idea;
}
export function ColorPickerCell({ idea }: ColorPickerCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const updateIdea = useUpdateIdea();
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleColorSelect = (color: string | null) => {
updateIdea.mutate({
id: idea.id,
dto: { color },
});
handleClose();
};
const open = Boolean(anchorEl);
return (
<>
<Tooltip title="Выбрать цвет">
<Box
onClick={handleClick}
data-testid="color-picker-trigger"
sx={{
width: 24,
height: 24,
borderRadius: '50%',
backgroundColor: idea.color ?? 'transparent',
border: idea.color ? 'none' : '2px dashed',
borderColor: 'divider',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:hover': {
opacity: 0.8,
transform: 'scale(1.1)',
},
transition: 'all 0.2s',
}}
/>
</Tooltip>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
slotProps={{
paper: {
'data-testid': 'color-picker-popover',
} as React.HTMLAttributes<HTMLDivElement>,
}}
>
<Box
sx={{
p: 1,
display: 'flex',
gap: 0.5,
flexWrap: 'wrap',
maxWidth: 180,
}}
>
{COLORS.map((color) => (
<IconButton
key={color}
size="small"
onClick={() => handleColorSelect(color)}
data-testid={`color-option-${color.replace('#', '')}`}
sx={{
p: 0.5,
border: idea.color === color ? '2px solid' : 'none',
borderColor: 'primary.main',
}}
>
<Circle sx={{ color, fontSize: 24 }} />
</IconButton>
))}
<Tooltip title="Убрать цвет">
<IconButton
size="small"
onClick={() => handleColorSelect(null)}
data-testid="color-clear-button"
sx={{ p: 0.5 }}
>
<Clear sx={{ fontSize: 24, color: 'text.secondary' }} />
</IconButton>
</Tooltip>
</Box>
</Popover>
</>
);
}

View File

@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
import {
IconButton,
Menu,
MenuItem,
Checkbox,
ListItemText,
Tooltip,
Divider,
Typography,
Box,
} from '@mui/material';
import { Settings } from '@mui/icons-material';
import type { Table } from '@tanstack/react-table';
import type { Idea } from '../../types/idea';
const STORAGE_KEY = 'team-planner-column-visibility';
const columnLabels: Record<string, string> = {
drag: 'Перетаскивание',
color: 'Цвет',
title: 'Название',
status: 'Статус',
priority: 'Приоритет',
module: 'Модуль',
targetAudience: 'Целевая аудитория',
pain: 'Боль',
aiRole: 'Роль AI',
verificationMethod: 'Способ проверки',
description: 'Описание',
estimatedHours: 'Оценка',
actions: 'Действия',
};
// Колонки, которые нельзя скрыть
const alwaysVisibleColumns = ['drag', 'title', 'actions'];
interface ColumnVisibilityProps {
table: Table<Idea>;
}
export function ColumnVisibility({ table }: ColumnVisibilityProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
// Загрузка сохранённых настроек при монтировании
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const visibility = JSON.parse(saved) as Record<string, boolean>;
table.setColumnVisibility(visibility);
} catch {
// Игнорируем ошибки парсинга
}
}
}, [table]);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleToggle = (columnId: string) => {
const column = table.getColumn(columnId);
if (!column) return;
const newVisibility = !column.getIsVisible();
column.toggleVisibility(newVisibility);
// Сохраняем в localStorage
const currentVisibility = table.getState().columnVisibility;
const updatedVisibility = {
...currentVisibility,
[columnId]: newVisibility,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedVisibility));
};
const handleShowAll = () => {
const allVisible: Record<string, boolean> = {};
table.getAllColumns().forEach((col) => {
allVisible[col.id] = true;
});
table.setColumnVisibility(allVisible);
localStorage.setItem(STORAGE_KEY, JSON.stringify(allVisible));
};
const columns = table.getAllColumns().filter((col) => col.id in columnLabels);
return (
<>
<Tooltip title="Настройка колонок">
<IconButton
onClick={handleClick}
size="small"
data-testid="column-visibility-button"
sx={{ ml: 1 }}
>
<Settings />
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
data-testid="column-visibility-menu"
slotProps={{
paper: {
sx: { minWidth: 220 },
},
}}
>
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="subtitle2" color="text.secondary">
Показать колонки
</Typography>
</Box>
<Divider />
{columns.map((column) => {
const isAlwaysVisible = alwaysVisibleColumns.includes(column.id);
return (
<MenuItem
key={column.id}
onClick={() => !isAlwaysVisible && handleToggle(column.id)}
disabled={isAlwaysVisible}
dense
data-testid={`column-visibility-item-${column.id}`}
>
<Checkbox
checked={column.getIsVisible()}
disabled={isAlwaysVisible}
size="small"
sx={{ p: 0, mr: 1 }}
/>
<ListItemText primary={columnLabels[column.id]} />
</MenuItem>
);
})}
<Divider />
<MenuItem
onClick={handleShowAll}
dense
data-testid="column-visibility-show-all"
>
<ListItemText
primary="Показать все"
slotProps={{ primary: { color: 'primary' } }}
/>
</MenuItem>
</Menu>
</>
);
}

View File

@ -0,0 +1,97 @@
import { createContext, useContext } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { TableRow, TableCell, Box } from '@mui/material';
import { DragIndicator } from '@mui/icons-material';
import { flexRender } from '@tanstack/react-table';
import type { Row } from '@tanstack/react-table';
import type { Idea } from '../../types/idea';
// Контекст для передачи информации о drag handle в ячейку
interface DragHandleContextValue {
attributes: ReturnType<typeof useSortable>['attributes'];
listeners: ReturnType<typeof useSortable>['listeners'];
isDragging: boolean;
}
const DragHandleContext = createContext<DragHandleContextValue | null>(null);
// Компонент drag handle для использования в колонке
export function DragHandle() {
const context = useContext(DragHandleContext);
if (!context) {
return null;
}
const { attributes, listeners, isDragging } = context;
return (
<Box
{...attributes}
{...listeners}
data-testid="drag-handle"
sx={{
cursor: isDragging ? 'grabbing' : 'grab',
display: 'flex',
alignItems: 'center',
color: 'text.secondary',
touchAction: 'none',
'&:hover': { color: 'text.primary' },
}}
>
<DragIndicator fontSize="small" />
</Box>
);
}
interface DraggableRowProps {
row: Row<Idea>;
}
export function DraggableRow({ row }: DraggableRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: row.original.id });
// Используем CSS.Translate вместо CSS.Transform для лучшей совместимости с таблицами
const style = {
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
backgroundColor: row.original.color
? `${row.original.color}15`
: isDragging
? 'action.hover'
: undefined,
position: isDragging ? ('relative' as const) : undefined,
zIndex: isDragging ? 1000 : undefined,
'&:hover': {
backgroundColor: row.original.color
? `${row.original.color}25`
: undefined,
},
};
return (
<DragHandleContext.Provider value={{ attributes, listeners, isDragging }}>
<TableRow
ref={setNodeRef}
hover
sx={style}
data-testid={`idea-row-${row.original.id}`}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
</DragHandleContext.Provider>
);
}

View File

@ -1,9 +1,25 @@
import { useMemo } from 'react'; import { useMemo, useState, Fragment, useCallback } from 'react';
import { import {
useReactTable, useReactTable,
getCoreRowModel, getCoreRowModel,
flexRender, flexRender,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
} from '@dnd-kit/core';
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { import {
Table, Table,
TableBody, TableBody,
@ -17,23 +33,261 @@ import {
Box, Box,
Typography, Typography,
TablePagination, TablePagination,
Collapse,
} from '@mui/material'; } from '@mui/material';
import { Inbox } from '@mui/icons-material'; import { Inbox } from '@mui/icons-material';
import { useIdeasQuery, useDeleteIdea } from '../../hooks/useIdeas'; import { ColumnVisibility } from './ColumnVisibility';
import {
useIdeasQuery,
useDeleteIdea,
useReorderIdeas,
useUpdateIdea,
} 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 { CommentsPanel } from '../CommentsPanel';
import { AiEstimateModal } from '../AiEstimateModal';
import { SpecificationModal } from '../SpecificationModal';
import { IdeaDetailModal } from '../IdeaDetailModal';
import type { EstimateResult } from '../../services/ai';
import type { Idea, UpdateIdeaDto } from '../../types/idea';
import { useEstimateConfig } from '../../hooks/useSettings';
const SKELETON_COLUMNS_COUNT = 7; const SKELETON_COLUMNS_COUNT = 13;
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 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();
const estimateConfig = useEstimateConfig();
// ID активно перетаскиваемого элемента
const [activeId, setActiveId] = useState<string | null>(null);
// ID идеи с раскрытыми комментариями
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 [detailModalOpen, setDetailModalOpen] = useState(false);
const [detailIdea, setDetailIdea] = useState<Idea | null>(null);
// История ТЗ
const specificationHistory = useSpecificationHistory(
specificationIdea?.id ?? null,
);
const handleToggleComments = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
const handleEstimate = useCallback(
(id: string) => {
setEstimatingId(id);
setEstimateModalOpen(true);
setEstimateResult(null);
estimateIdea.mutate(id, {
onSuccess: (result) => {
setEstimateResult(result);
setEstimatingId(null);
},
onError: () => {
setEstimatingId(null);
},
});
},
[estimateIdea],
);
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 ?? 'medium',
breakdown: idea.estimateDetails.breakdown,
recommendations: idea.estimateDetails.recommendations,
estimatedAt: idea.estimatedAt ?? new Date().toISOString(),
});
setEstimateModalOpen(true);
};
const handleSpecification = useCallback(
(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);
},
});
},
[generateSpecification],
);
const handleCloseSpecificationModal = () => {
setSpecificationModalOpen(false);
setSpecificationIdea(null);
setGeneratedSpecification(null);
};
const handleSaveSpecification = (specification: string) => {
if (!specificationIdea) return;
updateIdea.mutate(
{ id: specificationIdea.id, dto: { 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 handleViewDetails = useCallback((idea: Idea) => {
setDetailIdea(idea);
setDetailModalOpen(true);
}, []);
const handleCloseDetailModal = () => {
setDetailModalOpen(false);
setDetailIdea(null);
};
const handleSaveDetail = (id: string, dto: UpdateIdeaDto) => {
updateIdea.mutate(
{ id, dto },
{
onSuccess: () => {
// Обновляем только те поля, которые были отправлены в dto
// Это сохраняет specification и другие поля которые не редактировались
setDetailIdea((prev) => {
if (!prev) return prev;
const updates: Partial<Idea> = {};
(Object.keys(dto) as (keyof UpdateIdeaDto)[]).forEach((key) => {
if (dto[key] !== undefined) {
(updates as Record<string, unknown>)[key] = dto[key];
}
});
return { ...prev, ...updates };
});
},
},
);
};
const handleOpenSpecificationFromDetail = (idea: Idea) => {
handleCloseDetailModal();
handleSpecification(idea);
};
const handleOpenEstimateFromDetail = (idea: Idea) => {
handleCloseDetailModal();
if (idea.estimatedHours) {
handleViewEstimate(idea);
} else {
handleEstimate(idea.id);
}
};
const columns = useMemo( const columns = useMemo(
() => createColumns((id) => deleteIdea.mutate(id)), () =>
[deleteIdea], createColumns({
onDelete: (id) => deleteIdea.mutate(id),
onToggleComments: handleToggleComments,
onEstimate: handleEstimate,
onViewEstimate: handleViewEstimate,
onSpecification: handleSpecification,
onViewDetails: handleViewDetails,
expandedId,
estimatingId,
generatingSpecificationId,
estimateConfig,
}),
[
deleteIdea,
expandedId,
estimatingId,
generatingSpecificationId,
handleEstimate,
handleSpecification,
handleViewDetails,
estimateConfig,
],
); );
// eslint-disable-next-line react-hooks/incompatible-library // eslint-disable-next-line react-hooks/incompatible-library
@ -43,8 +297,49 @@ export function IdeasTable() {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
manualSorting: true, manualSorting: true,
manualPagination: true, manualPagination: true,
getRowId: (row) => row.id,
}); });
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (over && active.id !== over.id && data?.data) {
const oldIndex = data.data.findIndex((item) => item.id === active.id);
const newIndex = data.data.findIndex((item) => item.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
// Создаём новый порядок
const items = [...data.data];
const [movedItem] = items.splice(oldIndex, 1);
items.splice(newIndex, 0, movedItem);
// Отправляем на сервер новый порядок
const reorderItems = items.map((item, index) => ({
id: item.id,
order: index,
}));
reorderIdeas.mutate(reorderItems);
}
}
};
const handleSort = (columnId: string) => { const handleSort = (columnId: string) => {
setSorting(columnId); setSorting(columnId);
}; };
@ -67,101 +362,168 @@ export function IdeasTable() {
); );
} }
const rows = table.getRowModel().rows;
const rowIds = rows.map((row) => row.original.id);
const activeRow = activeId
? rows.find((row) => row.original.id === activeId)
: null;
return ( return (
<Paper sx={{ width: '100%', overflow: 'hidden' }}> <Paper
<TableContainer> sx={{ width: '100%', overflow: 'hidden' }}
<Table stickyHeader size="small"> data-testid="ideas-table-container"
<TableHead> >
{table.getHeaderGroups().map((headerGroup) => ( <Box
<TableRow key={headerGroup.id}> sx={{
{headerGroup.headers.map((header) => ( display: 'flex',
<TableCell justifyContent: 'flex-end',
key={header.id} alignItems: 'center',
sx={{ px: 2,
fontWeight: 600, py: 1,
backgroundColor: 'grey.100', borderBottom: 1,
width: header.getSize(), borderColor: 'divider',
}} }}
> >
{header.column.getCanSort() ? ( <ColumnVisibility table={table} />
<TableSortLabel </Box>
active={sorting.sortBy === header.id} <DndContext
direction={ sensors={sensors}
sorting.sortBy === header.id collisionDetection={closestCenter}
? (sorting.sortOrder.toLowerCase() as modifiers={[restrictToVerticalAxis]}
| 'asc' onDragStart={handleDragStart}
| 'desc') onDragEnd={handleDragEnd}
: 'asc' >
} <TableContainer>
onClick={() => handleSort(header.id)} <Table stickyHeader size="small" data-testid="ideas-table">
> <TableHead>
{flexRender( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableCell
key={header.id}
sx={{
fontWeight: 600,
backgroundColor: 'grey.100',
width: header.getSize(),
}}
>
{header.column.getCanSort() ? (
<TableSortLabel
active={sorting.sortBy === header.id}
direction={
sorting.sortBy === header.id
? (sorting.sortOrder.toLowerCase() as
| 'asc'
| 'desc')
: 'asc'
}
onClick={() => handleSort(header.id)}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableSortLabel>
) : (
flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext(), header.getContext(),
)} )
</TableSortLabel> )}
) : ( </TableCell>
flexRender( ))}
header.column.columnDef.header,
header.getContext(),
)
)}
</TableCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index}>
{Array.from({ length: SKELETON_COLUMNS_COUNT }).map(
(_, colIndex) => (
<TableCell key={colIndex}>
<Skeleton variant="text" />
</TableCell>
),
)}
</TableRow> </TableRow>
)) ))}
) : table.getRowModel().rows.length === 0 ? ( </TableHead>
<TableRow> <TableBody>
<TableCell colSpan={SKELETON_COLUMNS_COUNT}> {isLoading ? (
<Box Array.from({ length: 5 }).map((_, index) => (
sx={{ <TableRow key={index}>
py: 8, {Array.from({ length: SKELETON_COLUMNS_COUNT }).map(
display: 'flex', (_, colIndex) => (
flexDirection: 'column', <TableCell key={colIndex}>
alignItems: 'center', <Skeleton variant="text" />
color: 'text.secondary', </TableCell>
}} ),
> )}
<Inbox sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} /> </TableRow>
<Typography variant="h6">Идей пока нет</Typography> ))
<Typography variant="body2"> ) : rows.length === 0 ? (
Создайте первую идею, чтобы начать <TableRow>
</Typography> <TableCell colSpan={SKELETON_COLUMNS_COUNT}>
</Box> <Box
</TableCell> sx={{
</TableRow> py: 8,
) : ( display: 'flex',
table.getRowModel().rows.map((row) => ( flexDirection: 'column',
<TableRow alignItems: 'center',
key={row.id} color: 'text.secondary',
hover }}
sx={{ data-testid="ideas-empty-state"
backgroundColor: row.original.color >
? `${row.original.color}15` <Inbox sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
: undefined, <Typography variant="h6">Идей пока нет</Typography>
'&:hover': { <Typography variant="body2">
backgroundColor: row.original.color Создайте первую идею, чтобы начать
? `${row.original.color}25` </Typography>
: undefined, </Box>
}, </TableCell>
}} </TableRow>
) : (
<SortableContext
items={rowIds}
strategy={verticalListSortingStrategy}
> >
{row.getVisibleCells().map((cell) => ( {rows.map((row) => (
<TableCell key={cell.id}> <Fragment key={row.id}>
<DraggableRow row={row} />
<TableRow>
<TableCell
colSpan={SKELETON_COLUMNS_COUNT}
sx={{
p: 0,
borderBottom:
expandedId === row.original.id ? 1 : 0,
borderColor: 'divider',
}}
>
<Collapse
in={expandedId === row.original.id}
unmountOnExit
>
<CommentsPanel ideaId={row.original.id} />
</Collapse>
</TableCell>
</TableRow>
</Fragment>
))}
</SortableContext>
)}
</TableBody>
</Table>
</TableContainer>
<DragOverlay dropAnimation={null}>
{activeRow ? (
<Table
size="small"
sx={{
backgroundColor: 'background.paper',
boxShadow: 6,
borderRadius: 1,
'& td': {
backgroundColor: activeRow.original.color
? `${activeRow.original.color}30`
: 'action.selected',
},
}}
>
<TableBody>
<TableRow>
{activeRow.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
sx={{ width: cell.column.getSize() }}
>
{flexRender( {flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext(), cell.getContext(),
@ -169,11 +531,11 @@ export function IdeasTable() {
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
)) </TableBody>
)} </Table>
</TableBody> ) : null}
</Table> </DragOverlay>
</TableContainer> </DndContext>
{data && ( {data && (
<TablePagination <TablePagination
component="div" component="div"
@ -185,6 +547,40 @@ 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}
estimateConfig={estimateConfig}
/>
<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}
/>
<IdeaDetailModal
open={detailModalOpen}
onClose={handleCloseDetailModal}
idea={detailIdea}
onSave={handleSaveDetail}
isSaving={updateIdea.isPending}
onOpenSpecification={handleOpenSpecificationFromDetail}
onOpenEstimate={handleOpenEstimateFromDetail}
estimateConfig={estimateConfig}
/>
</Paper> </Paper>
); );
} }

View File

@ -1,9 +1,36 @@
import { createColumnHelper } from '@tanstack/react-table'; import { createColumnHelper } from '@tanstack/react-table';
import { Chip, Box, IconButton } from '@mui/material'; import {
import { Delete } from '@mui/icons-material'; Chip,
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea'; Box,
IconButton,
Tooltip,
CircularProgress,
Typography,
} from '@mui/material';
import {
Delete,
Comment,
ExpandLess,
AutoAwesome,
AccessTime,
Description,
Visibility,
} from '@mui/icons-material';
import type {
Idea,
IdeaStatus,
IdeaPriority,
IdeaComplexity,
} from '../../types/idea';
import { EditableCell } from './EditableCell'; import { EditableCell } from './EditableCell';
import { ColorPickerCell } from './ColorPickerCell';
import { statusOptions, priorityOptions } from './constants'; import { statusOptions, priorityOptions } from './constants';
import { DragHandle } from './DraggableRow';
import {
formatEstimate,
type EstimateConfig,
DEFAULT_ESTIMATE_CONFIG,
} from '../../utils/estimate';
const columnHelper = createColumnHelper<Idea>(); const columnHelper = createColumnHelper<Idea>();
@ -28,7 +55,63 @@ const priorityColors: Record<
critical: 'error', critical: 'error',
}; };
export const createColumns = (onDelete: (id: string) => void) => [ 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',
};
interface ColumnsConfig {
onDelete: (id: string) => void;
onToggleComments: (id: string) => void;
onEstimate: (id: string) => void;
onViewEstimate: (idea: Idea) => void;
onSpecification: (idea: Idea) => void;
onViewDetails: (idea: Idea) => void;
expandedId: string | null;
estimatingId: string | null;
generatingSpecificationId: string | null;
estimateConfig?: EstimateConfig;
}
export const createColumns = ({
onDelete,
onToggleComments,
onEstimate,
onViewEstimate,
onSpecification,
onViewDetails,
expandedId,
estimatingId,
generatingSpecificationId,
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
}: ColumnsConfig) => [
columnHelper.display({
id: 'drag',
header: '',
cell: () => <DragHandle />,
size: 40,
enableSorting: false,
}),
columnHelper.accessor('color', {
header: 'Цвет',
cell: (info) => <ColorPickerCell idea={info.row.original} />,
size: 60,
enableSorting: false,
}),
columnHelper.accessor('title', { columnHelper.accessor('title', {
header: 'Название', header: 'Название',
cell: (info) => ( cell: (info) => (
@ -114,6 +197,60 @@ export const createColumns = (onDelete: (id: string) => void) => [
), ),
size: 150, size: 150,
}), }),
columnHelper.accessor('pain', {
header: 'Боль',
cell: (info) => {
const value = info.getValue();
return (
<EditableCell
idea={info.row.original}
field="pain"
value={value}
renderDisplay={(val) => {
if (!val) return '—';
return val.length > 60 ? `${val.slice(0, 60)}...` : val;
}}
/>
);
},
size: 180,
}),
columnHelper.accessor('aiRole', {
header: 'Роль AI',
cell: (info) => {
const value = info.getValue();
return (
<EditableCell
idea={info.row.original}
field="aiRole"
value={value}
renderDisplay={(val) => {
if (!val) return '—';
return val.length > 60 ? `${val.slice(0, 60)}...` : val;
}}
/>
);
},
size: 180,
}),
columnHelper.accessor('verificationMethod', {
header: 'Способ проверки',
cell: (info) => {
const value = info.getValue();
return (
<EditableCell
idea={info.row.original}
field="verificationMethod"
value={value}
renderDisplay={(val) => {
if (!val) return '—';
return val.length > 60 ? `${val.slice(0, 60)}...` : val;
}}
/>
);
},
size: 180,
}),
columnHelper.accessor('description', { columnHelper.accessor('description', {
header: 'Описание', header: 'Описание',
cell: (info) => { cell: (info) => {
@ -132,18 +269,144 @@ export const createColumns = (onDelete: (id: string) => void) => [
}, },
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">
{formatEstimate(idea.estimatedHours, estimateConfig)}
</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) => {
<IconButton const idea = info.row.original;
size="small" const ideaId = idea.id;
onClick={() => onDelete(info.row.original.id)} const isExpanded = expandedId === ideaId;
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }} const isEstimating = estimatingId === ideaId;
> const isGeneratingSpec = generatingSpecificationId === ideaId;
<Delete fontSize="small" /> const hasSpecification = !!idea.specification;
</IconButton> return (
), <Box sx={{ display: 'flex', gap: 0.5 }}>
size: 50, <Tooltip title="Подробнее">
<IconButton
size="small"
onClick={() => onViewDetails(idea)}
data-testid="view-details-button"
sx={{ opacity: 0.6, '&:hover': { opacity: 1 } }}
>
<Visibility fontSize="small" />
</IconButton>
</Tooltip>
<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="Комментарии">
<IconButton
size="small"
onClick={() => onToggleComments(ideaId)}
color={isExpanded ? 'primary' : 'default'}
data-testid="toggle-comments-button"
sx={{ opacity: isExpanded ? 1 : 0.5, '&:hover': { opacity: 1 } }}
>
{isExpanded ? (
<ExpandLess fontSize="small" />
) : (
<Comment fontSize="small" />
)}
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={() => onDelete(ideaId)}
data-testid="delete-idea-button"
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<Delete fontSize="small" />
</IconButton>
</Box>
);
},
size: 180,
}), }),
]; ];

View File

@ -0,0 +1,54 @@
import { Box, Button, Typography, Paper } from '@mui/material';
import { Login } from '@mui/icons-material';
import keycloak from '../../services/keycloak';
export function LoginPage() {
const handleLogin = () => {
void keycloak.login();
};
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
bgcolor: 'background.default',
}}
>
<Paper
elevation={3}
sx={{
p: 6,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: 400,
textAlign: 'center',
}}
>
<Typography variant="h4" component="h1" gutterBottom fontWeight="bold">
Team Planner
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
Приложение для управления бэклогом идей команды
</Typography>
<Button
variant="contained"
size="large"
startIcon={<Login />}
onClick={handleLogin}
sx={{ mb: 4, px: 4, py: 1.5 }}
>
Войти
</Button>
<Typography variant="body2" color="text.secondary">
Для получения доступа обратитесь к Николаю Вигдорову
</Typography>
</Paper>
</Box>
);
}

View File

@ -0,0 +1 @@
export { LoginPage } from './LoginPage';

View File

@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
TextField,
Button,
Paper,
Alert,
} from '@mui/material';
import { useSettingsQuery, useUpdateSettings } from '../../hooks/useSettings';
import { DEFAULT_ESTIMATE_CONFIG } from '../../utils/estimate';
export function SettingsPage() {
const { data: settings, isLoading } = useSettingsQuery();
const updateSettings = useUpdateSettings();
const [hoursPerDay, setHoursPerDay] = useState(
String(DEFAULT_ESTIMATE_CONFIG.hoursPerDay),
);
const [daysPerWeek, setDaysPerWeek] = useState(
String(DEFAULT_ESTIMATE_CONFIG.daysPerWeek),
);
useEffect(() => {
if (settings) {
setHoursPerDay(
String(settings.hoursPerDay ?? DEFAULT_ESTIMATE_CONFIG.hoursPerDay),
);
setDaysPerWeek(
String(settings.daysPerWeek ?? DEFAULT_ESTIMATE_CONFIG.daysPerWeek),
);
}
}, [settings]);
const handleSave = () => {
const hpd = Number(hoursPerDay);
const dpw = Number(daysPerWeek);
if (hpd > 0 && hpd <= 24 && dpw > 0 && dpw <= 7) {
updateSettings.mutate({ hoursPerDay: hpd, daysPerWeek: dpw });
}
};
const hpdNum = Number(hoursPerDay);
const dpwNum = Number(daysPerWeek);
const isValid =
hpdNum > 0 && hpdNum <= 24 && dpwNum > 0 && dpwNum <= 7;
if (isLoading) return null;
return (
<Box sx={{ maxWidth: 500 }}>
<Typography variant="h5" sx={{ mb: 3 }}>
Настройки
</Typography>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Формат оценки трудозатрат
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Эти значения используются для конвертации оценок из формата «1w 3d 7h»
в часы и обратно.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Часов в рабочем дне"
type="number"
value={hoursPerDay}
onChange={(e) => setHoursPerDay(e.target.value)}
slotProps={{ htmlInput: { min: 1, max: 24 } }}
helperText="От 1 до 24"
error={!hpdNum || hpdNum < 1 || hpdNum > 24}
size="small"
/>
<TextField
label="Рабочих дней в неделе"
type="number"
value={daysPerWeek}
onChange={(e) => setDaysPerWeek(e.target.value)}
slotProps={{ htmlInput: { min: 1, max: 7 } }}
helperText="От 1 до 7"
error={!dpwNum || dpwNum < 1 || dpwNum > 7}
size="small"
/>
</Box>
<Box sx={{ mt: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="contained"
onClick={handleSave}
disabled={!isValid || updateSettings.isPending}
>
Сохранить
</Button>
{updateSettings.isSuccess && (
<Alert severity="success" sx={{ py: 0 }}>
Сохранено
</Alert>
)}
{updateSettings.isError && (
<Alert severity="error" sx={{ py: 0 }}>
Ошибка сохранения
</Alert>
)}
</Box>
</Paper>
</Box>
);
}

Some files were not shown because too many files have changed in this diff Show More