Compare commits
13 Commits
dea0676169
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1556ff9a29 | |||
| 5ec631f229 | |||
| 593c573985 | |||
| 75015c1c85 | |||
| 990a6fe918 | |||
| 9d34deb77d | |||
| 4d80480d0f | |||
| b270345e77 | |||
| 1b95fd9e55 | |||
| 7421f33de8 | |||
| 684e416588 | |||
| 890d6de92e | |||
| 2e46cc41a1 |
405
.drone.yml
405
.drone.yml
@ -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
1
.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
@vigdorov:registry=https://git.vigdorov.ru/api/packages/vigdorov/npm/
|
||||||
1
.prettierrc
Normal file
1
.prettierrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
"@vigdorov/prettier-config"
|
||||||
42
CONTEXT.md
42
CONTEXT.md
@ -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)
|
||||||
|
|||||||
@ -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. Это экономит время и ресурсы при отладке
|
||||||
|
|||||||
@ -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
|
||||||
- Перемещение идей в списке для ручной сортировки
|
- Перемещение идей в списке для ручной сортировки
|
||||||
|
|||||||
49
ROADMAP.md
49
ROADMAP.md
@ -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: Права доступа 📋
|
||||||
|
|
||||||
> **Гранулярная система прав доступа и панель администратора**
|
> **Гранулярная система прав доступа и панель администратора**
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install ALL dependencies (including devDependencies for build)
|
|
||||||
RUN npm install --include=dev
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install only production dependencies
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm install --only=production && npm cache clean --force
|
|
||||||
|
|
||||||
# Copy built application from builder
|
|
||||||
COPY --from=builder /app/dist ./dist
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
|
||||||
adduser -S nestjs -u 1001
|
|
||||||
|
|
||||||
# Change ownership
|
|
||||||
RUN chown -R nestjs:nodejs /app
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER nestjs
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 4001
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
|
|
||||||
CMD node -e "require('http').get('http://localhost:4001/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
|
||||||
|
|
||||||
# Start the application
|
|
||||||
CMD ["node", "dist/main"]
|
|
||||||
@ -1,39 +1,3 @@
|
|||||||
// @ts-check
|
import {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" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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' })
|
||||||
|
|||||||
68
backend/src/ideas/idea-events.handler.ts
Normal file
68
backend/src/ideas/idea-events.handler.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { IdeasService } from './ideas.service';
|
||||||
|
import { IdeaStatus } from './entities/idea.entity';
|
||||||
|
import {
|
||||||
|
HypothesisLinkedEvent,
|
||||||
|
HypothesisCompletedEvent,
|
||||||
|
} from '../nats/events';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IdeaEventsHandler {
|
||||||
|
private readonly logger = new Logger(IdeaEventsHandler.name);
|
||||||
|
|
||||||
|
constructor(private ideasService: IdeasService) {}
|
||||||
|
|
||||||
|
async handleHypothesisLinked(payload: HypothesisLinkedEvent) {
|
||||||
|
try {
|
||||||
|
const idea = await this.ideasService.findOne(payload.hypothesisId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
idea.status === IdeaStatus.BACKLOG ||
|
||||||
|
idea.status === IdeaStatus.TODO
|
||||||
|
) {
|
||||||
|
await this.ideasService.update(idea.id, {
|
||||||
|
status: IdeaStatus.IN_PROGRESS,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Idea ${idea.id} status changed to in_progress (was ${idea.status})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.log(
|
||||||
|
`Idea ${idea.id} already in status ${idea.status}, skipping`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to handle hypothesis.linked for ${payload.hypothesisId}`,
|
||||||
|
(error as Error).stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleHypothesisCompleted(payload: HypothesisCompletedEvent) {
|
||||||
|
try {
|
||||||
|
const idea = await this.ideasService.findOne(payload.hypothesisId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
idea.status !== IdeaStatus.DONE &&
|
||||||
|
idea.status !== IdeaStatus.CANCELLED
|
||||||
|
) {
|
||||||
|
await this.ideasService.update(idea.id, {
|
||||||
|
status: IdeaStatus.DONE,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Idea ${idea.id} status changed to done (was ${idea.status})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.log(
|
||||||
|
`Idea ${idea.id} already in status ${idea.status}, skipping`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to handle hypothesis.completed for ${payload.hypothesisId}`,
|
||||||
|
(error as Error).stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
21
backend/src/migrations/1770500000000-UserSettings.ts
Normal file
21
backend/src/migrations/1770500000000-UserSettings.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class UserSettings1770500000000 implements MigrationInterface {
|
||||||
|
name = 'UserSettings1770500000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "user_settings" (
|
||||||
|
"user_id" VARCHAR(255) NOT NULL,
|
||||||
|
"settings" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "PK_user_settings" PRIMARY KEY ("user_id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE "user_settings"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/nats/events.ts
Normal file
18
backend/src/nats/events.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export const HYPOTHESIS_STREAM = 'HYPOTHESIS_EVENTS';
|
||||||
|
|
||||||
|
export const HypothesisSubjects = {
|
||||||
|
LINKED: 'hypothesis.linked',
|
||||||
|
COMPLETED: 'hypothesis.completed',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface HypothesisLinkedEvent {
|
||||||
|
hypothesisId: string;
|
||||||
|
targetType: 'goal' | 'track';
|
||||||
|
targetId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HypothesisCompletedEvent {
|
||||||
|
hypothesisId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
165
backend/src/nats/nats-consumer.service.ts
Normal file
165
backend/src/nats/nats-consumer.service.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
connect,
|
||||||
|
NatsConnection,
|
||||||
|
JetStreamClient,
|
||||||
|
JetStreamManager,
|
||||||
|
JetStreamPullSubscription,
|
||||||
|
StringCodec,
|
||||||
|
AckPolicy,
|
||||||
|
DeliverPolicy,
|
||||||
|
RetentionPolicy,
|
||||||
|
StorageType,
|
||||||
|
consumerOpts,
|
||||||
|
} from 'nats';
|
||||||
|
import {
|
||||||
|
HYPOTHESIS_STREAM,
|
||||||
|
HypothesisSubjects,
|
||||||
|
HypothesisLinkedEvent,
|
||||||
|
HypothesisCompletedEvent,
|
||||||
|
} from './events';
|
||||||
|
import { IdeaEventsHandler } from '../ideas/idea-events.handler';
|
||||||
|
|
||||||
|
const CONSUMER_NAME = 'team-planner';
|
||||||
|
const PULL_BATCH = 10;
|
||||||
|
const PULL_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NatsConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(NatsConsumerService.name);
|
||||||
|
private nc: NatsConnection | null = null;
|
||||||
|
private sc = StringCodec();
|
||||||
|
private sub: JetStreamPullSubscription | null = null;
|
||||||
|
private pullTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private ideaEventsHandler: IdeaEventsHandler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const url = this.configService.get<string>('NATS_URL');
|
||||||
|
if (!url) {
|
||||||
|
this.logger.warn('NATS_URL not configured, NATS consuming disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.nc = await connect({ servers: url });
|
||||||
|
this.logger.log(`Connected to NATS at ${url}`);
|
||||||
|
|
||||||
|
const jsm: JetStreamManager = await this.nc.jetstreamManager();
|
||||||
|
await this.ensureStream(jsm);
|
||||||
|
await this.ensureConsumer(jsm);
|
||||||
|
|
||||||
|
const js: JetStreamClient = this.nc.jetstream();
|
||||||
|
await this.startConsuming(js);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to connect to NATS', (error as Error).stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
this.running = false;
|
||||||
|
if (this.pullTimer) {
|
||||||
|
clearInterval(this.pullTimer);
|
||||||
|
}
|
||||||
|
if (this.sub) {
|
||||||
|
this.sub.unsubscribe();
|
||||||
|
}
|
||||||
|
if (this.nc) {
|
||||||
|
await this.nc.drain();
|
||||||
|
this.logger.log('NATS connection drained');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureStream(jsm: JetStreamManager) {
|
||||||
|
try {
|
||||||
|
await jsm.streams.info(HYPOTHESIS_STREAM);
|
||||||
|
this.logger.log(`Stream ${HYPOTHESIS_STREAM} already exists`);
|
||||||
|
} catch {
|
||||||
|
await jsm.streams.add({
|
||||||
|
name: HYPOTHESIS_STREAM,
|
||||||
|
subjects: ['hypothesis.>'],
|
||||||
|
retention: RetentionPolicy.Limits,
|
||||||
|
storage: StorageType.File,
|
||||||
|
max_age: 7 * 24 * 60 * 60 * 1_000_000_000,
|
||||||
|
});
|
||||||
|
this.logger.log(`Stream ${HYPOTHESIS_STREAM} created`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureConsumer(jsm: JetStreamManager) {
|
||||||
|
try {
|
||||||
|
await jsm.consumers.info(HYPOTHESIS_STREAM, CONSUMER_NAME);
|
||||||
|
this.logger.log(`Consumer ${CONSUMER_NAME} already exists`);
|
||||||
|
} catch {
|
||||||
|
await jsm.consumers.add(HYPOTHESIS_STREAM, {
|
||||||
|
durable_name: CONSUMER_NAME,
|
||||||
|
ack_policy: AckPolicy.Explicit,
|
||||||
|
deliver_policy: DeliverPolicy.All,
|
||||||
|
});
|
||||||
|
this.logger.log(`Consumer ${CONSUMER_NAME} created`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startConsuming(js: JetStreamClient) {
|
||||||
|
const opts = consumerOpts();
|
||||||
|
opts.bind(HYPOTHESIS_STREAM, CONSUMER_NAME);
|
||||||
|
|
||||||
|
this.sub = await js.pullSubscribe('hypothesis.>', opts);
|
||||||
|
this.running = true;
|
||||||
|
|
||||||
|
void this.processMessages();
|
||||||
|
|
||||||
|
this.pullTimer = setInterval(() => {
|
||||||
|
if (this.running && this.sub) {
|
||||||
|
this.sub.pull({ batch: PULL_BATCH, expires: 5000 });
|
||||||
|
}
|
||||||
|
}, PULL_INTERVAL_MS);
|
||||||
|
|
||||||
|
// initial pull
|
||||||
|
this.sub.pull({ batch: PULL_BATCH, expires: 5000 });
|
||||||
|
|
||||||
|
this.logger.log('Started consuming from HYPOTHESIS_EVENTS stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processMessages() {
|
||||||
|
if (!this.sub) return;
|
||||||
|
|
||||||
|
for await (const msg of this.sub) {
|
||||||
|
if (!this.running) break;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = this.sc.decode(msg.data);
|
||||||
|
const subject = msg.subject;
|
||||||
|
|
||||||
|
this.logger.log(`Received ${subject}: ${raw}`);
|
||||||
|
|
||||||
|
if (subject === HypothesisSubjects.LINKED) {
|
||||||
|
const data: HypothesisLinkedEvent = JSON.parse(
|
||||||
|
raw,
|
||||||
|
) as HypothesisLinkedEvent;
|
||||||
|
await this.ideaEventsHandler.handleHypothesisLinked(data);
|
||||||
|
} else if (subject === HypothesisSubjects.COMPLETED) {
|
||||||
|
const data: HypothesisCompletedEvent = JSON.parse(
|
||||||
|
raw,
|
||||||
|
) as HypothesisCompletedEvent;
|
||||||
|
await this.ideaEventsHandler.handleHypothesisCompleted(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.ack();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error processing message', (error as Error).stack);
|
||||||
|
msg.nak();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/nats/nats.module.ts
Normal file
9
backend/src/nats/nats.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IdeasModule } from '../ideas/ideas.module';
|
||||||
|
import { NatsConsumerService } from './nats-consumer.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [IdeasModule],
|
||||||
|
providers: [NatsConsumerService],
|
||||||
|
})
|
||||||
|
export class NatsModule {}
|
||||||
24
backend/src/settings/settings.controller.ts
Normal file
24
backend/src/settings/settings.controller.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Controller, Get, Put, Body, Req } from '@nestjs/common';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
interface RequestWithUser {
|
||||||
|
user: { userId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('settings')
|
||||||
|
export class SettingsController {
|
||||||
|
constructor(private readonly settingsService: SettingsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async get(@Req() req: RequestWithUser) {
|
||||||
|
return this.settingsService.get(req.user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
async update(
|
||||||
|
@Req() req: RequestWithUser,
|
||||||
|
@Body() body: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
return this.settingsService.update(req.user.userId, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/settings/settings.module.ts
Normal file
12
backend/src/settings/settings.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { UserSettings } from './user-settings.entity';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
import { SettingsController } from './settings.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([UserSettings])],
|
||||||
|
controllers: [SettingsController],
|
||||||
|
providers: [SettingsService],
|
||||||
|
})
|
||||||
|
export class SettingsModule {}
|
||||||
30
backend/src/settings/settings.service.ts
Normal file
30
backend/src/settings/settings.service.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { UserSettings } from './user-settings.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SettingsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UserSettings)
|
||||||
|
private readonly repo: Repository<UserSettings>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async get(userId: string): Promise<Record<string, unknown>> {
|
||||||
|
const row = await this.repo.findOne({ where: { userId } });
|
||||||
|
return row?.settings ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
userId: string,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
let row = await this.repo.findOne({ where: { userId } });
|
||||||
|
if (!row) {
|
||||||
|
row = this.repo.create({ userId, settings: {} });
|
||||||
|
}
|
||||||
|
row.settings = { ...row.settings, ...patch };
|
||||||
|
await this.repo.save(row);
|
||||||
|
return row.settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/src/settings/user-settings.entity.ts
Normal file
22
backend/src/settings/user-settings.entity.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('user_settings')
|
||||||
|
export class UserSettings {
|
||||||
|
@PrimaryColumn({ name: 'user_id', type: 'varchar', length: 255 })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@ -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' })
|
||||||
|
|||||||
@ -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`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: team-planner-db
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: teamplanner
|
|
||||||
POSTGRES_PASSWORD: teamplanner
|
|
||||||
POSTGRES_DB: teamplanner
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U teamplanner"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build arguments
|
|
||||||
ARG VITE_API_URL=""
|
|
||||||
ARG VITE_KEYCLOAK_URL="https://auth.vigdorov.ru"
|
|
||||||
ARG VITE_KEYCLOAK_REALM="team-planner"
|
|
||||||
ARG VITE_KEYCLOAK_CLIENT_ID="team-planner-frontend"
|
|
||||||
|
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
|
||||||
ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL
|
|
||||||
ENV VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM
|
|
||||||
ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# Copy built files from builder
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Copy nginx configuration
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
|
||||||
CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1
|
|
||||||
|
|
||||||
# Start nginx
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@ -1,41 +1,3 @@
|
|||||||
import js from '@eslint/js'
|
import {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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|||||||
@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
489
frontend/src/components/IdeaDetailModal/IdeaDetailModal.tsx
Normal file
489
frontend/src/components/IdeaDetailModal/IdeaDetailModal.tsx
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Close, Edit, Description, AutoAwesome } from '@mui/icons-material';
|
||||||
|
import type {
|
||||||
|
Idea,
|
||||||
|
IdeaStatus,
|
||||||
|
IdeaPriority,
|
||||||
|
UpdateIdeaDto,
|
||||||
|
} from '../../types/idea';
|
||||||
|
import { statusOptions, priorityOptions } from '../IdeasTable/constants';
|
||||||
|
import {
|
||||||
|
formatEstimate,
|
||||||
|
type EstimateConfig,
|
||||||
|
DEFAULT_ESTIMATE_CONFIG,
|
||||||
|
} from '../../utils/estimate';
|
||||||
|
|
||||||
|
interface IdeaDetailModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
idea: Idea | null;
|
||||||
|
onSave: (id: string, dto: UpdateIdeaDto) => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
onOpenSpecification: (idea: Idea) => void;
|
||||||
|
onOpenEstimate: (idea: Idea) => void;
|
||||||
|
estimateConfig?: EstimateConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<
|
||||||
|
IdeaStatus,
|
||||||
|
'default' | 'primary' | 'secondary' | 'success' | 'error'
|
||||||
|
> = {
|
||||||
|
backlog: 'default',
|
||||||
|
todo: 'primary',
|
||||||
|
in_progress: 'secondary',
|
||||||
|
done: 'success',
|
||||||
|
cancelled: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityColors: Record<
|
||||||
|
IdeaPriority,
|
||||||
|
'default' | 'info' | 'warning' | 'error'
|
||||||
|
> = {
|
||||||
|
low: 'default',
|
||||||
|
medium: 'info',
|
||||||
|
high: 'warning',
|
||||||
|
critical: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateString: string | null): string {
|
||||||
|
if (!dateString) return '—';
|
||||||
|
return new Date(dateString).toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdeaDetailModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
idea,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
onOpenSpecification,
|
||||||
|
onOpenEstimate,
|
||||||
|
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
|
||||||
|
}: IdeaDetailModalProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<UpdateIdeaDto>({});
|
||||||
|
|
||||||
|
// Сброс при открытии/закрытии или смене идеи
|
||||||
|
useEffect(() => {
|
||||||
|
if (idea) {
|
||||||
|
setFormData({
|
||||||
|
title: idea.title,
|
||||||
|
description: idea.description ?? '',
|
||||||
|
status: idea.status,
|
||||||
|
priority: idea.priority,
|
||||||
|
module: idea.module ?? '',
|
||||||
|
targetAudience: idea.targetAudience ?? '',
|
||||||
|
pain: idea.pain ?? '',
|
||||||
|
aiRole: idea.aiRole ?? '',
|
||||||
|
verificationMethod: idea.verificationMethod ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
}, [idea, open]);
|
||||||
|
|
||||||
|
if (!idea) return null;
|
||||||
|
|
||||||
|
const handleChange = (field: keyof UpdateIdeaDto, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(idea.id, formData);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setFormData({
|
||||||
|
title: idea.title,
|
||||||
|
description: idea.description ?? '',
|
||||||
|
status: idea.status,
|
||||||
|
priority: idea.priority,
|
||||||
|
module: idea.module ?? '',
|
||||||
|
targetAudience: idea.targetAudience ?? '',
|
||||||
|
pain: idea.pain ?? '',
|
||||||
|
aiRole: idea.aiRole ?? '',
|
||||||
|
verificationMethod: idea.verificationMethod ?? '',
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel =
|
||||||
|
statusOptions.find((s) => s.value === idea.status)?.label ?? idea.status;
|
||||||
|
const priorityLabel =
|
||||||
|
priorityOptions.find((p) => p.value === idea.priority)?.label ??
|
||||||
|
idea.priority;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
data-testid="idea-detail-modal"
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
value={formData.title ?? ''}
|
||||||
|
onChange={(e) => handleChange('title', e.target.value)}
|
||||||
|
variant="standard"
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { 'data-testid': 'idea-detail-title-input' },
|
||||||
|
}}
|
||||||
|
sx={{ '& input': { fontSize: '1.25rem', fontWeight: 500 } }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography variant="h6" data-testid="idea-detail-title">
|
||||||
|
{idea.title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<IconButton onClick={handleClose} size="small">
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Статус и приоритет */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Статус
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<Select
|
||||||
|
value={formData.status ?? idea.status}
|
||||||
|
onChange={(e) => handleChange('status', e.target.value)}
|
||||||
|
data-testid="idea-detail-status-select"
|
||||||
|
>
|
||||||
|
{statusOptions.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
label={statusLabel}
|
||||||
|
color={statusColors[idea.status]}
|
||||||
|
size="small"
|
||||||
|
data-testid="idea-detail-status"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Приоритет
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<Select
|
||||||
|
value={formData.priority ?? idea.priority}
|
||||||
|
onChange={(e) => handleChange('priority', e.target.value)}
|
||||||
|
data-testid="idea-detail-priority-select"
|
||||||
|
>
|
||||||
|
{priorityOptions.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
label={priorityLabel}
|
||||||
|
color={priorityColors[idea.priority]}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
data-testid="idea-detail-priority"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Модуль */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Модуль
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={formData.module ?? ''}
|
||||||
|
onChange={(e) => handleChange('module', e.target.value)}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { 'data-testid': 'idea-detail-module-input' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography data-testid="idea-detail-module">
|
||||||
|
{idea.module ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Целевая аудитория */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Целевая аудитория
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={formData.targetAudience ?? ''}
|
||||||
|
onChange={(e) => handleChange('targetAudience', e.target.value)}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: {
|
||||||
|
'data-testid': 'idea-detail-target-audience-input',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography data-testid="idea-detail-target-audience">
|
||||||
|
{idea.targetAudience ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Описание */}
|
||||||
|
<Grid size={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Описание
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={formData.description ?? ''}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { 'data-testid': 'idea-detail-description-input' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
data-testid="idea-detail-description"
|
||||||
|
sx={{ whiteSpace: 'pre-wrap' }}
|
||||||
|
>
|
||||||
|
{idea.description ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Боль */}
|
||||||
|
<Grid size={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Какую боль решает
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={formData.pain ?? ''}
|
||||||
|
onChange={(e) => handleChange('pain', e.target.value)}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { 'data-testid': 'idea-detail-pain-input' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
data-testid="idea-detail-pain"
|
||||||
|
sx={{ whiteSpace: 'pre-wrap' }}
|
||||||
|
>
|
||||||
|
{idea.pain ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Роль AI */}
|
||||||
|
<Grid size={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Роль AI
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={formData.aiRole ?? ''}
|
||||||
|
onChange={(e) => handleChange('aiRole', e.target.value)}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { 'data-testid': 'idea-detail-ai-role-input' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
data-testid="idea-detail-ai-role"
|
||||||
|
sx={{ whiteSpace: 'pre-wrap' }}
|
||||||
|
>
|
||||||
|
{idea.aiRole ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Способ проверки */}
|
||||||
|
<Grid size={12}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Способ проверки
|
||||||
|
</Typography>
|
||||||
|
{isEditing ? (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={formData.verificationMethod ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('verificationMethod', e.target.value)
|
||||||
|
}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: {
|
||||||
|
'data-testid': 'idea-detail-verification-method-input',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
data-testid="idea-detail-verification-method"
|
||||||
|
sx={{ whiteSpace: 'pre-wrap' }}
|
||||||
|
>
|
||||||
|
{idea.verificationMethod ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={12}>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* AI-оценка и ТЗ */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
AI-оценка
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography data-testid="idea-detail-estimate">
|
||||||
|
{formatEstimate(idea.estimatedHours, estimateConfig)}
|
||||||
|
</Typography>
|
||||||
|
{idea.complexity && (
|
||||||
|
<Chip label={idea.complexity} size="small" variant="outlined" />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<AutoAwesome />}
|
||||||
|
onClick={() => onOpenEstimate(idea)}
|
||||||
|
data-testid="idea-detail-open-estimate-button"
|
||||||
|
>
|
||||||
|
{idea.estimatedHours ? 'Подробнее' : 'Оценить'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Техническое задание
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography data-testid="idea-detail-specification-status">
|
||||||
|
{idea.specification ? 'Есть' : 'Нет'}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<Description />}
|
||||||
|
onClick={() => onOpenSpecification(idea)}
|
||||||
|
data-testid="idea-detail-open-specification-button"
|
||||||
|
>
|
||||||
|
{idea.specification ? 'Открыть' : 'Создать'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={12}>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Метаданные */}
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Создано
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" data-testid="idea-detail-created-at">
|
||||||
|
{formatDate(idea.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Обновлено
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" data-testid="idea-detail-updated-at">
|
||||||
|
{formatDate(idea.updatedAt)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={handleCancel}
|
||||||
|
data-testid="idea-detail-cancel-button"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
data-testid="idea-detail-save-button"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button onClick={handleClose}>Закрыть</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Edit />}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
data-testid="idea-detail-edit-button"
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/components/IdeaDetailModal/index.ts
Normal file
1
frontend/src/components/IdeaDetailModal/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { IdeaDetailModal } from './IdeaDetailModal';
|
||||||
@ -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 ?? ''}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
157
frontend/src/components/IdeasTable/ColumnVisibility.tsx
Normal file
157
frontend/src/components/IdeasTable/ColumnVisibility.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Checkbox,
|
||||||
|
ListItemText,
|
||||||
|
Tooltip,
|
||||||
|
Divider,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Settings } from '@mui/icons-material';
|
||||||
|
import type { Table } from '@tanstack/react-table';
|
||||||
|
import type { Idea } from '../../types/idea';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'team-planner-column-visibility';
|
||||||
|
|
||||||
|
const columnLabels: Record<string, string> = {
|
||||||
|
drag: 'Перетаскивание',
|
||||||
|
color: 'Цвет',
|
||||||
|
title: 'Название',
|
||||||
|
status: 'Статус',
|
||||||
|
priority: 'Приоритет',
|
||||||
|
module: 'Модуль',
|
||||||
|
targetAudience: 'Целевая аудитория',
|
||||||
|
pain: 'Боль',
|
||||||
|
aiRole: 'Роль AI',
|
||||||
|
verificationMethod: 'Способ проверки',
|
||||||
|
description: 'Описание',
|
||||||
|
estimatedHours: 'Оценка',
|
||||||
|
actions: 'Действия',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Колонки, которые нельзя скрыть
|
||||||
|
const alwaysVisibleColumns = ['drag', 'title', 'actions'];
|
||||||
|
|
||||||
|
interface ColumnVisibilityProps {
|
||||||
|
table: Table<Idea>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnVisibility({ table }: ColumnVisibilityProps) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
|
// Загрузка сохранённых настроек при монтировании
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const visibility = JSON.parse(saved) as Record<string, boolean>;
|
||||||
|
table.setColumnVisibility(visibility);
|
||||||
|
} catch {
|
||||||
|
// Игнорируем ошибки парсинга
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [table]);
|
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (columnId: string) => {
|
||||||
|
const column = table.getColumn(columnId);
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
const newVisibility = !column.getIsVisible();
|
||||||
|
column.toggleVisibility(newVisibility);
|
||||||
|
|
||||||
|
// Сохраняем в localStorage
|
||||||
|
const currentVisibility = table.getState().columnVisibility;
|
||||||
|
const updatedVisibility = {
|
||||||
|
...currentVisibility,
|
||||||
|
[columnId]: newVisibility,
|
||||||
|
};
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedVisibility));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowAll = () => {
|
||||||
|
const allVisible: Record<string, boolean> = {};
|
||||||
|
table.getAllColumns().forEach((col) => {
|
||||||
|
allVisible[col.id] = true;
|
||||||
|
});
|
||||||
|
table.setColumnVisibility(allVisible);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(allVisible));
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = table.getAllColumns().filter((col) => col.id in columnLabels);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Настройка колонок">
|
||||||
|
<IconButton
|
||||||
|
onClick={handleClick}
|
||||||
|
size="small"
|
||||||
|
data-testid="column-visibility-button"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
>
|
||||||
|
<Settings />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
data-testid="column-visibility-menu"
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: { minWidth: 220 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ px: 2, py: 1 }}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Показать колонки
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
{columns.map((column) => {
|
||||||
|
const isAlwaysVisible = alwaysVisibleColumns.includes(column.id);
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={column.id}
|
||||||
|
onClick={() => !isAlwaysVisible && handleToggle(column.id)}
|
||||||
|
disabled={isAlwaysVisible}
|
||||||
|
dense
|
||||||
|
data-testid={`column-visibility-item-${column.id}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
disabled={isAlwaysVisible}
|
||||||
|
size="small"
|
||||||
|
sx={{ p: 0, mr: 1 }}
|
||||||
|
/>
|
||||||
|
<ListItemText primary={columnLabels[column.id]} />
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Divider />
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleShowAll}
|
||||||
|
dense
|
||||||
|
data-testid="column-visibility-show-all"
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary="Показать все"
|
||||||
|
slotProps={{ primary: { color: 'primary' } }}
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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())}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
110
frontend/src/components/SettingsPage/SettingsPage.tsx
Normal file
110
frontend/src/components/SettingsPage/SettingsPage.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useSettingsQuery, useUpdateSettings } from '../../hooks/useSettings';
|
||||||
|
import { DEFAULT_ESTIMATE_CONFIG } from '../../utils/estimate';
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { data: settings, isLoading } = useSettingsQuery();
|
||||||
|
const updateSettings = useUpdateSettings();
|
||||||
|
|
||||||
|
const [hoursPerDay, setHoursPerDay] = useState(
|
||||||
|
String(DEFAULT_ESTIMATE_CONFIG.hoursPerDay),
|
||||||
|
);
|
||||||
|
const [daysPerWeek, setDaysPerWeek] = useState(
|
||||||
|
String(DEFAULT_ESTIMATE_CONFIG.daysPerWeek),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
setHoursPerDay(
|
||||||
|
String(settings.hoursPerDay ?? DEFAULT_ESTIMATE_CONFIG.hoursPerDay),
|
||||||
|
);
|
||||||
|
setDaysPerWeek(
|
||||||
|
String(settings.daysPerWeek ?? DEFAULT_ESTIMATE_CONFIG.daysPerWeek),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const hpd = Number(hoursPerDay);
|
||||||
|
const dpw = Number(daysPerWeek);
|
||||||
|
if (hpd > 0 && hpd <= 24 && dpw > 0 && dpw <= 7) {
|
||||||
|
updateSettings.mutate({ hoursPerDay: hpd, daysPerWeek: dpw });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hpdNum = Number(hoursPerDay);
|
||||||
|
const dpwNum = Number(daysPerWeek);
|
||||||
|
const isValid =
|
||||||
|
hpdNum > 0 && hpdNum <= 24 && dpwNum > 0 && dpwNum <= 7;
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 500 }}>
|
||||||
|
<Typography variant="h5" sx={{ mb: 3 }}>
|
||||||
|
Настройки
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
|
Формат оценки трудозатрат
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Эти значения используются для конвертации оценок из формата «1w 3d 7h»
|
||||||
|
в часы и обратно.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Часов в рабочем дне"
|
||||||
|
type="number"
|
||||||
|
value={hoursPerDay}
|
||||||
|
onChange={(e) => setHoursPerDay(e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: 1, max: 24 } }}
|
||||||
|
helperText="От 1 до 24"
|
||||||
|
error={!hpdNum || hpdNum < 1 || hpdNum > 24}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Рабочих дней в неделе"
|
||||||
|
type="number"
|
||||||
|
value={daysPerWeek}
|
||||||
|
onChange={(e) => setDaysPerWeek(e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: 1, max: 7 } }}
|
||||||
|
helperText="От 1 до 7"
|
||||||
|
error={!dpwNum || dpwNum < 1 || dpwNum > 7}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isValid || updateSettings.isPending}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
{updateSettings.isSuccess && (
|
||||||
|
<Alert severity="success" sx={{ py: 0 }}>
|
||||||
|
Сохранено
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{updateSettings.isError && (
|
||||||
|
<Alert severity="error" sx={{ py: 0 }}>
|
||||||
|
Ошибка сохранения
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/components/SettingsPage/index.ts
Normal file
1
frontend/src/components/SettingsPage/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { SettingsPage } from './SettingsPage';
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
33
frontend/src/hooks/useSettings.ts
Normal file
33
frontend/src/hooks/useSettings.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { settingsApi } from '../services/settings';
|
||||||
|
import type { UserSettings } from '../types/settings';
|
||||||
|
import {
|
||||||
|
DEFAULT_ESTIMATE_CONFIG,
|
||||||
|
type EstimateConfig,
|
||||||
|
} from '../utils/estimate';
|
||||||
|
|
||||||
|
export function useSettingsQuery() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: settingsApi.get,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (patch: Partial<UserSettings>) => settingsApi.update(patch),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEstimateConfig(): EstimateConfig {
|
||||||
|
const { data } = useSettingsQuery();
|
||||||
|
return {
|
||||||
|
hoursPerDay: data?.hoursPerDay ?? DEFAULT_ESTIMATE_CONFIG.hoursPerDay,
|
||||||
|
daysPerWeek: data?.daysPerWeek ?? DEFAULT_ESTIMATE_CONFIG.daysPerWeek,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
14
frontend/src/services/settings.ts
Normal file
14
frontend/src/services/settings.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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[]> => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
4
frontend/src/types/settings.ts
Normal file
4
frontend/src/types/settings.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface UserSettings {
|
||||||
|
hoursPerDay?: number;
|
||||||
|
daysPerWeek?: number;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
75
frontend/src/utils/estimate.ts
Normal file
75
frontend/src/utils/estimate.ts
Normal 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);
|
||||||
|
}
|
||||||
4
frontend/test-results/.last-run.json
Normal file
4
frontend/test-results/.last-run.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@ -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"]
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
);
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: postgres-pvc
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 10Gi
|
|
||||||
storageClassName: local-path
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
@ -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
9463
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -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
45
service.yaml
Normal 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
|
||||||
@ -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();
|
||||||
|
|
||||||
// Ждём редирект обратно на приложение
|
// Ждём редирект обратно на приложение
|
||||||
|
|||||||
@ -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
314
tests/e2e/phase3.2.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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
@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user