Compare commits

...

13 Commits

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:57:41 +03:00
5ec631f229 manual ci
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone Build is passing
2026-02-08 15:03:28 +03:00
593c573985 migrate to ci-templates
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-08 14:11:40 +03:00
75015c1c85 fix db ci
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-08 11:28:27 +03:00
990a6fe918 change db
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-08 01:53:04 +03:00
9d34deb77d fix nats path
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-08 01:05:40 +03:00
4d80480d0f add broker
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-07 20:24:31 +03:00
b270345e77 fix ci
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 12:26:40 +03:00
1b95fd9e55 fix CI for template
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 12:20:29 +03:00
7421f33de8 fix bus phase 3/2
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 12:05:57 +03:00
684e416588 add view any columns and view mode for ideas
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 11:41:01 +03:00
890d6de92e fix login error on keycloak template
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 11:16:56 +03:00
2e46cc41a1 fix lint
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-15 02:36:24 +03:00
95 changed files with 8183 additions and 5437 deletions

View File

@ -1,328 +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: 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
- 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
- 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
trigger:
branch: [main, master]
event: [push, custom]
--- ---
kind: pipeline kind: pipeline
type: kubernetes type: kubernetes
name: main-pipeline name: keycloak-theme
# Триггер: запускать при изменениях в backend, frontend или .drone.yml volumes:
trigger: - name: shared
branch: temp: {}
- main
- master
event:
- push
steps: steps:
# ============================================================ - 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
# --- Сборка 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
# --- Сборка 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
# --- Сборка Keycloak темы ---
- name: build-keycloak-theme - name: build-keycloak-theme
image: plugins/kaniko image: gcr.io/kaniko-project/executor:debug
when: depends_on: [check-changes]
changeset: volumes:
includes: - name: shared
- keycloak-theme/** path: /shared
- .drone.yml environment:
excludes: HARBOR_USER:
- keycloak-theme/README.md
- keycloak-theme/**/*.md
settings:
registry: registry.vigdorov.ru
repo: registry.vigdorov.ru/library/keycloak-team-planner
dockerfile: keycloak-theme/Dockerfile
context: keycloak-theme
tags:
- ${DRONE_COMMIT_SHA:0:7}
- "26.5.0"
- latest
username:
from_secret: HARBOR_USER from_secret: HARBOR_USER
password: HARBOR_PASSWORD:
from_secret: HARBOR_PASSWORD from_secret: HARBOR_PASSWORD
no_push_metadata: true
# ============================================================
# ДЕПЛОЙ (только после завершения ОБЕИХ сборок)
# ============================================================
# --- Развертывание 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: 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 if [ -f /shared/.skip ]; then
echo "❌ Rollout failed! Collecting diagnostics..." echo "Skipping build"
echo "" exit 0
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 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 export IMAGE_TAG=$(echo $DRONE_COMMIT_SHA | cut -c1-7)
echo "❌ Rollout failed! Collecting diagnostics..." export REGISTRY="registry.vigdorov.ru"
echo "" export REPO="$REGISTRY/library/keycloak-team-planner"
echo "=== DEPLOYMENT STATUS ===" mkdir -p /kaniko/.docker
kubectl get deployment team-planner-frontend -n $APP_NAMESPACE -o wide echo "{\"auths\":{\"$REGISTRY\":{\"username\":\"$HARBOR_USER\",\"password\":\"$HARBOR_PASSWORD\"}}}" > /kaniko/.docker/config.json
echo "" /kaniko/executor \
echo "=== PODS STATUS ===" --dockerfile=keycloak-theme/Dockerfile \
kubectl get pods -n $APP_NAMESPACE -l app=team-planner-frontend -o wide --context=dir:///drone/src/keycloak-theme \
echo "" --destination=$REPO:$IMAGE_TAG \
echo "=== DESCRIBE DEPLOYMENT ===" --destination=$REPO:latest \
kubectl describe deployment team-planner-frontend -n $APP_NAMESPACE --cache=false
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)"
# --- Развертывание Keycloak темы ---
- name: deploy-keycloak-theme - name: deploy-keycloak-theme
image: alpine/k8s:1.28.2 image: alpine/k8s:1.28.2
depends_on: depends_on: [build-keycloak-theme]
- build-keycloak-theme volumes:
when: - name: shared
changeset: path: /shared
includes:
- keycloak-theme/**
- .drone.yml
excludes:
- keycloak-theme/README.md
- keycloak-theme/**/*.md
environment: environment:
KUBE_CONFIG_CONTENT: KUBE_CONFIG_CONTENT:
from_secret: KUBE_CONFIG from_secret: KUBE_CONFIG
commands: 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 KEYCLOAK_NAMESPACE="auth"
- export IMAGE_TAG="${DRONE_COMMIT_SHA:0:7}"
- export KEYCLOAK_IMAGE="registry.vigdorov.ru/library/keycloak-team-planner:$IMAGE_TAG"
- kubectl cluster-info
- kubectl set image statefulset/keycloak-keycloakx keycloak=$KEYCLOAK_IMAGE -n $KEYCLOAK_NAMESPACE
- echo "📋 Waiting for rollout..."
- | - |
if ! kubectl rollout status statefulset/keycloak-keycloakx -n $KEYCLOAK_NAMESPACE --timeout=180s; then if [ -f /shared/.skip ]; then
echo "❌ Rollout failed! Collecting diagnostics..." echo "Skipping deploy"
kubectl get pods -n $KEYCLOAK_NAMESPACE -l app.kubernetes.io/name=keycloakx -o wide exit 0
kubectl describe statefulset keycloak-keycloakx -n $KEYCLOAK_NAMESPACE
exit 1
fi fi
- echo "✅ Keycloak theme deployed (image:$IMAGE_TAG)"
---
kind: pipeline
type: kubernetes
name: infra-pipeline
# Триггер: запускать только при изменениях в k8s конфигах
trigger:
branch:
- main
- master
event:
- push
paths:
include:
- k8s/**
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 \ mkdir -p ~/.kube
--from-literal=db-name="$DB_NAME" \ echo "$KUBE_CONFIG_CONTENT" > ~/.kube/config
--from-literal=db-user="$DB_USER" \ chmod 600 ~/.kube/config
--from-literal=db-password="$DB_PASSWORD" \ sed -i "s|https://127.0.0.1:6443|https://10.10.10.100:6443|g" ~/.kube/config
--namespace=$APP_NAMESPACE \ export IMAGE_TAG=$(echo $DRONE_COMMIT_SHA | cut -c1-7)
--dry-run=client -o yaml | kubectl apply -f - kubectl set image statefulset/keycloak-keycloakx keycloak=registry.vigdorov.ru/library/keycloak-team-planner:$IMAGE_TAG -n auth
- echo "✅ Secrets created/updated" kubectl rollout status statefulset/keycloak-keycloakx -n auth --timeout=180s
# --- Развертывание инфраструктуры (PostgreSQL, Services, Ingress) --- trigger:
- name: deploy-infra branch: [main, master]
image: alpine/k8s:1.28.2 event: [push, custom]
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"

1
.npmrc Normal file
View File

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

1
.prettierrc Normal file
View File

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

View File

@ -6,8 +6,8 @@
## Текущий статус ## Текущий статус
**Этап:** Фаза 3.1 завершена ✅ | Новые требования (Фазы 4-8) запланированы 📋 **Этап:** Фаза 3.2 завершена ✅
**Фаза MVP:** Базовый функционал + авторизация + расширенный функционал + AI-оценка + мини-ТЗ + история ТЗ готовы **Фаза MVP:** Базовый функционал + авторизация + расширенный функционал + AI-оценка + мини-ТЗ + история ТЗ + полный просмотр идеи готовы
**Следующий этап:** Фаза 4 — Права доступа **Следующий этап:** Фаза 4 — Права доступа
**Последнее обновление:** 2026-01-15 **Последнее обновление:** 2026-01-15
@ -17,6 +17,7 @@
| Дата | Что сделано | | Дата | Что сделано |
|------|-------------| |------|-------------|
| 2026-02-08 | **Инфра:** Миграция CI/CD на ci-templates (service.yaml + .drone.yml), удалены Dockerfile/k8s/nginx/docker-compose |
| 2025-12-29 | Созданы REQUIREMENTS.md, CLAUDE.md, CONTEXT.md | | 2025-12-29 | Созданы REQUIREMENTS.md, CLAUDE.md, CONTEXT.md |
| 2025-12-29 | Создан ARCHITECTURE.md (C4, sequences, API, UI prototypes, спецификация) | | 2025-12-29 | Создан ARCHITECTURE.md (C4, sequences, API, UI prototypes, спецификация) |
| 2025-12-29 | Создан ROADMAP.md — план разработки по фазам | | 2025-12-29 | Создан ROADMAP.md — план разработки по фазам |
@ -81,6 +82,14 @@
| 2026-01-15 | **Фаза 3.1:** AI-промпты (ТЗ и оценка) теперь учитывают комментарии к идее | | 2026-01-15 | **Фаза 3.1:** AI-промпты (ТЗ и оценка) теперь учитывают комментарии к идее |
| 2026-01-15 | **Планирование:** Добавлены новые требования — права доступа, аудит, WebSocket, темная тема, экспорт | | 2026-01-15 | **Планирование:** Добавлены новые требования — права доступа, аудит, WebSocket, темная тема, экспорт |
| 2026-01-15 | **Документация:** Обновлены REQUIREMENTS.md, ARCHITECTURE.md, ROADMAP.md — добавлены Фазы 4-8 | | 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 |
--- ---
@ -88,9 +97,24 @@
> Смотри [ROADMAP.md](ROADMAP.md) для полного плана разработки > Смотри [ROADMAP.md](ROADMAP.md) для полного плана разработки
**Готово:** Фазы 0-3.1 завершены ✅ **Готово:** Фазы 0-3.2 завершены ✅
**Следующий шаг:** Фаза 4 — Права доступа 📋 **Следующий шаг:** Фаза 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-8):
**Фаза 4: Права доступа** **Фаза 4: Права доступа**
@ -147,7 +171,8 @@ team-planner/
│ ├── auth.setup.ts # Авторизация для тестов (Keycloak) │ ├── auth.setup.ts # Авторизация для тестов (Keycloak)
│ ├── phase1.spec.ts # Тесты Фазы 1 (17 тестов) │ ├── phase1.spec.ts # Тесты Фазы 1 (17 тестов)
│ ├── phase2.spec.ts # Тесты Фазы 2 (37 тестов — D&D, цвета, комментарии, команда) │ ├── phase2.spec.ts # Тесты Фазы 2 (37 тестов — D&D, цвета, комментарии, команда)
── phase3.spec.ts # Тесты Фазы 3 (20 тестов — AI-оценка + мини-ТЗ) ── phase3.spec.ts # Тесты Фазы 3 (20 тестов — AI-оценка + мини-ТЗ)
│ └── phase3.2.spec.ts # Тесты Фазы 3.2 (15 тестов — детальный просмотр, column visibility) ✅
├── backend/ # NestJS API ├── backend/ # NestJS API
│ ├── src/ │ ├── src/
│ │ ├── auth/ # Модуль авторизации ✅ │ │ ├── auth/ # Модуль авторизации ✅
@ -178,7 +203,8 @@ team-planner/
│ │ ├── IdeasTable/ │ │ ├── IdeasTable/
│ │ │ ├── IdeasTable.tsx # Таблица с DndContext │ │ │ ├── IdeasTable.tsx # Таблица с DndContext
│ │ │ ├── DraggableRow.tsx # Сортируемая строка (useSortable) │ │ │ ├── DraggableRow.tsx # Сортируемая строка (useSortable)
│ │ │ ├── columns.tsx # Колонки + drag handle │ │ │ ├── columns.tsx # Колонки + drag handle (13 колонок)
│ │ │ ├── ColumnVisibility.tsx # Управление видимостью колонок ✅
│ │ │ └── ... │ │ │ └── ...
│ │ ├── IdeasFilters/ # Фильтры │ │ ├── IdeasFilters/ # Фильтры
│ │ ├── CreateIdeaModal/ # Модалка создания │ │ ├── CreateIdeaModal/ # Модалка создания
@ -188,7 +214,8 @@ team-planner/
│ │ │ └── RolesManager.tsx # Управление ролями │ │ │ └── RolesManager.tsx # Управление ролями
│ │ ├── CommentsPanel/ # Комментарии к идеям │ │ ├── CommentsPanel/ # Комментарии к идеям
│ │ ├── AiEstimateModal/ # Модалка AI-оценки (Фаза 3) ✅ │ │ ├── AiEstimateModal/ # Модалка AI-оценки (Фаза 3) ✅
│ │ ── SpecificationModal/ # Модалка мини-ТЗ (Фаза 3.1) ✅ │ │ ── SpecificationModal/ # Модалка мини-ТЗ (Фаза 3.1) ✅
│ │ └── IdeaDetailModal/ # Модалка детального просмотра (Фаза 3.2) ✅
│ ├── hooks/ │ ├── hooks/
│ │ ├── useIdeas.ts # React Query хуки + useReorderIdeas │ │ ├── useIdeas.ts # React Query хуки + useReorderIdeas
│ │ └── useAi.ts # useEstimateIdea + useGenerateSpecification + history hooks ✅ │ │ └── useAi.ts # useEstimateIdea + useGenerateSpecification + history hooks ✅
@ -246,8 +273,9 @@ team-planner/
- **Keycloak:** auth.vigdorov.ru, realm `team-planner`, client `team-planner-frontend` - **Keycloak:** auth.vigdorov.ru, realm `team-planner`, client `team-planner-frontend`
- **Keycloak Theme:** Кастомная тема `team-planner` в стиле MUI, образ `registry.vigdorov.ru/library/keycloak-team-planner` - **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) - **Production URL:** https://team-planner.vigdorov.ru (добавлен в Valid redirect URIs и Web origins клиента Keycloak)
- **CI/CD:** Drone CI (.drone.yml) — сборка backend/frontend/keycloak-theme, деплой в K8s namespace `team-planner` - **CI/CD:** Drone CI (.drone.yml) — 3 pipeline'а: main-pipeline (backend/frontend), infra-pipeline (k8s), keycloak-theme-pipeline (отдельный с git diff проверкой)
- **E2E тесты:** Все компоненты имеют `data-testid` для стабильных селекторов. Перед написанием тестов читай E2E_TESTING.md! - **E2E тесты:** Все компоненты имеют `data-testid` для стабильных селекторов. Перед написанием тестов читай E2E_TESTING.md!
- **AI Proxy:** Интеграция с ai-proxy-service (K8s namespace `ai-proxy`), модель `claude-3.7-sonnet`, env: AI_PROXY_BASE_URL, AI_PROXY_API_KEY - **AI Proxy:** Интеграция с ai-proxy-service (K8s namespace `ai-proxy`), модель `claude-3.7-sonnet`, env: AI_PROXY_BASE_URL, AI_PROXY_API_KEY
- **История ТЗ:** При перегенерации старая версия сохраняется в `specification_history`, можно просмотреть/восстановить/удалить - **История ТЗ:** При перегенерации старая версия сохраняется в `specification_history`, можно просмотреть/восстановить/удалить
- **Комментарии в AI:** Промпты для генерации ТЗ и оценки трудозатрат теперь включают комментарии к идее для лучшей точности - **Комментарии в AI:** Промпты для генерации ТЗ и оценки трудозатрат теперь включают комментарии к идее для лучшей точности
- **Keycloak Theme CI:** Отдельный pipeline проверяет `git diff HEAD~1 HEAD -- keycloak-theme/` и пропускает сборку/деплой если нет изменений (экономия ресурсов, нет влияния на Keycloak)

View File

@ -269,15 +269,30 @@ await expect(newElement).toBeVisible({ timeout: 5000 });
## Запуск тестов ## Запуск тестов
```bash ```bash
# Все тесты # Все тесты (из корня проекта)
npx playwright test npm run test
# Конкретный файл # Конкретный файл
npx playwright test e2e/phase2.spec.ts npx playwright test e2e/phase2.spec.ts
# Конкретный тест по имени
npx playwright test -g "Drag handle имеет правильный курсор"
# С UI режимом для отладки # С UI режимом для отладки
npx playwright test --ui npx playwright test --ui
# Только упавшие тесты # Только упавшие тесты
npx playwright test --last-failed npx playwright test --last-failed
``` ```
## Правила исправления тестов
**ВАЖНО:** При исправлении сломанных тестов:
1. **НЕ запускай полный прогон** после каждого исправления
2. **Запускай только сломанный тест** для проверки исправления:
```bash
npx playwright test -g "Название теста"
```
3. **Полный прогон** делай только когда все сломанные тесты исправлены
4. Это экономит время и ресурсы при отладке

View File

@ -33,9 +33,17 @@
- Отображение автора в таблице и детальном просмотре - Отображение автора в таблице и детальном просмотре
#### 1.3 Редактирование идей #### 1.3 Редактирование идей
- **Полный просмотр**: пользователь может просмотреть ВСЕ поля идеи (включая pain, aiRole, verificationMethod)
- **Полное редактирование**: пользователь может отредактировать ВСЕ редактируемые поля идеи
- **Inline-редактирование**: возможность редактировать любое поле прямо в таблице двойным кликом - **Inline-редактирование**: возможность редактировать любое поле прямо в таблице двойным кликом
- **Детальный просмотр**: модалка с полной информацией об идее
- Открывается в **режиме просмотра** (readonly)
- Кнопка "Редактировать" переводит в **режим редактирования**
- Кнопка "Сохранить" сохраняет изменения
- Кнопка "Отмена" отменяет изменения
- **Column visibility**: возможность скрыть/показать колонки таблицы
- **Быстрое изменение статуса и приоритета** через dropdown - **Быстрое изменение статуса и приоритета** через dropdown
- **Автосохранение** изменений - **Автосохранение** изменений (для inline-редактирования)
#### 1.4 Drag & Drop #### 1.4 Drag & Drop
- Перемещение идей в списке для ручной сортировки - Перемещение идей в списке для ручной сортировки

View File

@ -15,6 +15,7 @@
| 2 | Расширенный функционал | ✅ Завершена | Drag&Drop, цвета, комментарии, команда | | 2 | Расширенный функционал | ✅ Завершена | Drag&Drop, цвета, комментарии, команда |
| 3 | AI-интеграция | ✅ Завершена | Оценка времени, рекомендации | | 3 | AI-интеграция | ✅ Завершена | Оценка времени, рекомендации |
| 3.1 | Генерация мини-ТЗ | ✅ Завершена | Генерация, редактирование, история ТЗ | | 3.1 | Генерация мини-ТЗ | ✅ Завершена | Генерация, редактирование, история ТЗ |
| 3.2 | Полный просмотр идеи | ✅ Завершена | Просмотр и редактирование всех полей |
| 4 | Права доступа | 📋 Планируется | Гранулярные права, панель админа | | 4 | Права доступа | 📋 Планируется | Гранулярные права, панель админа |
| 5 | Аудит и история | 📋 Планируется | Логирование действий, восстановление | | 5 | Аудит и история | 📋 Планируется | Логирование действий, восстановление |
| 6 | Real-time и WebSocket | 📋 Планируется | Многопользовательская работа | | 6 | Real-time и WebSocket | 📋 Планируется | Многопользовательская работа |
@ -247,6 +248,54 @@
--- ---
## Фаза 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: Права доступа 📋 ## Фаза 4: Права доступа 📋
> **Гранулярная система прав доступа и панель администратора** > **Гранулярная система прав доступа и панель администратора**

View File

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

View File

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

View File

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

View File

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

View File

@ -37,6 +37,7 @@
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"jwks-rsa": "^3.2.0", "jwks-rsa": "^3.2.0",
"nats": "^2.29.3",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
@ -45,8 +46,6 @@
"typeorm": "^0.3.28" "typeorm": "^0.3.28"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
@ -55,12 +54,10 @@
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@vigdorov/eslint-config": "^1.0.1",
"@vigdorov/typescript-config": "^1.1.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0", "jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",

View File

@ -1,5 +1,10 @@
import { Controller, Post, Get, Delete, Body, Param } from '@nestjs/common'; import { Controller, Post, Get, Delete, Body, Param } from '@nestjs/common';
import { AiService, EstimateResult, SpecificationResult, SpecificationHistoryItem } from './ai.service'; import {
AiService,
EstimateResult,
SpecificationResult,
SpecificationHistoryItem,
} from './ai.service';
import { EstimateIdeaDto, GenerateSpecificationDto } from './dto'; import { EstimateIdeaDto, GenerateSpecificationDto } from './dto';
@Controller('ai') @Controller('ai')

View File

@ -8,7 +8,9 @@ import { SpecificationHistory } from '../ideas/entities/specification-history.en
import { Comment } from '../comments/entities/comment.entity'; import { Comment } from '../comments/entities/comment.entity';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Idea, TeamMember, SpecificationHistory, Comment])], imports: [
TypeOrmModule.forFeature([Idea, TeamMember, SpecificationHistory, Comment]),
],
controllers: [AiController], controllers: [AiController],
providers: [AiService], providers: [AiService],
exports: [AiService], exports: [AiService],

View File

@ -36,6 +36,21 @@ export interface SpecificationHistoryItem {
createdAt: Date; createdAt: Date;
} }
interface AiProxyResponse {
choices: {
message: {
content: string;
};
}[];
}
interface ParsedEstimate {
totalHours?: number;
complexity?: string;
breakdown?: RoleEstimate[];
recommendations?: string[];
}
@Injectable() @Injectable()
export class AiService { export class AiService {
private readonly logger = new Logger(AiService.name); private readonly logger = new Logger(AiService.name);
@ -103,7 +118,9 @@ export class AiService {
}; };
} }
async getSpecificationHistory(ideaId: string): Promise<SpecificationHistoryItem[]> { async getSpecificationHistory(
ideaId: string,
): Promise<SpecificationHistoryItem[]> {
const history = await this.specificationHistoryRepository.find({ const history = await this.specificationHistoryRepository.find({
where: { ideaId }, where: { ideaId },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
@ -120,18 +137,26 @@ export class AiService {
async deleteSpecificationHistoryItem(historyId: string): Promise<void> { async deleteSpecificationHistoryItem(historyId: string): Promise<void> {
const result = await this.specificationHistoryRepository.delete(historyId); const result = await this.specificationHistoryRepository.delete(historyId);
if (result.affected === 0) { if (result.affected === 0) {
throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND); throw new HttpException(
'Запись истории не найдена',
HttpStatus.NOT_FOUND,
);
} }
} }
async restoreSpecificationFromHistory(historyId: string): Promise<SpecificationResult> { async restoreSpecificationFromHistory(
historyId: string,
): Promise<SpecificationResult> {
const historyItem = await this.specificationHistoryRepository.findOne({ const historyItem = await this.specificationHistoryRepository.findOne({
where: { id: historyId }, where: { id: historyId },
relations: ['idea'], relations: ['idea'],
}); });
if (!historyItem) { if (!historyItem) {
throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND); throw new HttpException(
'Запись истории не найдена',
HttpStatus.NOT_FOUND,
);
} }
const idea = historyItem.idea; const idea = historyItem.idea;
@ -194,14 +219,21 @@ export class AiService {
await this.ideaRepository.update(ideaId, { await this.ideaRepository.update(ideaId, {
estimatedHours: result.totalHours, estimatedHours: result.totalHours,
complexity: result.complexity, complexity: result.complexity,
estimateDetails: { breakdown: result.breakdown, recommendations: result.recommendations }, estimateDetails: {
breakdown: result.breakdown,
recommendations: result.recommendations,
},
estimatedAt: result.estimatedAt, estimatedAt: result.estimatedAt,
}); });
return result; return result;
} }
private buildPrompt(idea: Idea, teamMembers: TeamMember[], comments: Comment[]): string { private buildPrompt(
idea: Idea,
teamMembers: TeamMember[],
comments: Comment[],
): string {
const teamInfo = teamMembers const teamInfo = teamMembers
.map((m) => { .map((m) => {
const prod = m.productivity; const prod = m.productivity;
@ -211,7 +243,8 @@ export class AiService {
const rolesSummary = this.getRolesSummary(teamMembers); const rolesSummary = this.getRolesSummary(teamMembers);
const commentsSection = comments.length > 0 const commentsSection =
comments.length > 0
? `## Комментарии к идее ? `## Комментарии к идее
${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')} ${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')}
@ -269,7 +302,8 @@ ${rolesSummary}
} }
private buildSpecificationPrompt(idea: Idea, comments: Comment[]): string { private buildSpecificationPrompt(idea: Idea, comments: Comment[]): string {
const commentsSection = comments.length > 0 const commentsSection =
comments.length > 0
? `## Комментарии к идее ? `## Комментарии к идее
${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')} ${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')}
@ -345,13 +379,14 @@ ${commentsSection}## Требования к ТЗ
); );
} }
const data = await response.json(); const data = (await response.json()) as AiProxyResponse;
return data.choices[0].message.content; return data.choices[0].message.content;
} catch (error) { } catch (error: unknown) {
if (error instanceof HttpException) { if (error instanceof HttpException) {
throw error; throw error;
} }
this.logger.error(`AI Proxy call failed: ${error.message}`); const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`AI Proxy call failed: ${message}`);
throw new HttpException( throw new HttpException(
'Не удалось подключиться к AI сервису', 'Не удалось подключиться к AI сервису',
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
@ -374,20 +409,33 @@ ${commentsSection}## Требования к ТЗ
} }
cleanJson = cleanJson.trim(); cleanJson = cleanJson.trim();
const parsed = JSON.parse(cleanJson); 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 { return {
ideaId: idea.id, ideaId: idea.id,
ideaTitle: idea.title, ideaTitle: idea.title,
totalHours: Number(parsed.totalHours) || 0, totalHours: Number(parsed.totalHours) || 0,
complexity: parsed.complexity || 'medium', complexity,
breakdown: Array.isArray(parsed.breakdown) ? parsed.breakdown : [], breakdown: Array.isArray(parsed.breakdown) ? parsed.breakdown : [],
recommendations: Array.isArray(parsed.recommendations) recommendations: Array.isArray(parsed.recommendations)
? parsed.recommendations ? parsed.recommendations
: [], : [],
estimatedAt: new Date(), estimatedAt: new Date(),
}; };
} catch (error) { } catch {
this.logger.error(`Failed to parse AI response: ${aiResponse}`); this.logger.error(`Failed to parse AI response: ${aiResponse}`);
throw new HttpException( throw new HttpException(
'Не удалось разобрать ответ AI', 'Не удалось разобрать ответ AI',

View File

@ -9,6 +9,8 @@ import { CommentsModule } from './comments/comments.module';
import { TeamModule } from './team/team.module'; import { TeamModule } from './team/team.module';
import { AuthModule, JwtAuthGuard } from './auth'; import { AuthModule, JwtAuthGuard } from './auth';
import { AiModule } from './ai/ai.module'; import { AiModule } from './ai/ai.module';
import { SettingsModule } from './settings/settings.module';
import { NatsModule } from './nats/nats.module';
@Module({ @Module({
imports: [ imports: [
@ -36,6 +38,8 @@ import { AiModule } from './ai/ai.module';
CommentsModule, CommentsModule,
TeamModule, TeamModule,
AiModule, AiModule,
SettingsModule,
NatsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@ -18,7 +18,10 @@ export class CommentsService {
}); });
} }
async create(ideaId: string, createCommentDto: CreateCommentDto): Promise<Comment> { async create(
ideaId: string,
createCommentDto: CreateCommentDto,
): Promise<Comment> {
const comment = this.commentsRepository.create({ const comment = this.commentsRepository.create({
...createCommentDto, ...createCommentDto,
ideaId, ideaId,

View File

@ -73,7 +73,13 @@ export class Idea {
order: number; order: number;
// AI-оценка // AI-оценка
@Column({ name: 'estimated_hours', type: 'decimal', precision: 10, scale: 2, nullable: true }) @Column({
name: 'estimated_hours',
type: 'decimal',
precision: 10,
scale: 2,
nullable: true,
})
estimatedHours: number | null; estimatedHours: number | null;
@Column({ type: 'varchar', length: 20, nullable: true }) @Column({ type: 'varchar', length: 20, nullable: true })
@ -89,7 +95,11 @@ export class Idea {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
specification: string | null; specification: string | null;
@Column({ name: 'specification_generated_at', type: 'timestamp', nullable: true }) @Column({
name: 'specification_generated_at',
type: 'timestamp',
nullable: true,
})
specificationGeneratedAt: Date | null; specificationGeneratedAt: Date | null;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })

View File

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

View File

@ -4,11 +4,12 @@ import { IdeasService } from './ideas.service';
import { IdeasController } from './ideas.controller'; import { IdeasController } from './ideas.controller';
import { Idea } from './entities/idea.entity'; import { Idea } from './entities/idea.entity';
import { SpecificationHistory } from './entities/specification-history.entity'; import { SpecificationHistory } from './entities/specification-history.entity';
import { IdeaEventsHandler } from './idea-events.handler';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Idea, SpecificationHistory])], imports: [TypeOrmModule.forFeature([Idea, SpecificationHistory])],
controllers: [IdeasController], controllers: [IdeasController],
providers: [IdeasService], providers: [IdeasService, IdeaEventsHandler],
exports: [IdeasService, TypeOrmModule], exports: [IdeasService, IdeaEventsHandler, TypeOrmModule],
}) })
export class IdeasModule {} export class IdeasModule {}

View File

@ -16,7 +16,9 @@ export class CreateCommentsTable1736899200000 implements MigrationInterface {
CONSTRAINT "FK_comments_idea_id" FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE 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")`); await queryRunner.query(
`CREATE INDEX "IDX_comments_idea_id" ON "comments" ("idea_id")`,
);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {

View File

@ -84,7 +84,9 @@ export class CreateRolesTable1736899400000 implements MigrationInterface {
`); `);
// 5. Удаляем foreign key и role_id // 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 CONSTRAINT "FK_team_members_role"`,
);
await queryRunner.query(`ALTER TABLE "team_members" DROP COLUMN "role_id"`); await queryRunner.query(`ALTER TABLE "team_members" DROP COLUMN "role_id"`);
// 6. Удаляем таблицу roles // 6. Удаляем таблицу roles

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,16 @@ export class TeamMember {
@Column({ name: 'role_id', type: 'uuid' }) @Column({ name: 'role_id', type: 'uuid' })
roleId: string; roleId: string;
@Column({ type: 'jsonb', default: { trivial: 1, simple: 4, medium: 12, complex: 32, veryComplex: 60 } }) @Column({
type: 'jsonb',
default: {
trivial: 1,
simple: 4,
medium: 12,
complex: 32,
veryComplex: 60,
},
})
productivity: ProductivityMatrix; productivity: ProductivityMatrix;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })

View File

@ -1,4 +1,8 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Role } from './entities/role.entity'; import { Role } from './entities/role.entity';
@ -31,7 +35,9 @@ export class RolesService {
where: { name: createDto.name }, where: { name: createDto.name },
}); });
if (existing) { if (existing) {
throw new ConflictException(`Role with name "${createDto.name}" already exists`); throw new ConflictException(
`Role with name "${createDto.name}" already exists`,
);
} }
const maxSortOrder = await this.roleRepository const maxSortOrder = await this.roleRepository
@ -54,7 +60,9 @@ export class RolesService {
where: { name: updateDto.name }, where: { name: updateDto.name },
}); });
if (existing) { if (existing) {
throw new ConflictException(`Role with name "${updateDto.name}" already exists`); throw new ConflictException(
`Role with name "${updateDto.name}" already exists`,
);
} }
} }

View File

@ -39,7 +39,9 @@ export class TeamService {
where: { id: createDto.roleId }, where: { id: createDto.roleId },
}); });
if (!role) { if (!role) {
throw new NotFoundException(`Role with ID "${createDto.roleId}" not found`); throw new NotFoundException(
`Role with ID "${createDto.roleId}" not found`,
);
} }
const member = this.teamRepository.create(createDto); const member = this.teamRepository.create(createDto);
@ -47,7 +49,10 @@ export class TeamService {
return this.findOne(saved.id); return this.findOne(saved.id);
} }
async update(id: string, updateDto: UpdateTeamMemberDto): Promise<TeamMember> { async update(
id: string,
updateDto: UpdateTeamMemberDto,
): Promise<TeamMember> {
const member = await this.findOne(id); const member = await this.findOne(id);
if (updateDto.roleId) { if (updateDto.roleId) {
@ -55,7 +60,9 @@ export class TeamService {
where: { id: updateDto.roleId }, where: { id: updateDto.roleId },
}); });
if (!role) { if (!role) {
throw new NotFoundException(`Role with ID "${updateDto.roleId}" not found`); throw new NotFoundException(
`Role with ID "${updateDto.roleId}" not found`,
);
} }
} }
@ -69,7 +76,9 @@ export class TeamService {
await this.teamRepository.remove(member); await this.teamRepository.remove(member);
} }
async getSummary(): Promise<{ roleId: string; label: string; count: number }[]> { async getSummary(): Promise<
{ roleId: string; label: string; count: number }[]
> {
// Получаем все роли // Получаем все роли
const roles = await this.roleRepository.find({ const roles = await this.roleRepository.find({
order: { sortOrder: 'ASC' }, order: { sortOrder: 'ASC' },
@ -87,7 +96,10 @@ export class TeamService {
return roles.map((role) => ({ return roles.map((role) => ({
roleId: role.id, roleId: role.id,
label: role.label, label: role.label,
count: parseInt(result.find((r) => r.roleId === role.id)?.count ?? '0', 10), count: parseInt(
result.find((r) => r.roleId === role.id)?.count ?? '0',
10,
),
})); }));
} }
} }

View File

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

View File

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

View File

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

View File

@ -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;"]

View File

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

View File

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

View File

@ -30,18 +30,14 @@
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vigdorov/eslint-config": "^1.0.1",
"@vigdorov/typescript-config": "^1.1.0",
"@vigdorov/vite-config": "^1.0.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.4.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4" "vite": "^7.2.4"
} }
} }

View File

@ -11,11 +11,19 @@ import {
Tabs, Tabs,
Tab, Tab,
} from '@mui/material'; } from '@mui/material';
import { Add, Logout, Person, Lightbulb, Group } from '@mui/icons-material'; import {
Add,
Logout,
Person,
Lightbulb,
Group,
Settings,
} from '@mui/icons-material';
import { IdeasTable } from './components/IdeasTable'; import { IdeasTable } from './components/IdeasTable';
import { IdeasFilters } from './components/IdeasFilters'; import { IdeasFilters } from './components/IdeasFilters';
import { CreateIdeaModal } from './components/CreateIdeaModal'; import { CreateIdeaModal } from './components/CreateIdeaModal';
import { TeamPage } from './components/TeamPage'; import { TeamPage } from './components/TeamPage';
import { SettingsPage } from './components/SettingsPage';
import { useIdeasStore } from './store/ideas'; import { useIdeasStore } from './store/ideas';
import { useAuth } from './hooks/useAuth'; import { useAuth } from './hooks/useAuth';
@ -63,16 +71,24 @@ function App() {
{/* Tabs */} {/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)}> <Tabs value={tab} onChange={(_, v: number) => setTab(v)}>
<Tab icon={<Lightbulb />} iconPosition="start" label="Идеи" /> <Tab icon={<Lightbulb />} iconPosition="start" label="Идеи" />
<Tab icon={<Group />} iconPosition="start" label="Команда" /> <Tab icon={<Group />} iconPosition="start" label="Команда" />
<Tab icon={<Settings />} iconPosition="start" label="Настройки" />
</Tabs> </Tabs>
</Box> </Box>
{/* Content */} {/* Content */}
{tab === 0 && ( {tab === 0 && (
<> <>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<IdeasFilters /> <IdeasFilters />
<Button <Button
variant="contained" variant="contained"
@ -88,6 +104,8 @@ function App() {
)} )}
{tab === 1 && <TeamPage />} {tab === 1 && <TeamPage />}
{tab === 2 && <SettingsPage />}
</Container> </Container>
); );
} }

