Compare commits
20 Commits
2953a97a46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1556ff9a29 | |||
| 5ec631f229 | |||
| 593c573985 | |||
| 75015c1c85 | |||
| 990a6fe918 | |||
| 9d34deb77d | |||
| 4d80480d0f | |||
| b270345e77 | |||
| 1b95fd9e55 | |||
| 7421f33de8 | |||
| 684e416588 | |||
| 890d6de92e | |||
| 2e46cc41a1 | |||
| dea0676169 | |||
| 739a7d172d | |||
| 85e7966c97 | |||
| 5366347bcc | |||
| 9e43ad65c5 | |||
| 8f9fa581eb | |||
| 61b856254b |
363
.drone.yml
363
.drone.yml
@ -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
|
||||
type: kubernetes
|
||||
name: main-pipeline
|
||||
|
||||
# Триггер: запускать при изменениях в backend, frontend или .drone.yml
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
- master
|
||||
event:
|
||||
- push
|
||||
name: ci
|
||||
|
||||
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-backend
|
||||
image: plugins/kaniko
|
||||
when:
|
||||
changeset:
|
||||
includes:
|
||||
- backend/**
|
||||
- .drone.yml
|
||||
excludes:
|
||||
- backend/README.md
|
||||
- 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
|
||||
- name: build
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
depends_on: [prepare]
|
||||
environment:
|
||||
HARBOR_USER:
|
||||
from_secret: HARBOR_USER
|
||||
HARBOR_PASSWORD:
|
||||
from_secret: HARBOR_PASSWORD
|
||||
commands:
|
||||
- /busybox/sh .ci/scripts/build.sh
|
||||
|
||||
# --- Сборка Frontend образа (параллельно с backend) ---
|
||||
- name: build-frontend
|
||||
image: plugins/kaniko
|
||||
when:
|
||||
changeset:
|
||||
includes:
|
||||
- frontend/**
|
||||
- .drone.yml
|
||||
excludes:
|
||||
- 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
|
||||
- name: deploy
|
||||
image: alpine:3.19
|
||||
depends_on: [build]
|
||||
environment:
|
||||
KUBE_CONFIG:
|
||||
from_secret: KUBE_CONFIG
|
||||
commands:
|
||||
- apk add --no-cache bash yq kubectl helm
|
||||
- bash .ci/scripts/deploy.sh
|
||||
|
||||
# ============================================================
|
||||
# ДЕПЛОЙ (только после завершения ОБЕИХ сборок)
|
||||
# ============================================================
|
||||
|
||||
# --- Развертывание 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)"
|
||||
trigger:
|
||||
branch: [main, master]
|
||||
event: [push, custom]
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: kubernetes
|
||||
name: infra-pipeline
|
||||
name: keycloak-theme
|
||||
|
||||
# Триггер: запускать только при изменениях в k8s конфигах
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
- master
|
||||
event:
|
||||
- push
|
||||
paths:
|
||||
include:
|
||||
- k8s/**
|
||||
volumes:
|
||||
- name: shared
|
||||
temp: {}
|
||||
|
||||
steps:
|
||||
# --- Создание секретов (УДАЛИТЬ ПОСЛЕ ПЕРВОГО ДЕПЛОЯ) ---
|
||||
- name: create-secrets
|
||||
image: alpine/k8s:1.28.2
|
||||
environment:
|
||||
KUBE_CONFIG_CONTENT:
|
||||
from_secret: KUBE_CONFIG
|
||||
DB_NAME:
|
||||
from_secret: DB_NAME
|
||||
DB_USER:
|
||||
from_secret: DB_USER
|
||||
DB_PASSWORD:
|
||||
from_secret: DB_PASSWORD
|
||||
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"
|
||||
- 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"
|
||||
- name: check-changes
|
||||
image: alpine/git
|
||||
volumes:
|
||||
- name: shared
|
||||
path: /shared
|
||||
commands:
|
||||
- |
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- keycloak-theme/ 2>/dev/null | grep -v '\.md$' || true)
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "No changes in keycloak-theme/ - skipping"
|
||||
touch /shared/.skip
|
||||
else
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
fi
|
||||
|
||||
# --- Развертывание инфраструктуры (PostgreSQL, Services, Ingress) ---
|
||||
- name: deploy-infra
|
||||
image: alpine/k8s:1.28.2
|
||||
depends_on:
|
||||
- create-secrets
|
||||
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 HOSTNAME="team-planner.vigdorov.ru"
|
||||
- export SECRET_NAME="wildcard-cert"
|
||||
- kubectl cluster-info
|
||||
- kubectl create namespace $APP_NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
|
||||
- kubectl apply -n $APP_NAMESPACE -f k8s/postgres-pvc.yaml
|
||||
- kubectl apply -n $APP_NAMESPACE -f k8s/postgres-statefulset.yaml
|
||||
- kubectl apply -n $APP_NAMESPACE -f k8s/postgres-service.yaml
|
||||
- kubectl apply -n $APP_NAMESPACE -f k8s/backend-service.yaml
|
||||
- kubectl apply -n $APP_NAMESPACE -f k8s/frontend-service.yaml
|
||||
- sed -e "s|__HOSTNAME__|$HOSTNAME|g" -e "s|__SECRET_NAME__|$SECRET_NAME|g" k8s/ingress.yaml | kubectl apply -n $APP_NAMESPACE -f -
|
||||
- echo "✅ Infrastructure updated"
|
||||
- name: build-keycloak-theme
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [check-changes]
|
||||
volumes:
|
||||
- name: shared
|
||||
path: /shared
|
||||
environment:
|
||||
HARBOR_USER:
|
||||
from_secret: HARBOR_USER
|
||||
HARBOR_PASSWORD:
|
||||
from_secret: HARBOR_PASSWORD
|
||||
commands:
|
||||
- |
|
||||
if [ -f /shared/.skip ]; then
|
||||
echo "Skipping build"
|
||||
exit 0
|
||||
fi
|
||||
- |
|
||||
export IMAGE_TAG=$(echo $DRONE_COMMIT_SHA | cut -c1-7)
|
||||
export REGISTRY="registry.vigdorov.ru"
|
||||
export REPO="$REGISTRY/library/keycloak-team-planner"
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"$REGISTRY\":{\"username\":\"$HARBOR_USER\",\"password\":\"$HARBOR_PASSWORD\"}}}" > /kaniko/.docker/config.json
|
||||
/kaniko/executor \
|
||||
--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
2
.gitignore
vendored
@ -35,3 +35,5 @@ Thumbs.db
|
||||
|
||||
# Kubernetes secrets
|
||||
k8s/secrets.yaml
|
||||
|
||||
.playwright-mcp
|
||||
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@ -0,0 +1 @@
|
||||
@vigdorov:registry=https://git.vigdorov.ru/api/packages/vigdorov/npm/
|
||||
1
.prettierrc
Normal file
1
.prettierrc
Normal file
@ -0,0 +1 @@
|
||||
"@vigdorov/prettier-config"
|
||||
955
ARCHITECTURE.md
955
ARCHITECTURE.md
File diff suppressed because it is too large
Load Diff
20
CLAUDE.md
20
CLAUDE.md
@ -9,6 +9,7 @@
|
||||
2. CONTEXT.md — текущий статус
|
||||
3. ROADMAP.md — план и задачи
|
||||
4. REQUIREMENTS.md / ARCHITECTURE.md — по необходимости
|
||||
5. E2E_TESTING.md — **перед написанием тестов!**
|
||||
|
||||
После работы обнови CONTEXT.md.
|
||||
|
||||
@ -23,13 +24,15 @@
|
||||
- [ROADMAP.md](ROADMAP.md) — план разработки, задачи по фазам
|
||||
- [REQUIREMENTS.md](REQUIREMENTS.md) — требования к продукту
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — C4, sequence diagrams, API контракты, UI прототипы
|
||||
- [E2E_TESTING.md](E2E_TESTING.md) — **читай перед написанием тестов!** Гайд по e2e тестированию
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
team-planner/
|
||||
├── backend/ # NestJS API
|
||||
└── frontend/ # React + TypeScript
|
||||
├── frontend/ # React + TypeScript
|
||||
└── tests/ # E2E тесты (Playwright)
|
||||
```
|
||||
|
||||
## Ключевые сущности
|
||||
@ -51,3 +54,18 @@ team-planner/
|
||||
|
||||
Используется ai-proxy service для оценки трудозатрат.
|
||||
Гайд: `/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
|
||||
```
|
||||
|
||||
162
CONTEXT.md
162
CONTEXT.md
@ -6,9 +6,10 @@
|
||||
|
||||
## Текущий статус
|
||||
|
||||
**Этап:** Фаза 2 — Drag & Drop ✅, Авторизация ✅, далее цвета/комментарии/команда
|
||||
**Фаза MVP:** Базовый функционал + авторизация готовы
|
||||
**Последнее обновление:** 2026-01-14
|
||||
**Этап:** Фаза 3.2 завершена ✅
|
||||
**Фаза MVP:** Базовый функционал + авторизация + расширенный функционал + AI-оценка + мини-ТЗ + история ТЗ + полный просмотр идеи готовы
|
||||
**Следующий этап:** Фаза 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 | Создан ARCHITECTURE.md (C4, sequences, API, UI prototypes, спецификация) |
|
||||
| 2025-12-29 | Создан ROADMAP.md — план разработки по фазам |
|
||||
@ -46,6 +48,48 @@
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@ -53,7 +97,48 @@
|
||||
|
||||
> Смотри [ROADMAP.md](ROADMAP.md) для полного плана разработки
|
||||
|
||||
**Сейчас:** Фаза 2 — цветовая маркировка, комментарии, управление командой
|
||||
**Готово:** Фазы 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
|
||||
|
||||
---
|
||||
|
||||
@ -67,28 +152,48 @@ team-planner/
|
||||
├── REQUIREMENTS.md # Требования к продукту
|
||||
├── ARCHITECTURE.md # Архитектура, API, UI
|
||||
├── ROADMAP.md # План разработки
|
||||
├── E2E_TESTING.md # Гайд по E2E тестированию ✅
|
||||
├── 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 тесты
|
||||
│ └── e2e/ # Playwright E2E тесты ✅
|
||||
│ ├── auth.setup.ts # Авторизация для тестов (Keycloak)
|
||||
│ ├── phase1.spec.ts # Тесты Фазы 1 (11 тестов)
|
||||
│ └── phase2.spec.ts # Тесты Фазы 2 (D&D, цвета, комментарии, команда)
|
||||
│ ├── 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
|
||||
│ ├── src/
|
||||
│ │ ├── auth/ # Модуль авторизации ✅
|
||||
│ │ │ ├── jwt.strategy.ts # JWT валидация через JWKS
|
||||
│ │ │ ├── jwt-auth.guard.ts # Глобальный guard
|
||||
│ │ │ └── decorators/public.decorator.ts # @Public() для открытых endpoints
|
||||
│ │ ├── ideas/ # Модуль идей (готов + reorder)
|
||||
│ │ ├── 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)
|
||||
│ │ ├── team/ # Модуль команды (Фаза 2) — TeamMember + Role entities
|
||||
│ │ ├── 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 приложение
|
||||
├── src/
|
||||
@ -98,16 +203,30 @@ team-planner/
|
||||
│ │ ├── IdeasTable/
|
||||
│ │ │ ├── IdeasTable.tsx # Таблица с DndContext
|
||||
│ │ │ ├── DraggableRow.tsx # Сортируемая строка (useSortable)
|
||||
│ │ │ ├── columns.tsx # Колонки + drag handle
|
||||
│ │ │ ├── columns.tsx # Колонки + drag handle (13 колонок)
|
||||
│ │ │ ├── ColumnVisibility.tsx # Управление видимостью колонок ✅
|
||||
│ │ │ └── ...
|
||||
│ │ ├── IdeasFilters/ # Фильтры
|
||||
│ │ └── CreateIdeaModal/ # Модалка создания
|
||||
│ │ ├── CreateIdeaModal/ # Модалка создания
|
||||
│ │ ├── TeamPage/ # Страница команды (Фаза 2)
|
||||
│ │ │ ├── TeamPage.tsx # Табы: Участники / Роли
|
||||
│ │ │ ├── TeamMemberModal.tsx # Модалка участника
|
||||
│ │ │ └── RolesManager.tsx # Управление ролями
|
||||
│ │ ├── CommentsPanel/ # Комментарии к идеям
|
||||
│ │ ├── AiEstimateModal/ # Модалка AI-оценки (Фаза 3) ✅
|
||||
│ │ ├── SpecificationModal/ # Модалка мини-ТЗ (Фаза 3.1) ✅
|
||||
│ │ └── IdeaDetailModal/ # Модалка детального просмотра (Фаза 3.2) ✅
|
||||
│ ├── hooks/
|
||||
│ │ └── useIdeas.ts # React Query хуки + useReorderIdeas
|
||||
│ │ ├── useIdeas.ts # React Query хуки + useReorderIdeas
|
||||
│ │ └── useAi.ts # useEstimateIdea + useGenerateSpecification + history hooks ✅
|
||||
│ ├── services/
|
||||
│ │ ├── api.ts # Axios + auth interceptors
|
||||
│ │ ├── keycloak.ts # Keycloak instance ✅
|
||||
│ │ └── ideas.ts # API методы + reorder()
|
||||
│ │ ├── ideas.ts # API методы + reorder()
|
||||
│ │ ├── team.ts # API команды
|
||||
│ │ ├── roles.ts # API ролей
|
||||
│ │ ├── comments.ts # API комментариев
|
||||
│ │ └── ai.ts # AI Proxy API (Фаза 3 + 3.1) ✅
|
||||
│ ├── store/
|
||||
│ │ └── ideas.ts # Zustand store
|
||||
│ └── types/
|
||||
@ -129,7 +248,9 @@ team-planner/
|
||||
| Data Fetching | React Query | Кэширование, оптимистичные обновления |
|
||||
| Язык интерфейса | Русский | Требование проекта |
|
||||
| Авторизация | Keycloak | Внешний IdP, OIDC, редиректы |
|
||||
| Keycloak Theme | Custom FreeMarker | Единый стиль с приложением (MUI) |
|
||||
| E2E тесты | Playwright | Быстрее Selenium, лучше API, auto-wait |
|
||||
| CI/CD | Drone CI | Kubernetes pipeline, автодеплой |
|
||||
|
||||
---
|
||||
|
||||
@ -143,9 +264,18 @@ team-planner/
|
||||
|
||||
- **Интерфейс на русском языке** — все тексты, лейблы, placeholder'ы должны быть на русском
|
||||
- AI-интеграция через ai-proxy: `/Users/vigdorov/dev/gptunnel-service/INTEGRATION.md`
|
||||
- Многопользовательский режим НЕ нужен
|
||||
- Экспорт и интеграции НЕ нужны
|
||||
- **Многопользовательский режим НУЖЕН** — WebSocket, real-time обновления (Фаза 6)
|
||||
- **Экспорт НУЖЕН** — экспорт идеи в DOCX (Фаза 8)
|
||||
- **Права доступа НУЖНЫ** — гранулярная система прав, панель админа (Фаза 4)
|
||||
- **Аудит НУЖЕН** — история действий с восстановлением (Фаза 5)
|
||||
- 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)
|
||||
|
||||
@ -17,11 +17,12 @@
|
||||
## Локальное окружение
|
||||
|
||||
### Порты
|
||||
| Сервис | Порт |
|
||||
|--------|------|
|
||||
| Frontend (React) | 4000 |
|
||||
| Backend (NestJS) | 4001 |
|
||||
| PostgreSQL | 5432 |
|
||||
| Сервис | Порт | Описание |
|
||||
|--------|------|----------|
|
||||
| Frontend (React) | 4000 | Vite dev server |
|
||||
| Backend (NestJS) | 4001 | NestJS API |
|
||||
| PostgreSQL | 5432 | Docker container |
|
||||
| AI Proxy (туннель) | 3000 | SSH туннель к K8s |
|
||||
|
||||
### База данных
|
||||
PostgreSQL поднимается в Docker. Файл `docker-compose.yml` в корне проекта.
|
||||
@ -31,6 +32,63 @@ PostgreSQL поднимается в Docker. Файл `docker-compose.yml` в к
|
||||
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
298
E2E_TESTING.md
Normal 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. Это экономит время и ресурсы при отладке
|
||||
162
REQUIREMENTS.md
162
REQUIREMENTS.md
@ -27,21 +27,41 @@
|
||||
| Цвет | Цветовая маркировка строки | Color |
|
||||
| Оценка времени | AI-генерируемая оценка трудозатрат | Calculated |
|
||||
|
||||
#### 1.2 Редактирование идей
|
||||
- **Inline-редактирование**: возможность редактировать любое поле прямо в таблице двойным кликом
|
||||
- **Быстрое изменение статуса и приоритета** через dropdown
|
||||
- **Автосохранение** изменений
|
||||
#### 1.2 Автор идеи
|
||||
- При создании идеи автоматически сохраняется автор (текущий пользователь)
|
||||
- Автора идеи изменить нельзя (поле 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.1 Сортировка
|
||||
@ -89,6 +109,14 @@
|
||||
- Расчёт общего времени с учётом состава команды
|
||||
- Рекомендации по оптимизации
|
||||
|
||||
#### 3.4 Генерация мини-ТЗ
|
||||
- **Генерация ТЗ**: создание структурированного технического задания на основе описания идеи
|
||||
- **Структура ТЗ**: цель, функциональные требования, технические требования, критерии приёмки, зависимости и риски
|
||||
- **Сохранение**: ТЗ сохраняется в базе данных для повторного использования
|
||||
- **Просмотр**: возможность просмотреть сохранённое ТЗ по клику на кнопку
|
||||
- **Редактирование**: возможность изменить сгенерированное ТЗ вручную
|
||||
- **Интеграция с оценкой**: AI-оценка времени учитывает ТЗ для более точного расчёта
|
||||
|
||||
### 4. Комментарии
|
||||
|
||||
- Добавление комментариев к идее
|
||||
@ -96,6 +124,101 @@
|
||||
- Упоминание участников (@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
|
||||
- **ORM**: TypeORM
|
||||
- **API**: REST + WebSocket (для real-time обновлений)
|
||||
- **WebSocket**: @nestjs/websockets + Socket.io
|
||||
- **AI Integration**: ai-proxy service тут лежит гайд по интеграции /Users/vigdorov/dev/gptunnel-service/INTEGRATION.md
|
||||
- **Document Generation**: docx (для экспорта)
|
||||
- **Cron Jobs**: @nestjs/schedule (для очистки аудита)
|
||||
|
||||
### Frontend (React + TypeScript)
|
||||
|
||||
@ -137,25 +263,31 @@
|
||||
### Безопасность
|
||||
- Валидация входных данных
|
||||
- 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. Нужна ли многопользовательская работа и разграничение прав?
|
||||
НЕТ
|
||||
**ДА** — см. разделы 5 (Права доступа) и 7 (Многопользовательская работа)
|
||||
|
||||
2. Требуется ли история изменений (audit log)?
|
||||
НЕТ
|
||||
4. Нужен ли экспорт данных (CSV, Excel)?
|
||||
НЕТ
|
||||
5. Интеграция с внешними системами (Jira, Trello)?
|
||||
НЕТ
|
||||
**ДА** — см. раздел 6 (История действий)
|
||||
|
||||
3. Нужен ли экспорт данных?
|
||||
**ДА** — экспорт отдельной идеи в DOCX (см. раздел 1.6)
|
||||
|
||||
4. Интеграция с внешними системами (Jira, Trello)?
|
||||
**НЕТ** — не требуется
|
||||
|
||||
398
ROADMAP.md
398
ROADMAP.md
@ -12,8 +12,15 @@
|
||||
| 0 | Инициализация | ✅ Завершена | Настройка проектов, инфраструктура |
|
||||
| 1 | Базовый функционал | ✅ Завершена | CRUD идей, таблица, редактирование |
|
||||
| 1.5 | Авторизация | ✅ Завершена | Keycloak, JWT, защита API |
|
||||
| 2 | Расширенный функционал | 🔄 В процессе | Drag&Drop ✅, цвета, комментарии, команда |
|
||||
| 3 | AI-интеграция | ⏸️ Ожидает | Оценка времени, рекомендации |
|
||||
| 2 | Расширенный функционал | ✅ Завершена | Drag&Drop, цвета, комментарии, команда |
|
||||
| 3 | AI-интеграция | ✅ Завершена | Оценка времени, рекомендации |
|
||||
| 3.1 | Генерация мини-ТЗ | ✅ Завершена | Генерация, редактирование, история ТЗ |
|
||||
| 3.2 | Полный просмотр идеи | ✅ Завершена | Просмотр и редактирование всех полей |
|
||||
| 4 | Права доступа | 📋 Планируется | Гранулярные права, панель админа |
|
||||
| 5 | Аудит и история | 📋 Планируется | Логирование действий, восстановление |
|
||||
| 6 | Real-time и WebSocket | 📋 Планируется | Многопользовательская работа |
|
||||
| 7 | Темная тема | 📋 Планируется | Переключение светлая/тёмная |
|
||||
| 8 | Экспорт | 📋 Планируется | Экспорт идеи в DOCX |
|
||||
|
||||
---
|
||||
|
||||
@ -54,7 +61,7 @@
|
||||
- [x] PATCH /api/ideas/:id
|
||||
- [x] DELETE /api/ideas/:id
|
||||
- [x] Добавить валидацию
|
||||
- [ ] Написать тесты
|
||||
- [x] E2E тесты (Playwright)
|
||||
|
||||
### Frontend — Таблица идей
|
||||
- [x] Создать типы (types/idea.ts)
|
||||
@ -113,21 +120,19 @@
|
||||
|
||||
---
|
||||
|
||||
## Фаза 2: Расширенный функционал 🔄
|
||||
|
||||
> **Текущая фаза разработки**
|
||||
## Фаза 2: Расширенный функционал ✅
|
||||
|
||||
### Backend — Дополнения
|
||||
- [x] PATCH /api/ideas/reorder (изменение порядка)
|
||||
- [ ] Модуль Comments
|
||||
- [ ] Сущность Comment
|
||||
- [ ] GET /api/ideas/:id/comments
|
||||
- [ ] POST /api/ideas/:id/comments
|
||||
- [ ] DELETE /api/comments/:id
|
||||
- [ ] Модуль Team
|
||||
- [ ] Сущность TeamMember
|
||||
- [ ] CRUD endpoints
|
||||
- [ ] GET /api/team/summary
|
||||
- [x] Модуль Comments
|
||||
- [x] Сущность Comment
|
||||
- [x] GET /api/ideas/:id/comments
|
||||
- [x] POST /api/ideas/:id/comments
|
||||
- [x] DELETE /api/comments/:id
|
||||
- [x] Модуль Team
|
||||
- [x] Сущность TeamMember
|
||||
- [x] CRUD endpoints
|
||||
- [x] GET /api/team/summary
|
||||
|
||||
### Frontend — Drag & Drop ✅
|
||||
- [x] Интегрировать dnd-kit в таблицу
|
||||
@ -136,58 +141,346 @@
|
||||
- [x] Сохранение порядка на сервер (оптимистичные обновления)
|
||||
- [x] Сортировка по order по умолчанию
|
||||
|
||||
### Frontend — Цветовая маркировка
|
||||
- [ ] Добавить поле color в таблицу
|
||||
- [ ] Цветовой фон строки
|
||||
- [ ] Picker для выбора цвета
|
||||
- [ ] Фильтр по цвету
|
||||
### Frontend — Цветовая маркировка ✅
|
||||
- [x] Добавить поле color в таблицу
|
||||
- [x] Цветовой фон строки
|
||||
- [x] Picker для выбора цвета
|
||||
- [x] Фильтр по цвету
|
||||
|
||||
### Frontend — Комментарии
|
||||
- [ ] Раскрывающаяся панель под строкой
|
||||
- [ ] Список комментариев с тредами
|
||||
- [ ] Форма добавления комментария
|
||||
- [ ] Ответы на комментарии
|
||||
### Frontend — Комментарии ✅
|
||||
- [x] Раскрывающаяся панель под строкой
|
||||
- [x] Список комментариев
|
||||
- [x] Форма добавления комментария
|
||||
- [x] Удаление комментариев
|
||||
|
||||
### Frontend — Управление командой
|
||||
- [ ] Страница /team
|
||||
- [ ] Сводка по ролям
|
||||
- [ ] Таблица участников
|
||||
- [ ] Модалка добавления/редактирования
|
||||
- [ ] Матрица производительности (время на задачи по сложности)
|
||||
### Frontend — Управление командой ✅
|
||||
- [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
|
||||
- [ ] Интегрировать ai-proxy service
|
||||
- [ ] POST /api/ai/estimate
|
||||
- [ ] Получить идею и состав команды
|
||||
- [ ] Сформировать промпт
|
||||
- [ ] Отправить запрос в AI
|
||||
- [ ] Распарсить ответ
|
||||
- [ ] Сохранить оценку
|
||||
- [ ] Rate limiting для AI-запросов
|
||||
- [x] Интегрировать ai-proxy service
|
||||
- [x] POST /api/ai/estimate
|
||||
- [x] Получить идею и состав команды
|
||||
- [x] Сформировать промпт
|
||||
- [x] Отправить запрос в AI
|
||||
- [x] Распарсить ответ
|
||||
- [x] Сохранить оценку
|
||||
- [ ] Rate limiting для AI-запросов (опционально)
|
||||
|
||||
### Frontend — AI-оценка
|
||||
- [ ] Кнопка "Оценить AI" в строке/детали идеи
|
||||
- [ ] Модалка с результатом оценки
|
||||
- [ ] Общее время
|
||||
- [ ] Сложность
|
||||
- [ ] Разбивка по ролям
|
||||
- [ ] Рекомендации
|
||||
- [ ] Отображение оценки в таблице
|
||||
- [ ] Loading state для AI-запросов
|
||||
- [x] Кнопка "Оценить AI" в строке/детали идеи
|
||||
- [x] Модалка с результатом оценки
|
||||
- [x] Общее время
|
||||
- [x] Сложность
|
||||
- [x] Разбивка по ролям
|
||||
- [x] Рекомендации
|
||||
- [x] Отображение оценки в таблице
|
||||
- [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+ идей)
|
||||
- [ ] Keyboard shortcuts
|
||||
- [ ] Сохранение пресетов фильтров
|
||||
- [ ] Темная тема
|
||||
- [ ] Уведомления (email/push при упоминании)
|
||||
- [ ] Интеграция с Jira/Trello (опционально)
|
||||
|
||||
---
|
||||
|
||||
@ -195,5 +488,6 @@
|
||||
|
||||
1. **Вертикальная разработка** — делаем полный flow (BE → FE) для каждой фичи
|
||||
2. **Инкрементальность** — сначала базовое, потом улучшаем
|
||||
3. **Тестирование** — покрываем критичный функционал
|
||||
3. **Тестирование** — покрываем критичный функционал E2E тестами (см. [E2E_TESTING.md](E2E_TESTING.md))
|
||||
4. **Документирование** — обновляем CONTEXT.md после значимых изменений
|
||||
5. **data-testid** — все новые компоненты сразу получают data-testid для тестов
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
# Database (Shared dev instance on server)
|
||||
DB_HOST=10.10.10.100
|
||||
DB_PORT=30432
|
||||
DB_USERNAME=teamplanner
|
||||
DB_PASSWORD=teamplanner
|
||||
DB_DATABASE=teamplanner
|
||||
@ -11,3 +11,6 @@ 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
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@ -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"]
|
||||
@ -1,39 +1,3 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import {node} from '@vigdorov/eslint-config';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
export default node();
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
"class-validator": "^0.14.3",
|
||||
"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",
|
||||
@ -45,8 +46,6 @@
|
||||
"typeorm": "^0.3.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
@ -55,12 +54,10 @@
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vigdorov/eslint-config": "^1.0.1",
|
||||
"@vigdorov/typescript-config": "^1.1.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",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
|
||||
46
backend/src/ai/ai.controller.ts
Normal file
46
backend/src/ai/ai.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
18
backend/src/ai/ai.module.ts
Normal file
18
backend/src/ai/ai.module.ts
Normal 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 {}
|
||||
446
backend/src/ai/ai.service.ts
Normal file
446
backend/src/ai/ai.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
backend/src/ai/dto/estimate-idea.dto.ts
Normal file
6
backend/src/ai/dto/estimate-idea.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class EstimateIdeaDto {
|
||||
@IsUUID()
|
||||
ideaId: string;
|
||||
}
|
||||
6
backend/src/ai/dto/generate-specification.dto.ts
Normal file
6
backend/src/ai/dto/generate-specification.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class GenerateSpecificationDto {
|
||||
@IsUUID()
|
||||
ideaId: string;
|
||||
}
|
||||
2
backend/src/ai/dto/index.ts
Normal file
2
backend/src/ai/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './estimate-idea.dto';
|
||||
export * from './generate-specification.dto';
|
||||
@ -5,7 +5,12 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
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({
|
||||
imports: [
|
||||
@ -30,6 +35,11 @@ import { AuthModule, JwtAuthGuard } from './auth';
|
||||
}),
|
||||
AuthModule,
|
||||
IdeasModule,
|
||||
CommentsModule,
|
||||
TeamModule,
|
||||
AiModule,
|
||||
SettingsModule,
|
||||
NatsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
37
backend/src/comments/comments.controller.ts
Normal file
37
backend/src/comments/comments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
backend/src/comments/comments.module.ts
Normal file
13
backend/src/comments/comments.module.ts
Normal 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 {}
|
||||
39
backend/src/comments/comments.service.ts
Normal file
39
backend/src/comments/comments.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/comments/dto/create-comment.dto.ts
Normal file
12
backend/src/comments/dto/create-comment.dto.ts
Normal 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;
|
||||
}
|
||||
1
backend/src/comments/dto/index.ts
Normal file
1
backend/src/comments/dto/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './create-comment.dto';
|
||||
35
backend/src/comments/entities/comment.entity.ts
Normal file
35
backend/src/comments/entities/comment.entity.ts
Normal 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;
|
||||
}
|
||||
5
backend/src/comments/index.ts
Normal file
5
backend/src/comments/index.ts
Normal 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';
|
||||
@ -18,6 +18,10 @@ export class QueryIdeasDto {
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: string;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
export class UpdateIdeaDto extends PartialType(CreateIdeaDto) {
|
||||
@ -7,4 +7,8 @@ export class UpdateIdeaDto extends PartialType(CreateIdeaDto) {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
order?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
specification?: string;
|
||||
}
|
||||
|
||||
@ -72,6 +72,36 @@ export class Idea {
|
||||
@Column({ type: 'int', default: 0 })
|
||||
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' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
31
backend/src/ideas/entities/specification-history.entity.ts
Normal file
31
backend/src/ideas/entities/specification-history.entity.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Idea } from './idea.entity';
|
||||
|
||||
@Entity('specification_history')
|
||||
export class SpecificationHistory {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'idea_id' })
|
||||
ideaId: string;
|
||||
|
||||
@ManyToOne(() => Idea, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'idea_id' })
|
||||
idea: Idea;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
specification: string;
|
||||
|
||||
@Column({ name: 'idea_description_snapshot', type: 'text', nullable: true })
|
||||
ideaDescriptionSnapshot: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
68
backend/src/ideas/idea-events.handler.ts
Normal file
68
backend/src/ideas/idea-events.handler.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { IdeasService } from './ideas.service';
|
||||
import { IdeasController } from './ideas.controller';
|
||||
import { Idea } from './entities/idea.entity';
|
||||
import { SpecificationHistory } from './entities/specification-history.entity';
|
||||
import { IdeaEventsHandler } from './idea-events.handler';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Idea])],
|
||||
imports: [TypeOrmModule.forFeature([Idea, SpecificationHistory])],
|
||||
controllers: [IdeasController],
|
||||
providers: [IdeasService],
|
||||
exports: [IdeasService],
|
||||
providers: [IdeasService, IdeaEventsHandler],
|
||||
exports: [IdeasService, IdeaEventsHandler, TypeOrmModule],
|
||||
})
|
||||
export class IdeasModule {}
|
||||
|
||||
@ -26,6 +26,7 @@ export class IdeasService {
|
||||
priority,
|
||||
module,
|
||||
search,
|
||||
color,
|
||||
sortBy = 'order',
|
||||
sortOrder = 'ASC',
|
||||
page = 1,
|
||||
@ -60,6 +61,10 @@ export class IdeasService {
|
||||
queryBuilder.andWhere('idea.module = :module', { module });
|
||||
}
|
||||
|
||||
if (color) {
|
||||
queryBuilder.andWhere('idea.color = :color', { color });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
queryBuilder.andWhere(
|
||||
'(idea.title ILIKE :search OR idea.description ILIKE :search)',
|
||||
|
||||
28
backend/src/migrations/1736899200000-CreateCommentsTable.ts
Normal file
28
backend/src/migrations/1736899200000-CreateCommentsTable.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
@ -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"`);
|
||||
}
|
||||
}
|
||||
95
backend/src/migrations/1736899400000-CreateRolesTable.ts
Normal file
95
backend/src/migrations/1736899400000-CreateRolesTable.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
25
backend/src/migrations/1736899500000-AddAiEstimateFields.ts
Normal file
25
backend/src/migrations/1736899500000-AddAiEstimateFields.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddAiEstimateFields1736899500000 implements MigrationInterface {
|
||||
name = 'AddAiEstimateFields1736899500000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "ideas"
|
||||
ADD COLUMN "estimated_hours" DECIMAL(10, 2),
|
||||
ADD COLUMN "complexity" VARCHAR(20),
|
||||
ADD COLUMN "estimate_details" JSONB,
|
||||
ADD COLUMN "estimated_at" TIMESTAMP
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "ideas"
|
||||
DROP COLUMN "estimated_at",
|
||||
DROP COLUMN "estimate_details",
|
||||
DROP COLUMN "complexity",
|
||||
DROP COLUMN "estimated_hours"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSpecificationField1736942400000 implements MigrationInterface {
|
||||
name = 'AddSpecificationField1736942400000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "ideas"
|
||||
ADD COLUMN "specification" TEXT,
|
||||
ADD COLUMN "specification_generated_at" TIMESTAMP
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "ideas"
|
||||
DROP COLUMN "specification_generated_at",
|
||||
DROP COLUMN "specification"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSpecificationHistory1736943000000 implements MigrationInterface {
|
||||
name = 'AddSpecificationHistory1736943000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "specification_history" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"idea_id" uuid NOT NULL,
|
||||
"specification" text NOT NULL,
|
||||
"idea_description_snapshot" text,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_specification_history" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_specification_history_idea" FOREIGN KEY ("idea_id")
|
||||
REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE NO ACTION
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "IDX_specification_history_idea_id" ON "specification_history" ("idea_id")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_specification_history_idea_id"`);
|
||||
await queryRunner.query(`DROP TABLE "specification_history"`);
|
||||
}
|
||||
}
|
||||
21
backend/src/migrations/1770500000000-UserSettings.ts
Normal file
21
backend/src/migrations/1770500000000-UserSettings.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
18
backend/src/nats/events.ts
Normal file
18
backend/src/nats/events.ts
Normal 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;
|
||||
}
|
||||
165
backend/src/nats/nats-consumer.service.ts
Normal file
165
backend/src/nats/nats-consumer.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
backend/src/nats/nats.module.ts
Normal file
9
backend/src/nats/nats.module.ts
Normal 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 {}
|
||||
24
backend/src/settings/settings.controller.ts
Normal file
24
backend/src/settings/settings.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/settings/settings.module.ts
Normal file
12
backend/src/settings/settings.module.ts
Normal 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 {}
|
||||
30
backend/src/settings/settings.service.ts
Normal file
30
backend/src/settings/settings.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
backend/src/settings/user-settings.entity.ts
Normal file
22
backend/src/settings/user-settings.entity.ts
Normal 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;
|
||||
}
|
||||
16
backend/src/team/dto/create-role.dto.ts
Normal file
16
backend/src/team/dto/create-role.dto.ts
Normal 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;
|
||||
}
|
||||
48
backend/src/team/dto/create-team-member.dto.ts
Normal file
48
backend/src/team/dto/create-team-member.dto.ts
Normal 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;
|
||||
}
|
||||
4
backend/src/team/dto/index.ts
Normal file
4
backend/src/team/dto/index.ts
Normal 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';
|
||||
4
backend/src/team/dto/update-role.dto.ts
Normal file
4
backend/src/team/dto/update-role.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateRoleDto } from './create-role.dto';
|
||||
|
||||
export class UpdateRoleDto extends PartialType(CreateRoleDto) {}
|
||||
4
backend/src/team/dto/update-team-member.dto.ts
Normal file
4
backend/src/team/dto/update-team-member.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateTeamMemberDto } from './create-team-member.dto';
|
||||
|
||||
export class UpdateTeamMemberDto extends PartialType(CreateTeamMemberDto) {}
|
||||
33
backend/src/team/entities/role.entity.ts
Normal file
33
backend/src/team/entities/role.entity.ts
Normal 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;
|
||||
}
|
||||
53
backend/src/team/entities/team-member.entity.ts
Normal file
53
backend/src/team/entities/team-member.entity.ts
Normal 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;
|
||||
}
|
||||
5
backend/src/team/index.ts
Normal file
5
backend/src/team/index.ts
Normal 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';
|
||||
49
backend/src/team/roles.controller.ts
Normal file
49
backend/src/team/roles.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
77
backend/src/team/roles.service.ts
Normal file
77
backend/src/team/roles.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
53
backend/src/team/team.controller.ts
Normal file
53
backend/src/team/team.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
backend/src/team/team.module.ts
Normal file
16
backend/src/team/team.module.ts
Normal 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 {}
|
||||
105
backend/src/team/team.service.ts
Normal file
105
backend/src/team/team.service.ts
Normal 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,
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -1,25 +1,16 @@
|
||||
{
|
||||
"extends": "@vigdorov/typescript-config/node",
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"strictPropertyInitialization": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@ -1,46 +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 arguments
|
||||
ARG VITE_API_URL=""
|
||||
ARG VITE_KEYCLOAK_URL="https://auth.vigdorov.ru"
|
||||
ARG VITE_KEYCLOAK_REALM="team-planner"
|
||||
ARG VITE_KEYCLOAK_CLIENT_ID="team-planner-frontend"
|
||||
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL
|
||||
ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM
|
||||
ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID
|
||||
|
||||
# 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;"]
|
||||
@ -1,41 +1,3 @@
|
||||
import js from '@eslint/js'
|
||||
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'
|
||||
import {react} from '@vigdorov/eslint-config';
|
||||
|
||||
export default defineConfig([
|
||||
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',
|
||||
},
|
||||
},
|
||||
])
|
||||
export default react();
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -26,21 +26,18 @@
|
||||
"keycloak-js": "^26.2.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@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-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-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
@ -5,26 +6,38 @@ import {
|
||||
Button,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Chip,
|
||||
Avatar,
|
||||
Tabs,
|
||||
Tab,
|
||||
} from '@mui/material';
|
||||
import { Add, Logout } from '@mui/icons-material';
|
||||
import {
|
||||
Add,
|
||||
Logout,
|
||||
Person,
|
||||
Lightbulb,
|
||||
Group,
|
||||
Settings,
|
||||
} from '@mui/icons-material';
|
||||
import { IdeasTable } from './components/IdeasTable';
|
||||
import { IdeasFilters } from './components/IdeasFilters';
|
||||
import { CreateIdeaModal } from './components/CreateIdeaModal';
|
||||
import { TeamPage } from './components/TeamPage';
|
||||
import { SettingsPage } from './components/SettingsPage';
|
||||
import { useIdeasStore } from './store/ideas';
|
||||
import keycloak from './services/keycloak';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
|
||||
function App() {
|
||||
const { setCreateModalOpen } = useIdeasStore();
|
||||
|
||||
const handleLogout = () => {
|
||||
void keycloak.logout();
|
||||
};
|
||||
const { user, logout } = useAuth();
|
||||
const [tab, setTab] = useState(0);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: 4,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
@ -39,28 +52,60 @@ function App() {
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
Новая идея
|
||||
</Button>
|
||||
<Chip
|
||||
avatar={
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
<Person sx={{ fontSize: 16 }} />
|
||||
</Avatar>
|
||||
}
|
||||
label={user?.name ?? 'Пользователь'}
|
||||
variant="outlined"
|
||||
/>
|
||||
<Tooltip title="Выйти">
|
||||
<IconButton onClick={handleLogout} color="default">
|
||||
<IconButton onClick={logout} color="default" size="small">
|
||||
<Logout />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<IdeasFilters />
|
||||
{/* Tabs */}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
220
frontend/src/components/AiEstimateModal/AiEstimateModal.tsx
Normal file
220
frontend/src/components/AiEstimateModal/AiEstimateModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/AiEstimateModal/index.ts
Normal file
1
frontend/src/components/AiEstimateModal/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AiEstimateModal } from './AiEstimateModal';
|
||||
153
frontend/src/components/CommentsPanel/CommentsPanel.tsx
Normal file
153
frontend/src/components/CommentsPanel/CommentsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/CommentsPanel/index.ts
Normal file
1
frontend/src/components/CommentsPanel/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { CommentsPanel } from './CommentsPanel';
|
||||
@ -74,8 +74,9 @@ export function CreateIdeaModal() {
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
data-testid="create-idea-modal"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={handleSubmit} data-testid="create-idea-form">
|
||||
<DialogTitle>Новая идея</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
@ -91,6 +92,7 @@ export function CreateIdeaModal() {
|
||||
onChange={(e) => handleChange('title', e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
data-testid="idea-title-input"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
@ -178,11 +180,14 @@ export function CreateIdeaModal() {
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Отмена</Button>
|
||||
<Button onClick={handleClose} data-testid="cancel-create-idea">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={!formData.title || createIdea.isPending}
|
||||
data-testid="submit-create-idea"
|
||||
>
|
||||
{createIdea.isPending ? 'Создание...' : 'Создать'}
|
||||
</Button>
|
||||
|
||||
489
frontend/src/components/IdeaDetailModal/IdeaDetailModal.tsx
Normal file
489
frontend/src/components/IdeaDetailModal/IdeaDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/IdeaDetailModal/index.ts
Normal file
1
frontend/src/components/IdeaDetailModal/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { IdeaDetailModal } from './IdeaDetailModal';
|
||||
@ -9,11 +9,22 @@ import {
|
||||
Button,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import { Search, Clear } from '@mui/icons-material';
|
||||
import { Search, Clear, Circle } from '@mui/icons-material';
|
||||
import { useIdeasStore } from '../../store/ideas';
|
||||
import { useModulesQuery } from '../../hooks/useIdeas';
|
||||
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 }[] = [
|
||||
{ value: 'backlog', label: 'Бэклог' },
|
||||
{ value: 'todo', label: 'К выполнению' },
|
||||
@ -43,12 +54,17 @@ export function IdeasFilters() {
|
||||
}, [searchValue, setFilter]);
|
||||
|
||||
const hasFilters = Boolean(
|
||||
filters.status ?? filters.priority ?? filters.module ?? filters.search,
|
||||
filters.status ??
|
||||
filters.priority ??
|
||||
filters.module ??
|
||||
filters.search ??
|
||||
filters.color,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}
|
||||
data-testid="ideas-filters"
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
@ -56,6 +72,7 @@ export function IdeasFilters() {
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
sx={{ minWidth: 200 }}
|
||||
data-testid="search-input"
|
||||
slotProps={{
|
||||
input: {
|
||||
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>
|
||||
<Select<IdeaStatus | ''>
|
||||
value={filters.status ?? ''}
|
||||
@ -86,7 +107,11 @@ export function IdeasFilters() {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{ minWidth: 120 }}
|
||||
data-testid="filter-priority"
|
||||
>
|
||||
<InputLabel>Приоритет</InputLabel>
|
||||
<Select<IdeaPriority | ''>
|
||||
value={filters.priority ?? ''}
|
||||
@ -105,7 +130,11 @@ export function IdeasFilters() {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{ minWidth: 120 }}
|
||||
data-testid="filter-module"
|
||||
>
|
||||
<InputLabel>Модуль</InputLabel>
|
||||
<Select
|
||||
value={filters.module ?? ''}
|
||||
@ -121,6 +150,39 @@ export function IdeasFilters() {
|
||||
</Select>
|
||||
</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 && (
|
||||
<Button
|
||||
size="small"
|
||||
@ -129,6 +191,7 @@ export function IdeasFilters() {
|
||||
clearFilters();
|
||||
setSearchValue('');
|
||||
}}
|
||||
data-testid="clear-filters-button"
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
|
||||
126
frontend/src/components/IdeasTable/ColorPickerCell.tsx
Normal file
126
frontend/src/components/IdeasTable/ColorPickerCell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
157
frontend/src/components/IdeasTable/ColumnVisibility.tsx
Normal file
157
frontend/src/components/IdeasTable/ColumnVisibility.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -30,6 +30,7 @@ export function DragHandle() {
|
||||
<Box
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
data-testid="drag-handle"
|
||||
sx={{
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
display: 'flex',
|
||||
@ -79,7 +80,12 @@ export function DraggableRow({ row }: DraggableRowProps) {
|
||||
|
||||
return (
|
||||
<DragHandleContext.Provider value={{ attributes, listeners, isDragging }}>
|
||||
<TableRow ref={setNodeRef} hover sx={style}>
|
||||
<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())}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useState, Fragment, useCallback } from 'react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
@ -33,32 +33,261 @@ import {
|
||||
Box,
|
||||
Typography,
|
||||
TablePagination,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import { Inbox } from '@mui/icons-material';
|
||||
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 { 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 = 8;
|
||||
const SKELETON_COLUMNS_COUNT = 13;
|
||||
|
||||
export function IdeasTable() {
|
||||
const { data, isLoading, isError } = useIdeasQuery();
|
||||
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 } =
|
||||
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(
|
||||
() => 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
|
||||
@ -140,7 +369,23 @@ export function IdeasTable() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
|
||||
<Paper
|
||||
sx={{ width: '100%', overflow: 'hidden' }}
|
||||
data-testid="ideas-table-container"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<ColumnVisibility table={table} />
|
||||
</Box>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@ -149,7 +394,7 @@ export function IdeasTable() {
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<TableContainer>
|
||||
<Table stickyHeader size="small">
|
||||
<Table stickyHeader size="small" data-testid="ideas-table">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
@ -214,6 +459,7 @@ export function IdeasTable() {
|
||||
alignItems: 'center',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
data-testid="ideas-empty-state"
|
||||
>
|
||||
<Inbox sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
|
||||
<Typography variant="h6">Идей пока нет</Typography>
|
||||
@ -229,7 +475,27 @@ export function IdeasTable() {
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<DraggableRow key={row.id} row={row} />
|
||||
<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>
|
||||
)}
|
||||
@ -281,6 +547,40 @@ export function IdeasTable() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,36 @@
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { Chip, Box, IconButton } from '@mui/material';
|
||||
import { Delete } from '@mui/icons-material';
|
||||
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
|
||||
import {
|
||||
Chip,
|
||||
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 { ColorPickerCell } from './ColorPickerCell';
|
||||
import { statusOptions, priorityOptions } from './constants';
|
||||
import { DragHandle } from './DraggableRow';
|
||||
import {
|
||||
formatEstimate,
|
||||
type EstimateConfig,
|
||||
DEFAULT_ESTIMATE_CONFIG,
|
||||
} from '../../utils/estimate';
|
||||
|
||||
const columnHelper = createColumnHelper<Idea>();
|
||||
|
||||
@ -29,7 +55,50 @@ const priorityColors: Record<
|
||||
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: '',
|
||||
@ -37,6 +106,12 @@ export const createColumns = (onDelete: (id: string) => void) => [
|
||||
size: 40,
|
||||
enableSorting: false,
|
||||
}),
|
||||
columnHelper.accessor('color', {
|
||||
header: 'Цвет',
|
||||
cell: (info) => <ColorPickerCell idea={info.row.original} />,
|
||||
size: 60,
|
||||
enableSorting: false,
|
||||
}),
|
||||
columnHelper.accessor('title', {
|
||||
header: 'Название',
|
||||
cell: (info) => (
|
||||
@ -122,6 +197,60 @@ export const createColumns = (onDelete: (id: string) => void) => [
|
||||
),
|
||||
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', {
|
||||
header: 'Описание',
|
||||
cell: (info) => {
|
||||
@ -140,18 +269,144 @@ export const createColumns = (onDelete: (id: string) => void) => [
|
||||
},
|
||||
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({
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: (info) => (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDelete(info.row.original.id)}
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
),
|
||||
size: 50,
|
||||
cell: (info) => {
|
||||
const idea = info.row.original;
|
||||
const ideaId = idea.id;
|
||||
const isExpanded = expandedId === ideaId;
|
||||
const isEstimating = estimatingId === ideaId;
|
||||
const isGeneratingSpec = generatingSpecificationId === ideaId;
|
||||
const hasSpecification = !!idea.specification;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<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,
|
||||
}),
|
||||
];
|
||||
|
||||
110
frontend/src/components/SettingsPage/SettingsPage.tsx
Normal file
110
frontend/src/components/SettingsPage/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/SettingsPage/index.ts
Normal file
1
frontend/src/components/SettingsPage/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { SettingsPage } from './SettingsPage';
|
||||
@ -0,0 +1,541 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
TextField,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Tabs,
|
||||
Tab,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit,
|
||||
Save,
|
||||
Close,
|
||||
Refresh,
|
||||
Delete,
|
||||
Restore,
|
||||
Visibility,
|
||||
History,
|
||||
} from '@mui/icons-material';
|
||||
import Markdown from 'react-markdown';
|
||||
import type { Idea, SpecificationHistoryItem } from '../../types/idea';
|
||||
|
||||
interface SpecificationModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
idea: Idea | null;
|
||||
specification: string | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
onSave: (specification: string) => void;
|
||||
isSaving: boolean;
|
||||
onRegenerate: () => void;
|
||||
history: SpecificationHistoryItem[];
|
||||
isHistoryLoading: boolean;
|
||||
onDeleteHistoryItem: (historyId: string) => void;
|
||||
onRestoreFromHistory: (historyId: string) => void;
|
||||
isRestoring: boolean;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function TabPanel({ children, value, index }: TabPanelProps) {
|
||||
return (
|
||||
<div role="tabpanel" hidden={value !== index}>
|
||||
{value === index && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpecificationModal({
|
||||
open,
|
||||
onClose,
|
||||
idea,
|
||||
specification,
|
||||
isLoading,
|
||||
error,
|
||||
onSave,
|
||||
isSaving,
|
||||
onRegenerate,
|
||||
history,
|
||||
isHistoryLoading,
|
||||
onDeleteHistoryItem,
|
||||
onRestoreFromHistory,
|
||||
isRestoring,
|
||||
}: SpecificationModalProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedText, setEditedText] = useState('');
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
const [viewingHistoryItem, setViewingHistoryItem] =
|
||||
useState<SpecificationHistoryItem | null>(null);
|
||||
|
||||
// Сбрасываем состояние при открытии/закрытии
|
||||
useEffect(() => {
|
||||
if (open && specification) {
|
||||
setEditedText(specification);
|
||||
setIsEditing(false);
|
||||
setTabIndex(0);
|
||||
setViewingHistoryItem(null);
|
||||
}
|
||||
}, [open, specification]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditedText(specification ?? '');
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditedText(specification ?? '');
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(editedText);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
setViewingHistoryItem(null);
|
||||
setTabIndex(0);
|
||||
onRegenerate();
|
||||
};
|
||||
|
||||
const handleViewHistoryItem = (item: SpecificationHistoryItem) => {
|
||||
setViewingHistoryItem(item);
|
||||
};
|
||||
|
||||
const handleCloseHistoryView = () => {
|
||||
setViewingHistoryItem(null);
|
||||
};
|
||||
|
||||
const handleRestoreFromHistory = (historyId: string) => {
|
||||
onRestoreFromHistory(historyId);
|
||||
setViewingHistoryItem(null);
|
||||
setTabIndex(0);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const hasHistory = history.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
data-testid="specification-modal"
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h6" component="span">
|
||||
Техническое задание
|
||||
</Typography>
|
||||
{idea && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{idea.title}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{specification && !isLoading && !isEditing && !viewingHistoryItem && (
|
||||
<>
|
||||
<Tooltip title="Перегенерировать ТЗ">
|
||||
<IconButton
|
||||
onClick={handleRegenerate}
|
||||
size="small"
|
||||
color="primary"
|
||||
data-testid="specification-regenerate-button"
|
||||
>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Редактировать">
|
||||
<IconButton
|
||||
onClick={handleEdit}
|
||||
size="small"
|
||||
data-testid="specification-edit-button"
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
{/* Табы появляются только если есть история */}
|
||||
{hasHistory && !isEditing && !viewingHistoryItem && (
|
||||
<Tabs
|
||||
value={tabIndex}
|
||||
onChange={(_, newValue: number) => setTabIndex(newValue)}
|
||||
sx={{ px: 3, borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab label="Текущее ТЗ" data-testid="specification-tab-current" />
|
||||
<Tab
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<History fontSize="small" />
|
||||
История ({history.length})
|
||||
</Box>
|
||||
}
|
||||
data-testid="specification-tab-history"
|
||||
/>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<DialogContent dividers>
|
||||
{/* Просмотр исторического ТЗ */}
|
||||
{viewingHistoryItem && (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<IconButton size="small" onClick={handleCloseHistoryView}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
<Typography variant="subtitle2">
|
||||
Версия от {formatDate(viewingHistoryItem.createdAt)}
|
||||
</Typography>
|
||||
<Tooltip title="Восстановить эту версию">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
handleRestoreFromHistory(viewingHistoryItem.id)
|
||||
}
|
||||
disabled={isRestoring}
|
||||
data-testid="specification-restore-button"
|
||||
>
|
||||
<Restore />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{viewingHistoryItem.ideaDescriptionSnapshot && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="caption">
|
||||
Описание идеи на момент генерации:{' '}
|
||||
{viewingHistoryItem.ideaDescriptionSnapshot}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
<Box
|
||||
data-testid="specification-history-content"
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 1,
|
||||
maxHeight: '50vh',
|
||||
overflow: 'auto',
|
||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||
mt: 2,
|
||||
mb: 1,
|
||||
'&:first-of-type': { mt: 0 },
|
||||
},
|
||||
'& h1': { fontSize: '1.5rem', fontWeight: 600 },
|
||||
'& h2': { fontSize: '1.25rem', fontWeight: 600 },
|
||||
'& h3': { fontSize: '1.1rem', fontWeight: 600 },
|
||||
'& p': { mb: 1.5, lineHeight: 1.6 },
|
||||
'& ul, & ol': { pl: 3, mb: 1.5 },
|
||||
'& li': { mb: 0.5 },
|
||||
'& strong': { fontWeight: 600 },
|
||||
'& code': {
|
||||
bgcolor: 'grey.200',
|
||||
px: 0.5,
|
||||
py: 0.25,
|
||||
borderRadius: 0.5,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875em',
|
||||
},
|
||||
'& pre': {
|
||||
bgcolor: 'grey.200',
|
||||
p: 1.5,
|
||||
borderRadius: 1,
|
||||
overflow: 'auto',
|
||||
'& code': { bgcolor: 'transparent', p: 0 },
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeft: 3,
|
||||
borderColor: 'primary.main',
|
||||
pl: 2,
|
||||
ml: 0,
|
||||
fontStyle: 'italic',
|
||||
color: 'text.secondary',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Markdown>{viewingHistoryItem.specification}</Markdown>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Основной контент (не историческая версия) */}
|
||||
{!viewingHistoryItem && (
|
||||
<>
|
||||
<TabPanel value={tabIndex} index={0}>
|
||||
{isLoading && (
|
||||
<Box sx={{ py: 4 }} data-testid="specification-loading">
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
>
|
||||
Генерируем техническое задание...
|
||||
</Typography>
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ my: 2 }}
|
||||
data-testid="specification-error"
|
||||
>
|
||||
{error.message || 'Не удалось сгенерировать ТЗ'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && isEditing && (
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
minRows={15}
|
||||
maxRows={25}
|
||||
value={editedText}
|
||||
onChange={(e) => setEditedText(e.target.value)}
|
||||
placeholder="Введите техническое задание..."
|
||||
data-testid="specification-textarea"
|
||||
sx={{
|
||||
'& .MuiInputBase-input': {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && !isEditing && specification && (
|
||||
<Box>
|
||||
{idea?.specificationGeneratedAt && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 1, display: 'block' }}
|
||||
>
|
||||
Сгенерировано: {formatDate(idea.specificationGeneratedAt)}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
data-testid="specification-content"
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 1,
|
||||
maxHeight: '55vh',
|
||||
overflow: 'auto',
|
||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||
mt: 2,
|
||||
mb: 1,
|
||||
'&:first-of-type': { mt: 0 },
|
||||
},
|
||||
'& h1': { fontSize: '1.5rem', fontWeight: 600 },
|
||||
'& h2': { fontSize: '1.25rem', fontWeight: 600 },
|
||||
'& h3': { fontSize: '1.1rem', fontWeight: 600 },
|
||||
'& p': { mb: 1.5, lineHeight: 1.6 },
|
||||
'& ul, & ol': { pl: 3, mb: 1.5 },
|
||||
'& li': { mb: 0.5 },
|
||||
'& strong': { fontWeight: 600 },
|
||||
'& code': {
|
||||
bgcolor: 'grey.200',
|
||||
px: 0.5,
|
||||
py: 0.25,
|
||||
borderRadius: 0.5,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875em',
|
||||
},
|
||||
'& pre': {
|
||||
bgcolor: 'grey.200',
|
||||
p: 1.5,
|
||||
borderRadius: 1,
|
||||
overflow: 'auto',
|
||||
'& code': { bgcolor: 'transparent', p: 0 },
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeft: 3,
|
||||
borderColor: 'primary.main',
|
||||
pl: 2,
|
||||
ml: 0,
|
||||
fontStyle: 'italic',
|
||||
color: 'text.secondary',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Markdown>{specification}</Markdown>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabIndex} index={1}>
|
||||
{isHistoryLoading ? (
|
||||
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
) : history.length === 0 ? (
|
||||
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">История пуста</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List data-testid="specification-history-list">
|
||||
{history.map((item, index) => (
|
||||
<Box key={item.id}>
|
||||
{index > 0 && <Divider />}
|
||||
<ListItem
|
||||
data-testid={`specification-history-item-${String(index)}`}
|
||||
sx={{ pr: 16 }}
|
||||
secondaryAction={
|
||||
<>
|
||||
<Tooltip title="Просмотреть">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleViewHistoryItem(item)}
|
||||
data-testid={`specification-history-view-${String(index)}`}
|
||||
>
|
||||
<Visibility fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Восстановить">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
handleRestoreFromHistory(item.id)
|
||||
}
|
||||
disabled={isRestoring}
|
||||
data-testid={`specification-history-restore-${String(index)}`}
|
||||
>
|
||||
<Restore fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Удалить">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => onDeleteHistoryItem(item.id)}
|
||||
data-testid={`specification-history-delete-${String(index)}`}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{formatDate(item.createdAt)}
|
||||
</Typography>
|
||||
{item.ideaDescriptionSnapshot && (
|
||||
<Chip
|
||||
label="Описание изменилось"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{item.specification.slice(0, 150)}...
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</Box>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</TabPanel>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button onClick={handleCancel} disabled={isSaving}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
startIcon={<Save />}
|
||||
data-testid="specification-save-button"
|
||||
>
|
||||
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</>
|
||||
) : viewingHistoryItem ? (
|
||||
<Button onClick={handleCloseHistoryView}>Назад к текущему ТЗ</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onClose}
|
||||
startIcon={<Close />}
|
||||
data-testid="specification-close-button"
|
||||
>
|
||||
Закрыть
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/SpecificationModal/index.ts
Normal file
1
frontend/src/components/SpecificationModal/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './SpecificationModal';
|
||||
306
frontend/src/components/TeamPage/RolesManager.tsx
Normal file
306
frontend/src/components/TeamPage/RolesManager.tsx
Normal file
@ -0,0 +1,306 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { Add, Edit, Delete } from '@mui/icons-material';
|
||||
import {
|
||||
useRolesQuery,
|
||||
useCreateRole,
|
||||
useUpdateRole,
|
||||
useDeleteRole,
|
||||
} from '../../hooks/useRoles';
|
||||
import type { Role, CreateRoleDto } from '../../types/team';
|
||||
|
||||
interface RoleModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
role?: Role | null;
|
||||
}
|
||||
|
||||
function RoleModal({ open, onClose, role }: RoleModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [label, setLabel] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const createRole = useCreateRole();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(role?.name ?? '');
|
||||
setLabel(role?.label ?? '');
|
||||
setError('');
|
||||
}
|
||||
}, [open, role]);
|
||||
const updateRole = useUpdateRole();
|
||||
|
||||
const isEditing = !!role;
|
||||
const isPending = createRole.isPending || updateRole.isPending;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!name.trim() || !label.trim()) return;
|
||||
|
||||
const dto: CreateRoleDto = {
|
||||
name: name.trim().toLowerCase().replace(/\s+/g, '_'),
|
||||
label: label.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
await updateRole.mutateAsync({ id: role.id, dto });
|
||||
} else {
|
||||
await createRole.mutateAsync(dto);
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
data-testid="role-modal"
|
||||
>
|
||||
<form onSubmit={handleSubmit} data-testid="role-form">
|
||||
<DialogTitle>
|
||||
{isEditing ? 'Редактировать роль' : 'Добавить роль'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
<TextField
|
||||
label="Название (идентификатор)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
fullWidth
|
||||
autoFocus
|
||||
helperText="Латиница, без пробелов. Например: frontend, backend, devops"
|
||||
disabled={isEditing}
|
||||
data-testid="role-name-input"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Отображаемое название"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
required
|
||||
fullWidth
|
||||
helperText="Как роль будет отображаться в интерфейсе"
|
||||
data-testid="role-label-input"
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} data-testid="cancel-role-button">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={!name.trim() || !label.trim() || isPending}
|
||||
data-testid="submit-role-button"
|
||||
>
|
||||
{isEditing ? 'Сохранить' : 'Добавить'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function RolesManager() {
|
||||
const { data: roles = [], isLoading } = useRolesQuery();
|
||||
const deleteRole = useDeleteRole();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingRole(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (role: Role) => {
|
||||
setEditingRole(role);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (role: Role) => {
|
||||
if (!confirm(`Удалить роль "${role.label}"?`)) return;
|
||||
|
||||
setDeleteError('');
|
||||
try {
|
||||
await deleteRole.mutateAsync(role.id);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setDeleteError(`Не удалось удалить роль: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalOpen(false);
|
||||
setEditingRole(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box data-testid="roles-manager">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Управление ролями</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleAdd}
|
||||
data-testid="add-role-button"
|
||||
>
|
||||
Добавить роль
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{deleteError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2 }}
|
||||
onClose={() => setDeleteError('')}
|
||||
>
|
||||
{deleteError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small" data-testid="roles-table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>
|
||||
Идентификатор
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>
|
||||
Отображаемое название
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
|
||||
align="center"
|
||||
>
|
||||
Порядок
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
|
||||
/>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography
|
||||
color="text.secondary"
|
||||
data-testid="roles-empty-state"
|
||||
>
|
||||
Нет ролей. Добавьте первую роль.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<TableRow
|
||||
key={role.id}
|
||||
hover
|
||||
data-testid={`role-row-${role.id}`}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{role.name}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 500 }}>{role.label}</TableCell>
|
||||
<TableCell align="center">{role.sortOrder}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEdit(role)}
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
data-testid="edit-role-button"
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDelete(role)}
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
data-testid="delete-role-button"
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<RoleModal
|
||||
open={modalOpen}
|
||||
onClose={handleModalClose}
|
||||
role={editingRole}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
184
frontend/src/components/TeamPage/TeamMemberModal.tsx
Normal file
184
frontend/src/components/TeamPage/TeamMemberModal.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Box,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
Skeleton,
|
||||
} from '@mui/material';
|
||||
import type { TeamMember, ProductivityMatrix } from '../../types/team';
|
||||
import { complexityLabels } from '../../types/team';
|
||||
import { useCreateTeamMember, useUpdateTeamMember } from '../../hooks/useTeam';
|
||||
import { useRolesQuery } from '../../hooks/useRoles';
|
||||
|
||||
interface TeamMemberModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
member?: TeamMember | null;
|
||||
}
|
||||
|
||||
const defaultProductivity: ProductivityMatrix = {
|
||||
trivial: 1,
|
||||
simple: 4,
|
||||
medium: 12,
|
||||
complex: 32,
|
||||
veryComplex: 60,
|
||||
};
|
||||
|
||||
export function TeamMemberModal({
|
||||
open,
|
||||
onClose,
|
||||
member,
|
||||
}: TeamMemberModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [roleId, setRoleId] = useState('');
|
||||
const [productivity, setProductivity] =
|
||||
useState<ProductivityMatrix>(defaultProductivity);
|
||||
|
||||
const { data: roles = [], isLoading: rolesLoading } = useRolesQuery();
|
||||
const createMember = useCreateTeamMember();
|
||||
const updateMember = useUpdateTeamMember();
|
||||
|
||||
const isEditing = !!member;
|
||||
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
setName(member.name);
|
||||
setRoleId(member.roleId);
|
||||
setProductivity(member.productivity);
|
||||
} else {
|
||||
setName('');
|
||||
setRoleId(roles[0]?.id ?? '');
|
||||
setProductivity(defaultProductivity);
|
||||
}
|
||||
}, [member, open, roles]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !roleId) return;
|
||||
|
||||
const dto = { name: name.trim(), roleId, productivity };
|
||||
|
||||
if (isEditing) {
|
||||
await updateMember.mutateAsync({ id: member.id, dto });
|
||||
} else {
|
||||
await createMember.mutateAsync(dto);
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleProductivityChange = (
|
||||
key: keyof ProductivityMatrix,
|
||||
value: string,
|
||||
) => {
|
||||
const num = parseFloat(value) || 0;
|
||||
setProductivity((prev) => ({ ...prev, [key]: num }));
|
||||
};
|
||||
|
||||
const isPending = createMember.isPending || updateMember.isPending;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
data-testid="team-member-modal"
|
||||
>
|
||||
<form onSubmit={handleSubmit} data-testid="team-member-form">
|
||||
<DialogTitle>
|
||||
{isEditing ? 'Редактировать участника' : 'Добавить участника'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label="Имя"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
fullWidth
|
||||
autoFocus
|
||||
data-testid="member-name-input"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth data-testid="member-role-select">
|
||||
<InputLabel>Роль</InputLabel>
|
||||
{rolesLoading ? (
|
||||
<Skeleton variant="rectangular" height={56} />
|
||||
) : (
|
||||
<Select
|
||||
value={roleId}
|
||||
label="Роль"
|
||||
onChange={(e) => setRoleId(e.target.value)}
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<MenuItem key={role.id} value={role.id}>
|
||||
{role.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>
|
||||
Производительность (часы на задачу)
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}
|
||||
>
|
||||
{(
|
||||
Object.entries(complexityLabels) as [
|
||||
keyof ProductivityMatrix,
|
||||
string,
|
||||
][]
|
||||
).map(([key, label]) => (
|
||||
<TextField
|
||||
key={key}
|
||||
label={label}
|
||||
type="number"
|
||||
size="small"
|
||||
value={productivity[key]}
|
||||
onChange={(e) =>
|
||||
handleProductivityChange(key, e.target.value)
|
||||
}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">ч</InputAdornment>
|
||||
),
|
||||
},
|
||||
htmlInput: { min: 0, step: 0.5 },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} data-testid="cancel-member-button">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={!name.trim() || !roleId || isPending}
|
||||
data-testid="submit-member-button"
|
||||
>
|
||||
{isEditing ? 'Сохранить' : 'Добавить'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
261
frontend/src/components/TeamPage/TeamPage.tsx
Normal file
261
frontend/src/components/TeamPage/TeamPage.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Chip,
|
||||
Skeleton,
|
||||
Card,
|
||||
CardContent,
|
||||
Tabs,
|
||||
Tab,
|
||||
} from '@mui/material';
|
||||
import { Add, Edit, Delete, Group, Settings } from '@mui/icons-material';
|
||||
import {
|
||||
useTeamQuery,
|
||||
useTeamSummaryQuery,
|
||||
useDeleteTeamMember,
|
||||
} from '../../hooks/useTeam';
|
||||
import { complexityLabels } from '../../types/team';
|
||||
import type { TeamMember, ProductivityMatrix } from '../../types/team';
|
||||
import { TeamMemberModal } from './TeamMemberModal';
|
||||
import { RolesManager } from './RolesManager';
|
||||
|
||||
export function TeamPage() {
|
||||
const { data: members = [], isLoading } = useTeamQuery();
|
||||
const { data: summary = [] } = useTeamSummaryQuery();
|
||||
const deleteMember = useDeleteTeamMember();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingMember, setEditingMember] = useState<TeamMember | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingMember(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (member: TeamMember) => {
|
||||
setEditingMember(member);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm('Удалить участника команды?')) {
|
||||
deleteMember.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const totalMembers = summary.reduce((acc, s) => acc + s.count, 0);
|
||||
|
||||
return (
|
||||
<Box data-testid="team-page">
|
||||
{/* Вкладки */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs value={activeTab} onChange={(_, v: number) => setActiveTab(v)}>
|
||||
<Tab
|
||||
icon={<Group />}
|
||||
iconPosition="start"
|
||||
label="Участники"
|
||||
data-testid="team-tab-members"
|
||||
/>
|
||||
<Tab
|
||||
icon={<Settings />}
|
||||
iconPosition="start"
|
||||
label="Роли"
|
||||
data-testid="team-tab-roles"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{activeTab === 0 && (
|
||||
<>
|
||||
{/* Сводка по ролям */}
|
||||
<Box sx={{ mb: 3 }} data-testid="team-summary">
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<Group /> Состав команды ({totalMembers})
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
{summary.map((item) => (
|
||||
<Card
|
||||
key={item.roleId}
|
||||
variant="outlined"
|
||||
sx={{ minWidth: 150 }}
|
||||
data-testid={`role-card-${item.roleId}`}
|
||||
>
|
||||
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600 }}>
|
||||
{item.count}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{item.label}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Таблица участников */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Участники</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleAdd}
|
||||
data-testid="add-team-member-button"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small" data-testid="team-table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
|
||||
>
|
||||
Имя
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
|
||||
>
|
||||
Роль
|
||||
</TableCell>
|
||||
{(
|
||||
Object.keys(
|
||||
complexityLabels,
|
||||
) as (keyof ProductivityMatrix)[]
|
||||
).map((key) => (
|
||||
<TableCell
|
||||
key={key}
|
||||
align="center"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
backgroundColor: 'grey.100',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{complexityLabels[key]}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
|
||||
/>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography
|
||||
color="text.secondary"
|
||||
data-testid="team-empty-state"
|
||||
>
|
||||
Команда пока пуста. Добавьте первого участника.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<TableRow
|
||||
key={member.id}
|
||||
hover
|
||||
data-testid={`team-member-row-${member.id}`}
|
||||
>
|
||||
<TableCell sx={{ fontWeight: 500 }}>
|
||||
{member.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={member.role.label}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
{(
|
||||
Object.keys(
|
||||
complexityLabels,
|
||||
) as (keyof ProductivityMatrix)[]
|
||||
).map((key) => (
|
||||
<TableCell key={key} align="center">
|
||||
{member.productivity[key]}ч
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEdit(member)}
|
||||
data-testid="edit-team-member-button"
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDelete(member.id)}
|
||||
data-testid="delete-team-member-button"
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TeamMemberModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
member={editingMember}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && <RolesManager />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
3
frontend/src/components/TeamPage/index.ts
Normal file
3
frontend/src/components/TeamPage/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { TeamPage } from './TeamPage';
|
||||
export { TeamMemberModal } from './TeamMemberModal';
|
||||
export { RolesManager } from './RolesManager';
|
||||
74
frontend/src/hooks/useAi.ts
Normal file
74
frontend/src/hooks/useAi.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { aiApi } from '../services/ai';
|
||||
|
||||
const IDEAS_QUERY_KEY = 'ideas';
|
||||
const SPECIFICATION_HISTORY_KEY = 'specification-history';
|
||||
|
||||
export function useEstimateIdea() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (ideaId: string) => aiApi.estimateIdea(ideaId),
|
||||
onSuccess: () => {
|
||||
// Инвалидируем кэш идей чтобы обновить данные с новой оценкой
|
||||
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateSpecification() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (ideaId: string) => aiApi.generateSpecification(ideaId),
|
||||
onSuccess: (_, ideaId) => {
|
||||
// Инвалидируем кэш идей и историю
|
||||
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [SPECIFICATION_HISTORY_KEY, ideaId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSpecificationHistory(ideaId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: [SPECIFICATION_HISTORY_KEY, ideaId],
|
||||
queryFn: () => {
|
||||
if (!ideaId) throw new Error('ideaId is required');
|
||||
return aiApi.getSpecificationHistory(ideaId);
|
||||
},
|
||||
enabled: !!ideaId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSpecificationHistoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (historyId: string) =>
|
||||
aiApi.deleteSpecificationHistoryItem(historyId),
|
||||
onSuccess: () => {
|
||||
// Инвалидируем все запросы истории
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [SPECIFICATION_HISTORY_KEY],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRestoreSpecificationFromHistory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (historyId: string) =>
|
||||
aiApi.restoreSpecificationFromHistory(historyId),
|
||||
onSuccess: () => {
|
||||
// Инвалидируем кэш идей и историю
|
||||
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [SPECIFICATION_HISTORY_KEY],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
41
frontend/src/hooks/useAuth.ts
Normal file
41
frontend/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import keycloak from '../services/keycloak';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const tokenParsed = keycloak.tokenParsed as
|
||||
| {
|
||||
sub?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
email?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const user: User | null = tokenParsed
|
||||
? {
|
||||
id: tokenParsed.sub ?? '',
|
||||
name:
|
||||
tokenParsed.name ?? tokenParsed.preferred_username ?? 'Пользователь',
|
||||
email: tokenParsed.email ?? '',
|
||||
username: tokenParsed.preferred_username ?? '',
|
||||
}
|
||||
: null;
|
||||
|
||||
const logout = () => {
|
||||
void keycloak.logout();
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated: keycloak.authenticated,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
42
frontend/src/hooks/useComments.ts
Normal file
42
frontend/src/hooks/useComments.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { commentsApi } from '../services/comments';
|
||||
import type { CreateCommentDto } from '../types/comment';
|
||||
|
||||
export function useCommentsQuery(ideaId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['comments', ideaId],
|
||||
queryFn: () => {
|
||||
if (!ideaId) throw new Error('ideaId is required');
|
||||
return commentsApi.getByIdeaId(ideaId);
|
||||
},
|
||||
enabled: !!ideaId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ ideaId, dto }: { ideaId: string; dto: CreateCommentDto }) =>
|
||||
commentsApi.create(ideaId, dto),
|
||||
onSuccess: (_, variables) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['comments', variables.ideaId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: { id: string; ideaId: string }) =>
|
||||
commentsApi.delete(params.id),
|
||||
onSuccess: (_, variables) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['comments', variables.ideaId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
48
frontend/src/hooks/useRoles.ts
Normal file
48
frontend/src/hooks/useRoles.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { rolesApi } from '../services/roles';
|
||||
import type { CreateRoleDto, UpdateRoleDto } from '../types/team';
|
||||
|
||||
export const ROLES_QUERY_KEY = ['roles'];
|
||||
|
||||
export function useRolesQuery() {
|
||||
return useQuery({
|
||||
queryKey: ROLES_QUERY_KEY,
|
||||
queryFn: rolesApi.getAll,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRole() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateRoleDto) => rolesApi.create(dto),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRole() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: UpdateRoleDto }) =>
|
||||
rolesApi.update(id, dto),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
|
||||
void queryClient.invalidateQueries({ queryKey: ['team'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRole() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => rolesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
|
||||
void queryClient.invalidateQueries({ queryKey: ['team'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
33
frontend/src/hooks/useSettings.ts
Normal file
33
frontend/src/hooks/useSettings.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { settingsApi } from '../services/settings';
|
||||
import type { UserSettings } from '../types/settings';
|
||||
import {
|
||||
DEFAULT_ESTIMATE_CONFIG,
|
||||
type EstimateConfig,
|
||||
} from '../utils/estimate';
|
||||
|
||||
export function useSettingsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: settingsApi.get,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (patch: Partial<UserSettings>) => settingsApi.update(patch),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEstimateConfig(): EstimateConfig {
|
||||
const { data } = useSettingsQuery();
|
||||
return {
|
||||
hoursPerDay: data?.hoursPerDay ?? DEFAULT_ESTIMATE_CONFIG.hoursPerDay,
|
||||
daysPerWeek: data?.daysPerWeek ?? DEFAULT_ESTIMATE_CONFIG.daysPerWeek,
|
||||
};
|
||||
}
|
||||
51
frontend/src/hooks/useTeam.ts
Normal file
51
frontend/src/hooks/useTeam.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { teamApi } from '../services/team';
|
||||
import type { CreateTeamMemberDto, UpdateTeamMemberDto } from '../types/team';
|
||||
|
||||
export function useTeamQuery() {
|
||||
return useQuery({
|
||||
queryKey: ['team'],
|
||||
queryFn: teamApi.getAll,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTeamSummaryQuery() {
|
||||
return useQuery({
|
||||
queryKey: ['team', 'summary'],
|
||||
queryFn: teamApi.getSummary,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTeamMember() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateTeamMemberDto) => teamApi.create(dto),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['team'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTeamMember() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: UpdateTeamMemberDto }) =>
|
||||
teamApi.update(id, dto),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['team'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTeamMember() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => teamApi.delete(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['team'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
56
frontend/src/services/ai.ts
Normal file
56
frontend/src/services/ai.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { api } from './api';
|
||||
import type {
|
||||
IdeaComplexity,
|
||||
RoleEstimate,
|
||||
SpecificationResult,
|
||||
SpecificationHistoryItem,
|
||||
} from '../types/idea';
|
||||
|
||||
export interface EstimateResult {
|
||||
ideaId: string;
|
||||
ideaTitle: string;
|
||||
totalHours: number;
|
||||
complexity: IdeaComplexity;
|
||||
breakdown: RoleEstimate[];
|
||||
recommendations: string[];
|
||||
estimatedAt: string;
|
||||
}
|
||||
|
||||
export const aiApi = {
|
||||
estimateIdea: async (ideaId: string): Promise<EstimateResult> => {
|
||||
const { data } = await api.post<EstimateResult>('/ai/estimate', { ideaId });
|
||||
return data;
|
||||
},
|
||||
|
||||
generateSpecification: async (
|
||||
ideaId: string,
|
||||
): Promise<SpecificationResult> => {
|
||||
const { data } = await api.post<SpecificationResult>(
|
||||
'/ai/generate-specification',
|
||||
{ ideaId },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
getSpecificationHistory: async (
|
||||
ideaId: string,
|
||||
): Promise<SpecificationHistoryItem[]> => {
|
||||
const { data } = await api.get<SpecificationHistoryItem[]>(
|
||||
`/ai/specification-history/${ideaId}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
deleteSpecificationHistoryItem: async (historyId: string): Promise<void> => {
|
||||
await api.delete(`/ai/specification-history/${historyId}`);
|
||||
},
|
||||
|
||||
restoreSpecificationFromHistory: async (
|
||||
historyId: string,
|
||||
): Promise<SpecificationResult> => {
|
||||
const { data } = await api.post<SpecificationResult>(
|
||||
`/ai/specification-history/${historyId}/restore`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user