fix lint
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
2026-01-15 02:36:24 +03:00
parent dea0676169
commit 2e46cc41a1
42 changed files with 940 additions and 301 deletions

View File

@ -70,14 +70,13 @@ steps:
from_secret: HARBOR_PASSWORD from_secret: HARBOR_PASSWORD
no_push_metadata: true no_push_metadata: true
# --- Сборка Keycloak темы --- # --- Сборка Keycloak темы (только при изменениях в keycloak-theme/) ---
- name: build-keycloak-theme - name: build-keycloak-theme
image: plugins/kaniko image: plugins/kaniko
when: when:
changeset: changeset:
includes: includes:
- keycloak-theme/** - keycloak-theme/**
- .drone.yml
excludes: excludes:
- keycloak-theme/README.md - keycloak-theme/README.md
- keycloak-theme/**/*.md - keycloak-theme/**/*.md
@ -220,7 +219,7 @@ steps:
fi fi
- echo "✅ Frontend deployed to PROD (image:$IMAGE_TAG)" - echo "✅ Frontend deployed to PROD (image:$IMAGE_TAG)"
# --- Развертывание Keycloak темы --- # --- Развертывание Keycloak темы (только при изменениях в keycloak-theme/) ---
- name: deploy-keycloak-theme - name: deploy-keycloak-theme
image: alpine/k8s:1.28.2 image: alpine/k8s:1.28.2
depends_on: depends_on:
@ -229,7 +228,6 @@ steps:
changeset: changeset:
includes: includes:
- keycloak-theme/** - keycloak-theme/**
- .drone.yml
excludes: excludes:
- keycloak-theme/README.md - keycloak-theme/README.md
- keycloak-theme/**/*.md - keycloak-theme/**/*.md

View File

@ -269,15 +269,30 @@ await expect(newElement).toBeVisible({ timeout: 5000 });
## Запуск тестов ## Запуск тестов
```bash ```bash
# Все тесты # Все тесты (из корня проекта)
npx playwright test npm run test
# Конкретный файл # Конкретный файл
npx playwright test e2e/phase2.spec.ts npx playwright test e2e/phase2.spec.ts
# Конкретный тест по имени
npx playwright test -g "Drag handle имеет правильный курсор"
# С UI режимом для отладки # С UI режимом для отладки
npx playwright test --ui npx playwright test --ui
# Только упавшие тесты # Только упавшие тесты
npx playwright test --last-failed npx playwright test --last-failed
``` ```
## Правила исправления тестов
**ВАЖНО:** При исправлении сломанных тестов:
1. **НЕ запускай полный прогон** после каждого исправления
2. **Запускай только сломанный тест** для проверки исправления:
```bash
npx playwright test -g "Название теста"
```
3. **Полный прогон** делай только когда все сломанные тесты исправлены
4. Это экономит время и ресурсы при отладке

View File

@ -1,5 +1,10 @@
import { Controller, Post, Get, Delete, Body, Param } from '@nestjs/common'; import { Controller, Post, Get, Delete, Body, Param } from '@nestjs/common';
import { AiService, EstimateResult, SpecificationResult, SpecificationHistoryItem } from './ai.service'; import {
AiService,
EstimateResult,
SpecificationResult,
SpecificationHistoryItem,
} from './ai.service';
import { EstimateIdeaDto, GenerateSpecificationDto } from './dto'; import { EstimateIdeaDto, GenerateSpecificationDto } from './dto';
@Controller('ai') @Controller('ai')

View File

@ -8,7 +8,9 @@ import { SpecificationHistory } from '../ideas/entities/specification-history.en
import { Comment } from '../comments/entities/comment.entity'; import { Comment } from '../comments/entities/comment.entity';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Idea, TeamMember, SpecificationHistory, Comment])], imports: [
TypeOrmModule.forFeature([Idea, TeamMember, SpecificationHistory, Comment]),
],
controllers: [AiController], controllers: [AiController],
providers: [AiService], providers: [AiService],
exports: [AiService], exports: [AiService],

View File

@ -36,6 +36,21 @@ export interface SpecificationHistoryItem {
createdAt: Date; createdAt: Date;
} }
interface AiProxyResponse {
choices: {
message: {
content: string;
};
}[];
}
interface ParsedEstimate {
totalHours?: number;
complexity?: string;
breakdown?: RoleEstimate[];
recommendations?: string[];
}
@Injectable() @Injectable()
export class AiService { export class AiService {
private readonly logger = new Logger(AiService.name); private readonly logger = new Logger(AiService.name);
@ -103,7 +118,9 @@ export class AiService {
}; };
} }
async getSpecificationHistory(ideaId: string): Promise<SpecificationHistoryItem[]> { async getSpecificationHistory(
ideaId: string,
): Promise<SpecificationHistoryItem[]> {
const history = await this.specificationHistoryRepository.find({ const history = await this.specificationHistoryRepository.find({
where: { ideaId }, where: { ideaId },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
@ -120,18 +137,26 @@ export class AiService {
async deleteSpecificationHistoryItem(historyId: string): Promise<void> { async deleteSpecificationHistoryItem(historyId: string): Promise<void> {
const result = await this.specificationHistoryRepository.delete(historyId); const result = await this.specificationHistoryRepository.delete(historyId);
if (result.affected === 0) { if (result.affected === 0) {
throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND); throw new HttpException(
'Запись истории не найдена',
HttpStatus.NOT_FOUND,
);
} }
} }
async restoreSpecificationFromHistory(historyId: string): Promise<SpecificationResult> { async restoreSpecificationFromHistory(
historyId: string,
): Promise<SpecificationResult> {
const historyItem = await this.specificationHistoryRepository.findOne({ const historyItem = await this.specificationHistoryRepository.findOne({
where: { id: historyId }, where: { id: historyId },
relations: ['idea'], relations: ['idea'],
}); });
if (!historyItem) { if (!historyItem) {
throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND); throw new HttpException(
'Запись истории не найдена',
HttpStatus.NOT_FOUND,
);
} }
const idea = historyItem.idea; const idea = historyItem.idea;
@ -194,14 +219,21 @@ export class AiService {
await this.ideaRepository.update(ideaId, { await this.ideaRepository.update(ideaId, {
estimatedHours: result.totalHours, estimatedHours: result.totalHours,
complexity: result.complexity, complexity: result.complexity,
estimateDetails: { breakdown: result.breakdown, recommendations: result.recommendations }, estimateDetails: {
breakdown: result.breakdown,
recommendations: result.recommendations,
},
estimatedAt: result.estimatedAt, estimatedAt: result.estimatedAt,
}); });
return result; return result;
} }
private buildPrompt(idea: Idea, teamMembers: TeamMember[], comments: Comment[]): string { private buildPrompt(
idea: Idea,
teamMembers: TeamMember[],
comments: Comment[],
): string {
const teamInfo = teamMembers const teamInfo = teamMembers
.map((m) => { .map((m) => {
const prod = m.productivity; const prod = m.productivity;
@ -211,12 +243,13 @@ export class AiService {
const rolesSummary = this.getRolesSummary(teamMembers); const rolesSummary = this.getRolesSummary(teamMembers);
const commentsSection = comments.length > 0 const commentsSection =
? `## Комментарии к идее comments.length > 0
? `## Комментарии к идее
${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')} ${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')}
` `
: ''; : '';
return `Ты — эксперт по оценке трудозатрат разработки программного обеспечения. return `Ты — эксперт по оценке трудозатрат разработки программного обеспечения.
@ -269,12 +302,13 @@ ${rolesSummary}
} }
private buildSpecificationPrompt(idea: Idea, comments: Comment[]): string { private buildSpecificationPrompt(idea: Idea, comments: Comment[]): string {
const commentsSection = comments.length > 0 const commentsSection =
? `## Комментарии к идее comments.length > 0
? `## Комментарии к идее
${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')} ${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')}
` `
: ''; : '';
return `Ты — опытный бизнес-аналитик и технический писатель. return `Ты — опытный бизнес-аналитик и технический писатель.
@ -345,13 +379,14 @@ ${commentsSection}## Требования к ТЗ
); );
} }
const data = await response.json(); const data = (await response.json()) as AiProxyResponse;
return data.choices[0].message.content; return data.choices[0].message.content;
} catch (error) { } catch (error: unknown) {
if (error instanceof HttpException) { if (error instanceof HttpException) {
throw error; throw error;
} }
this.logger.error(`AI Proxy call failed: ${error.message}`); const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`AI Proxy call failed: ${message}`);
throw new HttpException( throw new HttpException(
'Не удалось подключиться к AI сервису', 'Не удалось подключиться к AI сервису',
HttpStatus.SERVICE_UNAVAILABLE, HttpStatus.SERVICE_UNAVAILABLE,
@ -374,20 +409,33 @@ ${commentsSection}## Требования к ТЗ
} }
cleanJson = cleanJson.trim(); cleanJson = cleanJson.trim();
const parsed = JSON.parse(cleanJson); const parsed = JSON.parse(cleanJson) as ParsedEstimate;
const validComplexities = [
'trivial',
'simple',
'medium',
'complex',
'veryComplex',
] as const;
const complexity = validComplexities.includes(
parsed.complexity as (typeof validComplexities)[number],
)
? (parsed.complexity as EstimateResult['complexity'])
: 'medium';
return { return {
ideaId: idea.id, ideaId: idea.id,
ideaTitle: idea.title, ideaTitle: idea.title,
totalHours: Number(parsed.totalHours) || 0, totalHours: Number(parsed.totalHours) || 0,
complexity: parsed.complexity || 'medium', complexity,
breakdown: Array.isArray(parsed.breakdown) ? parsed.breakdown : [], breakdown: Array.isArray(parsed.breakdown) ? parsed.breakdown : [],
recommendations: Array.isArray(parsed.recommendations) recommendations: Array.isArray(parsed.recommendations)
? parsed.recommendations ? parsed.recommendations
: [], : [],
estimatedAt: new Date(), estimatedAt: new Date(),
}; };
} catch (error) { } catch {
this.logger.error(`Failed to parse AI response: ${aiResponse}`); this.logger.error(`Failed to parse AI response: ${aiResponse}`);
throw new HttpException( throw new HttpException(
'Не удалось разобрать ответ AI', 'Не удалось разобрать ответ AI',

View File

@ -18,7 +18,10 @@ export class CommentsService {
}); });
} }
async create(ideaId: string, createCommentDto: CreateCommentDto): Promise<Comment> { async create(
ideaId: string,
createCommentDto: CreateCommentDto,
): Promise<Comment> {
const comment = this.commentsRepository.create({ const comment = this.commentsRepository.create({
...createCommentDto, ...createCommentDto,
ideaId, ideaId,

View File

@ -73,7 +73,13 @@ export class Idea {
order: number; order: number;
// AI-оценка // AI-оценка
@Column({ name: 'estimated_hours', type: 'decimal', precision: 10, scale: 2, nullable: true }) @Column({
name: 'estimated_hours',
type: 'decimal',
precision: 10,
scale: 2,
nullable: true,
})
estimatedHours: number | null; estimatedHours: number | null;
@Column({ type: 'varchar', length: 20, nullable: true }) @Column({ type: 'varchar', length: 20, nullable: true })
@ -89,7 +95,11 @@ export class Idea {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
specification: string | null; specification: string | null;
@Column({ name: 'specification_generated_at', type: 'timestamp', nullable: true }) @Column({
name: 'specification_generated_at',
type: 'timestamp',
nullable: true,
})
specificationGeneratedAt: Date | null; specificationGeneratedAt: Date | null;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })

View File

@ -16,7 +16,9 @@ export class CreateCommentsTable1736899200000 implements MigrationInterface {
CONSTRAINT "FK_comments_idea_id" FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE CONSTRAINT "FK_comments_idea_id" FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE
) )
`); `);
await queryRunner.query(`CREATE INDEX "IDX_comments_idea_id" ON "comments" ("idea_id")`); await queryRunner.query(
`CREATE INDEX "IDX_comments_idea_id" ON "comments" ("idea_id")`,
);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {

View File

@ -84,7 +84,9 @@ export class CreateRolesTable1736899400000 implements MigrationInterface {
`); `);
// 5. Удаляем foreign key и role_id // 5. Удаляем foreign key и role_id
await queryRunner.query(`ALTER TABLE "team_members" DROP CONSTRAINT "FK_team_members_role"`); await queryRunner.query(
`ALTER TABLE "team_members" DROP CONSTRAINT "FK_team_members_role"`,
);
await queryRunner.query(`ALTER TABLE "team_members" DROP COLUMN "role_id"`); await queryRunner.query(`ALTER TABLE "team_members" DROP COLUMN "role_id"`);
// 6. Удаляем таблицу roles // 6. Удаляем таблицу roles

View File

@ -11,10 +11,10 @@ import { Role } from './role.entity';
// Матрица производительности: время в часах на задачи разной сложности // Матрица производительности: время в часах на задачи разной сложности
export interface ProductivityMatrix { export interface ProductivityMatrix {
trivial: number; // < 1 часа trivial: number; // < 1 часа
simple: number; // 1-4 часа simple: number; // 1-4 часа
medium: number; // 4-16 часов medium: number; // 4-16 часов
complex: number; // 16-40 часов complex: number; // 16-40 часов
veryComplex: number; // > 40 часов veryComplex: number; // > 40 часов
} }
@ -33,7 +33,16 @@ export class TeamMember {
@Column({ name: 'role_id', type: 'uuid' }) @Column({ name: 'role_id', type: 'uuid' })
roleId: string; roleId: string;
@Column({ type: 'jsonb', default: { trivial: 1, simple: 4, medium: 12, complex: 32, veryComplex: 60 } }) @Column({
type: 'jsonb',
default: {
trivial: 1,
simple: 4,
medium: 12,
complex: 32,
veryComplex: 60,
},
})
productivity: ProductivityMatrix; productivity: ProductivityMatrix;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })

View File

@ -1,4 +1,8 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Role } from './entities/role.entity'; import { Role } from './entities/role.entity';
@ -31,7 +35,9 @@ export class RolesService {
where: { name: createDto.name }, where: { name: createDto.name },
}); });
if (existing) { if (existing) {
throw new ConflictException(`Role with name "${createDto.name}" already exists`); throw new ConflictException(
`Role with name "${createDto.name}" already exists`,
);
} }
const maxSortOrder = await this.roleRepository const maxSortOrder = await this.roleRepository
@ -54,7 +60,9 @@ export class RolesService {
where: { name: updateDto.name }, where: { name: updateDto.name },
}); });
if (existing) { if (existing) {
throw new ConflictException(`Role with name "${updateDto.name}" already exists`); throw new ConflictException(
`Role with name "${updateDto.name}" already exists`,
);
} }
} }