View File

@ -28,6 +28,11 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import type { EstimateResult } from '../../services/ai'; import type { EstimateResult } from '../../services/ai';
import type { IdeaComplexity } from '../../types/idea'; import type { IdeaComplexity } from '../../types/idea';
import {
formatEstimate,
type EstimateConfig,
DEFAULT_ESTIMATE_CONFIG,
} from '../../utils/estimate';
interface AiEstimateModalProps { interface AiEstimateModalProps {
open: boolean; open: boolean;
@ -35,6 +40,7 @@ interface AiEstimateModalProps {
result: EstimateResult | null; result: EstimateResult | null;
isLoading: boolean; isLoading: boolean;
error: Error | null; error: Error | null;
estimateConfig?: EstimateConfig;
} }
const complexityLabels: Record<IdeaComplexity, string> = { const complexityLabels: Record<IdeaComplexity, string> = {
@ -56,24 +62,13 @@ const complexityColors: Record<
veryComplex: 'error', veryComplex: 'error',
}; };
function formatHours(hours: number): string {
if (hours < 8) {
return `${hours} ч`;
}
const days = Math.floor(hours / 8);
const remainingHours = hours % 8;
if (remainingHours === 0) {
return `${days} д`;
}
return `${days} д ${remainingHours} ч`;
}
export function AiEstimateModal({ export function AiEstimateModal({
open, open,
onClose, onClose,
result, result,
isLoading, isLoading,
error, error,
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
}: AiEstimateModalProps) { }: AiEstimateModalProps) {
return ( return (
<Dialog <Dialog
@ -120,10 +115,12 @@ export function AiEstimateModal({
}} }}
> >
<Box sx={{ textAlign: 'center' }}> <Box sx={{ textAlign: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}> <Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}
>
<AccessTime color="primary" /> <AccessTime color="primary" />
<Typography variant="h4" component="span"> <Typography variant="h4" component="span">
{formatHours(result.totalHours)} {formatEstimate(result.totalHours, estimateConfig)}
</Typography> </Typography>
</Box> </Box>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@ -131,7 +128,9 @@ export function AiEstimateModal({
</Typography> </Typography>
</Box> </Box>
<Box sx={{ textAlign: 'center' }}> <Box sx={{ textAlign: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}> <Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}
>
<TrendingUp color="primary" /> <TrendingUp color="primary" />
<Chip <Chip
label={complexityLabels[result.complexity]} label={complexityLabels[result.complexity]}
@ -148,7 +147,11 @@ export function AiEstimateModal({
{/* Разбивка по ролям */} {/* Разбивка по ролям */}
{result.breakdown.length > 0 && ( {result.breakdown.length > 0 && (
<Box> <Box>
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography
variant="subtitle2"
gutterBottom
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
Разбивка по ролям Разбивка по ролям
</Typography> </Typography>
<Paper variant="outlined"> <Paper variant="outlined">
@ -161,9 +164,14 @@ export function AiEstimateModal({
</TableHead> </TableHead>
<TableBody> <TableBody>
{result.breakdown.map((item, index) => ( {result.breakdown.map((item, index) => (
<TableRow key={index} data-testid={`estimate-breakdown-row-${index}`}> <TableRow
key={index}
data-testid={`estimate-breakdown-row-${String(index)}`}
>
<TableCell>{item.role}</TableCell> <TableCell>{item.role}</TableCell>
<TableCell align="right">{formatHours(item.hours)}</TableCell> <TableCell align="right">
{formatEstimate(item.hours, estimateConfig)}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@ -175,13 +183,21 @@ export function AiEstimateModal({
{/* Рекомендации */} {/* Рекомендации */}
{result.recommendations.length > 0 && ( {result.recommendations.length > 0 && (
<Box> <Box>
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography
variant="subtitle2"
gutterBottom
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<Lightbulb fontSize="small" color="warning" /> <Lightbulb fontSize="small" color="warning" />
Рекомендации Рекомендации
</Typography> </Typography>
<List dense disablePadding> <List dense disablePadding>
{result.recommendations.map((rec, index) => ( {result.recommendations.map((rec, index) => (
<ListItem key={index} disableGutters data-testid={`estimate-recommendation-${index}`}> <ListItem
key={index}
disableGutters
data-testid={`estimate-recommendation-${String(index)}`}
>
<ListItemIcon sx={{ minWidth: 32 }}> <ListItemIcon sx={{ minWidth: 32 }}>
<CheckCircle fontSize="small" color="success" /> <CheckCircle fontSize="small" color="success" />
</ListItemIcon> </ListItemIcon>

View File

@ -70,11 +70,19 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
<CircularProgress size={24} /> <CircularProgress size={24} />
</Box> </Box>
) : comments.length === 0 ? ( ) : comments.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }} data-testid="comments-empty"> <Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2 }}
data-testid="comments-empty"
>
Пока нет комментариев Пока нет комментариев
</Typography> </Typography>
) : ( ) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 2 }} data-testid="comments-list"> <Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 2 }}
data-testid="comments-list"
>
{comments.map((comment) => ( {comments.map((comment) => (
<Paper <Paper
key={comment.id} key={comment.id}
@ -83,7 +91,11 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
sx={{ p: 1.5, display: 'flex', alignItems: 'flex-start', gap: 1 }} sx={{ p: 1.5, display: 'flex', alignItems: 'flex-start', gap: 1 }}
> >
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }} data-testid="comment-text"> <Typography
variant="body2"
sx={{ whiteSpace: 'pre-wrap' }}
data-testid="comment-text"
>
{comment.text} {comment.text}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@ -104,7 +116,12 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
</Box> </Box>
)} )}
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', gap: 1 }} data-testid="comment-form"> <Box
component="form"
onSubmit={handleSubmit}
sx={{ display: 'flex', gap: 1 }}
data-testid="comment-form"
>
<TextField <TextField
size="small" size="small"
placeholder="Добавить комментарий... (Ctrl+Enter)" placeholder="Добавить комментарий... (Ctrl+Enter)"
@ -114,7 +131,7 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
fullWidth fullWidth
multiline multiline
maxRows={3} maxRows={3}
inputProps={{ 'data-testid': 'comment-input' }} slotProps={{ htmlInput: { 'data-testid': 'comment-input' } }}
/> />
<Button <Button
type="submit" type="submit"

View File

@ -180,7 +180,9 @@ export function CreateIdeaModal() {
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleClose} data-testid="cancel-create-idea">Отмена</Button> <Button onClick={handleClose} data-testid="cancel-create-idea">
Отмена
</Button>
<Button <Button
type="submit" type="submit"
variant="contained" variant="contained"

View File

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

View File

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

View File

@ -54,7 +54,11 @@ export function IdeasFilters() {
}, [searchValue, setFilter]); }, [searchValue, setFilter]);
const hasFilters = Boolean( const hasFilters = Boolean(
filters.status ?? filters.priority ?? filters.module ?? filters.search ?? filters.color, filters.status ??
filters.priority ??
filters.module ??
filters.search ??
filters.color,
); );
return ( return (
@ -80,7 +84,11 @@ export function IdeasFilters() {
}} }}
/> />
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-status"> <FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-status"
>
<InputLabel>Статус</InputLabel> <InputLabel>Статус</InputLabel>
<Select<IdeaStatus | ''> <Select<IdeaStatus | ''>
value={filters.status ?? ''} value={filters.status ?? ''}
@ -99,7 +107,11 @@ export function IdeasFilters() {
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-priority"> <FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-priority"
>
<InputLabel>Приоритет</InputLabel> <InputLabel>Приоритет</InputLabel>
<Select<IdeaPriority | ''> <Select<IdeaPriority | ''>
value={filters.priority ?? ''} value={filters.priority ?? ''}
@ -118,7 +130,11 @@ export function IdeasFilters() {
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-module"> <FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-module"
>
<InputLabel>Модуль</InputLabel> <InputLabel>Модуль</InputLabel>
<Select <Select
value={filters.module ?? ''} value={filters.module ?? ''}
@ -134,7 +150,11 @@ export function IdeasFilters() {
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-color"> <FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-color"
>
<InputLabel>Цвет</InputLabel> <InputLabel>Цвет</InputLabel>
<Select <Select
value={filters.color ?? ''} value={filters.color ?? ''}

View File

@ -85,7 +85,15 @@ export function ColorPickerCell({ idea }: ColorPickerCellProps) {
} as React.HTMLAttributes<HTMLDivElement>, } as React.HTMLAttributes<HTMLDivElement>,
}} }}
> >
<Box sx={{ p: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap', maxWidth: 180 }}> <Box
sx={{
p: 1,
display: 'flex',
gap: 0.5,
flexWrap: 'wrap',
maxWidth: 180,
}}
>
{COLORS.map((color) => ( {COLORS.map((color) => (
<IconButton <IconButton
key={color} key={color}

View File

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

View File

@ -80,7 +80,12 @@ export function DraggableRow({ row }: DraggableRowProps) {
return ( return (
<DragHandleContext.Provider value={{ attributes, listeners, isDragging }}> <DragHandleContext.Provider value={{ attributes, listeners, isDragging }}>
<TableRow ref={setNodeRef} hover sx={style} data-testid={`idea-row-${row.original.id}`}> <TableRow
ref={setNodeRef}
hover
sx={style}
data-testid={`idea-row-${row.original.id}`}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}> <TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}

View File

@ -1,4 +1,4 @@
import { useMemo, useState, Fragment } from 'react'; import { useMemo, useState, Fragment, useCallback } from 'react';
import { import {
useReactTable, useReactTable,
getCoreRowModel, getCoreRowModel,
@ -36,6 +36,7 @@ import {
Collapse, Collapse,
} from '@mui/material'; } from '@mui/material';
import { Inbox } from '@mui/icons-material'; import { Inbox } from '@mui/icons-material';
import { ColumnVisibility } from './ColumnVisibility';
import { import {
useIdeasQuery, useIdeasQuery,
useDeleteIdea, useDeleteIdea,
@ -55,10 +56,12 @@ import { DraggableRow } from './DraggableRow';
import { CommentsPanel } from '../CommentsPanel'; import { CommentsPanel } from '../CommentsPanel';
import { AiEstimateModal } from '../AiEstimateModal'; import { AiEstimateModal } from '../AiEstimateModal';
import { SpecificationModal } from '../SpecificationModal'; import { SpecificationModal } from '../SpecificationModal';
import { IdeaDetailModal } from '../IdeaDetailModal';
import type { EstimateResult } from '../../services/ai'; import type { EstimateResult } from '../../services/ai';
import type { Idea } from '../../types/idea'; import type { Idea, UpdateIdeaDto } from '../../types/idea';
import { useEstimateConfig } from '../../hooks/useSettings';
const SKELETON_COLUMNS_COUNT = 10; const SKELETON_COLUMNS_COUNT = 13;
export function IdeasTable() { export function IdeasTable() {
const { data, isLoading, isError } = useIdeasQuery(); const { data, isLoading, isError } = useIdeasQuery();
@ -71,6 +74,7 @@ export function IdeasTable() {
const restoreSpecificationFromHistory = useRestoreSpecificationFromHistory(); const restoreSpecificationFromHistory = useRestoreSpecificationFromHistory();
const { sorting, setSorting, pagination, setPage, setLimit } = const { sorting, setSorting, pagination, setPage, setLimit } =
useIdeasStore(); useIdeasStore();
const estimateConfig = useEstimateConfig();
// ID активно перетаскиваемого элемента // ID активно перетаскиваемого элемента
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
@ -79,21 +83,33 @@ export function IdeasTable() {
// AI-оценка // AI-оценка
const [estimatingId, setEstimatingId] = useState<string | null>(null); const [estimatingId, setEstimatingId] = useState<string | null>(null);
const [estimateModalOpen, setEstimateModalOpen] = useState(false); const [estimateModalOpen, setEstimateModalOpen] = useState(false);
const [estimateResult, setEstimateResult] = useState<EstimateResult | null>(null); const [estimateResult, setEstimateResult] = useState<EstimateResult | null>(
null,
);
// ТЗ (спецификация) // ТЗ (спецификация)
const [specificationModalOpen, setSpecificationModalOpen] = useState(false); const [specificationModalOpen, setSpecificationModalOpen] = useState(false);
const [specificationIdea, setSpecificationIdea] = useState<Idea | null>(null); const [specificationIdea, setSpecificationIdea] = useState<Idea | null>(null);
const [generatedSpecification, setGeneratedSpecification] = useState<string | null>(null); const [generatedSpecification, setGeneratedSpecification] = useState<
const [generatingSpecificationId, setGeneratingSpecificationId] = useState<string | null>(null); 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 specificationHistory = useSpecificationHistory(
specificationIdea?.id ?? null,
);
const handleToggleComments = (id: string) => { const handleToggleComments = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id)); setExpandedId((prev) => (prev === id ? null : id));
}; };
const handleEstimate = (id: string) => { const handleEstimate = useCallback(
(id: string) => {
setEstimatingId(id); setEstimatingId(id);
setEstimateModalOpen(true); setEstimateModalOpen(true);
setEstimateResult(null); setEstimateResult(null);
@ -106,7 +122,9 @@ export function IdeasTable() {
setEstimatingId(null); setEstimatingId(null);
}, },
}); });
}; },
[estimateIdea],
);
const handleCloseEstimateModal = () => { const handleCloseEstimateModal = () => {
setEstimateModalOpen(false); setEstimateModalOpen(false);
@ -121,15 +139,16 @@ export function IdeasTable() {
ideaId: idea.id, ideaId: idea.id,
ideaTitle: idea.title, ideaTitle: idea.title,
totalHours: idea.estimatedHours, totalHours: idea.estimatedHours,
complexity: idea.complexity!, complexity: idea.complexity ?? 'medium',
breakdown: idea.estimateDetails.breakdown, breakdown: idea.estimateDetails.breakdown,
recommendations: idea.estimateDetails.recommendations, recommendations: idea.estimateDetails.recommendations,
estimatedAt: idea.estimatedAt!, estimatedAt: idea.estimatedAt ?? new Date().toISOString(),
}); });
setEstimateModalOpen(true); setEstimateModalOpen(true);
}; };
const handleSpecification = (idea: Idea) => { const handleSpecification = useCallback(
(idea: Idea) => {
setSpecificationIdea(idea); setSpecificationIdea(idea);
setSpecificationModalOpen(true); setSpecificationModalOpen(true);
@ -151,7 +170,9 @@ export function IdeasTable() {
setGeneratingSpecificationId(null); setGeneratingSpecificationId(null);
}, },
}); });
}; },
[generateSpecification],
);
const handleCloseSpecificationModal = () => { const handleCloseSpecificationModal = () => {
setSpecificationModalOpen(false); setSpecificationModalOpen(false);
@ -162,7 +183,7 @@ export function IdeasTable() {
const handleSaveSpecification = (specification: string) => { const handleSaveSpecification = (specification: string) => {
if (!specificationIdea) return; if (!specificationIdea) return;
updateIdea.mutate( updateIdea.mutate(
{ id: specificationIdea.id, data: { specification } }, { id: specificationIdea.id, dto: { specification } },
{ {
onSuccess: () => { onSuccess: () => {
setGeneratedSpecification(specification); setGeneratedSpecification(specification);
@ -197,6 +218,52 @@ export function IdeasTable() {
}); });
}; };
const handleViewDetails = useCallback((idea: Idea) => {
setDetailIdea(idea);
setDetailModalOpen(true);
}, []);
const handleCloseDetailModal = () => {
setDetailModalOpen(false);
setDetailIdea(null);
};
const handleSaveDetail = (id: string, dto: UpdateIdeaDto) => {
updateIdea.mutate(
{ id, dto },
{
onSuccess: () => {
// Обновляем только те поля, которые были отправлены в dto
// Это сохраняет specification и другие поля которые не редактировались
setDetailIdea((prev) => {
if (!prev) return prev;
const updates: Partial<Idea> = {};
(Object.keys(dto) as (keyof UpdateIdeaDto)[]).forEach((key) => {
if (dto[key] !== undefined) {
(updates as Record<string, unknown>)[key] = dto[key];
}
});
return { ...prev, ...updates };
});
},
},
);
};
const handleOpenSpecificationFromDetail = (idea: Idea) => {
handleCloseDetailModal();
handleSpecification(idea);
};
const handleOpenEstimateFromDetail = (idea: Idea) => {
handleCloseDetailModal();
if (idea.estimatedHours) {
handleViewEstimate(idea);
} else {
handleEstimate(idea.id);
}
};
const columns = useMemo( const columns = useMemo(
() => () =>
createColumns({ createColumns({
@ -205,11 +272,22 @@ export function IdeasTable() {
onEstimate: handleEstimate, onEstimate: handleEstimate,
onViewEstimate: handleViewEstimate, onViewEstimate: handleViewEstimate,
onSpecification: handleSpecification, onSpecification: handleSpecification,
onViewDetails: handleViewDetails,
expandedId, expandedId,
estimatingId, estimatingId,
generatingSpecificationId, generatingSpecificationId,
estimateConfig,
}), }),
[deleteIdea, expandedId, estimatingId, generatingSpecificationId], [
deleteIdea,
expandedId,
estimatingId,
generatingSpecificationId,
handleEstimate,
handleSpecification,
handleViewDetails,
estimateConfig,
],
); );
// eslint-disable-next-line react-hooks/incompatible-library // eslint-disable-next-line react-hooks/incompatible-library
@ -291,7 +369,23 @@ export function IdeasTable() {
: null; : null;
return ( return (
<Paper sx={{ width: '100%', overflow: 'hidden' }} data-testid="ideas-table-container"> <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 <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@ -386,9 +480,17 @@ export function IdeasTable() {
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={SKELETON_COLUMNS_COUNT} colSpan={SKELETON_COLUMNS_COUNT}
sx={{ p: 0, borderBottom: expandedId === row.original.id ? 1 : 0, borderColor: 'divider' }} sx={{
p: 0,
borderBottom:
expandedId === row.original.id ? 1 : 0,
borderColor: 'divider',
}}
>
<Collapse
in={expandedId === row.original.id}
unmountOnExit
> >
<Collapse in={expandedId === row.original.id} unmountOnExit>
<CommentsPanel ideaId={row.original.id} /> <CommentsPanel ideaId={row.original.id} />
</Collapse> </Collapse>
</TableCell> </TableCell>
@ -451,6 +553,7 @@ export function IdeasTable() {
result={estimateResult} result={estimateResult}
isLoading={estimateIdea.isPending && !estimateResult} isLoading={estimateIdea.isPending && !estimateResult}
error={estimateIdea.error} error={estimateIdea.error}
estimateConfig={estimateConfig}
/> />
<SpecificationModal <SpecificationModal
open={specificationModalOpen} open={specificationModalOpen}
@ -468,6 +571,16 @@ export function IdeasTable() {
onRestoreFromHistory={handleRestoreFromHistory} onRestoreFromHistory={handleRestoreFromHistory}
isRestoring={restoreSpecificationFromHistory.isPending} isRestoring={restoreSpecificationFromHistory.isPending}
/> />
<IdeaDetailModal
open={detailModalOpen}
onClose={handleCloseDetailModal}
idea={detailIdea}
onSave={handleSaveDetail}
isSaving={updateIdea.isPending}
onOpenSpecification={handleOpenSpecificationFromDetail}
onOpenEstimate={handleOpenEstimateFromDetail}
estimateConfig={estimateConfig}
/>
</Paper> </Paper>
); );
} }

View File

@ -1,11 +1,36 @@
import { createColumnHelper } from '@tanstack/react-table'; import { createColumnHelper } from '@tanstack/react-table';
import { Chip, Box, IconButton, Tooltip, CircularProgress, Typography } from '@mui/material'; import {
import { Delete, Comment, ExpandLess, AutoAwesome, AccessTime, Description } from '@mui/icons-material'; Chip,
import type { Idea, IdeaStatus, IdeaPriority, IdeaComplexity } from '../../types/idea'; Box,
IconButton,
Tooltip,
CircularProgress,
Typography,
} from '@mui/material';
import {
Delete,
Comment,
ExpandLess,
AutoAwesome,
AccessTime,
Description,
Visibility,
} from '@mui/icons-material';
import type {
Idea,
IdeaStatus,
IdeaPriority,
IdeaComplexity,
} from '../../types/idea';
import { EditableCell } from './EditableCell'; import { EditableCell } from './EditableCell';
import { ColorPickerCell } from './ColorPickerCell'; import { ColorPickerCell } from './ColorPickerCell';
import { statusOptions, priorityOptions } from './constants'; import { statusOptions, priorityOptions } from './constants';
import { DragHandle } from './DraggableRow'; import { DragHandle } from './DraggableRow';
import {
formatEstimate,
type EstimateConfig,
DEFAULT_ESTIMATE_CONFIG,
} from '../../utils/estimate';
const columnHelper = createColumnHelper<Idea>(); const columnHelper = createColumnHelper<Idea>();
@ -49,26 +74,31 @@ const complexityColors: Record<
veryComplex: 'error', veryComplex: 'error',
}; };
function formatHoursShort(hours: number): string {
if (hours < 8) {
return `${hours}ч`;
}
const days = Math.floor(hours / 8);
return `${days}д`;
}
interface ColumnsConfig { interface ColumnsConfig {
onDelete: (id: string) => void; onDelete: (id: string) => void;
onToggleComments: (id: string) => void; onToggleComments: (id: string) => void;
onEstimate: (id: string) => void; onEstimate: (id: string) => void;
onViewEstimate: (idea: Idea) => void; onViewEstimate: (idea: Idea) => void;
onSpecification: (idea: Idea) => void; onSpecification: (idea: Idea) => void;
onViewDetails: (idea: Idea) => void;
expandedId: string | null; expandedId: string | null;
estimatingId: string | null; estimatingId: string | null;
generatingSpecificationId: string | null; generatingSpecificationId: string | null;
estimateConfig?: EstimateConfig;
} }
export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEstimate, onSpecification, expandedId, estimatingId, generatingSpecificationId }: ColumnsConfig) => [ export const createColumns = ({
onDelete,
onToggleComments,
onEstimate,
onViewEstimate,
onSpecification,
onViewDetails,
expandedId,
estimatingId,
generatingSpecificationId,
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
}: ColumnsConfig) => [
columnHelper.display({ columnHelper.display({
id: 'drag', id: 'drag',
header: '', header: '',
@ -167,6 +197,60 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
), ),
size: 150, size: 150,
}), }),
columnHelper.accessor('pain', {
header: 'Боль',
cell: (info) => {
const value = info.getValue();
return (
<EditableCell
idea={info.row.original}
field="pain"
value={value}
renderDisplay={(val) => {
if (!val) return '—';
return val.length > 60 ? `${val.slice(0, 60)}...` : val;
}}
/>
);
},
size: 180,
}),
columnHelper.accessor('aiRole', {
header: 'Роль AI',
cell: (info) => {
const value = info.getValue();
return (
<EditableCell
idea={info.row.original}
field="aiRole"
value={value}
renderDisplay={(val) => {
if (!val) return '—';
return val.length > 60 ? `${val.slice(0, 60)}...` : val;
}}
/>
);
},
size: 180,
}),
columnHelper.accessor('verificationMethod', {
header: 'Способ проверки',
cell: (info) => {
const value = info.getValue();
return (
<EditableCell
idea={info.row.original}
field="verificationMethod"
value={value}
renderDisplay={(val) => {
if (!val) return '—';
return val.length > 60 ? `${val.slice(0, 60)}...` : val;
}}
/>
);
},
size: 180,
}),
columnHelper.accessor('description', { columnHelper.accessor('description', {
header: 'Описание', header: 'Описание',
cell: (info) => { cell: (info) => {
@ -217,7 +301,7 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
> >
<AccessTime fontSize="small" color="action" /> <AccessTime fontSize="small" color="action" />
<Typography variant="body2"> <Typography variant="body2">
{formatHoursShort(idea.estimatedHours)} {formatEstimate(idea.estimatedHours, estimateConfig)}
</Typography> </Typography>
{idea.complexity && ( {idea.complexity && (
<Chip <Chip
@ -246,7 +330,19 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
const hasSpecification = !!idea.specification; const hasSpecification = !!idea.specification;
return ( return (
<Box sx={{ display: 'flex', gap: 0.5 }}> <Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title={hasSpecification ? 'Просмотр ТЗ' : 'Сгенерировать ТЗ'}> <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> <span>
<IconButton <IconButton
size="small" size="small"
@ -254,7 +350,10 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
disabled={isGeneratingSpec} disabled={isGeneratingSpec}
color={hasSpecification ? 'primary' : 'default'} color={hasSpecification ? 'primary' : 'default'}
data-testid="specification-button" data-testid="specification-button"
sx={{ opacity: hasSpecification ? 0.9 : 0.5, '&:hover': { opacity: 1 } }} sx={{
opacity: hasSpecification ? 0.9 : 0.5,
'&:hover': { opacity: 1 },
}}
> >
{isGeneratingSpec ? ( {isGeneratingSpec ? (
<CircularProgress size={18} /> <CircularProgress size={18} />
@ -290,7 +389,11 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
data-testid="toggle-comments-button" data-testid="toggle-comments-button"
sx={{ opacity: isExpanded ? 1 : 0.5, '&:hover': { opacity: 1 } }} sx={{ opacity: isExpanded ? 1 : 0.5, '&:hover': { opacity: 1 } }}
> >
{isExpanded ? <ExpandLess fontSize="small" /> : <Comment fontSize="small" />} {isExpanded ? (
<ExpandLess fontSize="small" />
) : (
<Comment fontSize="small" />
)}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<IconButton <IconButton
@ -304,6 +407,6 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
</Box> </Box>
); );
}, },
size: 150, size: 180,
}), }),
]; ];

View File

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

View File

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

View File

@ -17,7 +17,6 @@ import {
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
ListItemSecondaryAction,
Divider, Divider,
Chip, Chip,
} from '@mui/material'; } from '@mui/material';
@ -84,7 +83,8 @@ export function SpecificationModal({
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editedText, setEditedText] = useState(''); const [editedText, setEditedText] = useState('');
const [tabIndex, setTabIndex] = useState(0); const [tabIndex, setTabIndex] = useState(0);
const [viewingHistoryItem, setViewingHistoryItem] = useState<SpecificationHistoryItem | null>(null); const [viewingHistoryItem, setViewingHistoryItem] =
useState<SpecificationHistoryItem | null>(null);
// Сбрасываем состояние при открытии/закрытии // Сбрасываем состояние при открытии/закрытии
useEffect(() => { useEffect(() => {
@ -97,12 +97,12 @@ export function SpecificationModal({
}, [open, specification]); }, [open, specification]);
const handleEdit = () => { const handleEdit = () => {
setEditedText(specification || ''); setEditedText(specification ?? '');
setIsEditing(true); setIsEditing(true);
}; };
const handleCancel = () => { const handleCancel = () => {
setEditedText(specification || ''); setEditedText(specification ?? '');
setIsEditing(false); setIsEditing(false);
}; };
@ -152,7 +152,13 @@ export function SpecificationModal({
fullWidth fullWidth
data-testid="specification-modal" data-testid="specification-modal"
> >
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> <DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<Box> <Box>
<Typography variant="h6" component="span"> <Typography variant="h6" component="span">
Техническое задание Техническое задание
@ -194,7 +200,7 @@ export function SpecificationModal({
{hasHistory && !isEditing && !viewingHistoryItem && ( {hasHistory && !isEditing && !viewingHistoryItem && (
<Tabs <Tabs
value={tabIndex} value={tabIndex}
onChange={(_, newValue) => setTabIndex(newValue)} onChange={(_, newValue: number) => setTabIndex(newValue)}
sx={{ px: 3, borderBottom: 1, borderColor: 'divider' }} sx={{ px: 3, borderBottom: 1, borderColor: 'divider' }}
> >
<Tab label="Текущее ТЗ" data-testid="specification-tab-current" /> <Tab label="Текущее ТЗ" data-testid="specification-tab-current" />
@ -225,7 +231,9 @@ export function SpecificationModal({
<IconButton <IconButton
size="small" size="small"
color="primary" color="primary"
onClick={() => handleRestoreFromHistory(viewingHistoryItem.id)} onClick={() =>
handleRestoreFromHistory(viewingHistoryItem.id)
}
disabled={isRestoring} disabled={isRestoring}
data-testid="specification-restore-button" data-testid="specification-restore-button"
> >
@ -236,7 +244,8 @@ export function SpecificationModal({
{viewingHistoryItem.ideaDescriptionSnapshot && ( {viewingHistoryItem.ideaDescriptionSnapshot && (
<Alert severity="info" sx={{ mb: 2 }}> <Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="caption"> <Typography variant="caption">
Описание идеи на момент генерации: {viewingHistoryItem.ideaDescriptionSnapshot} Описание идеи на момент генерации:{' '}
{viewingHistoryItem.ideaDescriptionSnapshot}
</Typography> </Typography>
</Alert> </Alert>
)} )}
@ -248,7 +257,11 @@ export function SpecificationModal({
borderRadius: 1, borderRadius: 1,
maxHeight: '50vh', maxHeight: '50vh',
overflow: 'auto', overflow: 'auto',
'& h1, & h2, & h3, & h4, & h5, & h6': { mt: 2, mb: 1, '&:first-of-type': { mt: 0 } }, '& h1, & h2, & h3, & h4, & h5, & h6': {
mt: 2,
mb: 1,
'&:first-of-type': { mt: 0 },
},
'& h1': { fontSize: '1.5rem', fontWeight: 600 }, '& h1': { fontSize: '1.5rem', fontWeight: 600 },
'& h2': { fontSize: '1.25rem', fontWeight: 600 }, '& h2': { fontSize: '1.25rem', fontWeight: 600 },
'& h3': { fontSize: '1.1rem', fontWeight: 600 }, '& h3': { fontSize: '1.1rem', fontWeight: 600 },
@ -256,9 +269,29 @@ export function SpecificationModal({
'& ul, & ol': { pl: 3, mb: 1.5 }, '& ul, & ol': { pl: 3, mb: 1.5 },
'& li': { mb: 0.5 }, '& li': { mb: 0.5 },
'& strong': { fontWeight: 600 }, '& strong': { fontWeight: 600 },
'& code': { bgcolor: 'grey.200', px: 0.5, py: 0.25, borderRadius: 0.5, fontFamily: 'monospace', fontSize: '0.875em' }, '& code': {
'& pre': { bgcolor: 'grey.200', p: 1.5, borderRadius: 1, overflow: 'auto', '& code': { bgcolor: 'transparent', p: 0 } }, bgcolor: 'grey.200',
'& blockquote': { borderLeft: 3, borderColor: 'primary.main', pl: 2, ml: 0, fontStyle: 'italic', color: 'text.secondary' }, 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> <Markdown>{viewingHistoryItem.specification}</Markdown>
@ -272,7 +305,11 @@ export function SpecificationModal({
<TabPanel value={tabIndex} index={0}> <TabPanel value={tabIndex} index={0}>
{isLoading && ( {isLoading && (
<Box sx={{ py: 4 }} data-testid="specification-loading"> <Box sx={{ py: 4 }} data-testid="specification-loading">
<Typography variant="body2" color="text.secondary" gutterBottom> <Typography
variant="body2"
color="text.secondary"
gutterBottom
>
Генерируем техническое задание... Генерируем техническое задание...
</Typography> </Typography>
<LinearProgress /> <LinearProgress />
@ -280,7 +317,11 @@ export function SpecificationModal({
)} )}
{error && ( {error && (
<Alert severity="error" sx={{ my: 2 }} data-testid="specification-error"> <Alert
severity="error"
sx={{ my: 2 }}
data-testid="specification-error"
>
{error.message || 'Не удалось сгенерировать ТЗ'} {error.message || 'Не удалось сгенерировать ТЗ'}
</Alert> </Alert>
)} )}
@ -307,7 +348,11 @@ export function SpecificationModal({
{!isLoading && !error && !isEditing && specification && ( {!isLoading && !error && !isEditing && specification && (
<Box> <Box>
{idea?.specificationGeneratedAt && ( {idea?.specificationGeneratedAt && (
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}> <Typography
variant="caption"
color="text.secondary"
sx={{ mb: 1, display: 'block' }}
>
Сгенерировано: {formatDate(idea.specificationGeneratedAt)} Сгенерировано: {formatDate(idea.specificationGeneratedAt)}
</Typography> </Typography>
)} )}
@ -319,7 +364,11 @@ export function SpecificationModal({
borderRadius: 1, borderRadius: 1,
maxHeight: '55vh', maxHeight: '55vh',
overflow: 'auto', overflow: 'auto',
'& h1, & h2, & h3, & h4, & h5, & h6': { mt: 2, mb: 1, '&:first-of-type': { mt: 0 } }, '& h1, & h2, & h3, & h4, & h5, & h6': {
mt: 2,
mb: 1,
'&:first-of-type': { mt: 0 },
},
'& h1': { fontSize: '1.5rem', fontWeight: 600 }, '& h1': { fontSize: '1.5rem', fontWeight: 600 },
'& h2': { fontSize: '1.25rem', fontWeight: 600 }, '& h2': { fontSize: '1.25rem', fontWeight: 600 },
'& h3': { fontSize: '1.1rem', fontWeight: 600 }, '& h3': { fontSize: '1.1rem', fontWeight: 600 },
@ -327,9 +376,29 @@ export function SpecificationModal({
'& ul, & ol': { pl: 3, mb: 1.5 }, '& ul, & ol': { pl: 3, mb: 1.5 },
'& li': { mb: 0.5 }, '& li': { mb: 0.5 },
'& strong': { fontWeight: 600 }, '& strong': { fontWeight: 600 },
'& code': { bgcolor: 'grey.200', px: 0.5, py: 0.25, borderRadius: 0.5, fontFamily: 'monospace', fontSize: '0.875em' }, '& code': {
'& pre': { bgcolor: 'grey.200', p: 1.5, borderRadius: 1, overflow: 'auto', '& code': { bgcolor: 'transparent', p: 0 } }, bgcolor: 'grey.200',
'& blockquote': { borderLeft: 3, borderColor: 'primary.main', pl: 2, ml: 0, fontStyle: 'italic', color: 'text.secondary' }, 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> <Markdown>{specification}</Markdown>
@ -353,12 +422,54 @@ export function SpecificationModal({
<Box key={item.id}> <Box key={item.id}>
{index > 0 && <Divider />} {index > 0 && <Divider />}
<ListItem <ListItem
data-testid={`specification-history-item-${index}`} data-testid={`specification-history-item-${String(index)}`}
sx={{ pr: 16 }} 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 <ListItemText
primary={ primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Typography variant="body2"> <Typography variant="body2">
{formatDate(item.createdAt)} {formatDate(item.createdAt)}
</Typography> </Typography>
@ -387,38 +498,6 @@ export function SpecificationModal({
</Typography> </Typography>
} }
/> />
<ListItemSecondaryAction>
<Tooltip title="Просмотреть">
<IconButton
size="small"
onClick={() => handleViewHistoryItem(item)}
data-testid={`specification-history-view-${index}`}
>
<Visibility fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Восстановить">
<IconButton
size="small"
color="primary"
onClick={() => handleRestoreFromHistory(item.id)}
disabled={isRestoring}
data-testid={`specification-history-restore-${index}`}
>
<Restore fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Удалить">
<IconButton
size="small"
color="error"
onClick={() => onDeleteHistoryItem(item.id)}
data-testid={`specification-history-delete-${index}`}
>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem> </ListItem>
</Box> </Box>
))} ))}
@ -446,9 +525,7 @@ export function SpecificationModal({
</Button> </Button>
</> </>
) : viewingHistoryItem ? ( ) : viewingHistoryItem ? (
<Button onClick={handleCloseHistoryView}> <Button onClick={handleCloseHistoryView}>Назад к текущему ТЗ</Button>
Назад к текущему ТЗ
</Button>
) : ( ) : (
<Button <Button
onClick={onClose} onClick={onClose}

View File

@ -20,7 +20,12 @@ import {
Alert, Alert,
} from '@mui/material'; } from '@mui/material';
import { Add, Edit, Delete } from '@mui/icons-material'; import { Add, Edit, Delete } from '@mui/icons-material';
import { useRolesQuery, useCreateRole, useUpdateRole, useDeleteRole } from '../../hooks/useRoles'; import {
useRolesQuery,
useCreateRole,
useUpdateRole,
useDeleteRole,
} from '../../hooks/useRoles';
import type { Role, CreateRoleDto } from '../../types/team'; import type { Role, CreateRoleDto } from '../../types/team';
interface RoleModalProps { interface RoleModalProps {
@ -74,7 +79,13 @@ function RoleModal({ open, onClose, role }: RoleModalProps) {
}; };
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth data-testid="role-modal"> <Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
data-testid="role-modal"
>
<form onSubmit={handleSubmit} data-testid="role-form"> <form onSubmit={handleSubmit} data-testid="role-form">
<DialogTitle> <DialogTitle>
{isEditing ? 'Редактировать роль' : 'Добавить роль'} {isEditing ? 'Редактировать роль' : 'Добавить роль'}
@ -107,7 +118,9 @@ function RoleModal({ open, onClose, role }: RoleModalProps) {
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose} data-testid="cancel-role-button">Отмена</Button> <Button onClick={onClose} data-testid="cancel-role-button">
Отмена
</Button>
<Button <Button
type="submit" type="submit"
variant="contained" variant="contained"
@ -160,15 +173,31 @@ export function RolesManager() {
return ( return (
<Box data-testid="roles-manager"> <Box data-testid="roles-manager">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="h6">Управление ролями</Typography> <Typography variant="h6">Управление ролями</Typography>
<Button variant="contained" startIcon={<Add />} onClick={handleAdd} data-testid="add-role-button"> <Button
variant="contained"
startIcon={<Add />}
onClick={handleAdd}
data-testid="add-role-button"
>
Добавить роль Добавить роль
</Button> </Button>
</Box> </Box>
{deleteError && ( {deleteError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError('')}> <Alert
severity="error"
sx={{ mb: 2 }}
onClose={() => setDeleteError('')}
>
{deleteError} {deleteError}
</Alert> </Alert>
)} )}
@ -183,35 +212,58 @@ export function RolesManager() {
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}> <TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>
Отображаемое название Отображаемое название
</TableCell> </TableCell>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} align="center"> <TableCell
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
align="center"
>
Порядок Порядок
</TableCell> </TableCell>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} /> <TableCell
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
/>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
Array.from({ length: 3 }).map((_, i) => ( Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i}> <TableRow key={i}>
<TableCell><Skeleton /></TableCell> <TableCell>
<TableCell><Skeleton /></TableCell> <Skeleton />
<TableCell><Skeleton /></TableCell> </TableCell>
<TableCell><Skeleton /></TableCell> <TableCell>
<Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
</TableRow> </TableRow>
)) ))
) : roles.length === 0 ? ( ) : roles.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} sx={{ textAlign: 'center', py: 4 }}> <TableCell colSpan={4} sx={{ textAlign: 'center', py: 4 }}>
<Typography color="text.secondary" data-testid="roles-empty-state"> <Typography
color="text.secondary"
data-testid="roles-empty-state"
>
Нет ролей. Добавьте первую роль. Нет ролей. Добавьте первую роль.
</Typography> </Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
roles.map((role) => ( roles.map((role) => (
<TableRow key={role.id} hover data-testid={`role-row-${role.id}`}> <TableRow
key={role.id}
hover
data-testid={`role-row-${role.id}`}
>
<TableCell> <TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}> <Typography
variant="body2"
sx={{ fontFamily: 'monospace' }}
>
{role.name} {role.name}
</Typography> </Typography>
</TableCell> </TableCell>
@ -244,7 +296,11 @@ export function RolesManager() {
</Table> </Table>
</TableContainer> </TableContainer>
<RoleModal open={modalOpen} onClose={handleModalClose} role={editingRole} /> <RoleModal
open={modalOpen}
onClose={handleModalClose}
role={editingRole}
/>
</Box> </Box>
); );
} }

View File

@ -34,10 +34,15 @@ const defaultProductivity: ProductivityMatrix = {
veryComplex: 60, veryComplex: 60,
}; };
export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps) { export function TeamMemberModal({
open,
onClose,
member,
}: TeamMemberModalProps) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [roleId, setRoleId] = useState(''); const [roleId, setRoleId] = useState('');
const [productivity, setProductivity] = useState<ProductivityMatrix>(defaultProductivity); const [productivity, setProductivity] =
useState<ProductivityMatrix>(defaultProductivity);
const { data: roles = [], isLoading: rolesLoading } = useRolesQuery(); const { data: roles = [], isLoading: rolesLoading } = useRolesQuery();
const createMember = useCreateTeamMember(); const createMember = useCreateTeamMember();
@ -72,7 +77,10 @@ export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps)
onClose(); onClose();
}; };
const handleProductivityChange = (key: keyof ProductivityMatrix, value: string) => { const handleProductivityChange = (
key: keyof ProductivityMatrix,
value: string,
) => {
const num = parseFloat(value) || 0; const num = parseFloat(value) || 0;
setProductivity((prev) => ({ ...prev, [key]: num })); setProductivity((prev) => ({ ...prev, [key]: num }));
}; };
@ -80,7 +88,13 @@ export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps)
const isPending = createMember.isPending || updateMember.isPending; const isPending = createMember.isPending || updateMember.isPending;
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth data-testid="team-member-modal"> <Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
data-testid="team-member-modal"
>
<form onSubmit={handleSubmit} data-testid="team-member-form"> <form onSubmit={handleSubmit} data-testid="team-member-form">
<DialogTitle> <DialogTitle>
{isEditing ? 'Редактировать участника' : 'Добавить участника'} {isEditing ? 'Редактировать участника' : 'Добавить участника'}
@ -120,31 +134,47 @@ export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps)
Производительность (часы на задачу) Производительность (часы на задачу)
</Typography> </Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}> <Box
{(Object.entries(complexityLabels) as [keyof ProductivityMatrix, string][]).map( sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}
([key, label]) => ( >
{(
Object.entries(complexityLabels) as [
keyof ProductivityMatrix,
string,
][]
).map(([key, label]) => (
<TextField <TextField
key={key} key={key}
label={label} label={label}
type="number" type="number"
size="small" size="small"
value={productivity[key]} value={productivity[key]}
onChange={(e) => handleProductivityChange(key, e.target.value)} onChange={(e) =>
handleProductivityChange(key, e.target.value)
}
slotProps={{ slotProps={{
input: { input: {
endAdornment: <InputAdornment position="end">ч</InputAdornment>, endAdornment: (
<InputAdornment position="end">ч</InputAdornment>
),
}, },
htmlInput: { min: 0, step: 0.5 }, htmlInput: { min: 0, step: 0.5 },
}} }}
/> />
), ))}
)}
</Box> </Box>
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose} data-testid="cancel-member-button">Отмена</Button> <Button onClick={onClose} data-testid="cancel-member-button">
<Button type="submit" variant="contained" disabled={!name.trim() || !roleId || isPending} data-testid="submit-member-button"> Отмена
</Button>
<Button
type="submit"
variant="contained"
disabled={!name.trim() || !roleId || isPending}
data-testid="submit-member-button"
>
{isEditing ? 'Сохранить' : 'Добавить'} {isEditing ? 'Сохранить' : 'Добавить'}
</Button> </Button>
</DialogActions> </DialogActions>

View File

@ -19,7 +19,11 @@ import {
Tab, Tab,
} from '@mui/material'; } from '@mui/material';
import { Add, Edit, Delete, Group, Settings } from '@mui/icons-material'; import { Add, Edit, Delete, Group, Settings } from '@mui/icons-material';
import { useTeamQuery, useTeamSummaryQuery, useDeleteTeamMember } from '../../hooks/useTeam'; import {
useTeamQuery,
useTeamSummaryQuery,
useDeleteTeamMember,
} from '../../hooks/useTeam';
import { complexityLabels } from '../../types/team'; import { complexityLabels } from '../../types/team';
import type { TeamMember, ProductivityMatrix } from '../../types/team'; import type { TeamMember, ProductivityMatrix } from '../../types/team';
import { TeamMemberModal } from './TeamMemberModal'; import { TeamMemberModal } from './TeamMemberModal';
@ -56,9 +60,19 @@ export function TeamPage() {
<Box data-testid="team-page"> <Box data-testid="team-page">
{/* Вкладки */} {/* Вкладки */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}> <Tabs value={activeTab} onChange={(_, v: number) => setActiveTab(v)}>
<Tab icon={<Group />} iconPosition="start" label="Участники" data-testid="team-tab-members" /> <Tab
<Tab icon={<Settings />} iconPosition="start" label="Роли" data-testid="team-tab-roles" /> icon={<Group />}
iconPosition="start"
label="Участники"
data-testid="team-tab-members"
/>
<Tab
icon={<Settings />}
iconPosition="start"
label="Роли"
data-testid="team-tab-roles"
/>
</Tabs> </Tabs>
</Box> </Box>
@ -66,12 +80,20 @@ export function TeamPage() {
<> <>
{/* Сводка по ролям */} {/* Сводка по ролям */}
<Box sx={{ mb: 3 }} data-testid="team-summary"> <Box sx={{ mb: 3 }} data-testid="team-summary">
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}> <Typography
variant="h6"
sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
>
<Group /> Состав команды ({totalMembers}) <Group /> Состав команды ({totalMembers})
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{summary.map((item) => ( {summary.map((item) => (
<Card key={item.roleId} variant="outlined" sx={{ minWidth: 150 }} data-testid={`role-card-${item.roleId}`}> <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 } }}> <CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="h4" sx={{ fontWeight: 600 }}> <Typography variant="h4" sx={{ fontWeight: 600 }}>
{item.count} {item.count}
@ -86,9 +108,21 @@ export function TeamPage() {
</Box> </Box>
{/* Таблица участников */} {/* Таблица участников */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="h6">Участники</Typography> <Typography variant="h6">Участники</Typography>
<Button variant="contained" startIcon={<Add />} onClick={handleAdd} data-testid="add-team-member-button"> <Button
variant="contained"
startIcon={<Add />}
onClick={handleAdd}
data-testid="add-team-member-button"
>
Добавить Добавить
</Button> </Button>
</Box> </Box>
@ -97,48 +131,91 @@ export function TeamPage() {
<Table size="small" data-testid="team-table"> <Table size="small" data-testid="team-table">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>Имя</TableCell> <TableCell
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>Роль</TableCell> sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
{(Object.keys(complexityLabels) as (keyof ProductivityMatrix)[]).map((key) => ( >
Имя
</TableCell>
<TableCell
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
>
Роль
</TableCell>
{(
Object.keys(
complexityLabels,
) as (keyof ProductivityMatrix)[]
).map((key) => (
<TableCell <TableCell
key={key} key={key}
align="center" align="center"
sx={{ fontWeight: 600, backgroundColor: 'grey.100', fontSize: '0.75rem' }} sx={{
fontWeight: 600,
backgroundColor: 'grey.100',
fontSize: '0.75rem',
}}
> >
{complexityLabels[key]} {complexityLabels[key]}
</TableCell> </TableCell>
))} ))}
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} /> <TableCell
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
/>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
Array.from({ length: 3 }).map((_, i) => ( Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i}> <TableRow key={i}>
<TableCell><Skeleton /></TableCell> <TableCell>
<TableCell><Skeleton /></TableCell> <Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
{Array.from({ length: 5 }).map((_, j) => ( {Array.from({ length: 5 }).map((_, j) => (
<TableCell key={j}><Skeleton /></TableCell> <TableCell key={j}>
<Skeleton />
</TableCell>
))} ))}
<TableCell><Skeleton /></TableCell> <TableCell>
<Skeleton />
</TableCell>
</TableRow> </TableRow>
)) ))
) : members.length === 0 ? ( ) : members.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={8} sx={{ textAlign: 'center', py: 4 }}> <TableCell colSpan={8} sx={{ textAlign: 'center', py: 4 }}>
<Typography color="text.secondary" data-testid="team-empty-state"> <Typography
color="text.secondary"
data-testid="team-empty-state"
>
Команда пока пуста. Добавьте первого участника. Команда пока пуста. Добавьте первого участника.
</Typography> </Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
members.map((member) => ( members.map((member) => (
<TableRow key={member.id} hover data-testid={`team-member-row-${member.id}`}> <TableRow
<TableCell sx={{ fontWeight: 500 }}>{member.name}</TableCell> key={member.id}
<TableCell> hover
<Chip label={member.role.label} size="small" variant="outlined" /> data-testid={`team-member-row-${member.id}`}
>
<TableCell sx={{ fontWeight: 500 }}>
{member.name}
</TableCell> </TableCell>
{(Object.keys(complexityLabels) as (keyof ProductivityMatrix)[]).map((key) => ( <TableCell>
<Chip
label={member.role.label}
size="small"
variant="outlined"
/>
</TableCell>
{(
Object.keys(
complexityLabels,
) as (keyof ProductivityMatrix)[]
).map((key) => (
<TableCell key={key} align="center"> <TableCell key={key} align="center">
{member.productivity[key]}ч {member.productivity[key]}ч
</TableCell> </TableCell>

View File

@ -24,7 +24,9 @@ export function useGenerateSpecification() {
onSuccess: (_, ideaId) => { onSuccess: (_, ideaId) => {
// Инвалидируем кэш идей и историю // Инвалидируем кэш идей и историю
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] }); void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY, ideaId] }); void queryClient.invalidateQueries({
queryKey: [SPECIFICATION_HISTORY_KEY, ideaId],
});
}, },
}); });
} }
@ -32,7 +34,10 @@ export function useGenerateSpecification() {
export function useSpecificationHistory(ideaId: string | null) { export function useSpecificationHistory(ideaId: string | null) {
return useQuery({ return useQuery({
queryKey: [SPECIFICATION_HISTORY_KEY, ideaId], queryKey: [SPECIFICATION_HISTORY_KEY, ideaId],
queryFn: () => aiApi.getSpecificationHistory(ideaId!), queryFn: () => {
if (!ideaId) throw new Error('ideaId is required');
return aiApi.getSpecificationHistory(ideaId);
},
enabled: !!ideaId, enabled: !!ideaId,
}); });
} }
@ -41,10 +46,13 @@ export function useDeleteSpecificationHistoryItem() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (historyId: string) => aiApi.deleteSpecificationHistoryItem(historyId), mutationFn: (historyId: string) =>
aiApi.deleteSpecificationHistoryItem(historyId),
onSuccess: () => { onSuccess: () => {
// Инвалидируем все запросы истории // Инвалидируем все запросы истории
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY] }); void queryClient.invalidateQueries({
queryKey: [SPECIFICATION_HISTORY_KEY],
});
}, },
}); });
} }
@ -53,11 +61,14 @@ export function useRestoreSpecificationFromHistory() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (historyId: string) => aiApi.restoreSpecificationFromHistory(historyId), mutationFn: (historyId: string) =>
aiApi.restoreSpecificationFromHistory(historyId),
onSuccess: () => { onSuccess: () => {
// Инвалидируем кэш идей и историю // Инвалидируем кэш идей и историю
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] }); void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY] }); void queryClient.invalidateQueries({
queryKey: [SPECIFICATION_HISTORY_KEY],
});
}, },
}); });
} }

View File

@ -8,19 +8,22 @@ export interface User {
} }
export function useAuth() { export function useAuth() {
const tokenParsed = keycloak.tokenParsed as { const tokenParsed = keycloak.tokenParsed as
| {
sub?: string; sub?: string;
name?: string; name?: string;
preferred_username?: string; preferred_username?: string;
email?: string; email?: string;
given_name?: string; given_name?: string;
family_name?: string; family_name?: string;
} | undefined; }
| undefined;
const user: User | null = tokenParsed const user: User | null = tokenParsed
? { ? {
id: tokenParsed.sub ?? '', id: tokenParsed.sub ?? '',
name: tokenParsed.name ?? tokenParsed.preferred_username ?? 'Пользователь', name:
tokenParsed.name ?? tokenParsed.preferred_username ?? 'Пользователь',
email: tokenParsed.email ?? '', email: tokenParsed.email ?? '',
username: tokenParsed.preferred_username ?? '', username: tokenParsed.preferred_username ?? '',
} }
@ -32,7 +35,7 @@ export function useAuth() {
return { return {
user, user,
isAuthenticated: keycloak.authenticated ?? false, isAuthenticated: keycloak.authenticated,
logout, logout,
}; };
} }

View File

@ -5,7 +5,10 @@ import type { CreateCommentDto } from '../types/comment';
export function useCommentsQuery(ideaId: string | null) { export function useCommentsQuery(ideaId: string | null) {
return useQuery({ return useQuery({
queryKey: ['comments', ideaId], queryKey: ['comments', ideaId],
queryFn: () => commentsApi.getByIdeaId(ideaId!), queryFn: () => {
if (!ideaId) throw new Error('ideaId is required');
return commentsApi.getByIdeaId(ideaId);
},
enabled: !!ideaId, enabled: !!ideaId,
}); });
} }
@ -17,7 +20,9 @@ export function useCreateComment() {
mutationFn: ({ ideaId, dto }: { ideaId: string; dto: CreateCommentDto }) => mutationFn: ({ ideaId, dto }: { ideaId: string; dto: CreateCommentDto }) =>
commentsApi.create(ideaId, dto), commentsApi.create(ideaId, dto),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['comments', variables.ideaId] }); void queryClient.invalidateQueries({
queryKey: ['comments', variables.ideaId],
});
}, },
}); });
} }
@ -29,7 +34,9 @@ export function useDeleteComment() {
mutationFn: (params: { id: string; ideaId: string }) => mutationFn: (params: { id: string; ideaId: string }) =>
commentsApi.delete(params.id), commentsApi.delete(params.id),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['comments', variables.ideaId] }); void queryClient.invalidateQueries({
queryKey: ['comments', variables.ideaId],
});
}, },
}); });
} }

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

View File

@ -22,7 +22,7 @@ export function useCreateTeamMember() {
return useMutation({ return useMutation({
mutationFn: (dto: CreateTeamMemberDto) => teamApi.create(dto), mutationFn: (dto: CreateTeamMemberDto) => teamApi.create(dto),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team'] }); void queryClient.invalidateQueries({ queryKey: ['team'] });
}, },
}); });
} }
@ -34,7 +34,7 @@ export function useUpdateTeamMember() {
mutationFn: ({ id, dto }: { id: string; dto: UpdateTeamMemberDto }) => mutationFn: ({ id, dto }: { id: string; dto: UpdateTeamMemberDto }) =>
teamApi.update(id, dto), teamApi.update(id, dto),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team'] }); void queryClient.invalidateQueries({ queryKey: ['team'] });
}, },
}); });
} }
@ -45,7 +45,7 @@ export function useDeleteTeamMember() {
return useMutation({ return useMutation({
mutationFn: (id: string) => teamApi.delete(id), mutationFn: (id: string) => teamApi.delete(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team'] }); void queryClient.invalidateQueries({ queryKey: ['team'] });
}, },
}); });
} }

