diff --git a/CONTEXT.md b/CONTEXT.md index bca9a67..0263b5e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -88,6 +88,7 @@ | 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 | --- @@ -271,8 +272,9 @@ team-planner/ - **Keycloak:** auth.vigdorov.ru, realm `team-planner`, client `team-planner-frontend` - **Keycloak Theme:** Кастомная тема `team-planner` в стиле MUI, образ `registry.vigdorov.ru/library/keycloak-team-planner` - **Production URL:** https://team-planner.vigdorov.ru (добавлен в Valid redirect URIs и Web origins клиента Keycloak) -- **CI/CD:** Drone CI (.drone.yml) — сборка 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! - **AI Proxy:** Интеграция с ai-proxy-service (K8s namespace `ai-proxy`), модель `claude-3.7-sonnet`, env: AI_PROXY_BASE_URL, AI_PROXY_API_KEY - **История ТЗ:** При перегенерации старая версия сохраняется в `specification_history`, можно просмотреть/восстановить/удалить - **Комментарии в AI:** Промпты для генерации ТЗ и оценки трудозатрат теперь включают комментарии к идее для лучшей точности +- **Keycloak Theme CI:** Отдельный pipeline проверяет `git diff HEAD~1 HEAD -- keycloak-theme/` и пропускает сборку/деплой если нет изменений (экономия ресурсов, нет влияния на Keycloak) diff --git a/backend/.env.example b/backend/.env.example index 99b6c81..6b20b05 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,3 +11,6 @@ PORT=4001 # Keycloak KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner KEYCLOAK_CLIENT_ID=team-planner-frontend + +# NATS +NATS_URL=nats://10.10.10.100:30422 diff --git a/backend/package.json b/backend/package.json index 11ddb30..3cd4677 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,6 +37,7 @@ "class-validator": "^0.14.3", "dotenv": "^16.4.7", "jwks-rsa": "^3.2.0", + "nats": "^2.29.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.16.3", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0a261b7..76e9c3d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,8 @@ import { CommentsModule } from './comments/comments.module'; import { TeamModule } from './team/team.module'; import { AuthModule, JwtAuthGuard } from './auth'; import { AiModule } from './ai/ai.module'; +import { SettingsModule } from './settings/settings.module'; +import { NatsModule } from './nats/nats.module'; @Module({ imports: [ @@ -36,6 +38,8 @@ import { AiModule } from './ai/ai.module'; CommentsModule, TeamModule, AiModule, + SettingsModule, + NatsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/ideas/idea-events.handler.ts b/backend/src/ideas/idea-events.handler.ts new file mode 100644 index 0000000..bf6f47a --- /dev/null +++ b/backend/src/ideas/idea-events.handler.ts @@ -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, + ); + } + } +} diff --git a/backend/src/ideas/ideas.module.ts b/backend/src/ideas/ideas.module.ts index 3b381bf..dec2e8a 100644 --- a/backend/src/ideas/ideas.module.ts +++ b/backend/src/ideas/ideas.module.ts @@ -4,11 +4,12 @@ import { IdeasService } from './ideas.service'; import { IdeasController } from './ideas.controller'; import { Idea } from './entities/idea.entity'; import { SpecificationHistory } from './entities/specification-history.entity'; +import { IdeaEventsHandler } from './idea-events.handler'; @Module({ imports: [TypeOrmModule.forFeature([Idea, SpecificationHistory])], controllers: [IdeasController], - providers: [IdeasService], - exports: [IdeasService, TypeOrmModule], + providers: [IdeasService, IdeaEventsHandler], + exports: [IdeasService, IdeaEventsHandler, TypeOrmModule], }) export class IdeasModule {} diff --git a/backend/src/migrations/1770500000000-UserSettings.ts b/backend/src/migrations/1770500000000-UserSettings.ts new file mode 100644 index 0000000..b69c554 --- /dev/null +++ b/backend/src/migrations/1770500000000-UserSettings.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UserSettings1770500000000 implements MigrationInterface { + name = 'UserSettings1770500000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`DROP TABLE "user_settings"`); + } +} diff --git a/backend/src/nats/events.ts b/backend/src/nats/events.ts new file mode 100644 index 0000000..321714b --- /dev/null +++ b/backend/src/nats/events.ts @@ -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; +} diff --git a/backend/src/nats/nats-consumer.service.ts b/backend/src/nats/nats-consumer.service.ts new file mode 100644 index 0000000..00c9d55 --- /dev/null +++ b/backend/src/nats/nats-consumer.service.ts @@ -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 | null = null; + private running = false; + + constructor( + private configService: ConfigService, + private ideaEventsHandler: IdeaEventsHandler, + ) {} + + async onModuleInit() { + const url = this.configService.get('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(); + } + } + } +} diff --git a/backend/src/nats/nats.module.ts b/backend/src/nats/nats.module.ts new file mode 100644 index 0000000..929b2bf --- /dev/null +++ b/backend/src/nats/nats.module.ts @@ -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 {} diff --git a/backend/src/settings/settings.controller.ts b/backend/src/settings/settings.controller.ts new file mode 100644 index 0000000..0fd28d5 --- /dev/null +++ b/backend/src/settings/settings.controller.ts @@ -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, + ) { + return this.settingsService.update(req.user.userId, body); + } +} diff --git a/backend/src/settings/settings.module.ts b/backend/src/settings/settings.module.ts new file mode 100644 index 0000000..57fbb06 --- /dev/null +++ b/backend/src/settings/settings.module.ts @@ -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 {} diff --git a/backend/src/settings/settings.service.ts b/backend/src/settings/settings.service.ts new file mode 100644 index 0000000..dae83fe --- /dev/null +++ b/backend/src/settings/settings.service.ts @@ -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, + ) {} + + async get(userId: string): Promise> { + const row = await this.repo.findOne({ where: { userId } }); + return row?.settings ?? {}; + } + + async update( + userId: string, + patch: Record, + ): Promise> { + 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; + } +} diff --git a/backend/src/settings/user-settings.entity.ts b/backend/src/settings/user-settings.entity.ts new file mode 100644 index 0000000..e4ebbd6 --- /dev/null +++ b/backend/src/settings/user-settings.entity.ts @@ -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; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 22d452d..2be6d8a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,11 +11,19 @@ import { Tabs, Tab, } 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 { IdeasFilters } from './components/IdeasFilters'; import { CreateIdeaModal } from './components/CreateIdeaModal'; import { TeamPage } from './components/TeamPage'; +import { SettingsPage } from './components/SettingsPage'; import { useIdeasStore } from './store/ideas'; import { useAuth } from './hooks/useAuth'; @@ -66,6 +74,7 @@ function App() { setTab(v)}> } iconPosition="start" label="Идеи" /> } iconPosition="start" label="Команда" /> + } iconPosition="start" label="Настройки" /> @@ -95,6 +104,8 @@ function App() { )} {tab === 1 && } + + {tab === 2 && } ); } diff --git a/frontend/src/components/AiEstimateModal/AiEstimateModal.tsx b/frontend/src/components/AiEstimateModal/AiEstimateModal.tsx index 3a0ead1..7e482a2 100644 --- a/frontend/src/components/AiEstimateModal/AiEstimateModal.tsx +++ b/frontend/src/components/AiEstimateModal/AiEstimateModal.tsx @@ -28,6 +28,11 @@ import { } from '@mui/icons-material'; import type { EstimateResult } from '../../services/ai'; import type { IdeaComplexity } from '../../types/idea'; +import { + formatEstimate, + type EstimateConfig, + DEFAULT_ESTIMATE_CONFIG, +} from '../../utils/estimate'; interface AiEstimateModalProps { open: boolean; @@ -35,6 +40,7 @@ interface AiEstimateModalProps { result: EstimateResult | null; isLoading: boolean; error: Error | null; + estimateConfig?: EstimateConfig; } const complexityLabels: Record = { @@ -56,24 +62,13 @@ const complexityColors: Record< veryComplex: 'error', }; -function formatHours(hours: number): string { - if (hours < 8) { - return `${String(hours)} ч`; - } - const days = Math.floor(hours / 8); - const remainingHours = hours % 8; - if (remainingHours === 0) { - return `${String(days)} д`; - } - return `${String(days)} д ${String(remainingHours)} ч`; -} - export function AiEstimateModal({ open, onClose, result, isLoading, error, + estimateConfig = DEFAULT_ESTIMATE_CONFIG, }: AiEstimateModalProps) { return ( - {formatHours(result.totalHours)} + {formatEstimate(result.totalHours, estimateConfig)} @@ -175,7 +170,7 @@ export function AiEstimateModal({ > {item.role} - {formatHours(item.hours)} + {formatEstimate(item.hours, estimateConfig)} ))} diff --git a/frontend/src/components/IdeaDetailModal/IdeaDetailModal.tsx b/frontend/src/components/IdeaDetailModal/IdeaDetailModal.tsx index b624913..257dacd 100644 --- a/frontend/src/components/IdeaDetailModal/IdeaDetailModal.tsx +++ b/frontend/src/components/IdeaDetailModal/IdeaDetailModal.tsx @@ -24,6 +24,11 @@ import type { UpdateIdeaDto, } from '../../types/idea'; import { statusOptions, priorityOptions } from '../IdeasTable/constants'; +import { + formatEstimate, + type EstimateConfig, + DEFAULT_ESTIMATE_CONFIG, +} from '../../utils/estimate'; interface IdeaDetailModalProps { open: boolean; @@ -33,6 +38,7 @@ interface IdeaDetailModalProps { isSaving: boolean; onOpenSpecification: (idea: Idea) => void; onOpenEstimate: (idea: Idea) => void; + estimateConfig?: EstimateConfig; } const statusColors: Record< @@ -61,15 +67,6 @@ function formatDate(dateString: string | null): string { return new Date(dateString).toLocaleString('ru-RU'); } -function formatHours(hours: number | null): string { - if (!hours) return '—'; - if (hours < 8) return `${String(hours)} ч`; - const days = Math.floor(hours / 8); - const remainingHours = hours % 8; - if (remainingHours === 0) return `${String(days)} д`; - return `${String(days)} д ${String(remainingHours)} ч`; -} - export function IdeaDetailModal({ open, onClose, @@ -78,6 +75,7 @@ export function IdeaDetailModal({ isSaving, onOpenSpecification, onOpenEstimate, + estimateConfig = DEFAULT_ESTIMATE_CONFIG, }: IdeaDetailModalProps) { const [isEditing, setIsEditing] = useState(false); const [formData, setFormData] = useState({}); @@ -394,7 +392,7 @@ export function IdeaDetailModal({ - {formatHours(idea.estimatedHours)} + {formatEstimate(idea.estimatedHours, estimateConfig)} {idea.complexity && ( diff --git a/frontend/src/components/IdeasTable/IdeasTable.tsx b/frontend/src/components/IdeasTable/IdeasTable.tsx index 379c989..a5af589 100644 --- a/frontend/src/components/IdeasTable/IdeasTable.tsx +++ b/frontend/src/components/IdeasTable/IdeasTable.tsx @@ -59,6 +59,7 @@ import { SpecificationModal } from '../SpecificationModal'; import { IdeaDetailModal } from '../IdeaDetailModal'; import type { EstimateResult } from '../../services/ai'; import type { Idea, UpdateIdeaDto } from '../../types/idea'; +import { useEstimateConfig } from '../../hooks/useSettings'; const SKELETON_COLUMNS_COUNT = 13; @@ -73,6 +74,7 @@ export function IdeasTable() { const restoreSpecificationFromHistory = useRestoreSpecificationFromHistory(); const { sorting, setSorting, pagination, setPage, setLimit } = useIdeasStore(); + const estimateConfig = useEstimateConfig(); // ID активно перетаскиваемого элемента const [activeId, setActiveId] = useState(null); @@ -274,6 +276,7 @@ export function IdeasTable() { expandedId, estimatingId, generatingSpecificationId, + estimateConfig, }), [ deleteIdea, @@ -283,6 +286,7 @@ export function IdeasTable() { handleEstimate, handleSpecification, handleViewDetails, + estimateConfig, ], ); @@ -549,6 +553,7 @@ export function IdeasTable() { result={estimateResult} isLoading={estimateIdea.isPending && !estimateResult} error={estimateIdea.error} + estimateConfig={estimateConfig} /> ); diff --git a/frontend/src/components/IdeasTable/columns.tsx b/frontend/src/components/IdeasTable/columns.tsx index fe45866..ea96852 100644 --- a/frontend/src/components/IdeasTable/columns.tsx +++ b/frontend/src/components/IdeasTable/columns.tsx @@ -26,6 +26,11 @@ import { EditableCell } from './EditableCell'; import { ColorPickerCell } from './ColorPickerCell'; import { statusOptions, priorityOptions } from './constants'; import { DragHandle } from './DraggableRow'; +import { + formatEstimate, + type EstimateConfig, + DEFAULT_ESTIMATE_CONFIG, +} from '../../utils/estimate'; const columnHelper = createColumnHelper(); @@ -69,14 +74,6 @@ const complexityColors: Record< veryComplex: 'error', }; -function formatHoursShort(hours: number): string { - if (hours < 8) { - return `${String(hours)}ч`; - } - const days = Math.floor(hours / 8); - return `${String(days)}д`; -} - interface ColumnsConfig { onDelete: (id: string) => void; onToggleComments: (id: string) => void; @@ -87,6 +84,7 @@ interface ColumnsConfig { expandedId: string | null; estimatingId: string | null; generatingSpecificationId: string | null; + estimateConfig?: EstimateConfig; } export const createColumns = ({ @@ -99,6 +97,7 @@ export const createColumns = ({ expandedId, estimatingId, generatingSpecificationId, + estimateConfig = DEFAULT_ESTIMATE_CONFIG, }: ColumnsConfig) => [ columnHelper.display({ id: 'drag', @@ -302,7 +301,7 @@ export const createColumns = ({ > - {formatHoursShort(idea.estimatedHours)} + {formatEstimate(idea.estimatedHours, estimateConfig)} {idea.complexity && ( { + 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 ( + + + Настройки + + + + + Формат оценки трудозатрат + + + Эти значения используются для конвертации оценок из формата «1w 3d 7h» + в часы и обратно. + + + + setHoursPerDay(e.target.value)} + slotProps={{ htmlInput: { min: 1, max: 24 } }} + helperText="От 1 до 24" + error={!hpdNum || hpdNum < 1 || hpdNum > 24} + size="small" + /> + setDaysPerWeek(e.target.value)} + slotProps={{ htmlInput: { min: 1, max: 7 } }} + helperText="От 1 до 7" + error={!dpwNum || dpwNum < 1 || dpwNum > 7} + size="small" + /> + + + + + {updateSettings.isSuccess && ( + + Сохранено + + )} + {updateSettings.isError && ( + + Ошибка сохранения + + )} + + + + ); +} diff --git a/frontend/src/components/SettingsPage/index.ts b/frontend/src/components/SettingsPage/index.ts new file mode 100644 index 0000000..db6dcbd --- /dev/null +++ b/frontend/src/components/SettingsPage/index.ts @@ -0,0 +1 @@ +export { SettingsPage } from './SettingsPage'; diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts new file mode 100644 index 0000000..01b2c05 --- /dev/null +++ b/frontend/src/hooks/useSettings.ts @@ -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) => 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, + }; +} diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts new file mode 100644 index 0000000..1030b18 --- /dev/null +++ b/frontend/src/services/settings.ts @@ -0,0 +1,14 @@ +import { api } from './api'; +import type { UserSettings } from '../types/settings'; + +export const settingsApi = { + get: async (): Promise => { + const response = await api.get('/api/settings'); + return response.data; + }, + + update: async (patch: Partial): Promise => { + const response = await api.put('/api/settings', patch); + return response.data; + }, +}; diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts new file mode 100644 index 0000000..b62d52c --- /dev/null +++ b/frontend/src/types/settings.ts @@ -0,0 +1,4 @@ +export interface UserSettings { + hoursPerDay?: number; + daysPerWeek?: number; +} diff --git a/frontend/src/utils/estimate.ts b/frontend/src/utils/estimate.ts new file mode 100644 index 0000000..d492b76 --- /dev/null +++ b/frontend/src/utils/estimate.ts @@ -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); +} diff --git a/package-lock.json b/package-lock.json index 569a259..9ca34ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "class-validator": "^0.14.3", "dotenv": "^16.4.7", "jwks-rsa": "^3.2.0", + "nats": "^2.29.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.16.3", @@ -12991,6 +12992,30 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nats": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz", + "integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==", + "license": "Apache-2.0", + "dependencies": { + "nkeys.js": "1.1.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/nkeys.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz", + "integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==", + "license": "Apache-2.0", + "dependencies": { + "tweetnacl": "1.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -13429,6 +13454,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/uid": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz",