This commit is contained in:
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: [
|
||||
|
||||
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 { 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 {}
|
||||
|
||||
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;
|
||||
}
|
||||
@ -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() {
|
||||
<Tabs value={tab} onChange={(_, v: number) => setTab(v)}>
|
||||
<Tab icon={<Lightbulb />} iconPosition="start" label="Идеи" />
|
||||
<Tab icon={<Group />} iconPosition="start" label="Команда" />
|
||||
<Tab icon={<Settings />} iconPosition="start" label="Настройки" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
@ -95,6 +104,8 @@ function App() {
|
||||
)}
|
||||
|
||||
{tab === 1 && <TeamPage />}
|
||||
|
||||
{tab === 2 && <SettingsPage />}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<IdeaComplexity, string> = {
|
||||
@ -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 (
|
||||
<Dialog
|
||||
@ -125,7 +120,7 @@ export function AiEstimateModal({
|
||||
>
|
||||
<AccessTime color="primary" />
|
||||
<Typography variant="h4" component="span">
|
||||
{formatHours(result.totalHours)}
|
||||
{formatEstimate(result.totalHours, estimateConfig)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
@ -175,7 +170,7 @@ export function AiEstimateModal({
|
||||
>
|
||||
<TableCell>{item.role}</TableCell>
|
||||
<TableCell align="right">
|
||||
{formatHours(item.hours)}
|
||||
{formatEstimate(item.hours, estimateConfig)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@ -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<UpdateIdeaDto>({});
|
||||
@ -394,7 +392,7 @@ export function IdeaDetailModal({
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography data-testid="idea-detail-estimate">
|
||||
{formatHours(idea.estimatedHours)}
|
||||
{formatEstimate(idea.estimatedHours, estimateConfig)}
|
||||
</Typography>
|
||||
{idea.complexity && (
|
||||
<Chip label={idea.complexity} size="small" variant="outlined" />
|
||||
|
||||
@ -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<string | null>(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}
|
||||
/>
|
||||
<SpecificationModal
|
||||
open={specificationModalOpen}
|
||||
@ -574,6 +579,7 @@ export function IdeasTable() {
|
||||
isSaving={updateIdea.isPending}
|
||||
onOpenSpecification={handleOpenSpecificationFromDetail}
|
||||
onOpenEstimate={handleOpenEstimateFromDetail}
|
||||
estimateConfig={estimateConfig}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@ -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<Idea>();
|
||||
|
||||
@ -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 = ({
|
||||
>
|
||||
<AccessTime fontSize="small" color="action" />
|
||||
<Typography variant="body2">
|
||||
{formatHoursShort(idea.estimatedHours)}
|
||||
{formatEstimate(idea.estimatedHours, estimateConfig)}
|
||||
</Typography>
|
||||
{idea.complexity && (
|
||||
<Chip
|
||||
|
||||
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';
|
||||
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,
|
||||
};
|
||||
}
|
||||
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;
|
||||
},
|
||||
};
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
31
package-lock.json
generated
31
package-lock.json
generated
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user