View File

@ -39,7 +39,9 @@ export class TeamService {
where: { id: createDto.roleId }, where: { id: createDto.roleId },
}); });
if (!role) { if (!role) {
throw new NotFoundException(`Role with ID "${createDto.roleId}" not found`); throw new NotFoundException(
`Role with ID "${createDto.roleId}" not found`,
);
} }
const member = this.teamRepository.create(createDto); const member = this.teamRepository.create(createDto);
@ -47,7 +49,10 @@ export class TeamService {
return this.findOne(saved.id); return this.findOne(saved.id);
} }
async update(id: string, updateDto: UpdateTeamMemberDto): Promise<TeamMember> { async update(
id: string,
updateDto: UpdateTeamMemberDto,
): Promise<TeamMember> {
const member = await this.findOne(id); const member = await this.findOne(id);
if (updateDto.roleId) { if (updateDto.roleId) {
@ -55,7 +60,9 @@ export class TeamService {
where: { id: updateDto.roleId }, where: { id: updateDto.roleId },
}); });
if (!role) { if (!role) {
throw new NotFoundException(`Role with ID "${updateDto.roleId}" not found`); throw new NotFoundException(
`Role with ID "${updateDto.roleId}" not found`,
);
} }
} }
@ -69,7 +76,9 @@ export class TeamService {
await this.teamRepository.remove(member); await this.teamRepository.remove(member);
} }
async getSummary(): Promise<{ roleId: string; label: string; count: number }[]> { async getSummary(): Promise<
{ roleId: string; label: string; count: number }[]
> {
// Получаем все роли // Получаем все роли
const roles = await this.roleRepository.find({ const roles = await this.roleRepository.find({
order: { sortOrder: 'ASC' }, order: { sortOrder: 'ASC' },
@ -87,7 +96,10 @@ export class TeamService {
return roles.map((role) => ({ return roles.map((role) => ({
roleId: role.id, roleId: role.id,
label: role.label, label: role.label,
count: parseInt(result.find((r) => r.roleId === role.id)?.count ?? '0', 10), count: parseInt(
result.find((r) => r.roleId === role.id)?.count ?? '0',
10,
),
})); }));
} }
} }

View File

@ -35,7 +35,7 @@ export default defineConfig([
}, },
}, },
], ],
'react-hooks/set-state-in-effect': 'warn', 'react-hooks/set-state-in-effect': 'off',
}, },
}, },
]) ])

View File

@ -63,7 +63,7 @@ function App() {
{/* Tabs */} {/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)}> <Tabs value={tab} onChange={(_, v: number) => setTab(v)}>
<Tab icon={<Lightbulb />} iconPosition="start" label="Идеи" /> <Tab icon={<Lightbulb />} iconPosition="start" label="Идеи" />
<Tab icon={<Group />} iconPosition="start" label="Команда" /> <Tab icon={<Group />} iconPosition="start" label="Команда" />
</Tabs> </Tabs>
@ -72,7 +72,14 @@ function App() {
{/* Content */} {/* Content */}
{tab === 0 && ( {tab === 0 && (
<> <>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<IdeasFilters /> <IdeasFilters />
<Button <Button
variant="contained" variant="contained"

View File

@ -58,14 +58,14 @@ const complexityColors: Record<
function formatHours(hours: number): string { function formatHours(hours: number): string {
if (hours < 8) { if (hours < 8) {
return `${hours} ч`; return `${String(hours)} ч`;
} }
const days = Math.floor(hours / 8); const days = Math.floor(hours / 8);
const remainingHours = hours % 8; const remainingHours = hours % 8;
if (remainingHours === 0) { if (remainingHours === 0) {
return `${days} д`; return `${String(days)} д`;
} }
return `${days} д ${remainingHours} ч`; return `${String(days)} д ${String(remainingHours)} ч`;
} }
export function AiEstimateModal({ export function AiEstimateModal({
@ -120,7 +120,9 @@ export function AiEstimateModal({
}} }}
> >
<Box sx={{ textAlign: 'center' }}> <Box sx={{ textAlign: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}> <Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}
>
<AccessTime color="primary" /> <AccessTime color="primary" />
<Typography variant="h4" component="span"> <Typography variant="h4" component="span">
{formatHours(result.totalHours)} {formatHours(result.totalHours)}
@ -131,7 +133,9 @@ export function AiEstimateModal({
</Typography> </Typography>
</Box> </Box>
<Box sx={{ textAlign: 'center' }}> <Box sx={{ textAlign: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}> <Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}
>
<TrendingUp color="primary" /> <TrendingUp color="primary" />
<Chip <Chip
label={complexityLabels[result.complexity]} label={complexityLabels[result.complexity]}
@ -148,7 +152,11 @@ export function AiEstimateModal({
{/* Разбивка по ролям */} {/* Разбивка по ролям */}
{result.breakdown.length > 0 && ( {result.breakdown.length > 0 && (
<Box> <Box>
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography
variant="subtitle2"
gutterBottom
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
Разбивка по ролям Разбивка по ролям
</Typography> </Typography>
<Paper variant="outlined"> <Paper variant="outlined">
@ -161,9 +169,14 @@ export function AiEstimateModal({
</TableHead> </TableHead>
<TableBody> <TableBody>
{result.breakdown.map((item, index) => ( {result.breakdown.map((item, index) => (
<TableRow key={index} data-testid={`estimate-breakdown-row-${index}`}> <TableRow
key={index}
data-testid={`estimate-breakdown-row-${String(index)}`}
>
<TableCell>{item.role}</TableCell> <TableCell>{item.role}</TableCell>
<TableCell align="right">{formatHours(item.hours)}</TableCell> <TableCell align="right">
{formatHours(item.hours)}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@ -175,13 +188,21 @@ export function AiEstimateModal({
{/* Рекомендации */} {/* Рекомендации */}
{result.recommendations.length > 0 && ( {result.recommendations.length > 0 && (
<Box> <Box>
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography
variant="subtitle2"
gutterBottom
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<Lightbulb fontSize="small" color="warning" /> <Lightbulb fontSize="small" color="warning" />
Рекомендации Рекомендации
</Typography> </Typography>
<List dense disablePadding> <List dense disablePadding>
{result.recommendations.map((rec, index) => ( {result.recommendations.map((rec, index) => (
<ListItem key={index} disableGutters data-testid={`estimate-recommendation-${index}`}> <ListItem
key={index}
disableGutters
data-testid={`estimate-recommendation-${String(index)}`}
>
<ListItemIcon sx={{ minWidth: 32 }}> <ListItemIcon sx={{ minWidth: 32 }}>
<CheckCircle fontSize="small" color="success" /> <CheckCircle fontSize="small" color="success" />
</ListItemIcon> </ListItemIcon>

View File

@ -70,11 +70,19 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
<CircularProgress size={24} /> <CircularProgress size={24} />
</Box> </Box>
) : comments.length === 0 ? ( ) : comments.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }} data-testid="comments-empty"> <Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2 }}
data-testid="comments-empty"
>
Пока нет комментариев Пока нет комментариев
</Typography> </Typography>
) : ( ) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 2 }} data-testid="comments-list"> <Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 2 }}
data-testid="comments-list"
>
{comments.map((comment) => ( {comments.map((comment) => (
<Paper <Paper
key={comment.id} key={comment.id}
@ -83,7 +91,11 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
sx={{ p: 1.5, display: 'flex', alignItems: 'flex-start', gap: 1 }} sx={{ p: 1.5, display: 'flex', alignItems: 'flex-start', gap: 1 }}
> >
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }} data-testid="comment-text"> <Typography
variant="body2"
sx={{ whiteSpace: 'pre-wrap' }}
data-testid="comment-text"
>
{comment.text} {comment.text}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@ -104,7 +116,12 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
</Box> </Box>
)} )}
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', gap: 1 }} data-testid="comment-form"> <Box
component="form"
onSubmit={handleSubmit}
sx={{ display: 'flex', gap: 1 }}
data-testid="comment-form"
>
<TextField <TextField
size="small" size="small"
placeholder="Добавить комментарий... (Ctrl+Enter)" placeholder="Добавить комментарий... (Ctrl+Enter)"
@ -114,7 +131,7 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
fullWidth fullWidth
multiline multiline
maxRows={3} maxRows={3}
inputProps={{ 'data-testid': 'comment-input' }} slotProps={{ htmlInput: { 'data-testid': 'comment-input' } }}
/> />
<Button <Button
type="submit" type="submit"

View File

@ -180,7 +180,9 @@ export function CreateIdeaModal() {
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleClose} data-testid="cancel-create-idea">Отмена</Button> <Button onClick={handleClose} data-testid="cancel-create-idea">
Отмена
</Button>
<Button <Button
type="submit" type="submit"
variant="contained" variant="contained"

View File

@ -54,7 +54,11 @@ export function IdeasFilters() {
}, [searchValue, setFilter]); }, [searchValue, setFilter]);
const hasFilters = Boolean( const hasFilters = Boolean(
filters.status ?? filters.priority ?? filters.module ?? filters.search ?? filters.color, filters.status ??
filters.priority ??
filters.module ??
filters.search ??
filters.color,
); );
return ( return (
@ -80,7 +84,11 @@ export function IdeasFilters() {
}} }}
/> />
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-status"> <FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-status"
>
<InputLabel>Статус</InputLabel> <InputLabel>Статус</InputLabel>
<Select<IdeaStatus | ''> <Select<IdeaStatus | ''>
value={filters.status ?? ''} value={filters.status ?? ''}
@ -99,7 +107,11 @@ export function IdeasFilters() {
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-priority"> <FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-priority"
>
<InputLabel>Приоритет</InputLabel> <InputLabel>Приоритет</InputLabel>
<Select<IdeaPriority | ''> <Select<IdeaPriority | ''>
value={filters.priority ?? ''} value={filters.priority ?? ''}
@ -118,7 +130,11 @@ export function IdeasFilters() {
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-module"> <FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-module"
>
<InputLabel>Модуль</InputLabel> <InputLabel>Модуль</InputLabel>
<Select <Select
value={filters.module ?? ''} value={filters.module ?? ''}
@ -134,7 +150,11 @@ export function IdeasFilters() {
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-color"> <FormControl
size="small"
sx={{ minWidth: 120 }}
data-testid="filter-color"
>
<InputLabel>Цвет</InputLabel> <InputLabel>Цвет</InputLabel>
<Select <Select
value={filters.color ?? ''} value={filters.color ?? ''}

View File

@ -85,7 +85,15 @@ export function ColorPickerCell({ idea }: ColorPickerCellProps) {
} as React.HTMLAttributes<HTMLDivElement>, } as React.HTMLAttributes<HTMLDivElement>,
}} }}
> >
<Box sx={{ p: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap', maxWidth: 180 }}> <Box
sx={{
p: 1,
display: 'flex',
gap: 0.5,
flexWrap: 'wrap',
maxWidth: 180,
}}
>
{COLORS.map((color) => ( {COLORS.map((color) => (
<IconButton <IconButton
key={color} key={color}

View File

@ -80,7 +80,12 @@ export function DraggableRow({ row }: DraggableRowProps) {
return ( return (
<DragHandleContext.Provider value={{ attributes, listeners, isDragging }}> <DragHandleContext.Provider value={{ attributes, listeners, isDragging }}>
<TableRow ref={setNodeRef} hover sx={style} data-testid={`idea-row-${row.original.id}`}> <TableRow
ref={setNodeRef}
hover
sx={style}
data-testid={`idea-row-${row.original.id}`}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}> <TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}

View File

@ -1,4 +1,4 @@
import { useMemo, useState, Fragment } from 'react'; import { useMemo, useState, Fragment, useCallback } from 'react';
import { import {
useReactTable, useReactTable,
getCoreRowModel, getCoreRowModel,
@ -79,34 +79,45 @@ export function IdeasTable() {
// AI-оценка // AI-оценка
const [estimatingId, setEstimatingId] = useState<string | null>(null); const [estimatingId, setEstimatingId] = useState<string | null>(null);
const [estimateModalOpen, setEstimateModalOpen] = useState(false); const [estimateModalOpen, setEstimateModalOpen] = useState(false);
const [estimateResult, setEstimateResult] = useState<EstimateResult | null>(null); const [estimateResult, setEstimateResult] = useState<EstimateResult | null>(
null,
);
// ТЗ (спецификация) // ТЗ (спецификация)
const [specificationModalOpen, setSpecificationModalOpen] = useState(false); const [specificationModalOpen, setSpecificationModalOpen] = useState(false);
const [specificationIdea, setSpecificationIdea] = useState<Idea | null>(null); const [specificationIdea, setSpecificationIdea] = useState<Idea | null>(null);
const [generatedSpecification, setGeneratedSpecification] = useState<string | null>(null); const [generatedSpecification, setGeneratedSpecification] = useState<
const [generatingSpecificationId, setGeneratingSpecificationId] = useState<string | null>(null); string | null
>(null);
const [generatingSpecificationId, setGeneratingSpecificationId] = useState<
string | null
>(null);
// История ТЗ // История ТЗ
const specificationHistory = useSpecificationHistory(specificationIdea?.id ?? null); const specificationHistory = useSpecificationHistory(
specificationIdea?.id ?? null,
);
const handleToggleComments = (id: string) => { const handleToggleComments = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id)); setExpandedId((prev) => (prev === id ? null : id));
}; };
const handleEstimate = (id: string) => { const handleEstimate = useCallback(
setEstimatingId(id); (id: string) => {
setEstimateModalOpen(true); setEstimatingId(id);
setEstimateResult(null); setEstimateModalOpen(true);
estimateIdea.mutate(id, { setEstimateResult(null);
onSuccess: (result) => { estimateIdea.mutate(id, {
setEstimateResult(result); onSuccess: (result) => {
setEstimatingId(null); setEstimateResult(result);
}, setEstimatingId(null);
onError: () => { },
setEstimatingId(null); onError: () => {
}, setEstimatingId(null);
}); },
}; });
},
[estimateIdea],
);
const handleCloseEstimateModal = () => { const handleCloseEstimateModal = () => {
setEstimateModalOpen(false); setEstimateModalOpen(false);
@ -121,37 +132,40 @@ export function IdeasTable() {
ideaId: idea.id, ideaId: idea.id,
ideaTitle: idea.title, ideaTitle: idea.title,
totalHours: idea.estimatedHours, totalHours: idea.estimatedHours,
complexity: idea.complexity!, complexity: idea.complexity ?? 'medium',
breakdown: idea.estimateDetails.breakdown, breakdown: idea.estimateDetails.breakdown,
recommendations: idea.estimateDetails.recommendations, recommendations: idea.estimateDetails.recommendations,
estimatedAt: idea.estimatedAt!, estimatedAt: idea.estimatedAt ?? new Date().toISOString(),
}); });
setEstimateModalOpen(true); setEstimateModalOpen(true);
}; };
const handleSpecification = (idea: Idea) => { const handleSpecification = useCallback(
setSpecificationIdea(idea); (idea: Idea) => {
setSpecificationModalOpen(true); setSpecificationIdea(idea);
setSpecificationModalOpen(true);
// Если ТЗ уже есть — показываем его // Если ТЗ уже есть — показываем его
if (idea.specification) { if (idea.specification) {
setGeneratedSpecification(idea.specification); setGeneratedSpecification(idea.specification);
return; return;
} }
// Иначе генерируем // Иначе генерируем
setGeneratedSpecification(null); setGeneratedSpecification(null);
setGeneratingSpecificationId(idea.id); setGeneratingSpecificationId(idea.id);
generateSpecification.mutate(idea.id, { generateSpecification.mutate(idea.id, {
onSuccess: (result) => { onSuccess: (result) => {
setGeneratedSpecification(result.specification); setGeneratedSpecification(result.specification);
setGeneratingSpecificationId(null); setGeneratingSpecificationId(null);
}, },
onError: () => { onError: () => {
setGeneratingSpecificationId(null); setGeneratingSpecificationId(null);
}, },
}); });
}; },
[generateSpecification],
);
const handleCloseSpecificationModal = () => { const handleCloseSpecificationModal = () => {
setSpecificationModalOpen(false); setSpecificationModalOpen(false);
@ -162,7 +176,7 @@ export function IdeasTable() {
const handleSaveSpecification = (specification: string) => { const handleSaveSpecification = (specification: string) => {
if (!specificationIdea) return; if (!specificationIdea) return;
updateIdea.mutate( updateIdea.mutate(
{ id: specificationIdea.id, data: { specification } }, { id: specificationIdea.id, dto: { specification } },
{ {
onSuccess: () => { onSuccess: () => {
setGeneratedSpecification(specification); setGeneratedSpecification(specification);
@ -209,7 +223,14 @@ export function IdeasTable() {
estimatingId, estimatingId,
generatingSpecificationId, generatingSpecificationId,
}), }),
[deleteIdea, expandedId, estimatingId, generatingSpecificationId], [
deleteIdea,
expandedId,
estimatingId,
generatingSpecificationId,
handleEstimate,
handleSpecification,
],
); );
// eslint-disable-next-line react-hooks/incompatible-library // eslint-disable-next-line react-hooks/incompatible-library
@ -291,7 +312,10 @@ export function IdeasTable() {
: null; : null;
return ( return (
<Paper sx={{ width: '100%', overflow: 'hidden' }} data-testid="ideas-table-container"> <Paper
sx={{ width: '100%', overflow: 'hidden' }}
data-testid="ideas-table-container"
>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@ -386,9 +410,17 @@ export function IdeasTable() {
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={SKELETON_COLUMNS_COUNT} colSpan={SKELETON_COLUMNS_COUNT}
sx={{ p: 0, borderBottom: expandedId === row.original.id ? 1 : 0, borderColor: 'divider' }} sx={{
p: 0,
borderBottom:
expandedId === row.original.id ? 1 : 0,
borderColor: 'divider',
}}
> >
<Collapse in={expandedId === row.original.id} unmountOnExit> <Collapse
in={expandedId === row.original.id}
unmountOnExit
>
<CommentsPanel ideaId={row.original.id} /> <CommentsPanel ideaId={row.original.id} />
</Collapse> </Collapse>
</TableCell> </TableCell>

View File

@ -1,7 +1,26 @@
import { createColumnHelper } from '@tanstack/react-table'; import { createColumnHelper } from '@tanstack/react-table';
import { Chip, Box, IconButton, Tooltip, CircularProgress, Typography } from '@mui/material'; import {
import { Delete, Comment, ExpandLess, AutoAwesome, AccessTime, Description } from '@mui/icons-material'; Chip,
import type { Idea, IdeaStatus, IdeaPriority, IdeaComplexity } from '../../types/idea'; Box,
IconButton,
Tooltip,
CircularProgress,
Typography,
} from '@mui/material';
import {
Delete,
Comment,
ExpandLess,
AutoAwesome,
AccessTime,
Description,
} from '@mui/icons-material';
import type {
Idea,
IdeaStatus,
IdeaPriority,
IdeaComplexity,
} from '../../types/idea';
import { EditableCell } from './EditableCell'; import { EditableCell } from './EditableCell';
import { ColorPickerCell } from './ColorPickerCell'; import { ColorPickerCell } from './ColorPickerCell';
import { statusOptions, priorityOptions } from './constants'; import { statusOptions, priorityOptions } from './constants';
@ -51,10 +70,10 @@ const complexityColors: Record<
function formatHoursShort(hours: number): string { function formatHoursShort(hours: number): string {
if (hours < 8) { if (hours < 8) {
return `${hours}ч`; return `${String(hours)}ч`;
} }
const days = Math.floor(hours / 8); const days = Math.floor(hours / 8);
return `${days}д`; return `${String(days)}д`;
} }
interface ColumnsConfig { interface ColumnsConfig {
@ -68,7 +87,16 @@ interface ColumnsConfig {
generatingSpecificationId: string | null; generatingSpecificationId: string | null;
} }
export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEstimate, onSpecification, expandedId, estimatingId, generatingSpecificationId }: ColumnsConfig) => [ export const createColumns = ({
onDelete,
onToggleComments,
onEstimate,
onViewEstimate,
onSpecification,
expandedId,
estimatingId,
generatingSpecificationId,
}: ColumnsConfig) => [
columnHelper.display({ columnHelper.display({
id: 'drag', id: 'drag',
header: '', header: '',
@ -246,7 +274,9 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
const hasSpecification = !!idea.specification; const hasSpecification = !!idea.specification;
return ( return (
<Box sx={{ display: 'flex', gap: 0.5 }}> <Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title={hasSpecification ? 'Просмотр ТЗ' : 'Сгенерировать ТЗ'}> <Tooltip
title={hasSpecification ? 'Просмотр ТЗ' : 'Сгенерировать ТЗ'}
>
<span> <span>
<IconButton <IconButton
size="small" size="small"
@ -254,7 +284,10 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
disabled={isGeneratingSpec} disabled={isGeneratingSpec}
color={hasSpecification ? 'primary' : 'default'} color={hasSpecification ? 'primary' : 'default'}
data-testid="specification-button" data-testid="specification-button"
sx={{ opacity: hasSpecification ? 0.9 : 0.5, '&:hover': { opacity: 1 } }} sx={{
opacity: hasSpecification ? 0.9 : 0.5,
'&:hover': { opacity: 1 },
}}
> >
{isGeneratingSpec ? ( {isGeneratingSpec ? (
<CircularProgress size={18} /> <CircularProgress size={18} />
@ -290,7 +323,11 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
data-testid="toggle-comments-button" data-testid="toggle-comments-button"
sx={{ opacity: isExpanded ? 1 : 0.5, '&:hover': { opacity: 1 } }} sx={{ opacity: isExpanded ? 1 : 0.5, '&:hover': { opacity: 1 } }}
> >
{isExpanded ? <ExpandLess fontSize="small" /> : <Comment fontSize="small" />} {isExpanded ? (
<ExpandLess fontSize="small" />
) : (
<Comment fontSize="small" />
)}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<IconButton <IconButton

View File

@ -17,7 +17,6 @@ import {
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
ListItemSecondaryAction,
Divider, Divider,
Chip, Chip,
} from '@mui/material'; } from '@mui/material';
@ -84,7 +83,8 @@ export function SpecificationModal({
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editedText, setEditedText] = useState(''); const [editedText, setEditedText] = useState('');
const [tabIndex, setTabIndex] = useState(0); const [tabIndex, setTabIndex] = useState(0);
const [viewingHistoryItem, setViewingHistoryItem] = useState<SpecificationHistoryItem | null>(null); const [viewingHistoryItem, setViewingHistoryItem] =
useState<SpecificationHistoryItem | null>(null);
// Сбрасываем состояние при открытии/закрытии // Сбрасываем состояние при открытии/закрытии
useEffect(() => { useEffect(() => {
@ -97,12 +97,12 @@ export function SpecificationModal({
}, [open, specification]); }, [open, specification]);
const handleEdit = () => { const handleEdit = () => {
setEditedText(specification || ''); setEditedText(specification ?? '');
setIsEditing(true); setIsEditing(true);
}; };
const handleCancel = () => { const handleCancel = () => {
setEditedText(specification || ''); setEditedText(specification ?? '');
setIsEditing(false); setIsEditing(false);
}; };
@ -152,7 +152,13 @@ export function SpecificationModal({
fullWidth fullWidth
data-testid="specification-modal" data-testid="specification-modal"
> >
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> <DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<Box> <Box>
<Typography variant="h6" component="span"> <Typography variant="h6" component="span">
Техническое задание Техническое задание
@ -194,7 +200,7 @@ export function SpecificationModal({
{hasHistory && !isEditing && !viewingHistoryItem && ( {hasHistory && !isEditing && !viewingHistoryItem && (
<Tabs <Tabs
value={tabIndex} value={tabIndex}
onChange={(_, newValue) => setTabIndex(newValue)} onChange={(_, newValue: number) => setTabIndex(newValue)}
sx={{ px: 3, borderBottom: 1, borderColor: 'divider' }} sx={{ px: 3, borderBottom: 1, borderColor: 'divider' }}
> >
<Tab label="Текущее ТЗ" data-testid="specification-tab-current" /> <Tab label="Текущее ТЗ" data-testid="specification-tab-current" />
@ -225,7 +231,9 @@ export function SpecificationModal({
<IconButton <IconButton
size="small" size="small"
color="primary" color="primary"
onClick={() => handleRestoreFromHistory(viewingHistoryItem.id)} onClick={() =>
handleRestoreFromHistory(viewingHistoryItem.id)
}
disabled={isRestoring} disabled={isRestoring}
data-testid="specification-restore-button" data-testid="specification-restore-button"
> >
@ -236,7 +244,8 @@ export function SpecificationModal({
{viewingHistoryItem.ideaDescriptionSnapshot && ( {viewingHistoryItem.ideaDescriptionSnapshot && (
<Alert severity="info" sx={{ mb: 2 }}> <Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="caption"> <Typography variant="caption">
Описание идеи на момент генерации: {viewingHistoryItem.ideaDescriptionSnapshot} Описание идеи на момент генерации:{' '}
{viewingHistoryItem.ideaDescriptionSnapshot}
</Typography> </Typography>
</Alert> </Alert>
)} )}
@ -248,7 +257,11 @@ export function SpecificationModal({
borderRadius: 1, borderRadius: 1,
maxHeight: '50vh', maxHeight: '50vh',
overflow: 'auto', overflow: 'auto',
'& h1, & h2, & h3, & h4, & h5, & h6': { mt: 2, mb: 1, '&:first-of-type': { mt: 0 } }, '& h1, & h2, & h3, & h4, & h5, & h6': {
mt: 2,
mb: 1,
'&:first-of-type': { mt: 0 },
},
'& h1': { fontSize: '1.5rem', fontWeight: 600 }, '& h1': { fontSize: '1.5rem', fontWeight: 600 },
'& h2': { fontSize: '1.25rem', fontWeight: 600 }, '& h2': { fontSize: '1.25rem', fontWeight: 600 },
'& h3': { fontSize: '1.1rem', fontWeight: 600 }, '& h3': { fontSize: '1.1rem', fontWeight: 600 },
@ -256,9 +269,29 @@ export function SpecificationModal({
'& ul, & ol': { pl: 3, mb: 1.5 }, '& ul, & ol': { pl: 3, mb: 1.5 },
'& li': { mb: 0.5 }, '& li': { mb: 0.5 },
'& strong': { fontWeight: 600 }, '& strong': { fontWeight: 600 },
'& code': { bgcolor: 'grey.200', px: 0.5, py: 0.25, borderRadius: 0.5, fontFamily: 'monospace', fontSize: '0.875em' }, '& code': {
'& pre': { bgcolor: 'grey.200', p: 1.5, borderRadius: 1, overflow: 'auto', '& code': { bgcolor: 'transparent', p: 0 } }, bgcolor: 'grey.200',
'& blockquote': { borderLeft: 3, borderColor: 'primary.main', pl: 2, ml: 0, fontStyle: 'italic', color: 'text.secondary' }, px: 0.5,
py: 0.25,
borderRadius: 0.5,
fontFamily: 'monospace',
fontSize: '0.875em',
},
'& pre': {
bgcolor: 'grey.200',
p: 1.5,
borderRadius: 1,
overflow: 'auto',
'& code': { bgcolor: 'transparent', p: 0 },
},
'& blockquote': {
borderLeft: 3,
borderColor: 'primary.main',
pl: 2,
ml: 0,
fontStyle: 'italic',
color: 'text.secondary',
},
}} }}
> >
<Markdown>{viewingHistoryItem.specification}</Markdown> <Markdown>{viewingHistoryItem.specification}</Markdown>
@ -272,7 +305,11 @@ export function SpecificationModal({
<TabPanel value={tabIndex} index={0}> <TabPanel value={tabIndex} index={0}>
{isLoading && ( {isLoading && (
<Box sx={{ py: 4 }} data-testid="specification-loading"> <Box sx={{ py: 4 }} data-testid="specification-loading">
<Typography variant="body2" color="text.secondary" gutterBottom> <Typography
variant="body2"
color="text.secondary"
gutterBottom
>
Генерируем техническое задание... Генерируем техническое задание...
</Typography> </Typography>
<LinearProgress /> <LinearProgress />
@ -280,7 +317,11 @@ export function SpecificationModal({
)} )}
{error && ( {error && (
<Alert severity="error" sx={{ my: 2 }} data-testid="specification-error"> <Alert
severity="error"
sx={{ my: 2 }}
data-testid="specification-error"
>
{error.message || 'Не удалось сгенерировать ТЗ'} {error.message || 'Не удалось сгенерировать ТЗ'}
</Alert> </Alert>
)} )}
@ -307,7 +348,11 @@ export function SpecificationModal({
{!isLoading && !error && !isEditing && specification && ( {!isLoading && !error && !isEditing && specification && (
<Box> <Box>
{idea?.specificationGeneratedAt && ( {idea?.specificationGeneratedAt && (
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}> <Typography
variant="caption"
color="text.secondary"
sx={{ mb: 1, display: 'block' }}
>
Сгенерировано: {formatDate(idea.specificationGeneratedAt)} Сгенерировано: {formatDate(idea.specificationGeneratedAt)}
</Typography> </Typography>
)} )}
@ -319,7 +364,11 @@ export function SpecificationModal({
borderRadius: 1, borderRadius: 1,
maxHeight: '55vh', maxHeight: '55vh',
overflow: 'auto', overflow: 'auto',
'& h1, & h2, & h3, & h4, & h5, & h6': { mt: 2, mb: 1, '&:first-of-type': { mt: 0 } }, '& h1, & h2, & h3, & h4, & h5, & h6': {
mt: 2,
mb: 1,
'&:first-of-type': { mt: 0 },
},
'& h1': { fontSize: '1.5rem', fontWeight: 600 }, '& h1': { fontSize: '1.5rem', fontWeight: 600 },
'& h2': { fontSize: '1.25rem', fontWeight: 600 }, '& h2': { fontSize: '1.25rem', fontWeight: 600 },
'& h3': { fontSize: '1.1rem', fontWeight: 600 }, '& h3': { fontSize: '1.1rem', fontWeight: 600 },
@ -327,9 +376,29 @@ export function SpecificationModal({
'& ul, & ol': { pl: 3, mb: 1.5 }, '& ul, & ol': { pl: 3, mb: 1.5 },
'& li': { mb: 0.5 }, '& li': { mb: 0.5 },
'& strong': { fontWeight: 600 }, '& strong': { fontWeight: 600 },
'& code': { bgcolor: 'grey.200', px: 0.5, py: 0.25, borderRadius: 0.5, fontFamily: 'monospace', fontSize: '0.875em' }, '& code': {
'& pre': { bgcolor: 'grey.200', p: 1.5, borderRadius: 1, overflow: 'auto', '& code': { bgcolor: 'transparent', p: 0 } }, bgcolor: 'grey.200',
'& blockquote': { borderLeft: 3, borderColor: 'primary.main', pl: 2, ml: 0, fontStyle: 'italic', color: 'text.secondary' }, px: 0.5,
py: 0.25,
borderRadius: 0.5,
fontFamily: 'monospace',
fontSize: '0.875em',
},
'& pre': {
bgcolor: 'grey.200',
p: 1.5,
borderRadius: 1,
overflow: 'auto',
'& code': { bgcolor: 'transparent', p: 0 },
},
'& blockquote': {
borderLeft: 3,
borderColor: 'primary.main',
pl: 2,
ml: 0,
fontStyle: 'italic',
color: 'text.secondary',
},
}} }}
> >
<Markdown>{specification}</Markdown> <Markdown>{specification}</Markdown>
@ -353,12 +422,54 @@ export function SpecificationModal({
<Box key={item.id}> <Box key={item.id}>
{index > 0 && <Divider />} {index > 0 && <Divider />}
<ListItem <ListItem
data-testid={`specification-history-item-${index}`} data-testid={`specification-history-item-${String(index)}`}
sx={{ pr: 16 }} sx={{ pr: 16 }}
secondaryAction={
<>
<Tooltip title="Просмотреть">
<IconButton
size="small"
onClick={() => handleViewHistoryItem(item)}
data-testid={`specification-history-view-${String(index)}`}
>
<Visibility fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Восстановить">
<IconButton
size="small"
color="primary"
onClick={() =>
handleRestoreFromHistory(item.id)
}
disabled={isRestoring}
data-testid={`specification-history-restore-${String(index)}`}
>
<Restore fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Удалить">
<IconButton
size="small"
color="error"
onClick={() => onDeleteHistoryItem(item.id)}
data-testid={`specification-history-delete-${String(index)}`}
>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</>
}
> >
<ListItemText <ListItemText
primary={ primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Typography variant="body2"> <Typography variant="body2">
{formatDate(item.createdAt)} {formatDate(item.createdAt)}
</Typography> </Typography>
@ -387,38 +498,6 @@ export function SpecificationModal({
</Typography> </Typography>
} }
/> />
<ListItemSecondaryAction>
<Tooltip title="Просмотреть">
<IconButton
size="small"
onClick={() => handleViewHistoryItem(item)}
data-testid={`specification-history-view-${index}`}
>
<Visibility fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Восстановить">
<IconButton
size="small"
color="primary"
onClick={() => handleRestoreFromHistory(item.id)}
disabled={isRestoring}
data-testid={`specification-history-restore-${index}`}
>
<Restore fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Удалить">
<IconButton
size="small"
color="error"
onClick={() => onDeleteHistoryItem(item.id)}
data-testid={`specification-history-delete-${index}`}
>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem> </ListItem>
</Box> </Box>
))} ))}
@ -446,9 +525,7 @@ export function SpecificationModal({
</Button> </Button>
</> </>
) : viewingHistoryItem ? ( ) : viewingHistoryItem ? (
<Button onClick={handleCloseHistoryView}> <Button onClick={handleCloseHistoryView}>Назад к текущему ТЗ</Button>
Назад к текущему ТЗ
</Button>
) : ( ) : (
<Button <Button
onClick={onClose} onClick={onClose}

View File

@ -20,7 +20,12 @@ import {
Alert, Alert,
} from '@mui/material'; } from '@mui/material';
import { Add, Edit, Delete } from '@mui/icons-material'; import { Add, Edit, Delete } from '@mui/icons-material';
import { useRolesQuery, useCreateRole, useUpdateRole, useDeleteRole } from '../../hooks/useRoles'; import {
useRolesQuery,
useCreateRole,
useUpdateRole,
useDeleteRole,
} from '../../hooks/useRoles';
import type { Role, CreateRoleDto } from '../../types/team'; import type { Role, CreateRoleDto } from '../../types/team';
interface RoleModalProps { interface RoleModalProps {
@ -74,7 +79,13 @@ function RoleModal({ open, onClose, role }: RoleModalProps) {
}; };
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth data-testid="role-modal"> <Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
data-testid="role-modal"
>
<form onSubmit={handleSubmit} data-testid="role-form"> <form onSubmit={handleSubmit} data-testid="role-form">
<DialogTitle> <DialogTitle>
{isEditing ? 'Редактировать роль' : 'Добавить роль'} {isEditing ? 'Редактировать роль' : 'Добавить роль'}
@ -107,7 +118,9 @@ function RoleModal({ open, onClose, role }: RoleModalProps) {
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose} data-testid="cancel-role-button">Отмена</Button> <Button onClick={onClose} data-testid="cancel-role-button">
Отмена
</Button>
<Button <Button
type="submit" type="submit"
variant="contained" variant="contained"
@ -160,15 +173,31 @@ export function RolesManager() {
return ( return (
<Box data-testid="roles-manager"> <Box data-testid="roles-manager">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="h6">Управление ролями</Typography> <Typography variant="h6">Управление ролями</Typography>
<Button variant="contained" startIcon={<Add />} onClick={handleAdd} data-testid="add-role-button"> <Button
variant="contained"
startIcon={<Add />}
onClick={handleAdd}
data-testid="add-role-button"
>
Добавить роль Добавить роль
</Button> </Button>
</Box> </Box>
{deleteError && ( {deleteError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError('')}> <Alert
severity="error"
sx={{ mb: 2 }}
onClose={() => setDeleteError('')}
>
{deleteError} {deleteError}
</Alert> </Alert>
)} )}
@ -183,35 +212,58 @@ export function RolesManager() {
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}> <TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>
Отображаемое название Отображаемое название
</TableCell> </TableCell>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} align="center"> <TableCell
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
align="center"
>
Порядок Порядок
</TableCell> </TableCell>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} /> <TableCell
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
/>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
Array.from({ length: 3 }).map((_, i) => ( Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i}> <TableRow key={i}>
<TableCell><Skeleton /></TableCell> <TableCell>
<TableCell><Skeleton /></TableCell> <Skeleton />
<TableCell><Skeleton /></TableCell> </TableCell>
<TableCell><Skeleton /></TableCell> <TableCell>
<Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
</TableRow> </TableRow>
)) ))
) : roles.length === 0 ? ( ) : roles.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} sx={{ textAlign: 'center', py: 4 }}> <TableCell colSpan={4} sx={{ textAlign: 'center', py: 4 }}>
<Typography color="text.secondary" data-testid="roles-empty-state"> <Typography
color="text.secondary"
data-testid="roles-empty-state"
>
Нет ролей. Добавьте первую роль. Нет ролей. Добавьте первую роль.
</Typography> </Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
roles.map((role) => ( roles.map((role) => (
<TableRow key={role.id} hover data-testid={`role-row-${role.id}`}> <TableRow
key={role.id}
hover
data-testid={`role-row-${role.id}`}
>
<TableCell> <TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}> <Typography
variant="body2"
sx={{ fontFamily: 'monospace' }}
>
{role.name} {role.name}
</Typography> </Typography>
</TableCell> </TableCell>
@ -244,7 +296,11 @@ export function RolesManager() {
</Table> </Table>
</TableContainer> </TableContainer>
<RoleModal open={modalOpen} onClose={handleModalClose} role={editingRole} /> <RoleModal
open={modalOpen}
onClose={handleModalClose}
role={editingRole}
/>
</Box> </Box>
); );
} }

View File

@ -34,10 +34,15 @@ const defaultProductivity: ProductivityMatrix = {
veryComplex: 60, veryComplex: 60,
}; };
export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps) { export function TeamMemberModal({
open,
onClose,
member,
}: TeamMemberModalProps) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [roleId, setRoleId] = useState(''); const [roleId, setRoleId] = useState('');
const [productivity, setProductivity] = useState<ProductivityMatrix>(defaultProductivity); const [productivity, setProductivity] =
useState<ProductivityMatrix>(defaultProductivity);
const { data: roles = [], isLoading: rolesLoading } = useRolesQuery(); const { data: roles = [], isLoading: rolesLoading } = useRolesQuery();
const createMember = useCreateTeamMember(); const createMember = useCreateTeamMember();
@ -72,7 +77,10 @@ export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps)
onClose(); onClose();
}; };
const handleProductivityChange = (key: keyof ProductivityMatrix, value: string) => { const handleProductivityChange = (
key: keyof ProductivityMatrix,
value: string,
) => {
const num = parseFloat(value) || 0; const num = parseFloat(value) || 0;
setProductivity((prev) => ({ ...prev, [key]: num })); setProductivity((prev) => ({ ...prev, [key]: num }));
}; };
@ -80,7 +88,13 @@ export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps)
const isPending = createMember.isPending || updateMember.isPending; const isPending = createMember.isPending || updateMember.isPending;
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth data-testid="team-member-modal"> <Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
data-testid="team-member-modal"
>
<form onSubmit={handleSubmit} data-testid="team-member-form"> <form onSubmit={handleSubmit} data-testid="team-member-form">
<DialogTitle> <DialogTitle>
{isEditing ? 'Редактировать участника' : 'Добавить участника'} {isEditing ? 'Редактировать участника' : 'Добавить участника'}
@ -120,31 +134,47 @@ export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps)
Производительность (часы на задачу) Производительность (часы на задачу)
</Typography> </Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}> <Box
{(Object.entries(complexityLabels) as [keyof ProductivityMatrix, string][]).map( sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}
([key, label]) => ( >
<TextField {(
key={key} Object.entries(complexityLabels) as [
label={label} keyof ProductivityMatrix,
type="number" string,
size="small" ][]
value={productivity[key]} ).map(([key, label]) => (
onChange={(e) => handleProductivityChange(key, e.target.value)} <TextField
slotProps={{ key={key}
input: { label={label}
endAdornment: <InputAdornment position="end">ч</InputAdornment>, type="number"
}, size="small"
htmlInput: { min: 0, step: 0.5 }, value={productivity[key]}
}} onChange={(e) =>
/> handleProductivityChange(key, e.target.value)
), }
)} slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">ч</InputAdornment>
),
},
htmlInput: { min: 0, step: 0.5 },
}}
/>
))}
</Box> </Box>
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose} data-testid="cancel-member-button">Отмена</Button> <Button onClick={onClose} data-testid="cancel-member-button">
<Button type="submit" variant="contained" disabled={!name.trim() || !roleId || isPending} data-testid="submit-member-button"> Отмена
</Button>
<Button
type="submit"
variant="contained"
disabled={!name.trim() || !roleId || isPending}
data-testid="submit-member-button"
>
{isEditing ? 'Сохранить' : 'Добавить'} {isEditing ? 'Сохранить' : 'Добавить'}
</Button> </Button>
</DialogActions> </DialogActions>

View File

@ -19,7 +19,11 @@ import {
Tab, Tab,
} from '@mui/material'; } from '@mui/material';
import { Add, Edit, Delete, Group, Settings } from '@mui/icons-material'; import { Add, Edit, Delete, Group, Settings } from '@mui/icons-material';
import { useTeamQuery, useTeamSummaryQuery, useDeleteTeamMember } from '../../hooks/useTeam'; import {
useTeamQuery,
useTeamSummaryQuery,
useDeleteTeamMember,
} from '../../hooks/useTeam';
import { complexityLabels } from '../../types/team'; import { complexityLabels } from '../../types/team';
import type { TeamMember, ProductivityMatrix } from '../../types/team'; import type { TeamMember, ProductivityMatrix } from '../../types/team';
import { TeamMemberModal } from './TeamMemberModal'; import { TeamMemberModal } from './TeamMemberModal';
@ -56,9 +60,19 @@ export function TeamPage() {
<Box data-testid="team-page"> <Box data-testid="team-page">
{/* Вкладки */} {/* Вкладки */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}> <Tabs value={activeTab} onChange={(_, v: number) => setActiveTab(v)}>
<Tab icon={<Group />} iconPosition="start" label="Участники" data-testid="team-tab-members" /> <Tab
<Tab icon={<Settings />} iconPosition="start" label="Роли" data-testid="team-tab-roles" /> icon={<Group />}
iconPosition="start"
label="Участники"
data-testid="team-tab-members"
/>
<Tab
icon={<Settings />}
iconPosition="start"
label="Роли"
data-testid="team-tab-roles"
/>
</Tabs> </Tabs>
</Box> </Box>
@ -66,12 +80,20 @@ export function TeamPage() {
<> <>
{/* Сводка по ролям */} {/* Сводка по ролям */}
<Box sx={{ mb: 3 }} data-testid="team-summary"> <Box sx={{ mb: 3 }} data-testid="team-summary">
<Typography variant="h6" sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}> <Typography
variant="h6"
sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
>
<Group /> Состав команды ({totalMembers}) <Group /> Состав команды ({totalMembers})
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{summary.map((item) => ( {summary.map((item) => (
<Card key={item.roleId} variant="outlined" sx={{ minWidth: 150 }} data-testid={`role-card-${item.roleId}`}> <Card
key={item.roleId}
variant="outlined"
sx={{ minWidth: 150 }}
data-testid={`role-card-${item.roleId}`}
>
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}> <CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="h4" sx={{ fontWeight: 600 }}> <Typography variant="h4" sx={{ fontWeight: 600 }}>
{item.count} {item.count}
@ -86,9 +108,21 @@ export function TeamPage() {
</Box> </Box>
{/* Таблица участников */} {/* Таблица участников */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="h6">Участники</Typography> <Typography variant="h6">Участники</Typography>
<Button variant="contained" startIcon={<Add />} onClick={handleAdd} data-testid="add-team-member-button"> <Button
variant="contained"
startIcon={<Add />}
onClick={handleAdd}
data-testid="add-team-member-button"
>
Добавить Добавить
</Button> </Button>
</Box> </Box>
@ -97,48 +131,91 @@ export function TeamPage() {
<Table size="small" data-testid="team-table"> <Table size="small" data-testid="team-table">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>Имя</TableCell> <TableCell
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>Роль</TableCell> sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
{(Object.keys(complexityLabels) as (keyof ProductivityMatrix)[]).map((key) => ( >
Имя
</TableCell>
<TableCell
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
>
Роль
</TableCell>
{(
Object.keys(
complexityLabels,
) as (keyof ProductivityMatrix)[]
).map((key) => (
<TableCell <TableCell
key={key} key={key}
align="center" align="center"
sx={{ fontWeight: 600, backgroundColor: 'grey.100', fontSize: '0.75rem' }} sx={{
fontWeight: 600,
backgroundColor: 'grey.100',
fontSize: '0.75rem',
}}
> >
{complexityLabels[key]} {complexityLabels[key]}
</TableCell> </TableCell>
))} ))}
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} /> <TableCell
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
/>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
Array.from({ length: 3 }).map((_, i) => ( Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i}> <TableRow key={i}>
<TableCell><Skeleton /></TableCell> <TableCell>
<TableCell><Skeleton /></TableCell> <Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
{Array.from({ length: 5 }).map((_, j) => ( {Array.from({ length: 5 }).map((_, j) => (
<TableCell key={j}><Skeleton /></TableCell> <TableCell key={j}>
<Skeleton />
</TableCell>
))} ))}
<TableCell><Skeleton /></TableCell> <TableCell>
<Skeleton />
</TableCell>
</TableRow> </TableRow>
)) ))
) : members.length === 0 ? ( ) : members.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={8} sx={{ textAlign: 'center', py: 4 }}> <TableCell colSpan={8} sx={{ textAlign: 'center', py: 4 }}>
<Typography color="text.secondary" data-testid="team-empty-state"> <Typography
color="text.secondary"
data-testid="team-empty-state"
>
Команда пока пуста. Добавьте первого участника. Команда пока пуста. Добавьте первого участника.
</Typography> </Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
members.map((member) => ( members.map((member) => (
<TableRow key={member.id} hover data-testid={`team-member-row-${member.id}`}> <TableRow
<TableCell sx={{ fontWeight: 500 }}>{member.name}</TableCell> key={member.id}
<TableCell> hover
<Chip label={member.role.label} size="small" variant="outlined" /> data-testid={`team-member-row-${member.id}`}
>
<TableCell sx={{ fontWeight: 500 }}>
{member.name}
</TableCell> </TableCell>
{(Object.keys(complexityLabels) as (keyof ProductivityMatrix)[]).map((key) => ( <TableCell>
<Chip
label={member.role.label}
size="small"
variant="outlined"
/>
</TableCell>
{(
Object.keys(
complexityLabels,
) as (keyof ProductivityMatrix)[]
).map((key) => (
<TableCell key={key} align="center"> <TableCell key={key} align="center">
{member.productivity[key]}ч {member.productivity[key]}ч
</TableCell> </TableCell>

View File

@ -24,7 +24,9 @@ export function useGenerateSpecification() {
onSuccess: (_, ideaId) => { onSuccess: (_, ideaId) => {
// Инвалидируем кэш идей и историю // Инвалидируем кэш идей и историю
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] }); void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY, ideaId] }); void queryClient.invalidateQueries({
queryKey: [SPECIFICATION_HISTORY_KEY, ideaId],
});
}, },
}); });
} }
@ -32,7 +34,10 @@ export function useGenerateSpecification() {
export function useSpecificationHistory(ideaId: string | null) { export function useSpecificationHistory(ideaId: string | null) {
return useQuery({ return useQuery({
queryKey: [SPECIFICATION_HISTORY_KEY, ideaId], queryKey: [SPECIFICATION_HISTORY_KEY, ideaId],
queryFn: () => aiApi.getSpecificationHistory(ideaId!), queryFn: () => {
if (!ideaId) throw new Error('ideaId is required');
return aiApi.getSpecificationHistory(ideaId);
},
enabled: !!ideaId, enabled: !!ideaId,
}); });
} }
@ -41,10 +46,13 @@ export function useDeleteSpecificationHistoryItem() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (historyId: string) => aiApi.deleteSpecificationHistoryItem(historyId), mutationFn: (historyId: string) =>
aiApi.deleteSpecificationHistoryItem(historyId),
onSuccess: () => { onSuccess: () => {
// Инвалидируем все запросы истории // Инвалидируем все запросы истории
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY] }); void queryClient.invalidateQueries({
queryKey: [SPECIFICATION_HISTORY_KEY],
});
}, },
}); });
} }
@ -53,11 +61,14 @@ export function useRestoreSpecificationFromHistory() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (historyId: string) => aiApi.restoreSpecificationFromHistory(historyId), mutationFn: (historyId: string) =>
aiApi.restoreSpecificationFromHistory(historyId),
onSuccess: () => { onSuccess: () => {
// Инвалидируем кэш идей и историю // Инвалидируем кэш идей и историю
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] }); void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY] }); void queryClient.invalidateQueries({
queryKey: [SPECIFICATION_HISTORY_KEY],
});
}, },
}); });
} }

View File

@ -8,19 +8,22 @@ export interface User {
} }
export function useAuth() { export function useAuth() {
const tokenParsed = keycloak.tokenParsed as { const tokenParsed = keycloak.tokenParsed as
sub?: string; | {
name?: string; sub?: string;
preferred_username?: string; name?: string;
email?: string; preferred_username?: string;
given_name?: string; email?: string;
family_name?: string; given_name?: string;
} | undefined; family_name?: string;
}
| undefined;
const user: User | null = tokenParsed const user: User | null = tokenParsed
? { ? {
id: tokenParsed.sub ?? '', id: tokenParsed.sub ?? '',
name: tokenParsed.name ?? tokenParsed.preferred_username ?? 'Пользователь', name:
tokenParsed.name ?? tokenParsed.preferred_username ?? 'Пользователь',
email: tokenParsed.email ?? '', email: tokenParsed.email ?? '',
username: tokenParsed.preferred_username ?? '', username: tokenParsed.preferred_username ?? '',
} }
@ -32,7 +35,7 @@ export function useAuth() {
return { return {
user, user,
isAuthenticated: keycloak.authenticated ?? false, isAuthenticated: keycloak.authenticated,
logout, logout,
}; };
} }

View File

@ -5,7 +5,10 @@ import type { CreateCommentDto } from '../types/comment';
export function useCommentsQuery(ideaId: string | null) { export function useCommentsQuery(ideaId: string | null) {
return useQuery({ return useQuery({
queryKey: ['comments', ideaId], queryKey: ['comments', ideaId],
queryFn: () => commentsApi.getByIdeaId(ideaId!), queryFn: () => {
if (!ideaId) throw new Error('ideaId is required');
return commentsApi.getByIdeaId(ideaId);
},
enabled: !!ideaId, enabled: !!ideaId,
}); });
} }
@ -17,7 +20,9 @@ export function useCreateComment() {
mutationFn: ({ ideaId, dto }: { ideaId: string; dto: CreateCommentDto }) => mutationFn: ({ ideaId, dto }: { ideaId: string; dto: CreateCommentDto }) =>
commentsApi.create(ideaId, dto), commentsApi.create(ideaId, dto),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['comments', variables.ideaId] }); void queryClient.invalidateQueries({
queryKey: ['comments', variables.ideaId],
});
}, },
}); });
} }
@ -29,7 +34,9 @@ export function useDeleteComment() {
mutationFn: (params: { id: string; ideaId: string }) => mutationFn: (params: { id: string; ideaId: string }) =>
commentsApi.delete(params.id), commentsApi.delete(params.id),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['comments', variables.ideaId] }); void queryClient.invalidateQueries({
queryKey: ['comments', variables.ideaId],
});
}, },
}); });
} }

View File

@ -22,7 +22,7 @@ export function useCreateTeamMember() {
return useMutation({ return useMutation({
mutationFn: (dto: CreateTeamMemberDto) => teamApi.create(dto), mutationFn: (dto: CreateTeamMemberDto) => teamApi.create(dto),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team'] }); void queryClient.invalidateQueries({ queryKey: ['team'] });
}, },
}); });
} }
@ -34,7 +34,7 @@ export function useUpdateTeamMember() {
mutationFn: ({ id, dto }: { id: string; dto: UpdateTeamMemberDto }) => mutationFn: ({ id, dto }: { id: string; dto: UpdateTeamMemberDto }) =>
teamApi.update(id, dto), teamApi.update(id, dto),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team'] }); void queryClient.invalidateQueries({ queryKey: ['team'] });
}, },
}); });
} }
@ -45,7 +45,7 @@ export function useDeleteTeamMember() {
return useMutation({ return useMutation({
mutationFn: (id: string) => teamApi.delete(id), mutationFn: (id: string) => teamApi.delete(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team'] }); void queryClient.invalidateQueries({ queryKey: ['team'] });
}, },
}); });
} }

View File

@ -1,5 +1,10 @@
import { api } from './api'; import { api } from './api';
import type { IdeaComplexity, RoleEstimate, SpecificationResult, SpecificationHistoryItem } from '../types/idea'; import type {
IdeaComplexity,
RoleEstimate,
SpecificationResult,
SpecificationHistoryItem,
} from '../types/idea';
export interface EstimateResult { export interface EstimateResult {
ideaId: string; ideaId: string;
@ -17,13 +22,22 @@ export const aiApi = {
return data; return data;
}, },
generateSpecification: async (ideaId: string): Promise<SpecificationResult> => { generateSpecification: async (
const { data } = await api.post<SpecificationResult>('/ai/generate-specification', { ideaId }); ideaId: string,
): Promise<SpecificationResult> => {
const { data } = await api.post<SpecificationResult>(
'/ai/generate-specification',
{ ideaId },
);
return data; return data;
}, },
getSpecificationHistory: async (ideaId: string): Promise<SpecificationHistoryItem[]> => { getSpecificationHistory: async (
const { data } = await api.get<SpecificationHistoryItem[]>(`/ai/specification-history/${ideaId}`); ideaId: string,
): Promise<SpecificationHistoryItem[]> => {
const { data } = await api.get<SpecificationHistoryItem[]>(
`/ai/specification-history/${ideaId}`,
);
return data; return data;
}, },
@ -31,8 +45,12 @@ export const aiApi = {
await api.delete(`/ai/specification-history/${historyId}`); await api.delete(`/ai/specification-history/${historyId}`);
}, },
restoreSpecificationFromHistory: async (historyId: string): Promise<SpecificationResult> => { restoreSpecificationFromHistory: async (
const { data } = await api.post<SpecificationResult>(`/ai/specification-history/${historyId}/restore`); historyId: string,
): Promise<SpecificationResult> => {
const { data } = await api.post<SpecificationResult>(
`/ai/specification-history/${historyId}/restore`,
);
return data; return data;
}, },
}; };

