This commit is contained in:
@ -70,14 +70,13 @@ steps:
|
||||
from_secret: HARBOR_PASSWORD
|
||||
no_push_metadata: true
|
||||
|
||||
# --- Сборка Keycloak темы ---
|
||||
# --- Сборка Keycloak темы (только при изменениях в keycloak-theme/) ---
|
||||
- name: build-keycloak-theme
|
||||
image: plugins/kaniko
|
||||
when:
|
||||
changeset:
|
||||
includes:
|
||||
- keycloak-theme/**
|
||||
- .drone.yml
|
||||
excludes:
|
||||
- keycloak-theme/README.md
|
||||
- keycloak-theme/**/*.md
|
||||
@ -220,7 +219,7 @@ steps:
|
||||
fi
|
||||
- echo "✅ Frontend deployed to PROD (image:$IMAGE_TAG)"
|
||||
|
||||
# --- Развертывание Keycloak темы ---
|
||||
# --- Развертывание Keycloak темы (только при изменениях в keycloak-theme/) ---
|
||||
- name: deploy-keycloak-theme
|
||||
image: alpine/k8s:1.28.2
|
||||
depends_on:
|
||||
@ -229,7 +228,6 @@ steps:
|
||||
changeset:
|
||||
includes:
|
||||
- keycloak-theme/**
|
||||
- .drone.yml
|
||||
excludes:
|
||||
- keycloak-theme/README.md
|
||||
- keycloak-theme/**/*.md
|
||||
|
||||
@ -269,15 +269,30 @@ await expect(newElement).toBeVisible({ timeout: 5000 });
|
||||
## Запуск тестов
|
||||
|
||||
```bash
|
||||
# Все тесты
|
||||
npx playwright test
|
||||
# Все тесты (из корня проекта)
|
||||
npm run test
|
||||
|
||||
# Конкретный файл
|
||||
npx playwright test e2e/phase2.spec.ts
|
||||
|
||||
# Конкретный тест по имени
|
||||
npx playwright test -g "Drag handle имеет правильный курсор"
|
||||
|
||||
# С UI режимом для отладки
|
||||
npx playwright test --ui
|
||||
|
||||
# Только упавшие тесты
|
||||
npx playwright test --last-failed
|
||||
```
|
||||
|
||||
## Правила исправления тестов
|
||||
|
||||
**ВАЖНО:** При исправлении сломанных тестов:
|
||||
|
||||
1. **НЕ запускай полный прогон** после каждого исправления
|
||||
2. **Запускай только сломанный тест** для проверки исправления:
|
||||
```bash
|
||||
npx playwright test -g "Название теста"
|
||||
```
|
||||
3. **Полный прогон** делай только когда все сломанные тесты исправлены
|
||||
4. Это экономит время и ресурсы при отладке
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
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';
|
||||
|
||||
@Controller('ai')
|
||||
|
||||
@ -8,7 +8,9 @@ import { SpecificationHistory } from '../ideas/entities/specification-history.en
|
||||
import { Comment } from '../comments/entities/comment.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Idea, TeamMember, SpecificationHistory, Comment])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Idea, TeamMember, SpecificationHistory, Comment]),
|
||||
],
|
||||
controllers: [AiController],
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
|
||||
@ -36,6 +36,21 @@ export interface SpecificationHistoryItem {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface AiProxyResponse {
|
||||
choices: {
|
||||
message: {
|
||||
content: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ParsedEstimate {
|
||||
totalHours?: number;
|
||||
complexity?: string;
|
||||
breakdown?: RoleEstimate[];
|
||||
recommendations?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
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({
|
||||
where: { ideaId },
|
||||
order: { createdAt: 'DESC' },
|
||||
@ -120,18 +137,26 @@ export class AiService {
|
||||
async deleteSpecificationHistoryItem(historyId: string): Promise<void> {
|
||||
const result = await this.specificationHistoryRepository.delete(historyId);
|
||||
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({
|
||||
where: { id: historyId },
|
||||
relations: ['idea'],
|
||||
});
|
||||
|
||||
if (!historyItem) {
|
||||
throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND);
|
||||
throw new HttpException(
|
||||
'Запись истории не найдена',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const idea = historyItem.idea;
|
||||
@ -194,14 +219,21 @@ export class AiService {
|
||||
await this.ideaRepository.update(ideaId, {
|
||||
estimatedHours: result.totalHours,
|
||||
complexity: result.complexity,
|
||||
estimateDetails: { breakdown: result.breakdown, recommendations: result.recommendations },
|
||||
estimateDetails: {
|
||||
breakdown: result.breakdown,
|
||||
recommendations: result.recommendations,
|
||||
},
|
||||
estimatedAt: result.estimatedAt,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private buildPrompt(idea: Idea, teamMembers: TeamMember[], comments: Comment[]): string {
|
||||
private buildPrompt(
|
||||
idea: Idea,
|
||||
teamMembers: TeamMember[],
|
||||
comments: Comment[],
|
||||
): string {
|
||||
const teamInfo = teamMembers
|
||||
.map((m) => {
|
||||
const prod = m.productivity;
|
||||
@ -211,7 +243,8 @@ export class AiService {
|
||||
|
||||
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')}
|
||||
|
||||
@ -269,7 +302,8 @@ ${rolesSummary}
|
||||
}
|
||||
|
||||
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')}
|
||||
|
||||
@ -345,13 +379,14 @@ ${commentsSection}## Требования к ТЗ
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as AiProxyResponse;
|
||||
return data.choices[0].message.content;
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof HttpException) {
|
||||
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(
|
||||
'Не удалось подключиться к AI сервису',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@ -374,20 +409,33 @@ ${commentsSection}## Требования к ТЗ
|
||||
}
|
||||
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 {
|
||||
ideaId: idea.id,
|
||||
ideaTitle: idea.title,
|
||||
totalHours: Number(parsed.totalHours) || 0,
|
||||
complexity: parsed.complexity || 'medium',
|
||||
complexity,
|
||||
breakdown: Array.isArray(parsed.breakdown) ? parsed.breakdown : [],
|
||||
recommendations: Array.isArray(parsed.recommendations)
|
||||
? parsed.recommendations
|
||||
: [],
|
||||
estimatedAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
} catch {
|
||||
this.logger.error(`Failed to parse AI response: ${aiResponse}`);
|
||||
throw new HttpException(
|
||||
'Не удалось разобрать ответ AI',
|
||||
|
||||
@ -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({
|
||||
...createCommentDto,
|
||||
ideaId,
|
||||
|
||||
@ -73,7 +73,13 @@ export class Idea {
|
||||
order: number;
|
||||
|
||||
// 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;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
@ -89,7 +95,11 @@ export class Idea {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
specification: string | null;
|
||||
|
||||
@Column({ name: 'specification_generated_at', type: 'timestamp', nullable: true })
|
||||
@Column({
|
||||
name: 'specification_generated_at',
|
||||
type: 'timestamp',
|
||||
nullable: true,
|
||||
})
|
||||
specificationGeneratedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
|
||||
@ -16,7 +16,9 @@ export class CreateCommentsTable1736899200000 implements MigrationInterface {
|
||||
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> {
|
||||
|
||||
@ -84,7 +84,9 @@ export class CreateRolesTable1736899400000 implements MigrationInterface {
|
||||
`);
|
||||
|
||||
// 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"`);
|
||||
|
||||
// 6. Удаляем таблицу roles
|
||||
|
||||
@ -33,7 +33,16 @@ export class TeamMember {
|
||||
@Column({ name: 'role_id', type: 'uuid' })
|
||||
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;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Role } from './entities/role.entity';
|
||||
@ -31,7 +35,9 @@ export class RolesService {
|
||||
where: { name: createDto.name },
|
||||
});
|
||||
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
|
||||
@ -54,7 +60,9 @@ export class RolesService {
|
||||
where: { name: updateDto.name },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Role with name "${updateDto.name}" already exists`);
|
||||
throw new ConflictException(
|
||||
`Role with name "${updateDto.name}" already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -39,7 +39,9 @@ export class TeamService {
|
||||
where: { id: createDto.roleId },
|
||||
});
|
||||
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);
|
||||
@ -47,7 +49,10 @@ export class TeamService {
|
||||
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);
|
||||
|
||||
if (updateDto.roleId) {
|
||||
@ -55,7 +60,9 @@ export class TeamService {
|
||||
where: { id: updateDto.roleId },
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
async getSummary(): Promise<{ roleId: string; label: string; count: number }[]> {
|
||||
async getSummary(): Promise<
|
||||
{ roleId: string; label: string; count: number }[]
|
||||
> {
|
||||
// Получаем все роли
|
||||
const roles = await this.roleRepository.find({
|
||||
order: { sortOrder: 'ASC' },
|
||||
@ -87,7 +96,10 @@ export class TeamService {
|
||||
return roles.map((role) => ({
|
||||
roleId: role.id,
|
||||
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,
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ export default defineConfig([
|
||||
},
|
||||
},
|
||||
],
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@ -63,7 +63,7 @@ function App() {
|
||||
|
||||
{/* Tabs */}
|
||||
<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={<Group />} iconPosition="start" label="Команда" />
|
||||
</Tabs>
|
||||
@ -72,7 +72,14 @@ function App() {
|
||||
{/* Content */}
|
||||
{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 />
|
||||
<Button
|
||||
variant="contained"
|
||||
|
||||
@ -58,14 +58,14 @@ const complexityColors: Record<
|
||||
|
||||
function formatHours(hours: number): string {
|
||||
if (hours < 8) {
|
||||
return `${hours} ч`;
|
||||
return `${String(hours)} ч`;
|
||||
}
|
||||
const days = Math.floor(hours / 8);
|
||||
const remainingHours = hours % 8;
|
||||
if (remainingHours === 0) {
|
||||
return `${days} д`;
|
||||
return `${String(days)} д`;
|
||||
}
|
||||
return `${days} д ${remainingHours} ч`;
|
||||
return `${String(days)} д ${String(remainingHours)} ч`;
|
||||
}
|
||||
|
||||
export function AiEstimateModal({
|
||||
@ -120,7 +120,9 @@ export function AiEstimateModal({
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
<Typography variant="h4" component="span">
|
||||
{formatHours(result.totalHours)}
|
||||
@ -131,7 +133,9 @@ export function AiEstimateModal({
|
||||
</Typography>
|
||||
</Box>
|
||||
<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" />
|
||||
<Chip
|
||||
label={complexityLabels[result.complexity]}
|
||||
@ -148,7 +152,11 @@ export function AiEstimateModal({
|
||||
{/* Разбивка по ролям */}
|
||||
{result.breakdown.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
gutterBottom
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
Разбивка по ролям
|
||||
</Typography>
|
||||
<Paper variant="outlined">
|
||||
@ -161,9 +169,14 @@ export function AiEstimateModal({
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{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 align="right">{formatHours(item.hours)}</TableCell>
|
||||
<TableCell align="right">
|
||||
{formatHours(item.hours)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@ -175,13 +188,21 @@ export function AiEstimateModal({
|
||||
{/* Рекомендации */}
|
||||
{result.recommendations.length > 0 && (
|
||||
<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" />
|
||||
Рекомендации
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
{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 }}>
|
||||
<CheckCircle fontSize="small" color="success" />
|
||||
</ListItemIcon>
|
||||
|
||||
@ -70,11 +70,19 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : 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>
|
||||
) : (
|
||||
<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) => (
|
||||
<Paper
|
||||
key={comment.id}
|
||||
@ -83,7 +91,11 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
|
||||
sx={{ p: 1.5, display: 'flex', alignItems: 'flex-start', gap: 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}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@ -104,7 +116,12 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
|
||||
</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
|
||||
size="small"
|
||||
placeholder="Добавить комментарий... (Ctrl+Enter)"
|
||||
@ -114,7 +131,7 @@ export function CommentsPanel({ ideaId }: CommentsPanelProps) {
|
||||
fullWidth
|
||||
multiline
|
||||
maxRows={3}
|
||||
inputProps={{ 'data-testid': 'comment-input' }}
|
||||
slotProps={{ htmlInput: { 'data-testid': 'comment-input' } }}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@ -180,7 +180,9 @@ export function CreateIdeaModal() {
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} data-testid="cancel-create-idea">Отмена</Button>
|
||||
<Button onClick={handleClose} data-testid="cancel-create-idea">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
|
||||
@ -54,7 +54,11 @@ export function IdeasFilters() {
|
||||
}, [searchValue, setFilter]);
|
||||
|
||||
const hasFilters = Boolean(
|
||||
filters.status ?? filters.priority ?? filters.module ?? filters.search ?? filters.color,
|
||||
filters.status ??
|
||||
filters.priority ??
|
||||
filters.module ??
|
||||
filters.search ??
|
||||
filters.color,
|
||||
);
|
||||
|
||||
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>
|
||||
<Select<IdeaStatus | ''>
|
||||
value={filters.status ?? ''}
|
||||
@ -99,7 +107,11 @@ export function IdeasFilters() {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-priority">
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{ minWidth: 120 }}
|
||||
data-testid="filter-priority"
|
||||
>
|
||||
<InputLabel>Приоритет</InputLabel>
|
||||
<Select<IdeaPriority | ''>
|
||||
value={filters.priority ?? ''}
|
||||
@ -118,7 +130,11 @@ export function IdeasFilters() {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-module">
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{ minWidth: 120 }}
|
||||
data-testid="filter-module"
|
||||
>
|
||||
<InputLabel>Модуль</InputLabel>
|
||||
<Select
|
||||
value={filters.module ?? ''}
|
||||
@ -134,7 +150,11 @@ export function IdeasFilters() {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 120 }} data-testid="filter-color">
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{ minWidth: 120 }}
|
||||
data-testid="filter-color"
|
||||
>
|
||||
<InputLabel>Цвет</InputLabel>
|
||||
<Select
|
||||
value={filters.color ?? ''}
|
||||
|
||||
@ -85,7 +85,15 @@ export function ColorPickerCell({ idea }: ColorPickerCellProps) {
|
||||
} 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) => (
|
||||
<IconButton
|
||||
key={color}
|
||||
|
||||
@ -80,7 +80,12 @@ export function DraggableRow({ row }: DraggableRowProps) {
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, Fragment } from 'react';
|
||||
import { useMemo, useState, Fragment, useCallback } from 'react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
@ -79,21 +79,30 @@ export function IdeasTable() {
|
||||
// AI-оценка
|
||||
const [estimatingId, setEstimatingId] = useState<string | null>(null);
|
||||
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 [specificationIdea, setSpecificationIdea] = useState<Idea | null>(null);
|
||||
const [generatedSpecification, setGeneratedSpecification] = useState<string | null>(null);
|
||||
const [generatingSpecificationId, setGeneratingSpecificationId] = useState<string | null>(null);
|
||||
const [generatedSpecification, setGeneratedSpecification] = useState<
|
||||
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) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
const handleEstimate = (id: string) => {
|
||||
const handleEstimate = useCallback(
|
||||
(id: string) => {
|
||||
setEstimatingId(id);
|
||||
setEstimateModalOpen(true);
|
||||
setEstimateResult(null);
|
||||
@ -106,7 +115,9 @@ export function IdeasTable() {
|
||||
setEstimatingId(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
[estimateIdea],
|
||||
);
|
||||
|
||||
const handleCloseEstimateModal = () => {
|
||||
setEstimateModalOpen(false);
|
||||
@ -121,15 +132,16 @@ export function IdeasTable() {
|
||||
ideaId: idea.id,
|
||||
ideaTitle: idea.title,
|
||||
totalHours: idea.estimatedHours,
|
||||
complexity: idea.complexity!,
|
||||
complexity: idea.complexity ?? 'medium',
|
||||
breakdown: idea.estimateDetails.breakdown,
|
||||
recommendations: idea.estimateDetails.recommendations,
|
||||
estimatedAt: idea.estimatedAt!,
|
||||
estimatedAt: idea.estimatedAt ?? new Date().toISOString(),
|
||||
});
|
||||
setEstimateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSpecification = (idea: Idea) => {
|
||||
const handleSpecification = useCallback(
|
||||
(idea: Idea) => {
|
||||
setSpecificationIdea(idea);
|
||||
setSpecificationModalOpen(true);
|
||||
|
||||
@ -151,7 +163,9 @@ export function IdeasTable() {
|
||||
setGeneratingSpecificationId(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
[generateSpecification],
|
||||
);
|
||||
|
||||
const handleCloseSpecificationModal = () => {
|
||||
setSpecificationModalOpen(false);
|
||||
@ -162,7 +176,7 @@ export function IdeasTable() {
|
||||
const handleSaveSpecification = (specification: string) => {
|
||||
if (!specificationIdea) return;
|
||||
updateIdea.mutate(
|
||||
{ id: specificationIdea.id, data: { specification } },
|
||||
{ id: specificationIdea.id, dto: { specification } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setGeneratedSpecification(specification);
|
||||
@ -209,7 +223,14 @@ export function IdeasTable() {
|
||||
estimatingId,
|
||||
generatingSpecificationId,
|
||||
}),
|
||||
[deleteIdea, expandedId, estimatingId, generatingSpecificationId],
|
||||
[
|
||||
deleteIdea,
|
||||
expandedId,
|
||||
estimatingId,
|
||||
generatingSpecificationId,
|
||||
handleEstimate,
|
||||
handleSpecification,
|
||||
],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
@ -291,7 +312,10 @@ export function IdeasTable() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper sx={{ width: '100%', overflow: 'hidden' }} data-testid="ideas-table-container">
|
||||
<Paper
|
||||
sx={{ width: '100%', overflow: 'hidden' }}
|
||||
data-testid="ideas-table-container"
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@ -386,9 +410,17 @@ export function IdeasTable() {
|
||||
<TableRow>
|
||||
<TableCell
|
||||
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} />
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
|
||||
@ -1,7 +1,26 @@
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { Chip, 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 {
|
||||
Chip,
|
||||
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 { ColorPickerCell } from './ColorPickerCell';
|
||||
import { statusOptions, priorityOptions } from './constants';
|
||||
@ -51,10 +70,10 @@ const complexityColors: Record<
|
||||
|
||||
function formatHoursShort(hours: number): string {
|
||||
if (hours < 8) {
|
||||
return `${hours}ч`;
|
||||
return `${String(hours)}ч`;
|
||||
}
|
||||
const days = Math.floor(hours / 8);
|
||||
return `${days}д`;
|
||||
return `${String(days)}д`;
|
||||
}
|
||||
|
||||
interface ColumnsConfig {
|
||||
@ -68,7 +87,16 @@ interface ColumnsConfig {
|
||||
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({
|
||||
id: 'drag',
|
||||
header: '',
|
||||
@ -246,7 +274,9 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
|
||||
const hasSpecification = !!idea.specification;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title={hasSpecification ? 'Просмотр ТЗ' : 'Сгенерировать ТЗ'}>
|
||||
<Tooltip
|
||||
title={hasSpecification ? 'Просмотр ТЗ' : 'Сгенерировать ТЗ'}
|
||||
>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
@ -254,7 +284,10 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
|
||||
disabled={isGeneratingSpec}
|
||||
color={hasSpecification ? 'primary' : 'default'}
|
||||
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 ? (
|
||||
<CircularProgress size={18} />
|
||||
@ -290,7 +323,11 @@ export const createColumns = ({ onDelete, onToggleComments, onEstimate, onViewEs
|
||||
data-testid="toggle-comments-button"
|
||||
sx={{ opacity: isExpanded ? 1 : 0.5, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
{isExpanded ? <ExpandLess fontSize="small" /> : <Comment fontSize="small" />}
|
||||
{isExpanded ? (
|
||||
<ExpandLess fontSize="small" />
|
||||
) : (
|
||||
<Comment fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
|
||||
@ -17,7 +17,6 @@ import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
Divider,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
@ -84,7 +83,8 @@ export function SpecificationModal({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedText, setEditedText] = useState('');
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
const [viewingHistoryItem, setViewingHistoryItem] = useState<SpecificationHistoryItem | null>(null);
|
||||
const [viewingHistoryItem, setViewingHistoryItem] =
|
||||
useState<SpecificationHistoryItem | null>(null);
|
||||
|
||||
// Сбрасываем состояние при открытии/закрытии
|
||||
useEffect(() => {
|
||||
@ -97,12 +97,12 @@ export function SpecificationModal({
|
||||
}, [open, specification]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditedText(specification || '');
|
||||
setEditedText(specification ?? '');
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditedText(specification || '');
|
||||
setEditedText(specification ?? '');
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
@ -152,7 +152,13 @@ export function SpecificationModal({
|
||||
fullWidth
|
||||
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>
|
||||
<Typography variant="h6" component="span">
|
||||
Техническое задание
|
||||
@ -194,7 +200,7 @@ export function SpecificationModal({
|
||||
{hasHistory && !isEditing && !viewingHistoryItem && (
|
||||
<Tabs
|
||||
value={tabIndex}
|
||||
onChange={(_, newValue) => setTabIndex(newValue)}
|
||||
onChange={(_, newValue: number) => setTabIndex(newValue)}
|
||||
sx={{ px: 3, borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab label="Текущее ТЗ" data-testid="specification-tab-current" />
|
||||
@ -225,7 +231,9 @@ export function SpecificationModal({
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleRestoreFromHistory(viewingHistoryItem.id)}
|
||||
onClick={() =>
|
||||
handleRestoreFromHistory(viewingHistoryItem.id)
|
||||
}
|
||||
disabled={isRestoring}
|
||||
data-testid="specification-restore-button"
|
||||
>
|
||||
@ -236,7 +244,8 @@ export function SpecificationModal({
|
||||
{viewingHistoryItem.ideaDescriptionSnapshot && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="caption">
|
||||
Описание идеи на момент генерации: {viewingHistoryItem.ideaDescriptionSnapshot}
|
||||
Описание идеи на момент генерации:{' '}
|
||||
{viewingHistoryItem.ideaDescriptionSnapshot}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
@ -248,7 +257,11 @@ export function SpecificationModal({
|
||||
borderRadius: 1,
|
||||
maxHeight: '50vh',
|
||||
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 },
|
||||
'& h2': { fontSize: '1.25rem', fontWeight: 600 },
|
||||
'& h3': { fontSize: '1.1rem', fontWeight: 600 },
|
||||
@ -256,9 +269,29 @@ export function SpecificationModal({
|
||||
'& ul, & ol': { pl: 3, mb: 1.5 },
|
||||
'& li': { mb: 0.5 },
|
||||
'& strong': { fontWeight: 600 },
|
||||
'& code': { bgcolor: 'grey.200', 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' },
|
||||
'& code': {
|
||||
bgcolor: 'grey.200',
|
||||
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>
|
||||
@ -272,7 +305,11 @@ export function SpecificationModal({
|
||||
<TabPanel value={tabIndex} index={0}>
|
||||
{isLoading && (
|
||||
<Box sx={{ py: 4 }} data-testid="specification-loading">
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
>
|
||||
Генерируем техническое задание...
|
||||
</Typography>
|
||||
<LinearProgress />
|
||||
@ -280,7 +317,11 @@ export function SpecificationModal({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ my: 2 }} data-testid="specification-error">
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ my: 2 }}
|
||||
data-testid="specification-error"
|
||||
>
|
||||
{error.message || 'Не удалось сгенерировать ТЗ'}
|
||||
</Alert>
|
||||
)}
|
||||
@ -307,7 +348,11 @@ export function SpecificationModal({
|
||||
{!isLoading && !error && !isEditing && specification && (
|
||||
<Box>
|
||||
{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)}
|
||||
</Typography>
|
||||
)}
|
||||
@ -319,7 +364,11 @@ export function SpecificationModal({
|
||||
borderRadius: 1,
|
||||
maxHeight: '55vh',
|
||||
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 },
|
||||
'& h2': { fontSize: '1.25rem', fontWeight: 600 },
|
||||
'& h3': { fontSize: '1.1rem', fontWeight: 600 },
|
||||
@ -327,9 +376,29 @@ export function SpecificationModal({
|
||||
'& ul, & ol': { pl: 3, mb: 1.5 },
|
||||
'& li': { mb: 0.5 },
|
||||
'& strong': { fontWeight: 600 },
|
||||
'& code': { bgcolor: 'grey.200', 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' },
|
||||
'& code': {
|
||||
bgcolor: 'grey.200',
|
||||
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>
|
||||
@ -353,12 +422,54 @@ export function SpecificationModal({
|
||||
<Box key={item.id}>
|
||||
{index > 0 && <Divider />}
|
||||
<ListItem
|
||||
data-testid={`specification-history-item-${index}`}
|
||||
data-testid={`specification-history-item-${String(index)}`}
|
||||
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
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{formatDate(item.createdAt)}
|
||||
</Typography>
|
||||
@ -387,38 +498,6 @@ export function SpecificationModal({
|
||||
</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>
|
||||
</Box>
|
||||
))}
|
||||
@ -446,9 +525,7 @@ export function SpecificationModal({
|
||||
</Button>
|
||||
</>
|
||||
) : viewingHistoryItem ? (
|
||||
<Button onClick={handleCloseHistoryView}>
|
||||
Назад к текущему ТЗ
|
||||
</Button>
|
||||
<Button onClick={handleCloseHistoryView}>Назад к текущему ТЗ</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onClose}
|
||||
|
||||
@ -20,7 +20,12 @@ import {
|
||||
Alert,
|
||||
} from '@mui/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';
|
||||
|
||||
interface RoleModalProps {
|
||||
@ -74,7 +79,13 @@ function RoleModal({ open, onClose, role }: RoleModalProps) {
|
||||
};
|
||||
|
||||
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">
|
||||
<DialogTitle>
|
||||
{isEditing ? 'Редактировать роль' : 'Добавить роль'}
|
||||
@ -107,7 +118,9 @@ function RoleModal({ open, onClose, role }: RoleModalProps) {
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} data-testid="cancel-role-button">Отмена</Button>
|
||||
<Button onClick={onClose} data-testid="cancel-role-button">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
@ -160,15 +173,31 @@ export function RolesManager() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
{deleteError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setDeleteError('')}>
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2 }}
|
||||
onClose={() => setDeleteError('')}
|
||||
>
|
||||
{deleteError}
|
||||
</Alert>
|
||||
)}
|
||||
@ -183,35 +212,58 @@ export function RolesManager() {
|
||||
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>
|
||||
Отображаемое название
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} align="center">
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
|
||||
align="center"
|
||||
>
|
||||
Порядок
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} />
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
|
||||
/>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton /></TableCell>
|
||||
<TableCell><Skeleton /></TableCell>
|
||||
<TableCell><Skeleton /></TableCell>
|
||||
<TableCell><Skeleton /></TableCell>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
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>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{role.name}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
@ -244,7 +296,11 @@ export function RolesManager() {
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<RoleModal open={modalOpen} onClose={handleModalClose} role={editingRole} />
|
||||
<RoleModal
|
||||
open={modalOpen}
|
||||
onClose={handleModalClose}
|
||||
role={editingRole}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@ -34,10 +34,15 @@ const defaultProductivity: ProductivityMatrix = {
|
||||
veryComplex: 60,
|
||||
};
|
||||
|
||||
export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps) {
|
||||
export function TeamMemberModal({
|
||||
open,
|
||||
onClose,
|
||||
member,
|
||||
}: TeamMemberModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [roleId, setRoleId] = useState('');
|
||||
const [productivity, setProductivity] = useState<ProductivityMatrix>(defaultProductivity);
|
||||
const [productivity, setProductivity] =
|
||||
useState<ProductivityMatrix>(defaultProductivity);
|
||||
|
||||
const { data: roles = [], isLoading: rolesLoading } = useRolesQuery();
|
||||
const createMember = useCreateTeamMember();
|
||||
@ -72,7 +77,10 @@ export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps)
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleProductivityChange = (key: keyof ProductivityMatrix, value: string) => {
|
||||
const handleProductivityChange = (
|
||||
key: keyof ProductivityMatrix,
|
||||
value: string,
|
||||
) => {
|
||||
const num = parseFloat(value) || 0;
|
||||
setProductivity((prev) => ({ ...prev, [key]: num }));
|
||||
};
|
||||
@ -80,7 +88,13 @@ export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps)
|
||||
const isPending = createMember.isPending || updateMember.isPending;
|
||||
|
||||
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">
|
||||
<DialogTitle>
|
||||
{isEditing ? 'Редактировать участника' : 'Добавить участника'}
|
||||
@ -120,31 +134,47 @@ export function TeamMemberModal({ open, onClose, member }: TeamMemberModalProps)
|
||||
Производительность (часы на задачу)
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
{(Object.entries(complexityLabels) as [keyof ProductivityMatrix, string][]).map(
|
||||
([key, label]) => (
|
||||
<Box
|
||||
sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}
|
||||
>
|
||||
{(
|
||||
Object.entries(complexityLabels) as [
|
||||
keyof ProductivityMatrix,
|
||||
string,
|
||||
][]
|
||||
).map(([key, label]) => (
|
||||
<TextField
|
||||
key={key}
|
||||
label={label}
|
||||
type="number"
|
||||
size="small"
|
||||
value={productivity[key]}
|
||||
onChange={(e) => handleProductivityChange(key, e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleProductivityChange(key, e.target.value)
|
||||
}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: <InputAdornment position="end">ч</InputAdornment>,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">ч</InputAdornment>
|
||||
),
|
||||
},
|
||||
htmlInput: { min: 0, step: 0.5 },
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} data-testid="cancel-member-button">Отмена</Button>
|
||||
<Button type="submit" variant="contained" disabled={!name.trim() || !roleId || isPending} data-testid="submit-member-button">
|
||||
<Button onClick={onClose} data-testid="cancel-member-button">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={!name.trim() || !roleId || isPending}
|
||||
data-testid="submit-member-button"
|
||||
>
|
||||
{isEditing ? 'Сохранить' : 'Добавить'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
@ -19,7 +19,11 @@ import {
|
||||
Tab,
|
||||
} from '@mui/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 type { TeamMember, ProductivityMatrix } from '../../types/team';
|
||||
import { TeamMemberModal } from './TeamMemberModal';
|
||||
@ -56,9 +60,19 @@ export function TeamPage() {
|
||||
<Box data-testid="team-page">
|
||||
{/* Вкладки */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
|
||||
<Tab icon={<Group />} iconPosition="start" label="Участники" data-testid="team-tab-members" />
|
||||
<Tab icon={<Settings />} iconPosition="start" label="Роли" data-testid="team-tab-roles" />
|
||||
<Tabs value={activeTab} onChange={(_, v: number) => setActiveTab(v)}>
|
||||
<Tab
|
||||
icon={<Group />}
|
||||
iconPosition="start"
|
||||
label="Участники"
|
||||
data-testid="team-tab-members"
|
||||
/>
|
||||
<Tab
|
||||
icon={<Settings />}
|
||||
iconPosition="start"
|
||||
label="Роли"
|
||||
data-testid="team-tab-roles"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
@ -66,12 +80,20 @@ export function TeamPage() {
|
||||
<>
|
||||
{/* Сводка по ролям */}
|
||||
<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})
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
{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 } }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600 }}>
|
||||
{item.count}
|
||||
@ -86,9 +108,21 @@ export function TeamPage() {
|
||||
</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>
|
||||
<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>
|
||||
</Box>
|
||||
@ -97,48 +131,91 @@ export function TeamPage() {
|
||||
<Table size="small" data-testid="team-table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>Имя</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}>Роль</TableCell>
|
||||
{(Object.keys(complexityLabels) as (keyof ProductivityMatrix)[]).map((key) => (
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
|
||||
>
|
||||
Имя
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
|
||||
>
|
||||
Роль
|
||||
</TableCell>
|
||||
{(
|
||||
Object.keys(
|
||||
complexityLabels,
|
||||
) as (keyof ProductivityMatrix)[]
|
||||
).map((key) => (
|
||||
<TableCell
|
||||
key={key}
|
||||
align="center"
|
||||
sx={{ fontWeight: 600, backgroundColor: 'grey.100', fontSize: '0.75rem' }}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
backgroundColor: 'grey.100',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{complexityLabels[key]}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell sx={{ fontWeight: 600, backgroundColor: 'grey.100' }} />
|
||||
<TableCell
|
||||
sx={{ fontWeight: 600, backgroundColor: 'grey.100' }}
|
||||
/>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton /></TableCell>
|
||||
<TableCell><Skeleton /></TableCell>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<TableCell key={j}><Skeleton /></TableCell>
|
||||
<TableCell key={j}>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell><Skeleton /></TableCell>
|
||||
<TableCell>
|
||||
<Skeleton />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : members.length === 0 ? (
|
||||
<TableRow>
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<TableRow key={member.id} hover data-testid={`team-member-row-${member.id}`}>
|
||||
<TableCell sx={{ fontWeight: 500 }}>{member.name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={member.role.label} size="small" variant="outlined" />
|
||||
<TableRow
|
||||
key={member.id}
|
||||
hover
|
||||
data-testid={`team-member-row-${member.id}`}
|
||||
>
|
||||
<TableCell sx={{ fontWeight: 500 }}>
|
||||
{member.name}
|
||||
</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">
|
||||
{member.productivity[key]}ч
|
||||
</TableCell>
|
||||
|
||||
@ -24,7 +24,9 @@ export function useGenerateSpecification() {
|
||||
onSuccess: (_, ideaId) => {
|
||||
// Инвалидируем кэш идей и историю
|
||||
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) {
|
||||
return useQuery({
|
||||
queryKey: [SPECIFICATION_HISTORY_KEY, ideaId],
|
||||
queryFn: () => aiApi.getSpecificationHistory(ideaId!),
|
||||
queryFn: () => {
|
||||
if (!ideaId) throw new Error('ideaId is required');
|
||||
return aiApi.getSpecificationHistory(ideaId);
|
||||
},
|
||||
enabled: !!ideaId,
|
||||
});
|
||||
}
|
||||
@ -41,10 +46,13 @@ export function useDeleteSpecificationHistoryItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (historyId: string) => aiApi.deleteSpecificationHistoryItem(historyId),
|
||||
mutationFn: (historyId: string) =>
|
||||
aiApi.deleteSpecificationHistoryItem(historyId),
|
||||
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();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (historyId: string) => aiApi.restoreSpecificationFromHistory(historyId),
|
||||
mutationFn: (historyId: string) =>
|
||||
aiApi.restoreSpecificationFromHistory(historyId),
|
||||
onSuccess: () => {
|
||||
// Инвалидируем кэш идей и историю
|
||||
void queryClient.invalidateQueries({ queryKey: [IDEAS_QUERY_KEY] });
|
||||
void queryClient.invalidateQueries({ queryKey: [SPECIFICATION_HISTORY_KEY] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [SPECIFICATION_HISTORY_KEY],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -8,19 +8,22 @@ export interface User {
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const tokenParsed = keycloak.tokenParsed as {
|
||||
const tokenParsed = keycloak.tokenParsed as
|
||||
| {
|
||||
sub?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
email?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
} | undefined;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const user: User | null = tokenParsed
|
||||
? {
|
||||
id: tokenParsed.sub ?? '',
|
||||
name: tokenParsed.name ?? tokenParsed.preferred_username ?? 'Пользователь',
|
||||
name:
|
||||
tokenParsed.name ?? tokenParsed.preferred_username ?? 'Пользователь',
|
||||
email: tokenParsed.email ?? '',
|
||||
username: tokenParsed.preferred_username ?? '',
|
||||
}
|
||||
@ -32,7 +35,7 @@ export function useAuth() {
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated: keycloak.authenticated ?? false,
|
||||
isAuthenticated: keycloak.authenticated,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
|
||||
@ -5,7 +5,10 @@ import type { CreateCommentDto } from '../types/comment';
|
||||
export function useCommentsQuery(ideaId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['comments', ideaId],
|
||||
queryFn: () => commentsApi.getByIdeaId(ideaId!),
|
||||
queryFn: () => {
|
||||
if (!ideaId) throw new Error('ideaId is required');
|
||||
return commentsApi.getByIdeaId(ideaId);
|
||||
},
|
||||
enabled: !!ideaId,
|
||||
});
|
||||
}
|
||||
@ -17,7 +20,9 @@ export function useCreateComment() {
|
||||
mutationFn: ({ ideaId, dto }: { ideaId: string; dto: CreateCommentDto }) =>
|
||||
commentsApi.create(ideaId, dto),
|
||||
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 }) =>
|
||||
commentsApi.delete(params.id),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', variables.ideaId] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['comments', variables.ideaId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export function useCreateTeamMember() {
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateTeamMemberDto) => teamApi.create(dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['team'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['team'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -34,7 +34,7 @@ export function useUpdateTeamMember() {
|
||||
mutationFn: ({ id, dto }: { id: string; dto: UpdateTeamMemberDto }) =>
|
||||
teamApi.update(id, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['team'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['team'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -45,7 +45,7 @@ export function useDeleteTeamMember() {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => teamApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['team'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['team'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
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 {
|
||||
ideaId: string;
|
||||
@ -17,13 +22,22 @@ export const aiApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
generateSpecification: async (ideaId: string): Promise<SpecificationResult> => {
|
||||
const { data } = await api.post<SpecificationResult>('/ai/generate-specification', { ideaId });
|
||||
generateSpecification: async (
|
||||
ideaId: string,
|
||||
): Promise<SpecificationResult> => {
|
||||
const { data } = await api.post<SpecificationResult>(
|
||||
'/ai/generate-specification',
|
||||
{ ideaId },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
getSpecificationHistory: async (ideaId: string): Promise<SpecificationHistoryItem[]> => {
|
||||
const { data } = await api.get<SpecificationHistoryItem[]>(`/ai/specification-history/${ideaId}`);
|
||||
getSpecificationHistory: async (
|
||||
ideaId: string,
|
||||
): Promise<SpecificationHistoryItem[]> => {
|
||||
const { data } = await api.get<SpecificationHistoryItem[]>(
|
||||
`/ai/specification-history/${ideaId}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
@ -31,8 +45,12 @@ export const aiApi = {
|
||||
await api.delete(`/ai/specification-history/${historyId}`);
|
||||
},
|
||||
|
||||
restoreSpecificationFromHistory: async (historyId: string): Promise<SpecificationResult> => {
|
||||
const { data } = await api.post<SpecificationResult>(`/ai/specification-history/${historyId}/restore`);
|
||||
restoreSpecificationFromHistory: async (
|
||||
historyId: string,
|
||||
): Promise<SpecificationResult> => {
|
||||
const { data } = await api.post<SpecificationResult>(
|
||||
`/ai/specification-history/${historyId}/restore`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@ -8,7 +8,10 @@ export const commentsApi = {
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
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 = {
|
||||
getAll: async (): Promise<TeamMember[]> => {
|
||||
|
||||
@ -6,7 +6,12 @@ export type IdeaStatus =
|
||||
| 'cancelled';
|
||||
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 {
|
||||
role: string;
|
||||
|
||||
@ -13,7 +13,7 @@ export interface CreateRoleDto {
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface UpdateRoleDto extends Partial<CreateRoleDto> {}
|
||||
export type UpdateRoleDto = Partial<CreateRoleDto>;
|
||||
|
||||
export interface ProductivityMatrix {
|
||||
trivial: number;
|
||||
@ -39,7 +39,7 @@ export interface CreateTeamMemberDto {
|
||||
productivity?: ProductivityMatrix;
|
||||
}
|
||||
|
||||
export interface UpdateTeamMemberDto extends Partial<CreateTeamMemberDto> {}
|
||||
export type UpdateTeamMemberDto = Partial<CreateTeamMemberDto>;
|
||||
|
||||
export interface TeamSummary {
|
||||
roleId: string;
|
||||
|
||||
77
package-lock.json
generated
77
package-lock.json
generated
@ -9,7 +9,8 @@
|
||||
"version": "1.0.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
"frontend",
|
||||
"tests"
|
||||
],
|
||||
"dependencies": {
|
||||
"class-transformer": "^0.5.1",
|
||||
@ -11427,6 +11428,22 @@
|
||||
"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": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||
@ -12001,6 +12018,21 @@
|
||||
"resolved": "frontend",
|
||||
"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": {
|
||||
"version": "2.0.5",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "3.7.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
|
||||
@ -13307,6 +13371,10 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/team-planner-e2e": {
|
||||
"resolved": "tests",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/token-types": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz",
|
||||
@ -13592,6 +13660,13 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"tests": {
|
||||
"name": "team-planner-e2e",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,16 +4,21 @@
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
"frontend",
|
||||
"tests"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently -n be,fe -c blue,green \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||
"dev:backend": "npm run dev -w backend",
|
||||
"dev:frontend": "npm run dev -w frontend",
|
||||
"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:backend": "npm run build -w backend",
|
||||
"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:down": "docker-compose down"
|
||||
},
|
||||
|
||||
@ -156,7 +156,8 @@ test.describe('Фаза 2: Цветовая маркировка', () => {
|
||||
|
||||
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();
|
||||
|
||||
const popover = page.locator('[data-testid="color-picker-popover"]');
|
||||
@ -170,16 +171,17 @@ test.describe('Фаза 2: Цветовая маркировка', () => {
|
||||
// Ждём закрытия popover
|
||||
await expect(popover).toBeHidden({ timeout: 3000 });
|
||||
|
||||
// Проверяем что строка получила цветной фон
|
||||
await page.waitForTimeout(300);
|
||||
const firstRow = page.locator('[data-testid^="idea-row-"]').first();
|
||||
const rowStyle = await firstRow.evaluate((el) => {
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
return bg;
|
||||
// Проверяем что строка получила цветной фон (ждем API ответа)
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Проверяем что color picker trigger показывает цвет (сам trigger имеет backgroundColor)
|
||||
const triggerStyle = await colorTrigger.evaluate((el) => {
|
||||
return getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
// Фон не должен быть прозрачным
|
||||
expect(rowStyle).not.toBe('rgba(0, 0, 0, 0)');
|
||||
// После выбора цвета, trigger должен показывать выбранный цвет (не transparent)
|
||||
expect(triggerStyle).not.toBe('transparent');
|
||||
expect(triggerStyle).not.toBe('rgba(0, 0, 0, 0)');
|
||||
});
|
||||
|
||||
test('Фильтр по цвету открывает dropdown с опциями', async ({ page }) => {
|
||||
|
||||
@ -418,10 +418,12 @@ test.describe('Фаза 3.1: Генерация мини-ТЗ', () => {
|
||||
const editButton = modal.locator('[data-testid="specification-edit-button"]');
|
||||
await editButton.click();
|
||||
|
||||
// Редактируем текст
|
||||
const textarea = modal.locator('[data-testid="specification-textarea"] textarea');
|
||||
// Редактируем текст (MUI TextField создает 2 textarea, берем первый видимый)
|
||||
const textarea = modal.locator(
|
||||
'[data-testid="specification-textarea"] textarea:not([aria-hidden="true"])',
|
||||
);
|
||||
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"]');
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
File diff suppressed because one or more lines are too long
@ -2,7 +2,7 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "AUTH_SESSION_ID",
|
||||
"value": "c2hWNkNfa0JhY1hOc1ZWTnhLdk1tOTllLnNBM2ZQTk5yRlBKek5lS3FoR093OFloU1ZyU3E1QzFadzVIU1Jta2lMRllqbXJxLW9QSEMxOFkzZWZDZDl3UHVKZUVaU0VvWWJTOVRNTHJJSUpZc1hB.keycloak-keycloakx-0-40655",
|
||||
"value": "aDItMWtlTDNkMEdGUGE1TTFqYmxvOFNyLjhnZHRLSUVtbW5ZZmp1RkpBc2lmdnROSWVIY3RLRXdlZmloN2I0WmV4UTRlVWY5dnRYVFZZNHlSdlE2OTdEVVJHT2NVNE11cGd1eVQ4RzVseUxnYThn.keycloak-keycloakx-0-24485",
|
||||
"domain": "auth.vigdorov.ru",
|
||||
"path": "/realms/team-planner/",
|
||||
"expires": -1,
|
||||
@ -12,17 +12,17 @@
|
||||
},
|
||||
{
|
||||
"name": "KC_AUTH_SESSION_HASH",
|
||||
"value": "\"gFqhBG3DVcCfpsSCaidKwK+Ziy23r6ddJ/rdb/jKDs8\"",
|
||||
"value": "\"w/aalxg9yi+TKbWYZgi8KimwA5UYaExWPPJvZT0MfoE\"",
|
||||
"domain": "auth.vigdorov.ru",
|
||||
"path": "/realms/team-planner/",
|
||||
"expires": 1768427781.187379,
|
||||
"expires": 1768433639.802328,
|
||||
"httpOnly": false,
|
||||
"secure": true,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "KEYCLOAK_IDENTITY",
|
||||
"value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2ZDRjMWU2My1hNTllLTQ0NjAtYThkYy05YTRiZjFjMTRjMDMifQ.eyJleHAiOjE3Njg0NjM3MjMsImlhdCI6MTc2ODQyNzcyMywianRpIjoiNGRmN2U5MzQtY2Q4Mi1hYTYwLTViNTUtMWFhZjVlMWViODJjIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnZpZ2Rvcm92LnJ1L3JlYWxtcy90ZWFtLXBsYW5uZXIiLCJzdWIiOiIyZDJiOTRmMC0xZWQ1LTQ0MTUtYmM4MC1jZTRlZWMxNDQ1NGQiLCJ0eXAiOiJTZXJpYWxpemVkLUlEIiwic2lkIjoic2hWNkNfa0JhY1hOc1ZWTnhLdk1tOTllIiwic3RhdGVfY2hlY2tlciI6Im9Ic2R0czlWR0RvV19EcjcxbG4tM2FjWDR1SmJuMWtzdHRCcVpzRnlPbDQifQ.Nbi8YdiZddWqY4rsS7b_hin9cbTedp2bOQ11I25tLdTH6VGGJaCP1T59pYd3OlqyDYPoD97uOBiobKTues1rwg",
|
||||
"value": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2ZDRjMWU2My1hNTllLTQ0NjAtYThkYy05YTRiZjFjMTRjMDMifQ.eyJleHAiOjE3Njg0Njk1ODEsImlhdCI6MTc2ODQzMzU4MSwianRpIjoiOGVhZGVkMjgtZTMxNC1hZWMyLWJmNTYtMjJlMDBmM2YzZGM1IiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnZpZ2Rvcm92LnJ1L3JlYWxtcy90ZWFtLXBsYW5uZXIiLCJzdWIiOiIyZDJiOTRmMC0xZWQ1LTQ0MTUtYmM4MC1jZTRlZWMxNDQ1NGQiLCJ0eXAiOiJTZXJpYWxpemVkLUlEIiwic2lkIjoiaDItMWtlTDNkMEdGUGE1TTFqYmxvOFNyIiwic3RhdGVfY2hlY2tlciI6ImxDcld3azFTQTJCSm1VaDlXNGdNbzRwLVBOc3h2UHVFUlZTWW1PLWtPSGMifQ.i_RwpCuiyyychgU4ODrBrgu-JA9R2TMyMM2q78LunCOkTXGCUdruGqPi-HuNk0lwH3mMo0Lr5Z8PGVBhalsNEw",
|
||||
"domain": "auth.vigdorov.ru",
|
||||
"path": "/realms/team-planner/",
|
||||
"expires": -1,
|
||||
@ -32,10 +32,10 @@
|
||||
},
|
||||
{
|
||||
"name": "KEYCLOAK_SESSION",
|
||||
"value": "gFqhBG3DVcCfpsSCaidKwK-Ziy23r6ddJ_rdb_jKDs8",
|
||||
"value": "w_aalxg9yi-TKbWYZgi8KimwA5UYaExWPPJvZT0MfoE",
|
||||
"domain": "auth.vigdorov.ru",
|
||||
"path": "/realms/team-planner/",
|
||||
"expires": 1768463723.271756,
|
||||
"expires": 1768469581.267433,
|
||||
"httpOnly": false,
|
||||
"secure": true,
|
||||
"sameSite": "None"
|
||||
|
||||
Reference in New Issue
Block a user