This commit is contained in:
@ -88,6 +88,7 @@
|
|||||||
| 2026-01-15 | **Фаза 3.2:** Кнопка "Подробнее" (Visibility icon) в actions колонке для открытия детального просмотра |
|
| 2026-01-15 | **Фаза 3.2:** Кнопка "Подробнее" (Visibility icon) в actions колонке для открытия детального просмотра |
|
||||||
| 2026-01-15 | **Фаза 3.2:** Исправлен баг — статус ТЗ сохраняется при редактировании идеи в модалке |
|
| 2026-01-15 | **Фаза 3.2:** Исправлен баг — статус ТЗ сохраняется при редактировании идеи в модалке |
|
||||||
| 2026-01-15 | **Testing:** E2E тесты Фазы 3.2 (Playwright) — 15 тестов покрывают детальный просмотр, редактирование, column visibility |
|
| 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:** 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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
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 {}
|
||||||
|
|||||||
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,
|
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';
|
||||||
|
|
||||||
@ -66,6 +74,7 @@ function App() {
|
|||||||
<Tabs value={tab} onChange={(_, v: number) => 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>
|
||||||
|
|
||||||
@ -95,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 `${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({
|
export function AiEstimateModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
result,
|
result,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
|
||||||
}: AiEstimateModalProps) {
|
}: AiEstimateModalProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -125,7 +120,7 @@ export function AiEstimateModal({
|
|||||||
>
|
>
|
||||||
<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">
|
||||||
@ -175,7 +170,7 @@ export function AiEstimateModal({
|
|||||||
>
|
>
|
||||||
<TableCell>{item.role}</TableCell>
|
<TableCell>{item.role}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{formatHours(item.hours)}
|
{formatEstimate(item.hours, estimateConfig)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -24,6 +24,11 @@ import type {
|
|||||||
UpdateIdeaDto,
|
UpdateIdeaDto,
|
||||||
} from '../../types/idea';
|
} from '../../types/idea';
|
||||||
import { statusOptions, priorityOptions } from '../IdeasTable/constants';
|
import { statusOptions, priorityOptions } from '../IdeasTable/constants';
|
||||||
|
import {
|
||||||
|
formatEstimate,
|
||||||
|
type EstimateConfig,
|
||||||
|
DEFAULT_ESTIMATE_CONFIG,
|
||||||
|
} from '../../utils/estimate';
|
||||||
|
|
||||||
interface IdeaDetailModalProps {
|
interface IdeaDetailModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -33,6 +38,7 @@ interface IdeaDetailModalProps {
|
|||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
onOpenSpecification: (idea: Idea) => void;
|
onOpenSpecification: (idea: Idea) => void;
|
||||||
onOpenEstimate: (idea: Idea) => void;
|
onOpenEstimate: (idea: Idea) => void;
|
||||||
|
estimateConfig?: EstimateConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<
|
const statusColors: Record<
|
||||||
@ -61,15 +67,6 @@ function formatDate(dateString: string | null): string {
|
|||||||
return new Date(dateString).toLocaleString('ru-RU');
|
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({
|
export function IdeaDetailModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@ -78,6 +75,7 @@ export function IdeaDetailModal({
|
|||||||
isSaving,
|
isSaving,
|
||||||
onOpenSpecification,
|
onOpenSpecification,
|
||||||
onOpenEstimate,
|
onOpenEstimate,
|
||||||
|
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
|
||||||
}: IdeaDetailModalProps) {
|
}: IdeaDetailModalProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [formData, setFormData] = useState<UpdateIdeaDto>({});
|
const [formData, setFormData] = useState<UpdateIdeaDto>({});
|
||||||
@ -394,7 +392,7 @@ export function IdeaDetailModal({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography data-testid="idea-detail-estimate">
|
<Typography data-testid="idea-detail-estimate">
|
||||||
{formatHours(idea.estimatedHours)}
|
{formatEstimate(idea.estimatedHours, estimateConfig)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{idea.complexity && (
|
{idea.complexity && (
|
||||||
<Chip label={idea.complexity} size="small" variant="outlined" />
|
<Chip label={idea.complexity} size="small" variant="outlined" />
|
||||||
|
|||||||
@ -59,6 +59,7 @@ import { SpecificationModal } from '../SpecificationModal';
|
|||||||
import { IdeaDetailModal } from '../IdeaDetailModal';
|
import { IdeaDetailModal } from '../IdeaDetailModal';
|
||||||
import type { EstimateResult } from '../../services/ai';
|
import type { EstimateResult } from '../../services/ai';
|
||||||
import type { Idea, UpdateIdeaDto } from '../../types/idea';
|
import type { Idea, UpdateIdeaDto } from '../../types/idea';
|
||||||
|
import { useEstimateConfig } from '../../hooks/useSettings';
|
||||||
|
|
||||||
const SKELETON_COLUMNS_COUNT = 13;
|
const SKELETON_COLUMNS_COUNT = 13;
|
||||||
|
|
||||||
@ -73,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);
|
||||||
@ -274,6 +276,7 @@ export function IdeasTable() {
|
|||||||
expandedId,
|
expandedId,
|
||||||
estimatingId,
|
estimatingId,
|
||||||
generatingSpecificationId,
|
generatingSpecificationId,
|
||||||
|
estimateConfig,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
deleteIdea,
|
deleteIdea,
|
||||||
@ -283,6 +286,7 @@ export function IdeasTable() {
|
|||||||
handleEstimate,
|
handleEstimate,
|
||||||
handleSpecification,
|
handleSpecification,
|
||||||
handleViewDetails,
|
handleViewDetails,
|
||||||
|
estimateConfig,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -549,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}
|
||||||
@ -574,6 +579,7 @@ export function IdeasTable() {
|
|||||||
isSaving={updateIdea.isPending}
|
isSaving={updateIdea.isPending}
|
||||||
onOpenSpecification={handleOpenSpecificationFromDetail}
|
onOpenSpecification={handleOpenSpecificationFromDetail}
|
||||||
onOpenEstimate={handleOpenEstimateFromDetail}
|
onOpenEstimate={handleOpenEstimateFromDetail}
|
||||||
|
estimateConfig={estimateConfig}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,6 +26,11 @@ 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>();
|
||||||
|
|
||||||
@ -69,14 +74,6 @@ const complexityColors: Record<
|
|||||||
veryComplex: 'error',
|
veryComplex: 'error',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatHoursShort(hours: number): string {
|
|
||||||
if (hours < 8) {
|
|
||||||
return `${String(hours)}ч`;
|
|
||||||
}
|
|
||||||
const days = Math.floor(hours / 8);
|
|
||||||
return `${String(days)}д`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ColumnsConfig {
|
interface ColumnsConfig {
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onToggleComments: (id: string) => void;
|
onToggleComments: (id: string) => void;
|
||||||
@ -87,6 +84,7 @@ interface ColumnsConfig {
|
|||||||
expandedId: string | null;
|
expandedId: string | null;
|
||||||
estimatingId: string | null;
|
estimatingId: string | null;
|
||||||
generatingSpecificationId: string | null;
|
generatingSpecificationId: string | null;
|
||||||
|
estimateConfig?: EstimateConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createColumns = ({
|
export const createColumns = ({
|
||||||
@ -99,6 +97,7 @@ export const createColumns = ({
|
|||||||
expandedId,
|
expandedId,
|
||||||
estimatingId,
|
estimatingId,
|
||||||
generatingSpecificationId,
|
generatingSpecificationId,
|
||||||
|
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
|
||||||
}: ColumnsConfig) => [
|
}: ColumnsConfig) => [
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id: 'drag',
|
id: 'drag',
|
||||||
@ -302,7 +301,7 @@ export const createColumns = ({
|
|||||||
>
|
>
|
||||||
<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
|
||||||
|
|||||||
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",
|
"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",
|
||||||
@ -12991,6 +12992,30 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/parse-entities": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||||
@ -13429,6 +13454,12 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/uid": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user