View File

@ -8,7 +8,10 @@ export const commentsApi = {
}, },
create: async (ideaId: string, dto: CreateCommentDto): Promise<Comment> => { create: async (ideaId: string, dto: CreateCommentDto): Promise<Comment> => {
const response = await api.post<Comment>(`/api/ideas/${ideaId}/comments`, dto); const response = await api.post<Comment>(
`/api/ideas/${ideaId}/comments`,
dto,
);
return response.data; return response.data;
}, },

View File

@ -1,5 +1,10 @@
import { api } from './api'; import { api } from './api';
import type { TeamMember, CreateTeamMemberDto, UpdateTeamMemberDto, TeamSummary } from '../types/team'; import type {
TeamMember,
CreateTeamMemberDto,
UpdateTeamMemberDto,
TeamSummary,
} from '../types/team';
export const teamApi = { export const teamApi = {
getAll: async (): Promise<TeamMember[]> => { getAll: async (): Promise<TeamMember[]> => {

View File

@ -6,7 +6,12 @@ export type IdeaStatus =
| 'cancelled'; | 'cancelled';
export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical'; export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical';
export type IdeaComplexity = 'trivial' | 'simple' | 'medium' | 'complex' | 'veryComplex'; export type IdeaComplexity =
| 'trivial'
| 'simple'
| 'medium'
| 'complex'
| 'veryComplex';
export interface RoleEstimate { export interface RoleEstimate {
role: string; role: string;

View File

@ -13,7 +13,7 @@ export interface CreateRoleDto {
sortOrder?: number; sortOrder?: number;
} }
export interface UpdateRoleDto extends Partial<CreateRoleDto> {} export type UpdateRoleDto = Partial<CreateRoleDto>;
export interface ProductivityMatrix { export interface ProductivityMatrix {
trivial: number; trivial: number;
@ -39,7 +39,7 @@ export interface CreateTeamMemberDto {
productivity?: ProductivityMatrix; productivity?: ProductivityMatrix;
} }
export interface UpdateTeamMemberDto extends Partial<CreateTeamMemberDto> {} export type UpdateTeamMemberDto = Partial<CreateTeamMemberDto>;
export interface TeamSummary { export interface TeamSummary {
roleId: string; roleId: string;

77
package-lock.json generated
View File

@ -9,7 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"workspaces": [ "workspaces": [
"backend", "backend",
"frontend" "frontend",
"tests"
], ],
"dependencies": { "dependencies": {
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@ -11427,6 +11428,22 @@
"passport": "^0.5.0 || ^0.6.0 || ^0.7.0" "passport": "^0.5.0 || ^0.6.0 || ^0.7.0"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@tokenizer/inflate": { "node_modules/@tokenizer/inflate": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
@ -12001,6 +12018,21 @@
"resolved": "frontend", "resolved": "frontend",
"link": true "link": true
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -13025,6 +13057,38 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
}, },
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.7.4", "version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
@ -13307,6 +13371,10 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "url": "https://github.com/chalk/supports-color?sponsor=1"
} }
}, },
"node_modules/team-planner-e2e": {
"resolved": "tests",
"link": true
},
"node_modules/token-types": { "node_modules/token-types": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz",
@ -13592,6 +13660,13 @@
"type": "github", "type": "github",
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
},
"tests": {
"name": "team-planner-e2e",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.40.0"
}
} }
} }
} }