View File

@ -1,5 +1,10 @@
import { api } from './api'; import { api } from './api';
import type { IdeaComplexity, RoleEstimate, SpecificationResult, SpecificationHistoryItem } from '../types/idea'; import type {
IdeaComplexity,
RoleEstimate,
SpecificationResult,
SpecificationHistoryItem,
} from '../types/idea';
export interface EstimateResult { export interface EstimateResult {
ideaId: string; ideaId: string;
@ -17,13 +22,22 @@ export const aiApi = {
return data; return data;
}, },
generateSpecification: async (ideaId: string): Promise<SpecificationResult> => { generateSpecification: async (
const { data } = await api.post<SpecificationResult>('/ai/generate-specification', { ideaId }); ideaId: string,
): Promise<SpecificationResult> => {
const { data } = await api.post<SpecificationResult>(
'/ai/generate-specification',
{ ideaId },
);
return data; return data;
}, },
getSpecificationHistory: async (ideaId: string): Promise<SpecificationHistoryItem[]> => { getSpecificationHistory: async (
const { data } = await api.get<SpecificationHistoryItem[]>(`/ai/specification-history/${ideaId}`); ideaId: string,
): Promise<SpecificationHistoryItem[]> => {
const { data } = await api.get<SpecificationHistoryItem[]>(
`/ai/specification-history/${ideaId}`,
);
return data; return data;
}, },
@ -31,8 +45,12 @@ export const aiApi = {
await api.delete(`/ai/specification-history/${historyId}`); await api.delete(`/ai/specification-history/${historyId}`);
}, },
restoreSpecificationFromHistory: async (historyId: string): Promise<SpecificationResult> => { restoreSpecificationFromHistory: async (
const { data } = await api.post<SpecificationResult>(`/ai/specification-history/${historyId}/restore`); historyId: string,
): Promise<SpecificationResult> => {
const { data } = await api.post<SpecificationResult>(
`/ai/specification-history/${historyId}/restore`,
);
return data; return data;
}, },
}; };

