add broker
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-07 20:24:31 +03:00
parent b270345e77
commit 4d80480d0f
26 changed files with 694 additions and 37 deletions

View File

@ -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)

View File

@ -11,3 +11,6 @@ PORT=4001
# Keycloak # Keycloak
KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner KEYCLOAK_REALM_URL=https://auth.vigdorov.ru/realms/team-planner
KEYCLOAK_CLIENT_ID=team-planner-frontend KEYCLOAK_CLIENT_ID=team-planner-frontend
# NATS
NATS_URL=nats://10.10.10.100:30422

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
); );
} }

View File

@ -28,6 +28,11 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import type { EstimateResult } from '../../services/ai'; import type { EstimateResult } from '../../services/ai';
import type { IdeaComplexity } from '../../types/idea'; import type { IdeaComplexity } from '../../types/idea';
import {
formatEstimate,
type EstimateConfig,
DEFAULT_ESTIMATE_CONFIG,
} from '../../utils/estimate';
interface AiEstimateModalProps { interface AiEstimateModalProps {
open: boolean; open: boolean;
@ -35,6 +40,7 @@ interface AiEstimateModalProps {
result: EstimateResult | null; result: EstimateResult | null;
isLoading: boolean; isLoading: boolean;
error: Error | null; error: Error | null;
estimateConfig?: EstimateConfig;
} }
const complexityLabels: Record<IdeaComplexity, string> = { const complexityLabels: Record<IdeaComplexity, string> = {
@ -56,24 +62,13 @@ const complexityColors: Record<
veryComplex: 'error', veryComplex: 'error',
}; };
function formatHours(hours: number): string {
if (hours < 8) {
return `${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>
))} ))}

View File

@ -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" />

View File

@ -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>
); );

View File

@ -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

View File

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

View File

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

View File

@ -0,0 +1,33 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsApi } from '../services/settings';
import type { UserSettings } from '../types/settings';
import {
DEFAULT_ESTIMATE_CONFIG,
type EstimateConfig,
} from '../utils/estimate';
export function useSettingsQuery() {
return useQuery({
queryKey: ['settings'],
queryFn: settingsApi.get,
});
}
export function useUpdateSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch: Partial<UserSettings>) => settingsApi.update(patch),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['settings'] });
},
});
}
export function useEstimateConfig(): EstimateConfig {
const { data } = useSettingsQuery();
return {
hoursPerDay: data?.hoursPerDay ?? DEFAULT_ESTIMATE_CONFIG.hoursPerDay,
daysPerWeek: data?.daysPerWeek ?? DEFAULT_ESTIMATE_CONFIG.daysPerWeek,
};
}

View File

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

View File

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

View File

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

31
package-lock.json generated
View File

@ -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",