View File

@ -4,16 +4,21 @@
"private": true, "private": true,
"workspaces": [ "workspaces": [
"backend", "backend",
"frontend" "frontend",
"tests"
], ],
"scripts": { "scripts": {
"dev": "concurrently -n be,fe -c blue,green \"npm run dev:backend\" \"npm run dev:frontend\"", "dev": "concurrently -n be,fe -c blue,green \"npm run dev:backend\" \"npm run dev:frontend\"",
"dev:backend": "npm run dev -w backend", "dev:backend": "npm run dev -w backend",
"dev:frontend": "npm run dev -w frontend", "dev:frontend": "npm run dev -w frontend",
"lint": "npm run -w backend lint && npm run -w frontend lint", "lint": "npm run -w backend lint && npm run -w frontend lint",
"format": "npm run -w backend format && npm run -w frontend format",
"build": "npm run build:backend && npm run build:frontend", "build": "npm run build:backend && npm run build:frontend",
"build:backend": "npm run build -w backend", "build:backend": "npm run build -w backend",
"build:frontend": "npm run build -w frontend", "build:frontend": "npm run build -w frontend",
"test": "npm run test -w tests",
"test:ui": "npm run test:ui -w tests",
"test:headed": "npm run test:headed -w tests",
"db:up": "docker-compose up -d postgres", "db:up": "docker-compose up -d postgres",
"db:down": "docker-compose down" "db:down": "docker-compose down"
}, },