View File

@ -8,7 +8,10 @@ export const commentsApi = {
}, },
create: async (ideaId: string, dto: CreateCommentDto): Promise<Comment> => { create: async (ideaId: string, dto: CreateCommentDto): Promise<Comment> => {
const response = await api.post<Comment>(`/api/ideas/${ideaId}/comments`, dto); const response = await api.post<Comment>(
`/api/ideas/${ideaId}/comments`,
dto,
);
return response.data; return response.data;
}, },

View File

@ -0,0 +1,14 @@
import { api } from './api';
import type { UserSettings } from '../types/settings';
export const settingsApi = {
get: async (): Promise<UserSettings> => {
const response = await api.get<UserSettings>('/api/settings');
return response.data;
},
update: async (patch: Partial<UserSettings>): Promise<UserSettings> => {
const response = await api.put<UserSettings>('/api/settings', patch);
return response.data;
},
};

View File

@ -1,5 +1,10 @@
import { api } from './api'; import { api } from './api';
import type { TeamMember, CreateTeamMemberDto, UpdateTeamMemberDto, TeamSummary } from '../types/team'; import type {
TeamMember,
CreateTeamMemberDto,
UpdateTeamMemberDto,
TeamSummary,
} from '../types/team';
export const teamApi = { export const teamApi = {
getAll: async (): Promise<TeamMember[]> => { getAll: async (): Promise<TeamMember[]> => {

View File

@ -6,7 +6,12 @@ export type IdeaStatus =
| 'cancelled'; | 'cancelled';
export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical'; export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical';
export type IdeaComplexity = 'trivial' | 'simple' | 'medium' | 'complex' | 'veryComplex'; export type IdeaComplexity =
| 'trivial'
| 'simple'
| 'medium'
| 'complex'
| 'veryComplex';
export interface RoleEstimate { export interface RoleEstimate {
role: string; role: string;

View File

@ -0,0 +1,4 @@
export interface UserSettings {
hoursPerDay?: number;
daysPerWeek?: number;
}

View File

@ -13,7 +13,7 @@ export interface CreateRoleDto {
sortOrder?: number; sortOrder?: number;
} }
export interface UpdateRoleDto extends Partial<CreateRoleDto> {} export type UpdateRoleDto = Partial<CreateRoleDto>;
export interface ProductivityMatrix { export interface ProductivityMatrix {
trivial: number; trivial: number;
@ -39,7 +39,7 @@ export interface CreateTeamMemberDto {
productivity?: ProductivityMatrix; productivity?: ProductivityMatrix;
} }
export interface UpdateTeamMemberDto extends Partial<CreateTeamMemberDto> {} export type UpdateTeamMemberDto = Partial<CreateTeamMemberDto>;
export interface TeamSummary { export interface TeamSummary {
roleId: string; roleId: string;

View File

@ -0,0 +1,75 @@
export interface EstimateConfig {
hoursPerDay: number;
daysPerWeek: number;
}
export const DEFAULT_ESTIMATE_CONFIG: EstimateConfig = {
hoursPerDay: 8,
daysPerWeek: 5,
};
/**
* Parse estimate string like "1w 3d 7h" into total hours.
* Also accepts a plain number (treated as hours for backwards compatibility).
* Returns null for empty/invalid input.
*/
export function parseEstimate(
input: string,
config: EstimateConfig = DEFAULT_ESTIMATE_CONFIG,
): number | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Plain number → hours
if (/^\d+(\.\d+)?$/.test(trimmed)) {
return Number(trimmed);
}
const weekMatch = /(\d+)\s*w/i.exec(trimmed);
const dayMatch = /(\d+)\s*d/i.exec(trimmed);
const hourMatch = /(\d+)\s*h/i.exec(trimmed);
if (!weekMatch && !dayMatch && !hourMatch) return null;
const weeks = weekMatch ? Number(weekMatch[1]) : 0;
const days = dayMatch ? Number(dayMatch[1]) : 0;
const hours = hourMatch ? Number(hourMatch[1]) : 0;
return weeks * config.daysPerWeek * config.hoursPerDay +
days * config.hoursPerDay +
hours;
}
/**
* Format hours into "1w 3d 7h" string.
* Returns "—" for null/0.
*/
export function formatEstimate(
hours: number | null | undefined,
config: EstimateConfig = DEFAULT_ESTIMATE_CONFIG,
): string {
if (!hours) return '—';
const hoursPerWeek = config.daysPerWeek * config.hoursPerDay;
const weeks = Math.floor(hours / hoursPerWeek);
let remaining = hours % hoursPerWeek;
const days = Math.floor(remaining / config.hoursPerDay);
remaining = remaining % config.hoursPerDay;
const parts: string[] = [];
if (weeks > 0) parts.push(`${String(weeks)}w`);
if (days > 0) parts.push(`${String(days)}d`);
if (remaining > 0) parts.push(`${String(remaining)}h`);
return parts.length > 0 ? parts.join(' ') : '—';
}
/**
* Short format for table cells — same as formatEstimate.
*/
export function formatEstimateShort(
hours: number | null | undefined,
config: EstimateConfig = DEFAULT_ESTIMATE_CONFIG,
): string {
return formatEstimate(hours, config);
}

View File

@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

View File

@ -1,27 +1,13 @@
{ {
"extends": "@vigdorov/typescript-config/react",
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"], "types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"] "include": ["src"]

View File

@ -1,25 +1,17 @@
{ {
"extends": "@vigdorov/typescript-config/base",
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023", "target": "ES2023",
"lib": ["ES2023"], "lib": ["ES2023"],
"module": "ESNext",
"types": ["node"], "types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]

View File

@ -1,11 +1,12 @@
import { defineConfig } from 'vite' import {spa} from '@vigdorov/vite-config';
import react from '@vitejs/plugin-react' import {mergeConfig} from 'vite';
// https://vite.dev/config/ export default mergeConfig(
export default defineConfig({ spa({
plugins: [react()],
server: {
port: 4000, port: 4000,
}),
{
server: {
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:4001', target: 'http://localhost:4001',
@ -13,4 +14,5 @@ export default defineConfig({
}, },
}, },
}, },
}) },
);

View File

@ -1,73 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: team-planner-backend
spec:
replicas: 1
selector:
matchLabels:
app: team-planner-backend
template:
metadata:
labels:
app: team-planner-backend
spec:
imagePullSecrets:
- name: harbor-creds
containers:
- name: team-planner-backend
image: __BACKEND_IMAGE__
ports:
- containerPort: 4001
env:
- name: NODE_ENV
value: "production"
- name: PORT
value: "4001"
- name: DB_HOST
value: "postgres-service"
- name: DB_PORT
value: "5432"
- name: DB_DATABASE
valueFrom:
secretKeyRef:
name: team-planner-secrets
key: db-name
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: team-planner-secrets
key: db-user
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: team-planner-secrets
key: db-password
- name: KEYCLOAK_REALM_URL
value: "https://auth.vigdorov.ru/realms/team-planner"
- name: AI_PROXY_BASE_URL
value: "http://ai-proxy-service.ai-proxy.svc.cluster.local:3000"
- name: AI_PROXY_API_KEY
valueFrom:
secretKeyRef:
name: team-planner-secrets
key: ai-proxy-api-key
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /api/health
port: 4001
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/health
port: 4001
initialDelaySeconds: 10
periodSeconds: 5

View File

@ -1,12 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: team-planner-backend-service
spec:
selector:
app: team-planner-backend
ports:
- protocol: TCP
port: 4001
targetPort: 4001
type: ClusterIP

View File

@ -1,40 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: team-planner-frontend
spec:
replicas: 1
selector:
matchLabels:
app: team-planner-frontend
template:
metadata:
labels:
app: team-planner-frontend
spec:
imagePullSecrets:
- name: harbor-creds
containers:
- name: team-planner-frontend
image: __FRONTEND_IMAGE__
ports:
- containerPort: 80
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 5
periodSeconds: 5

View File

@ -1,12 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: team-planner-frontend-service
spec:
selector:
app: team-planner-frontend
ports:
- protocol: TCP
port: 80
targetPort: 80
type: ClusterIP

View File

@ -1,33 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: team-planner-ingress
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- __HOSTNAME__
secretName: __SECRET_NAME__
rules:
- host: __HOSTNAME__
http:
paths:
# Backend API routes
- path: /api
pathType: Prefix
backend:
service:
name: team-planner-backend-service
port:
number: 4001
# Frontend routes (all other paths)
- path: /
pathType: Prefix
backend:
service:
name: team-planner-frontend-service
port:
number: 80

View File

@ -1,11 +0,0 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: local-path

View File

@ -1,12 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: postgres-service
spec:
selector:
app: postgres
ports:
- protocol: TCP
port: 5432
targetPort: 5432
type: ClusterIP

View File

@ -1,71 +0,0 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres-service
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:15-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: team-planner-secrets
key: db-name
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: team-planner-secrets
key: db-user
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: team-planner-secrets
key: db-password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
exec:
command:
- sh
- -c
- 'pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
exec:
command:
- sh
- -c
- 'pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 5
volumes:
- name: postgres-storage
persistentVolumeClaim:
claimName: postgres-pvc

View File

@ -1,16 +0,0 @@
# This is an example file. Create the actual secrets.yaml with your real values
# DO NOT commit secrets.yaml to git!
#
# To create the secrets in your cluster, run:
# kubectl create -f secrets.yaml -n prod-ns
apiVersion: v1
kind: Secret
metadata:
name: team-planner-secrets
type: Opaque
stringData:
# PostgreSQL credentials
db-name: "teamplanner"
db-user: "teamplanner"
db-password: "CHANGE_ME_STRONG_PASSWORD"

View File

@ -1,5 +1,5 @@
<#import "template.ftl" as layout> <#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password'); section> <@layout.registrationLayout displayMessage=true; section>
<#if section = "form"> <#if section = "form">
<form id="kc-form-login" action="${url.loginAction}" method="post"> <form id="kc-form-login" action="${url.loginAction}" method="post">
<div class="form-group"> <div class="form-group">

9463
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,21 +4,28 @@
"private": true, "private": true,
"workspaces": [ "workspaces": [
"backend", "backend",
"frontend" "frontend",
"tests"
], ],
"scripts": { "scripts": {
"dev": "concurrently -n be,fe -c blue,green \"npm run dev:backend\" \"npm run dev:frontend\"", "dev": "concurrently -n be,fe -c blue,green \"npm run dev:backend\" \"npm run dev:frontend\"",
"dev:backend": "npm run dev -w backend", "dev:backend": "npm run dev -w backend",
"dev:frontend": "npm run dev -w frontend", "dev:frontend": "npm run dev -w frontend",
"lint": "npm run -w backend lint && npm run -w frontend lint", "lint": "npm run -w backend lint && npm run -w frontend lint",
"format": "npm run -w backend format && npm run -w frontend format",
"build": "npm run build:backend && npm run build:frontend", "build": "npm run build:backend && npm run build:frontend",
"build:backend": "npm run build -w backend", "build:backend": "npm run build -w backend",
"build:frontend": "npm run build -w frontend", "build:frontend": "npm run build -w frontend",
"test": "npm run test -w tests",
"test:ui": "npm run test:ui -w tests",
"test:headed": "npm run test:headed -w tests",
"db:up": "docker-compose up -d postgres", "db:up": "docker-compose up -d postgres",
"db:down": "docker-compose down" "db:down": "docker-compose down"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "@vigdorov/prettier-config": "^1.0.0",
"concurrently": "^9.1.2",
"prettier": "^3.5.3"
}, },
"dependencies": { "dependencies": {
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",

45
service.yaml Normal file
View File

@ -0,0 +1,45 @@
service:
name: team-planner
type: full-stack
backend:
context: .
port: 4001
healthcheck: /api/health
resources:
cpu: 250m
memory: 256Mi
frontend:
context: .
buildArgs:
VITE_KEYCLOAK_URL: "https://auth.vigdorov.ru"
VITE_KEYCLOAK_REALM: "team-planner"
VITE_KEYCLOAK_CLIENT_ID: "team-planner-frontend"
deploy:
namespace: team-planner
domain: team-planner.vigdorov.ru
infrastructure:
postgres: true
env:
- name: NODE_ENV
value: "production"
- name: PORT
value: "4001"
- name: DB_HOST
value: "shared-postgres-service.shared-db.svc.cluster.local"
- name: DB_PORT
value: "5432"
- name: KEYCLOAK_REALM_URL
value: "https://auth.vigdorov.ru/realms/team-planner"
- name: AI_PROXY_BASE_URL
value: "http://ai-proxy-service.ai-proxy.svc.cluster.local:3000"
- name: NATS_URL
value: "nats://nats.nats:4222"
envFrom:
- secretRef:
name: team-planner-secrets

View File

@ -15,7 +15,7 @@ setup('authenticate', async ({ page }) => {
// Вводим креды // Вводим креды
await page.getByRole('textbox', { name: 'Username or email' }).fill('testuser'); await page.getByRole('textbox', { name: 'Username or email' }).fill('testuser');
await page.getByRole('textbox', { name: 'Password' }).fill('0'); await page.getByRole('textbox', { name: 'Password' }).fill('E7N-A26-2zf-wsA');
await page.getByRole('button', { name: 'Sign In' }).click(); await page.getByRole('button', { name: 'Sign In' }).click();
// Ждём редирект обратно на приложение // Ждём редирект обратно на приложение

View File

@ -156,7 +156,8 @@ test.describe('Фаза 2: Цветовая маркировка', () => {
test.skip(!hasData, 'Нет данных для тестирования'); test.skip(!hasData, 'Нет данных для тестирования');
const colorTrigger = page.locator('[data-testid="color-picker-trigger"]').first(); const firstRow = page.locator('[data-testid^="idea-row-"]').first();
const colorTrigger = firstRow.locator('[data-testid="color-picker-trigger"]');
await colorTrigger.click(); await colorTrigger.click();
const popover = page.locator('[data-testid="color-picker-popover"]'); const popover = page.locator('[data-testid="color-picker-popover"]');
@ -170,16 +171,17 @@ test.describe('Фаза 2: Цветовая маркировка', () => {
// Ждём закрытия popover // Ждём закрытия popover
await expect(popover).toBeHidden({ timeout: 3000 }); await expect(popover).toBeHidden({ timeout: 3000 });
// Проверяем что строка получила цветной фон // Проверяем что строка получила цветной фон (ждем API ответа)
await page.waitForTimeout(300); await page.waitForTimeout(500);
const firstRow = page.locator('[data-testid^="idea-row-"]').first();
const rowStyle = await firstRow.evaluate((el) => { // Проверяем что color picker trigger показывает цвет (сам trigger имеет backgroundColor)
const bg = getComputedStyle(el).backgroundColor; const triggerStyle = await colorTrigger.evaluate((el) => {
return bg; return getComputedStyle(el).backgroundColor;
}); });
// Фон не должен быть прозрачным // После выбора цвета, trigger должен показывать выбранный цвет (не transparent)
expect(rowStyle).not.toBe('rgba(0, 0, 0, 0)'); expect(triggerStyle).not.toBe('transparent');
expect(triggerStyle).not.toBe('rgba(0, 0, 0, 0)');
}); });
test('Фильтр по цвету открывает dropdown с опциями', async ({ page }) => { test('Фильтр по цвету открывает dropdown с опциями', async ({ page }) => {

314
tests/e2e/phase3.2.spec.ts Normal file
View File

@ -0,0 +1,314 @@
import { test, expect } from '@playwright/test';
/**
* E2E тесты для Фазы 3.2 Team Planner
* - Детальный просмотр идеи (IdeaDetailModal)
* - Управление видимостью колонок
*
* Используем data-testid для стабильных селекторов
*/
test.describe('Фаза 3.2: Детальный просмотр идеи', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-testid="ideas-table"]', { timeout: 10000 });
});
test('Кнопка "Подробнее" присутствует в каждой строке', async ({ page }) => {
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
const hasData = !(await emptyState.isVisible().catch(() => false));
if (hasData) {
const viewButtons = page.locator('[data-testid="view-details-button"]');
const buttonCount = await viewButtons.count();
expect(buttonCount).toBeGreaterThan(0);
}
});
test('Клик на кнопку "Подробнее" открывает модалку', async ({ page }) => {
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
const hasData = !(await emptyState.isVisible().catch(() => false));
test.skip(!hasData, 'Нет данных для тестирования');
const viewButton = page.locator('[data-testid="view-details-button"]').first();
await viewButton.click();
const modal = page.locator('[data-testid="idea-detail-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
});
test('Модалка показывает заголовок идеи', async ({ page }) => {
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
const hasData = !(await emptyState.isVisible().catch(() => false));
test.skip(!hasData, 'Нет данных для тестирования');
const viewButton = page.locator('[data-testid="view-details-button"]').first();
await viewButton.click();
const modal = page.locator('[data-testid="idea-detail-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
// Проверяем наличие заголовка
const title = modal.locator('[data-testid="idea-detail-title"]');
await expect(title).toBeVisible();
const titleText = await title.textContent();
expect(titleText?.length).toBeGreaterThan(0);
});
test('Модалка показывает все поля идеи', async ({ page }) => {
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
const hasData = !(await emptyState.isVisible().catch(() => false));
test.skip(!hasData, 'Нет данных для тестирования');
const viewButton = page.locator('[data-testid="view-details-button"]').first();
await viewButton.click();
const modal = page.locator('[data-testid="idea-detail-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
// Проверяем наличие основных полей
await expect(modal.locator('[data-testid="idea-detail-status"]')).toBeVisible();
await expect(modal.locator('[data-testid="idea-detail-priority"]')).toBeVisible();
await expect(modal.locator('[data-testid="idea-detail-description"]')).toBeVisible();
await expect(modal.locator('[data-testid="idea-detail-specification-status"]')).toBeVisible();
});
test('Кнопка редактирования переводит модалку в режим редактирования', async ({ page }) => {
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
const hasData = !(await emptyState.isVisible().catch(() => false));
test.skip(!hasData, 'Нет данных для тестирования');
const viewButton = page.locator('[data-testid="view-details-button"]').first();
await viewButton.click();
const modal = page.locator('[data-testid="idea-detail-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
// Нажимаем "Редактировать"
const editButton = modal.locator('[data-testid="idea-detail-edit-button"]');
await editButton.click();
// Должны появиться поля ввода
await expect(modal.locator('[data-testid="idea-detail-title-input"]')).toBeVisible();
// И кнопки сохранения/отмены
await expect(modal.locator('[data-testid="idea-detail-save-button"]')).toBeVisible();
await expect(modal.locator('[data-testid="idea-detail-cancel-button"]')).toBeVisible();
});
test('Кнопка "Отмена" возвращает режим просмотра', async ({ page }) => {
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
const hasData = !(await emptyState.isVisible().catch(() => false));
test.skip(!hasData, 'Нет данных для тестирования');
const viewButton = page.locator('[data-testid="view-details-button"]').first();
await viewButton.click();
const modal = page.locator('[data-testid="idea-detail-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
// Нажимаем "Редактировать"
await modal.locator('[data-testid="idea-detail-edit-button"]').click();
await expect(modal.locator('[data-testid="idea-detail-title-input"]')).toBeVisible();
// Нажимаем "Отмена"
await modal.locator('[data-testid="idea-detail-cancel-button"]').click();
// Должен вернуться режим просмотра
await expect(modal.locator('[data-testid="idea-detail-title"]')).toBeVisible();
await expect(modal.locator('[data-testid="idea-detail-edit-button"]')).toBeVisible();
});
test('Сохранение изменений работает корректно', async ({ page }) => {
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
const hasData = !(await emptyState.isVisible().catch(() => false));
test.skip(!hasData, 'Нет данных для тестирования');
const viewButton = page.locator('[data-testid="view-details-button"]').first();
await viewButton.click();
const modal = page.locator('[data-testid="idea-detail-modal"]');
await expect(modal).toBeVisible({ timeout: 5000 });
// Запоминаем исходный заголовок
const originalTitle = await modal.locator('[data-testid="idea-detail-title"]').textContent();
// Нажимаем "Редактировать"
await modal.locator('[data-testid="idea-detail-edit-button"]').click();
// Изменяем заголовок
const titleInput = modal.locator('[data-testid="idea-detail-title-input"]');
const newTitle = `${originalTitle} (изменено ${Date.now()})`;
await titleInput.fill(newTitle);
// Сохраняем
await modal.locator('[data-testid="idea-detail-save-button"]').click();
// Ждём возврата в режим просмотра
await expect(modal.locator('[data-testid="idea-detail-title"]')).toBeVisible({ timeout: 5000 });
// Проверяем что заголовок обновился
const updatedTitle = await modal.locator('[data-testid="idea-detail-title"]').textContent();
expect(updatedTitle).toBe(newTitle);
});
test('Статус ТЗ сохраняется после редактирования идеи', async ({ page }) => {
/**
* Регрессионный тест:
* 1. Сгенерировать ТЗ для идеи
* 2. Открыть модалку детального просмотра
* 3. Отредактировать и сохранить
* 4. Статус ТЗ должен остаться "Есть"
*/
const emptyState = page.locator('[data-testid="ideas-empty-state"]');
const hasData = !(await emptyState.isVisible().catch(() => false));
test.skip(!hasData, 'Нет данных для тестирования');
// Сначала генерируем ТЗ для первой идеи
const firstRow = page.locator('[data-testid^="idea-row-"]').first();
const specButton = firstRow.locator('[data-testid="specification-button"]');
await specButton.click();
const specModal = page.locator('[data-testid="specification-modal"]');
await expect(specModal).toBeVisible({ timeout: 5000 });
// Ждём пока сгенерируется ТЗ (контент или ошибка)
const specContent = specModal.locator('[data-testid="specification-content"]');
const specError = specModal.locator('[data-testid="specification-error"]');
await expect(specContent.or(specError)).toBeVisible({ timeout: 60000 });
const hasSpec = await specContent.isVisible().catch(() => false);
test.skip(!hasSpec, 'Не удалось сгенерировать ТЗ');
// Закрываем модалку ТЗ
await page.locator('[data-testid="specification-close-button"]').click();
await expect(specModal).not.toBeVisible({ timeout: 3000 });
// Теперь открываем детальный просмотр
const viewButton = firstRow.locator('[data-testid="view-details-button"]');
await viewButton.click();
const detailModal = page.locator('[data-testid="idea-detail-modal"]');
await expect(detailModal).toBeVisible({ timeout: 5000 });
// Проверяем что ТЗ есть
const specStatus = detailModal.locator('[data-testid="idea-detail-specification-status"]');
await expect(specStatus).toContainText('Есть');
// Нажимаем "Редактировать"
await detailModal.locator('[data-testid="idea-detail-edit-button"]').click();
// Меняем описание (не трогаем ТЗ)
const descInput = detailModal.locator('[data-testid="idea-detail-description-input"]');
await descInput.fill('Обновлённое описание для теста ' + Date.now());
// Сохраняем
await detailModal.locator('[data-testid="idea-detail-save-button"]').click();
// Ждём возврата в режим просмотра
await expect(detailModal.locator('[data-testid="idea-detail-title"]')).toBeVisible({ timeout: 5000 });
// БАГ: Статус ТЗ должен остаться "Есть", но показывает "Нет"
await expect(specStatus).toContainText('Есть');
});
});
test.describe('Фаза 3.2: Управление видимостью колонок', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-testid="ideas-table"]', { timeout: 10000 });
});
test('Кнопка настройки колонок присутствует', async ({ page }) => {
const visibilityButton = page.locator('[data-testid="column-visibility-button"]');
await expect(visibilityButton).toBeVisible();
});
test('Клик на кнопку открывает меню с колонками', async ({ page }) => {
const visibilityButton = page.locator('[data-testid="column-visibility-button"]');
await visibilityButton.click();
const menu = page.locator('[data-testid="column-visibility-menu"]');
await expect(menu).toBeVisible({ timeout: 3000 });
});
test('Меню содержит опции для всех колонок', async ({ page }) => {
const visibilityButton = page.locator('[data-testid="column-visibility-button"]');
await visibilityButton.click();
const menu = page.locator('[data-testid="column-visibility-menu"]');
await expect(menu).toBeVisible({ timeout: 3000 });
// Проверяем наличие основных колонок
await expect(menu.locator('[data-testid="column-visibility-item-status"]')).toBeVisible();
await expect(menu.locator('[data-testid="column-visibility-item-priority"]')).toBeVisible();
await expect(menu.locator('[data-testid="column-visibility-item-description"]')).toBeVisible();
});
test('Переключение видимости колонки работает', async ({ page }) => {
// Проверяем что колонка "Описание" видна
const table = page.locator('[data-testid="ideas-table"]');
const descHeader = table.locator('th', { hasText: 'Описание' });
await expect(descHeader).toBeVisible();
// Открываем меню
const visibilityButton = page.locator('[data-testid="column-visibility-button"]');
await visibilityButton.click();
// Скрываем колонку "Описание"
const descItem = page.locator('[data-testid="column-visibility-item-description"]');
await descItem.click();
// Закрываем меню кликом вне его
await page.keyboard.press('Escape');
// Колонка должна скрыться
await expect(descHeader).not.toBeVisible();
// Возвращаем обратно
await visibilityButton.click();
await descItem.click();
await page.keyboard.press('Escape');
// Колонка должна появиться снова
await expect(descHeader).toBeVisible();
});
test('Кнопка "Показать все" возвращает все колонки', async ({ page }) => {
// Скрываем несколько колонок
const visibilityButton = page.locator('[data-testid="column-visibility-button"]');
await visibilityButton.click();
await page.locator('[data-testid="column-visibility-item-description"]').click();
await page.locator('[data-testid="column-visibility-item-module"]').click();
// Нажимаем "Показать все"
await page.locator('[data-testid="column-visibility-show-all"]').click();
await page.keyboard.press('Escape');
// Все колонки должны быть видны
const table = page.locator('[data-testid="ideas-table"]');
await expect(table.locator('th', { hasText: 'Описание' })).toBeVisible();
await expect(table.locator('th', { hasText: 'Модуль' })).toBeVisible();
});
test('Название и действия нельзя скрыть', async ({ page }) => {
const visibilityButton = page.locator('[data-testid="column-visibility-button"]');
await visibilityButton.click();
// Проверяем что "Название" disabled
const titleItem = page.locator('[data-testid="column-visibility-item-title"]');
await expect(titleItem).toHaveAttribute('aria-disabled', 'true');
// Действия тоже disabled
const actionsItem = page.locator('[data-testid="column-visibility-item-actions"]');
await expect(actionsItem).toHaveAttribute('aria-disabled', 'true');
});
});

View File

@ -418,10 +418,12 @@ test.describe('Фаза 3.1: Генерация мини-ТЗ', () => {
const editButton = modal.locator('[data-testid="specification-edit-button"]'); const editButton = modal.locator('[data-testid="specification-edit-button"]');
await editButton.click(); await editButton.click();
// Редактируем текст // Редактируем текст (MUI TextField создает 2 textarea, берем первый видимый)
const textarea = modal.locator('[data-testid="specification-textarea"] textarea'); const textarea = modal.locator(
'[data-testid="specification-textarea"] textarea:not([aria-hidden="true"])',
);
const testText = '\n\n## Дополнительно\nТестовая правка ' + Date.now(); const testText = '\n\n## Дополнительно\nТестовая правка ' + Date.now();
await textarea.fill(await textarea.inputValue() + testText); await textarea.fill((await textarea.inputValue()) + testText);
// Сохраняем // Сохраняем
const saveButton = modal.locator('[data-testid="specification-save-button"]'); const saveButton = modal.locator('[data-testid="specification-save-button"]');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@
"cookies": [ "cookies": [
{ {
"name": "AUTH_SESSION_ID", "name": "AUTH_SESSION_ID",
"value": "c2hWNkNfa0JhY1hOc1ZWTnhLdk1tOTllLnNBM2ZQTk5yRlBKek5lS3FoR093OFloU1ZyU3E1QzFadzVIU1Jta2lMRllqbXJxLW9QSEMxOFkzZWZDZDl3UHVKZUVaU0VvWWJTOVRNTHJJSUpZc1hB.keycloak-keycloakx-0-40655", "value": "eDY0aWpRc3U0UEE3aTN0U2NranNrY29HLlVsTFZJcHVkMlNYd0g3LUkyMTZqdTQzak41bjRnX0FHQzJSZk4tUGp5eFJsYlB5VFBLbTloN2lEMFR6b2lCQ0RISjBtV2JqTmROdl9kOTBpMjlpRjFB.keycloak-keycloakx-0-37885",
"domain": "auth.vigdorov.ru", "domain": "auth.vigdorov.ru",
"path": "/realms/team-planner/", "path": "/realms/team-planner/",
"expires": -1, "expires": -1,
@ -12,17 +12,17 @@
}, },
{ {
"name": "KC_AUTH_SESSION_HASH", "name": "KC_AUTH_SESSION_HASH",
"value": "\"gFqhBG3DVcCfpsSCaidKwK+Ziy23r6ddJ/rdb/jKDs8\"", "value": "\"IDfhLlT83e6gUgo0mOmir0agF4uMho/Bgfm9pjzSUVA\"",
"domain": "auth.vigdorov.ru", "domain": "auth.vigdorov.ru",
"path": "/realms/team-planner/", "path": "/realms/team-planner/",
"expires": 1768427781.187379, "expires": 1768467678.660249,
"httpOnly": false, "httpOnly": false,
"secure": true, "secure": true,
"sameSite": "None" "sameSite": "None"
}, },
{ {
"name": "KEYCLOAK_IDENTITY", "name": "KEYCLOAK_IDENTITY",
"value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2ZDRjMWU2My1hNTllLTQ0NjAtYThkYy05YTRiZjFjMTRjMDMifQ.eyJleHAiOjE3Njg0NjM3MjMsImlhdCI6MTc2ODQyNzcyMywianRpIjoiNGRmN2U5MzQtY2Q4Mi1hYTYwLTViNTUtMWFhZjVlMWViODJjIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnZpZ2Rvcm92LnJ1L3JlYWxtcy90ZWFtLXBsYW5uZXIiLCJzdWIiOiIyZDJiOTRmMC0xZWQ1LTQ0MTUtYmM4MC1jZTRlZWMxNDQ1NGQiLCJ0eXAiOiJTZXJpYWxpemVkLUlEIiwic2lkIjoic2hWNkNfa0JhY1hOc1ZWTnhLdk1tOTllIiwic3RhdGVfY2hlY2tlciI6Im9Ic2R0czlWR0RvV19EcjcxbG4tM2FjWDR1SmJuMWtzdHRCcVpzRnlPbDQifQ.Nbi8YdiZddWqY4rsS7b_hin9cbTedp2bOQ11I25tLdTH6VGGJaCP1T59pYd3OlqyDYPoD97uOBiobKTues1rwg", "value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2ZDRjMWU2My1hNTllLTQ0NjAtYThkYy05YTRiZjFjMTRjMDMifQ.eyJleHAiOjE3Njg1MDM2MTksImlhdCI6MTc2ODQ2NzYxOSwianRpIjoiZGIyMzFlNjYtNTI3Ni1kMTBkLTA4ZDctZDQyZGQ2NDQ0YTY2IiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnZpZ2Rvcm92LnJ1L3JlYWxtcy90ZWFtLXBsYW5uZXIiLCJzdWIiOiIyZDJiOTRmMC0xZWQ1LTQ0MTUtYmM4MC1jZTRlZWMxNDQ1NGQiLCJ0eXAiOiJTZXJpYWxpemVkLUlEIiwic2lkIjoieDY0aWpRc3U0UEE3aTN0U2NranNrY29HIiwic3RhdGVfY2hlY2tlciI6IlhoT3l4WGNDVFA3WjNZdjAxUk1lVC1GemNTZldIOExXQmRVSDA2Z1VxbjgifQ.9bWdKiU_C-BW12XOxC-jbLvwCOUoAcdPOZNqplSAJwO4sqEP-DRYfyaYJM-3ZthLec37X-Xxp_KS6pPmQjl8kQ",
"domain": "auth.vigdorov.ru", "domain": "auth.vigdorov.ru",
"path": "/realms/team-planner/", "path": "/realms/team-planner/",
"expires": -1, "expires": -1,
@ -32,10 +32,10 @@
}, },
{ {
"name": "KEYCLOAK_SESSION", "name": "KEYCLOAK_SESSION",
"value": "gFqhBG3DVcCfpsSCaidKwK-Ziy23r6ddJ_rdb_jKDs8", "value": "IDfhLlT83e6gUgo0mOmir0agF4uMho_Bgfm9pjzSUVA",
"domain": "auth.vigdorov.ru", "domain": "auth.vigdorov.ru",
"path": "/realms/team-planner/", "path": "/realms/team-planner/",
"expires": 1768463723.271756, "expires": 1768503620.115594,
"httpOnly": false, "httpOnly": false,
"secure": true, "secure": true,
"sameSite": "None" "sameSite": "None"