View File

@ -156,7 +156,8 @@ test.describe('Фаза 2: Цветовая маркировка', () => {
test.skip(!hasData, 'Нет данных для тестирования'); test.skip(!hasData, 'Нет данных для тестирования');
const colorTrigger = page.locator('[data-testid="color-picker-trigger"]').first(); const firstRow = page.locator('[data-testid^="idea-row-"]').first();
const colorTrigger = firstRow.locator('[data-testid="color-picker-trigger"]');
await colorTrigger.click(); await colorTrigger.click();
const popover = page.locator('[data-testid="color-picker-popover"]'); const popover = page.locator('[data-testid="color-picker-popover"]');
@ -170,16 +171,17 @@ test.describe('Фаза 2: Цветовая маркировка', () => {
// Ждём закрытия popover // Ждём закрытия popover
await expect(popover).toBeHidden({ timeout: 3000 }); await expect(popover).toBeHidden({ timeout: 3000 });
// Проверяем что строка получила цветной фон // Проверяем что строка получила цветной фон (ждем API ответа)
await page.waitForTimeout(300); await page.waitForTimeout(500);
const firstRow = page.locator('[data-testid^="idea-row-"]').first();
const rowStyle = await firstRow.evaluate((el) => { // Проверяем что color picker trigger показывает цвет (сам trigger имеет backgroundColor)
const bg = getComputedStyle(el).backgroundColor; const triggerStyle = await colorTrigger.evaluate((el) => {
return bg; return getComputedStyle(el).backgroundColor;
}); });
// Фон не должен быть прозрачным // После выбора цвета, trigger должен показывать выбранный цвет (не transparent)
expect(rowStyle).not.toBe('rgba(0, 0, 0, 0)'); expect(triggerStyle).not.toBe('transparent');
expect(triggerStyle).not.toBe('rgba(0, 0, 0, 0)');
}); });
test('Фильтр по цвету открывает dropdown с опциями', async ({ page }) => { test('Фильтр по цвету открывает dropdown с опциями', async ({ page }) => {

View File

@ -418,10 +418,12 @@ test.describe('Фаза 3.1: Генерация мини-ТЗ', () => {
const editButton = modal.locator('[data-testid="specification-edit-button"]'); const editButton = modal.locator('[data-testid="specification-edit-button"]');
await editButton.click(); await editButton.click();
// Редактируем текст // Редактируем текст (MUI TextField создает 2 textarea, берем первый видимый)
const textarea = modal.locator('[data-testid="specification-textarea"] textarea'); const textarea = modal.locator(
'[data-testid="specification-textarea"] textarea:not([aria-hidden="true"])',
);
const testText = '\n\n## Дополнительно\nТестовая правка ' + Date.now(); const testText = '\n\n## Дополнительно\nТестовая правка ' + Date.now();
await textarea.fill(await textarea.inputValue() + testText); await textarea.fill((await textarea.inputValue()) + testText);
// Сохраняем // Сохраняем
const saveButton = modal.locator('[data-testid="specification-save-button"]'); const saveButton = modal.locator('[data-testid="specification-save-button"]');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@
"cookies": [ "cookies": [
{ {
"name": "AUTH_SESSION_ID", "name": "AUTH_SESSION_ID",
"value": "c2hWNkNfa0JhY1hOc1ZWTnhLdk1tOTllLnNBM2ZQTk5yRlBKek5lS3FoR093OFloU1ZyU3E1QzFadzVIU1Jta2lMRllqbXJxLW9QSEMxOFkzZWZDZDl3UHVKZUVaU0VvWWJTOVRNTHJJSUpZc1hB.keycloak-keycloakx-0-40655", "value": "aDItMWtlTDNkMEdGUGE1TTFqYmxvOFNyLjhnZHRLSUVtbW5ZZmp1RkpBc2lmdnROSWVIY3RLRXdlZmloN2I0WmV4UTRlVWY5dnRYVFZZNHlSdlE2OTdEVVJHT2NVNE11cGd1eVQ4RzVseUxnYThn.keycloak-keycloakx-0-24485",
"domain": "auth.vigdorov.ru", "domain": "auth.vigdorov.ru",
"path": "/realms/team-planner/", "path": "/realms/team-planner/",
"expires": -1, "expires": -1,
@ -12,17 +12,17 @@
}, },
{ {
"name": "KC_AUTH_SESSION_HASH", "name": "KC_AUTH_SESSION_HASH",
"value": "\"gFqhBG3DVcCfpsSCaidKwK+Ziy23r6ddJ/rdb/jKDs8\"", "value": "\"w/aalxg9yi+TKbWYZgi8KimwA5UYaExWPPJvZT0MfoE\"",
"domain": "auth.vigdorov.ru", "domain": "auth.vigdorov.ru",
"path": "/realms/team-planner/", "path": "/realms/team-planner/",
"expires": 1768427781.187379, "expires": 1768433639.802328,
"httpOnly": false, "httpOnly": false,
"secure": true, "secure": true,
"sameSite": "None" "sameSite": "None"
}, },
{ {
"name": "KEYCLOAK_IDENTITY", "name": "KEYCLOAK_IDENTITY",
"value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2ZDRjMWU2My1hNTllLTQ0NjAtYThkYy05YTRiZjFjMTRjMDMifQ.eyJleHAiOjE3Njg0NjM3MjMsImlhdCI6MTc2ODQyNzcyMywianRpIjoiNGRmN2U5MzQtY2Q4Mi1hYTYwLTViNTUtMWFhZjVlMWViODJjIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnZpZ2Rvcm92LnJ1L3JlYWxtcy90ZWFtLXBsYW5uZXIiLCJzdWIiOiIyZDJiOTRmMC0xZWQ1LTQ0MTUtYmM4MC1jZTRlZWMxNDQ1NGQiLCJ0eXAiOiJTZXJpYWxpemVkLUlEIiwic2lkIjoic2hWNkNfa0JhY1hOc1ZWTnhLdk1tOTllIiwic3RhdGVfY2hlY2tlciI6Im9Ic2R0czlWR0RvV19EcjcxbG4tM2FjWDR1SmJuMWtzdHRCcVpzRnlPbDQifQ.Nbi8YdiZddWqY4rsS7b_hin9cbTedp2bOQ11I25tLdTH6VGGJaCP1T59pYd3OlqyDYPoD97uOBiobKTues1rwg", "value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2ZDRjMWU2My1hNTllLTQ0NjAtYThkYy05YTRiZjFjMTRjMDMifQ.eyJleHAiOjE3Njg0Njk1ODEsImlhdCI6MTc2ODQzMzU4MSwianRpIjoiOGVhZGVkMjgtZTMxNC1hZWMyLWJmNTYtMjJlMDBmM2YzZGM1IiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnZpZ2Rvcm92LnJ1L3JlYWxtcy90ZWFtLXBsYW5uZXIiLCJzdWIiOiIyZDJiOTRmMC0xZWQ1LTQ0MTUtYmM4MC1jZTRlZWMxNDQ1NGQiLCJ0eXAiOiJTZXJpYWxpemVkLUlEIiwic2lkIjoiaDItMWtlTDNkMEdGUGE1TTFqYmxvOFNyIiwic3RhdGVfY2hlY2tlciI6ImxDcld3azFTQTJCSm1VaDlXNGdNbzRwLVBOc3h2UHVFUlZTWW1PLWtPSGMifQ.i_RwpCuiyyychgU4ODrBrgu-JA9R2TMyMM2q78LunCOkTXGCUdruGqPi-HuNk0lwH3mMo0Lr5Z8PGVBhalsNEw",
"domain": "auth.vigdorov.ru", "domain": "auth.vigdorov.ru",
"path": "/realms/team-planner/", "path": "/realms/team-planner/",
"expires": -1, "expires": -1,
@ -32,10 +32,10 @@
}, },
{ {
"name": "KEYCLOAK_SESSION", "name": "KEYCLOAK_SESSION",
"value": "gFqhBG3DVcCfpsSCaidKwK-Ziy23r6ddJ_rdb_jKDs8", "value": "w_aalxg9yi-TKbWYZgi8KimwA5UYaExWPPJvZT0MfoE",
"domain": "auth.vigdorov.ru", "domain": "auth.vigdorov.ru",
"path": "/realms/team-planner/", "path": "/realms/team-planner/",
"expires": 1768463723.271756, "expires": 1768469581.267433,
"httpOnly": false, "httpOnly": false,
"secure": true, "secure": true,
"sameSite": "None" "sameSite": "None"