From b6e6949a6e531ecf0e8887556af176743bb80f5d Mon Sep 17 00:00:00 2001 From: Gabriel Date: Thu, 24 Sep 2026 20:42:06 -0300 Subject: [PATCH 1/3] =?UTF-8?q?FEAT=20-=20Implementa=20=C3=A1rvore=20hier?= =?UTF-8?q?=C3=A1rquica,=20breadcrumb=20e=20filtros=20persistidos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/projects/projects.controller.ts | 200 ++- .../modules/projects/projects.repository.ts | 1009 +++++++++++-- .../modules/projects/projects.routes.test.ts | 792 +++++++--- .../src/modules/projects/projects.routes.ts | 74 +- .../modules/projects/projects.service.test.ts | 871 ++++++++--- .../src/modules/projects/projects.service.ts | 195 ++- .../src/modules/projects/projects.types.ts | 213 ++- frontend/src/api/api_backlog_tree.ts | 258 ++++ frontend/src/assets/styles/projects.css | 528 ++++++- frontend/src/backlog/Hierarchy.test.tsx | 538 +++++++ .../src/views/backlog/BacklogBreadcrumb.tsx | 67 + .../src/views/backlog/BacklogTreeView.tsx | 986 +++++++++++++ frontend/src/views/backlog/EpicsView.tsx | 1311 ++++++++++++++--- frontend/src/views/backlog/FeaturesView.tsx | 1294 +++++++++++++--- frontend/src/views/backlog/PbisView.tsx | 1112 ++++++-------- frontend/src/views/projects/ProjectsView.tsx | 1081 ++++++++++++-- 16 files changed, 8687 insertions(+), 1842 deletions(-) create mode 100644 frontend/src/api/api_backlog_tree.ts create mode 100644 frontend/src/backlog/Hierarchy.test.tsx create mode 100644 frontend/src/views/backlog/BacklogBreadcrumb.tsx create mode 100644 frontend/src/views/backlog/BacklogTreeView.tsx diff --git a/backend/src/modules/projects/projects.controller.ts b/backend/src/modules/projects/projects.controller.ts index 586f87c..4f4be07 100644 --- a/backend/src/modules/projects/projects.controller.ts +++ b/backend/src/modules/projects/projects.controller.ts @@ -1,80 +1,222 @@ -import { Request, Response, NextFunction } from "express"; -import { projectsService, ProjectsService } from "./projects.service.js"; +import { + Request, + Response, + NextFunction, +} from "express"; +import { + projectsService, + ProjectsService, +} from "./projects.service.js"; import { ValidationError } from "../../shared/errors.js"; import { z } from "zod"; const confirmation = z.object({ confirmado: z.literal(true), - impacto: z.object({ projeto: z.number().int().min(0).max(1), epicos: z.number().int().nonnegative(), features: z.number().int().nonnegative(), pbis: z.number().int().nonnegative() }).strict(), - justificativa: z.string().trim().max(2000).optional(), + + impacto: z + .object({ + projeto: z + .number() + .int() + .min(0) + .max(1), + + epicos: z + .number() + .int() + .nonnegative(), + + features: z + .number() + .int() + .nonnegative(), + + pbis: z + .number() + .int() + .nonnegative(), + }) + .strict(), + + justificativa: z + .string() + .trim() + .max(2000) + .optional(), }); -function getParamId(param: string | string[] | undefined): string { - if (Array.isArray(param)) return param[0] ?? ""; +function getParamId( + param: string | string[] | undefined, +): string { + if (Array.isArray(param)) { + return param[0] ?? ""; + } + return param ?? ""; } export class ProjectsController { - constructor(private readonly service: ProjectsService = projectsService) {} + constructor( + private readonly service: + ProjectsService = projectsService, + ) {} - create = async (req: Request, res: Response, next: NextFunction): Promise => { + create = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { - const usuarioId = req.auth?.id ?? null; - const result = await this.service.create(req.body, usuarioId); + const usuarioId = + req.auth?.id ?? null; + + const result = + await this.service.create( + req.body, + usuarioId, + ); + res.status(201).json(result); } catch (error) { next(error); } }; - list = async (req: Request, res: Response, next: NextFunction): Promise => { + list = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { - const result = await this.service.list(req.query); + const result = + await this.service.list( + req.query, + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } + }; + + getById = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const id = + getParamId(req.params.id); + + const result = + await this.service.getById(id); + res.status(200).json(result); } catch (error) { next(error); } }; - getById = async (req: Request, res: Response, next: NextFunction): Promise => { + backlogTree = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { - const id = getParamId(req.params.id); - const result = await this.service.getById(id); + const id = + getParamId(req.params.id); + + const result = + await this.service + .getBacklogTree(id); + res.status(200).json(result); } catch (error) { next(error); } }; - update = async (req: Request, res: Response, next: NextFunction): Promise => { + update = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { - const id = getParamId(req.params.id); - const usuarioId = req.auth?.id ?? null; - const result = await this.service.update(id, req.body, usuarioId); + const id = + getParamId(req.params.id); + + const usuarioId = + req.auth?.id ?? null; + + const result = + await this.service.update( + id, + req.body, + usuarioId, + ); + res.status(200).json(result); } catch (error) { next(error); } }; - archive = async (req: Request, res: Response, next: NextFunction): Promise => { + archive = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { - const id = getParamId(req.params.id); - const usuarioId = req.auth?.id ?? null; - const parsed = confirmation.safeParse(req.body); - if (!parsed.success) throw new ValidationError("Consulte a prévia e confirme explicitamente o arquivamento."); - const result = await this.service.archive(id, usuarioId, parsed.data.justificativa, parsed.data.impacto); + const id = + getParamId(req.params.id); + + const usuarioId = + req.auth?.id ?? null; + + const parsed = + confirmation.safeParse( + req.body, + ); + + if (!parsed.success) { + throw new ValidationError( + "Consulte a prévia e confirme explicitamente o arquivamento.", + ); + } + + const result = + await this.service.archive( + id, + usuarioId, + parsed.data.justificativa, + parsed.data.impacto, + ); + res.status(200).json(result); } catch (error) { next(error); } }; - archiveImpact = async (req: Request, res: Response, next: NextFunction): Promise => { + archiveImpact = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { - res.status(200).json(await this.service.archiveImpact(getParamId(req.params.id))); - } catch (error) { next(error); } + const id = + getParamId(req.params.id); + + const result = + await this.service + .archiveImpact(id); + + res.status(200).json(result); + } catch (error) { + next(error); + } }; } -export const projectsController = new ProjectsController(); +export const projectsController = + new ProjectsController(); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.repository.ts b/backend/src/modules/projects/projects.repository.ts index 19493e3..eb1ea88 100644 --- a/backend/src/modules/projects/projects.repository.ts +++ b/backend/src/modules/projects/projects.repository.ts @@ -1,4 +1,7 @@ -import { Pool, PoolClient } from "pg"; +import { + Pool, + PoolClient, +} from "pg"; import { pool } from "../../database/db.js"; import { CreateProjectDTO, @@ -7,9 +10,14 @@ import { Project, ProjectWithStats, PaginatedProjects, + ProjectBacklogTree, + BacklogTechnology, } from "./projects.types.js"; import { auditService } from "../audit/audit.service.js"; -import { ArchiveConflict, type ArchiveImpact } from "./archive.types.js"; +import { + ArchiveConflict, + type ArchiveImpact, +} from "./archive.types.js"; export class ProjectsRepository { private pool: Pool; @@ -18,8 +26,14 @@ export class ProjectsRepository { this.pool = customPool ?? pool; } - async findActiveByName(nome: string, excludeId?: string): Promise { - const params: unknown[] = [nome.trim()]; + async findActiveByName( + nome: string, + excludeId?: string, + ): Promise { + const params: unknown[] = [ + nome.trim(), + ]; + let query = ` SELECT * FROM projeto @@ -34,11 +48,18 @@ export class ProjectsRepository { query += ` LIMIT 1`; - const result = await this.pool.query(query, params); + const result = + await this.pool.query( + query, + params, + ); + return result.rows[0] ?? null; } - async findById(id: string): Promise { + async findById( + id: string, + ): Promise { const query = ` SELECT p.id, @@ -50,29 +71,402 @@ export class ProjectsRepository { p.created_at, p.updated_at, p.archived_at, - COALESCE((SELECT COUNT(*)::int FROM epico e WHERE e.projeto_id = p.id), 0) AS epicos_count, - COALESCE((SELECT COUNT(*)::int FROM documento d WHERE d.projeto_id = p.id), 0) AS documentos_count + COALESCE( + ( + SELECT COUNT(*)::int + FROM epico e + WHERE e.projeto_id = p.id + ), + 0 + ) AS epicos_count, + COALESCE( + ( + SELECT COUNT(*)::int + FROM documento d + WHERE d.projeto_id = p.id + ), + 0 + ) AS documentos_count FROM projeto p WHERE p.id = $1 `; - const result = await this.pool.query(query, [id]); + const result = + await this.pool + .query( + query, + [id], + ); + return result.rows[0] ?? null; } - async archiveImpact(id: string, connection: Pool | PoolClient = this.pool): Promise { - const result = await connection.query(` - SELECT CASE WHEN root.status = 'arquivado' THEN 0 ELSE 1 END AS projeto, - (SELECT COUNT(*)::int FROM epico WHERE projeto_id = $1 AND status != 'arquivado') AS epicos, - (SELECT COUNT(*)::int FROM feature f JOIN epico e ON e.id = f.epico_id WHERE e.projeto_id = $1 AND f.status != 'arquivado') AS features, - (SELECT COUNT(*)::int FROM pbi p JOIN feature f ON f.id = p.feature_id JOIN epico e ON e.id = f.epico_id WHERE e.projeto_id = $1 AND p.status != 'arquivado') AS pbis - FROM projeto root WHERE root.id = $1 - `, [id]); + async findBacklogTree( + id: string, + ): Promise { + const projectResult = + await this.pool.query< + Pick< + Project, + "id" | "nome" | "status" + > + >( + ` + SELECT + id, + nome, + status + FROM projeto + WHERE id = $1 + `, + [id], + ); + + const project = + projectResult.rows[0]; + + if (!project) { + return null; + } + + type ItemRow = { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + }; + + type FeatureRow = + ItemRow & { + epico_id: string; + }; + + type PbiRow = + ItemRow & { + feature_id: string; + codigo: string; + }; + + const technologyJoin = ( + entityAlias: string, + entityType: string, + ) => ` + LEFT JOIN entidade_tecnologia et + ON et.entidade_id = ${entityAlias}.id + AND et.entidade_tipo = '${entityType}' + LEFT JOIN tecnologia t + ON t.id = et.tecnologia_id + `; + + const technologyAggregate = ` + COALESCE( + jsonb_agg( + DISTINCT jsonb_build_object( + 'id', + t.id, + 'nome', + t.nome + ) + ) FILTER ( + WHERE t.id IS NOT NULL + ), + '[]'::jsonb + ) AS tecnologias + `; + + const [ + epicsResult, + featuresResult, + pbisResult, + ] = await Promise.all([ + this.pool.query( + ` + SELECT + e.id, + e.titulo, + e.status, + ${technologyAggregate} + FROM epico e + ${technologyJoin( + "e", + "epico", + )} + WHERE e.projeto_id = $1 + GROUP BY e.id + ORDER BY + e.created_at ASC, + e.id ASC + `, + [id], + ), + + this.pool.query( + ` + SELECT + f.id, + f.epico_id, + f.titulo, + f.status, + ${technologyAggregate} + FROM feature f + JOIN epico e + ON e.id = f.epico_id + ${technologyJoin( + "f", + "feature", + )} + WHERE e.projeto_id = $1 + GROUP BY f.id + ORDER BY + f.created_at ASC, + f.id ASC + `, + [id], + ), + + this.pool.query( + ` + SELECT + p.id, + p.feature_id, + p.codigo, + p.titulo, + p.status, + ${technologyAggregate} + FROM pbi p + JOIN feature f + ON f.id = p.feature_id + JOIN epico e + ON e.id = f.epico_id + ${technologyJoin( + "p", + "pbi", + )} + WHERE e.projeto_id = $1 + GROUP BY p.id + ORDER BY + p.created_at ASC, + p.id ASC + `, + [id], + ), + ]); + + const pbisByFeature = + new Map< + string, + PbiRow[] + >(); + + for (const pbi of pbisResult.rows) { + const current = + pbisByFeature.get( + pbi.feature_id, + ) ?? []; + + current.push(pbi); + + pbisByFeature.set( + pbi.feature_id, + current, + ); + } + + const featuresByEpic = + new Map< + string, + Array< + FeatureRow & { + pbis: PbiRow[]; + } + > + >(); + + for ( + const feature + of featuresResult.rows + ) { + const current = + featuresByEpic.get( + feature.epico_id, + ) ?? []; + + current.push({ + ...feature, + + pbis: + pbisByFeature.get( + feature.id, + ) ?? [], + }); + + featuresByEpic.set( + feature.epico_id, + current, + ); + } + + const technologies = + new Map< + string, + BacklogTechnology + >(); + + const collectTechnologies = ( + items: Array<{ + tecnologias: + BacklogTechnology[]; + }>, + ) => { + for (const item of items) { + for ( + const technology + of item.tecnologias + ) { + technologies.set( + technology.id, + technology, + ); + } + } + }; + + collectTechnologies( + epicsResult.rows, + ); + + collectTechnologies( + featuresResult.rows, + ); + + collectTechnologies( + pbisResult.rows, + ); + + return { + project, + + epics: + epicsResult.rows.map( + (epic) => ({ + id: epic.id, + titulo: epic.titulo, + status: epic.status, + + tecnologias: + epic.tecnologias, + + features: ( + featuresByEpic.get( + epic.id, + ) ?? [] + ).map( + (feature) => ({ + id: feature.id, + + titulo: + feature.titulo, + + status: + feature.status, + + tecnologias: + feature.tecnologias, + + pbis: + feature.pbis.map( + (pbi) => ({ + id: pbi.id, + + codigo: + pbi.codigo, + + titulo: + pbi.titulo, + + status: + pbi.status, + + tecnologias: + pbi.tecnologias, + }), + ), + }), + ), + }), + ), + + technologies: [ + ...technologies.values(), + ].sort( + (a, b) => + a.nome.localeCompare( + b.nome, + "pt-BR", + ), + ), + }; + } + + async archiveImpact( + id: string, + connection: + | Pool + | PoolClient = this.pool, + ): Promise { + const result = + await connection + .query( + ` + SELECT + CASE + WHEN root.status = 'arquivado' + THEN 0 + ELSE 1 + END AS projeto, + + ( + SELECT COUNT(*)::int + FROM epico + WHERE projeto_id = $1 + AND status != 'arquivado' + ) AS epicos, + + ( + SELECT COUNT(*)::int + FROM feature f + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + AND f.status != 'arquivado' + ) AS features, + + ( + SELECT COUNT(*)::int + FROM pbi p + JOIN feature f + ON f.id = p.feature_id + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + AND p.status != 'arquivado' + ) AS pbis + + FROM projeto root + WHERE root.id = $1 + `, + [id], + ); + return result.rows[0] ?? null; } - async create(data: CreateProjectDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + async create( + data: CreateProjectDTO, + usuarioId?: string | null, + ): Promise { + const client: PoolClient = + await this.pool.connect(); try { await client.query("BEGIN"); @@ -84,90 +478,186 @@ export class ProjectsRepository { descricao, status, data_inicio - ) VALUES ($1, $2, $3, $4, COALESCE($5, CURRENT_TIMESTAMP)) + ) + VALUES ( + $1, + $2, + $3, + $4, + COALESCE( + $5, + CURRENT_TIMESTAMP + ) + ) RETURNING * `; const projectValues = [ data.nome.trim(), data.cliente.trim(), - data.descricao?.trim() ?? null, + data.descricao?.trim() + ?? null, data.status ?? "ativo", data.data_inicio ?? null, ]; - const projectResult = await client.query(insertProjectQuery, projectValues); - const createdProject = projectResult.rows[0]; + const projectResult = + await client.query( + insertProjectQuery, + projectValues, + ); + + const createdProject = + projectResult.rows[0]; - // Registrar auditoria transacional await auditService.record( { - usuario_id: usuarioId ?? null, - entidade_tipo: "projeto", - entidade_id: createdProject.id, - acao: "CRIAR_PROJETO", + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + + entidade_id: + createdProject.id, + + acao: + "CRIAR_PROJETO", + dados_json: { - nome: createdProject.nome, - cliente: createdProject.cliente, - descricao: createdProject.descricao, - status: createdProject.status, - data_inicio: createdProject.data_inicio, + nome: + createdProject.nome, + + cliente: + createdProject.cliente, + + descricao: + createdProject.descricao, + + status: + createdProject.status, + + data_inicio: + createdProject.data_inicio, }, }, client, ); await client.query("COMMIT"); + return createdProject; } catch (error) { - await client.query("ROLLBACK"); + await client.query( + "ROLLBACK", + ); + throw error; } finally { client.release(); } } - async findAll(query: ProjectQueryDTO): Promise { - const whereConditions: string[] = []; + async findAll( + query: ProjectQueryDTO, + ): Promise { + const whereConditions: + string[] = []; + const params: unknown[] = []; + let paramIndex = 1; if (!query.status) { - whereConditions.push("p.status != 'arquivado'"); + whereConditions.push( + "p.status != 'arquivado'", + ); } - if (query.status && query.status !== "todos") { - whereConditions.push(`p.status = $${paramIndex}`); + if ( + query.status && + query.status !== "todos" + ) { + whereConditions.push( + `p.status = $${paramIndex}`, + ); + params.push(query.status); paramIndex++; } - if (query.busca && query.busca.trim().length > 0) { + if ( + query.busca && + query.busca.trim().length > 0 + ) { whereConditions.push( - `(p.nome ILIKE $${paramIndex} OR p.cliente ILIKE $${paramIndex} OR p.descricao ILIKE $${paramIndex})`, + `( + p.nome ILIKE $${paramIndex} + OR p.cliente ILIKE $${paramIndex} + OR p.descricao ILIKE $${paramIndex} + )`, ); - params.push(`%${query.busca.trim()}%`); + + params.push( + `%${query.busca.trim()}%`, + ); + paramIndex++; } - const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; - - // Total count - const countQuery = `SELECT COUNT(*)::int AS total FROM projeto p ${whereClause}`; - const countResult = await this.pool.query<{ total: number }>(countQuery, params); - const total = countResult.rows[0]?.total ?? 0; - - // Sorting - let orderByClause = "ORDER BY p.created_at DESC"; - if (query.order === "created_at_asc") { - orderByClause = "ORDER BY p.created_at ASC"; - } else if (query.order === "nome_asc") { - orderByClause = "ORDER BY p.nome ASC"; - } else if (query.order === "nome_desc") { - orderByClause = "ORDER BY p.nome DESC"; + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join( + " AND ", + )}` + : ""; + + const countQuery = ` + SELECT + COUNT(*)::int AS total + FROM projeto p + ${whereClause} + `; + + const countResult = + await this.pool.query<{ + total: number; + }>( + countQuery, + params, + ); + + const total = + countResult.rows[0]?.total + ?? 0; + + let orderByClause = + "ORDER BY p.created_at DESC"; + + if ( + query.order + === "created_at_asc" + ) { + orderByClause = + "ORDER BY p.created_at ASC"; + } else if ( + query.order === "nome_asc" + ) { + orderByClause = + "ORDER BY p.nome ASC"; + } else if ( + query.order === "nome_desc" + ) { + orderByClause = + "ORDER BY p.nome DESC"; } - const dataParams = [...params, query.limit, query.offset]; + const dataParams = [ + ...params, + query.limit, + query.offset, + ]; + const dataQuery = ` SELECT p.id, @@ -179,15 +669,38 @@ export class ProjectsRepository { p.created_at, p.updated_at, p.archived_at, - COALESCE((SELECT COUNT(*)::int FROM epico e WHERE e.projeto_id = p.id), 0) AS epicos_count, - COALESCE((SELECT COUNT(*)::int FROM documento d WHERE d.projeto_id = p.id), 0) AS documentos_count + + COALESCE( + ( + SELECT COUNT(*)::int + FROM epico e + WHERE e.projeto_id = p.id + ), + 0 + ) AS epicos_count, + + COALESCE( + ( + SELECT COUNT(*)::int + FROM documento d + WHERE d.projeto_id = p.id + ), + 0 + ) AS documentos_count + FROM projeto p ${whereClause} ${orderByClause} - LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + LIMIT $${paramIndex} + OFFSET $${paramIndex + 1} `; - const dataResult = await this.pool.query(dataQuery, dataParams); + const dataResult = + await this.pool + .query( + dataQuery, + dataParams, + ); return { items: dataResult.rows, @@ -197,51 +710,136 @@ export class ProjectsRepository { }; } - async update(id: string, data: UpdateProjectDTO, usuarioId?: string | null): Promise { - const client: PoolClient = await this.pool.connect(); + async update( + id: string, + data: UpdateProjectDTO, + usuarioId?: string | null, + ): Promise { + const client: PoolClient = + await this.pool.connect(); try { await client.query("BEGIN"); - const existingProject = (await client.query("SELECT * FROM projeto WHERE id = $1 FOR UPDATE", [id])).rows[0]; + const existingResult = + await client.query( + ` + SELECT * + FROM projeto + WHERE id = $1 + FOR UPDATE + `, + [id], + ); + + const existingProject = + existingResult.rows[0]; + if (!existingProject) { - await client.query("ROLLBACK"); + await client.query( + "ROLLBACK", + ); + return null; } + if ( + existingProject.status + === "arquivado" + ) { + throw new ArchiveConflict( + "Projeto arquivado está disponível apenas para leitura.", + ); + } + + if ( + String(data.status) + === "arquivado" + ) { + throw new ArchiveConflict( + "Use a ação de arquivamento com prévia e confirmação.", + ); + } + const updates: string[] = []; - if (existingProject.status === "arquivado") throw new ArchiveConflict("Projeto arquivado está disponível apenas para leitura."); - if (String(data.status) === "arquivado") throw new ArchiveConflict("Use a ação de arquivamento com prévia e confirmação."); const values: unknown[] = []; + let valIndex = 1; - if (data.nome !== undefined) { - updates.push(`nome = $${valIndex}`); - values.push(data.nome.trim()); + if ( + data.nome !== undefined + ) { + updates.push( + `nome = $${valIndex}`, + ); + + values.push( + data.nome.trim(), + ); + valIndex++; } - if (data.cliente !== undefined) { - updates.push(`cliente = $${valIndex}`); - values.push(data.cliente.trim()); + + if ( + data.cliente !== undefined + ) { + updates.push( + `cliente = $${valIndex}`, + ); + + values.push( + data.cliente.trim(), + ); + valIndex++; } - if (data.descricao !== undefined) { - updates.push(`descricao = $${valIndex}`); - values.push(data.descricao?.trim() ?? null); + + if ( + data.descricao + !== undefined + ) { + updates.push( + `descricao = $${valIndex}`, + ); + + values.push( + data.descricao?.trim() + ?? null, + ); + valIndex++; } - if (data.status !== undefined) { - updates.push(`status = $${valIndex}`); + + if ( + data.status !== undefined + ) { + updates.push( + `status = $${valIndex}`, + ); + values.push(data.status); valIndex++; } - if (data.data_inicio !== undefined) { - updates.push(`data_inicio = $${valIndex}`); - values.push(data.data_inicio ?? null); + + if ( + data.data_inicio + !== undefined + ) { + updates.push( + `data_inicio = $${valIndex}`, + ); + + values.push( + data.data_inicio ?? null, + ); + valIndex++; } - updates.push(`updated_at = CURRENT_TIMESTAMP`); + updates.push( + "updated_at = CURRENT_TIMESTAMP", + ); + values.push(id); const updateQuery = ` @@ -251,30 +849,61 @@ export class ProjectsRepository { RETURNING * `; - const result = await client.query(updateQuery, values); - const updated = result.rows[0]; + const result = + await client.query( + updateQuery, + values, + ); + + const updated = + result.rows[0]; - // Registrar auditoria await auditService.record( { - usuario_id: usuarioId ?? null, - entidade_tipo: "projeto", + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + entidade_id: id, - acao: "ATUALIZAR_PROJETO", - justificativa: data.justificativa ?? null, + + acao: + "ATUALIZAR_PROJETO", + + justificativa: + data.justificativa + ?? null, + dados_json: { alteracoes: data, + anterior: { - nome: existingProject.nome, - cliente: existingProject.cliente, - descricao: existingProject.descricao, - status: existingProject.status, + nome: + existingProject.nome, + + cliente: + existingProject.cliente, + + descricao: + existingProject.descricao, + + status: + existingProject.status, }, + novo: { - nome: updated.nome, - cliente: updated.cliente, - descricao: updated.descricao, - status: updated.status, + nome: + updated.nome, + + cliente: + updated.cliente, + + descricao: + updated.descricao, + + status: + updated.status, }, }, }, @@ -282,61 +911,188 @@ export class ProjectsRepository { ); await client.query("COMMIT"); + return await this.findById(id); } catch (error) { - await client.query("ROLLBACK"); + await client.query( + "ROLLBACK", + ); + throw error; } finally { client.release(); } } - async archive(id: string, usuarioId?: string | null, justificativa?: string, expected?: ArchiveImpact): Promise { - const client: PoolClient = await this.pool.connect(); + async archive( + id: string, + usuarioId?: string | null, + justificativa?: string, + expected?: ArchiveImpact, + ): Promise { + const client: PoolClient = + await this.pool.connect(); try { await client.query("BEGIN"); - // Keep preview validation, cascade and audit atomic, including concurrent - // child inserts. All hierarchy writers wait until this transaction ends. - await client.query("LOCK TABLE projeto, epico, feature, pbi IN SHARE ROW EXCLUSIVE MODE"); - const existingProject = (await client.query("SELECT * FROM projeto WHERE id = $1 FOR UPDATE", [id])).rows[0]; + await client.query( + ` + LOCK TABLE + projeto, + epico, + feature, + pbi + IN SHARE ROW EXCLUSIVE MODE + `, + ); + + const existingResult = + await client.query( + ` + SELECT * + FROM projeto + WHERE id = $1 + FOR UPDATE + `, + [id], + ); + + const existingProject = + existingResult.rows[0]; + if (!existingProject) { - await client.query("ROLLBACK"); + await client.query( + "ROLLBACK", + ); + return null; } - if (existingProject.status === "arquivado") { - await client.query("COMMIT"); + if ( + existingProject.status + === "arquivado" + ) { + await client.query( + "COMMIT", + ); + return existingProject; } - const impact = (await this.archiveImpact(id, client))!; - if (expected && (Object.keys(impact) as (keyof ArchiveImpact)[]).some(key => impact[key] !== expected[key])) { - throw new ArchiveConflict("A quantidade de itens mudou. Consulte a prévia e confirme novamente."); + + const impact = ( + await this.archiveImpact( + id, + client, + ) + )!; + + if ( + expected && + ( + Object.keys( + impact, + ) as ( + keyof ArchiveImpact + )[] + ).some( + (key) => + impact[key] + !== expected[key], + ) + ) { + throw new ArchiveConflict( + "A quantidade de itens mudou. Consulte a prévia e confirme novamente.", + ); } const updateQuery = ` UPDATE projeto - SET status = 'arquivado', archived_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING * `; - await client.query(updateQuery, [id]); - await client.query(`UPDATE epico SET status = 'arquivado', archived_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE projeto_id = $1 AND status != 'arquivado'`, [id]); - await client.query(`UPDATE feature SET status = 'arquivado', archived_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE epico_id IN (SELECT id FROM epico WHERE projeto_id = $1) AND status != 'arquivado'`, [id]); - await client.query(`UPDATE pbi SET status = 'arquivado', archived_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE feature_id IN (SELECT f.id FROM feature f JOIN epico e ON e.id = f.epico_id WHERE e.projeto_id = $1) AND status != 'arquivado'`, [id]); + await client.query( + updateQuery, + [id], + ); + + await client.query( + ` + UPDATE epico + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE projeto_id = $1 + AND status != 'arquivado' + `, + [id], + ); + + await client.query( + ` + UPDATE feature + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE epico_id IN ( + SELECT id + FROM epico + WHERE projeto_id = $1 + ) + AND status != 'arquivado' + `, + [id], + ); + + await client.query( + ` + UPDATE pbi + SET + status = 'arquivado', + archived_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE feature_id IN ( + SELECT f.id + FROM feature f + JOIN epico e + ON e.id = f.epico_id + WHERE e.projeto_id = $1 + ) + AND status != 'arquivado' + `, + [id], + ); await auditService.record( { - usuario_id: usuarioId ?? null, - entidade_tipo: "projeto", + usuario_id: + usuarioId ?? null, + + entidade_tipo: + "projeto", + entidade_id: id, - acao: "ARQUIVAR_PROJETO", - justificativa: justificativa ?? null, + + acao: + "ARQUIVAR_PROJETO", + + justificativa: + justificativa ?? null, + dados_json: { - status_anterior: existingProject.status, - status_novo: "arquivado", + status_anterior: + existingProject.status, + + status_novo: + "arquivado", + impacto: impact, }, }, @@ -344,9 +1100,13 @@ export class ProjectsRepository { ); await client.query("COMMIT"); + return await this.findById(id); } catch (error) { - await client.query("ROLLBACK"); + await client.query( + "ROLLBACK", + ); + throw error; } finally { client.release(); @@ -354,4 +1114,5 @@ export class ProjectsRepository { } } -export const projectsRepository = new ProjectsRepository(); +export const projectsRepository = + new ProjectsRepository(); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.routes.test.ts b/backend/src/modules/projects/projects.routes.test.ts index ed6c3bf..1e12dec 100644 --- a/backend/src/modules/projects/projects.routes.test.ts +++ b/backend/src/modules/projects/projects.routes.test.ts @@ -14,72 +14,180 @@ import { ProjectWithStats, PaginatedProjects, ProjectQueryDTO, + ProjectBacklogTree, } from "./projects.types.js"; class MockRepo extends ProjectsRepository { public projects: ProjectWithStats[] = []; async archiveImpact(id: string) { - return this.projects.some(project => project.id === id) - ? { projeto: 1, epicos: 2, features: 3, pbis: 4 } : null; + return this.projects.some((project) => project.id === id) + ? { + projeto: 1, + epicos: 2, + features: 3, + pbis: 4, + } + : null; } constructor() { super(); } - async findActiveByName(nome: string, excludeId?: string): Promise { + async findActiveByName( + nome: string, + excludeId?: string, + ): Promise { const trimmed = nome.trim().toLowerCase(); + const found = this.projects.find( - (p) => - p.nome.trim().toLowerCase() === trimmed && - p.status !== "arquivado" && - (!excludeId || p.id !== excludeId), + (project) => + project.nome.trim().toLowerCase() === trimmed && + project.status !== "arquivado" && + (!excludeId || project.id !== excludeId), ); + return found ? { ...found } : null; } - async findById(id: string): Promise { - const found = this.projects.find((p) => p.id === id); + async findById( + id: string, + ): Promise { + const found = this.projects.find( + (project) => project.id === id, + ); + return found ? { ...found } : null; } - async create(data: CreateProjectDTO, _usuarioId?: string | null): Promise { + async findBacklogTree( + id: string, + ): Promise { + const project = this.projects.find( + (item) => item.id === id, + ); + + if (!project) { + return null; + } + + return { + project: { + id: project.id, + nome: project.nome, + status: project.status, + }, + technologies: [ + { + id: "tech-react", + nome: "React", + }, + ], + epics: [ + { + id: "epic-1", + titulo: "Organizar backlog", + status: "rascunho", + tecnologias: [], + features: [ + { + id: "feature-1", + titulo: "Navegar hierarquia", + status: "concluido", + tecnologias: [ + { + id: "tech-react", + nome: "React", + }, + ], + pbis: [ + { + id: "pbi-1", + codigo: "PBI-01.4.1", + titulo: "Expandir árvore", + status: "rascunho", + tecnologias: [ + { + id: "tech-react", + nome: "React", + }, + ], + }, + ], + }, + ], + }, + ], + }; + } + + async create( + data: CreateProjectDTO, + _usuarioId?: string | null, + ): Promise { const newProject: ProjectWithStats = { id: "a0000000-0000-4000-8000-000000000001", nome: data.nome.trim(), cliente: data.cliente.trim(), descricao: data.descricao?.trim() ?? null, status: data.status ?? "ativo", - data_inicio: data.data_inicio ?? new Date().toISOString(), + data_inicio: + data.data_inicio ?? + new Date().toISOString(), created_at: new Date().toISOString(), updated_at: new Date().toISOString(), epicos_count: 0, documentos_count: 0, }; + this.projects.push(newProject); + return { ...newProject }; } - async findAll(query: ProjectQueryDTO): Promise { + async findAll( + query: ProjectQueryDTO, + ): Promise { let filtered = [...this.projects]; - if (query.status && query.status !== "todos") { - filtered = filtered.filter((p) => p.status === query.status); + if ( + query.status && + query.status !== "todos" + ) { + filtered = filtered.filter( + (project) => + project.status === query.status, + ); } - if (query.busca && query.busca.trim().length > 0) { - const q = query.busca.toLowerCase(); + if ( + query.busca && + query.busca.trim().length > 0 + ) { + const search = query.busca.toLowerCase(); + filtered = filtered.filter( - (p) => - p.nome.toLowerCase().includes(q) || - p.cliente.toLowerCase().includes(q) || - (p.descricao && p.descricao.toLowerCase().includes(q)), + (project) => + project.nome + .toLowerCase() + .includes(search) || + project.cliente + .toLowerCase() + .includes(search) || + (project.descricao && + project.descricao + .toLowerCase() + .includes(search)), ); } const total = filtered.length; - const paginated = filtered.slice(query.offset, query.offset + query.limit); + + const paginated = filtered.slice( + query.offset, + query.offset + query.limit, + ); return { items: paginated, @@ -89,28 +197,64 @@ class MockRepo extends ProjectsRepository { }; } - async update(id: string, data: UpdateProjectDTO, _usuarioId?: string | null): Promise { - const index = this.projects.findIndex((p) => p.id === id); - if (index === -1) return null; + async update( + id: string, + data: UpdateProjectDTO, + _usuarioId?: string | null, + ): Promise { + const index = this.projects.findIndex( + (project) => project.id === id, + ); + + if (index === -1) { + return null; + } const current = this.projects[index]; + const updated: ProjectWithStats = { ...current, - ...(data.nome !== undefined ? { nome: data.nome.trim() } : {}), - ...(data.cliente !== undefined ? { cliente: data.cliente.trim() } : {}), - ...(data.descricao !== undefined ? { descricao: data.descricao?.trim() ?? null } : {}), - ...(data.status !== undefined ? { status: data.status } : {}), - ...(data.data_inicio !== undefined ? { data_inicio: data.data_inicio ?? null } : {}), + ...(data.nome !== undefined + ? { nome: data.nome.trim() } + : {}), + ...(data.cliente !== undefined + ? { cliente: data.cliente.trim() } + : {}), + ...(data.descricao !== undefined + ? { + descricao: + data.descricao?.trim() ?? null, + } + : {}), + ...(data.status !== undefined + ? { status: data.status } + : {}), + ...(data.data_inicio !== undefined + ? { + data_inicio: + data.data_inicio ?? null, + } + : {}), updated_at: new Date().toISOString(), }; this.projects[index] = updated; + return { ...updated }; } - async archive(id: string, _usuarioId?: string | null, _justificativa?: string): Promise { - const index = this.projects.findIndex((p) => p.id === id); - if (index === -1) return null; + async archive( + id: string, + _usuarioId?: string | null, + _justificativa?: string, + ): Promise { + const index = this.projects.findIndex( + (project) => project.id === id, + ); + + if (index === -1) { + return null; + } const updated: ProjectWithStats = { ...this.projects[index], @@ -119,156 +263,460 @@ class MockRepo extends ProjectsRepository { }; this.projects[index] = updated; + return { ...updated }; } } -test("Testes de integração HTTP - Rotas de Projetos", async (t) => { - const mockRepo = new MockRepo(); - const service = new ProjectsService(mockRepo); - const controller = new ProjectsController(service); - - const testApp = express(); - testApp.use(express.json()); - - const router = express.Router(); - router.post("/", controller.create); - router.get("/", controller.list); - router.get("/:id/archive-impact", controller.archiveImpact); - router.get("/:id", controller.getById); - router.put("/:id", controller.update); - router.patch("/:id", controller.update); - router.patch("/:id/archive", controller.archive); - - testApp.use("/api/v1/projects", router); - testApp.use(errorHandler); - - let server: Server; - let baseUrl: string; - - before(async () => { - await new Promise((resolve) => { - server = testApp.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = `http://127.0.0.1:${addr.port}/api/v1/projects`; - resolve(); +test( + "Testes de integração HTTP - Rotas de Projetos", + async (context) => { + const mockRepo = new MockRepo(); + const service = new ProjectsService(mockRepo); + const controller = + new ProjectsController(service); + + const testApp = express(); + + testApp.use(express.json()); + + const router = express.Router(); + + router.post("/", controller.create); + router.get("/", controller.list); + + router.get( + "/:id/archive-impact", + controller.archiveImpact, + ); + + router.get( + "/:id/backlog-tree", + controller.backlogTree, + ); + + router.get("/:id", controller.getById); + router.put("/:id", controller.update); + router.patch("/:id", controller.update); + + router.patch( + "/:id/archive", + controller.archive, + ); + + testApp.use( + "/api/v1/projects", + router, + ); + + testApp.use(errorHandler); + + let server: Server; + let baseUrl: string; + + before(async () => { + await new Promise((resolve) => { + server = testApp.listen( + 0, + "127.0.0.1", + () => { + const address = + server.address() as AddressInfo; + + baseUrl = + `http://127.0.0.1:${address.port}` + + "/api/v1/projects"; + + resolve(); + }, + ); }); }); - }); - after(async () => { - await new Promise((resolve) => { - server.close(() => resolve()); - }); - }); - - await t.test("POST /api/v1/projects - cria projeto com status 201", async () => { - const res = await fetch(baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nome: "Novo Projeto HTTP", - cliente: "PRO4TECH", - descricao: "Descrição do projeto", - }), + after(async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); }); - assert.equal(res.status, 201); - const body = (await res.json()) as Project; - assert.equal(body.nome, "Novo Projeto HTTP"); - assert.equal(body.status, "ativo"); - }); - - await t.test("POST /api/v1/projects - retorna 400 se nome estiver vazio", async () => { - const res = await fetch(baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nome: "", - cliente: "PRO4TECH", - }), - }); + await context.test( + "POST /api/v1/projects - cria projeto com status 201", + async () => { + const response = await fetch( + baseUrl, + { + method: "POST", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + nome: "Novo Projeto HTTP", + cliente: "PRO4TECH", + descricao: + "Descrição do projeto", + }), + }, + ); + + assert.equal(response.status, 201); + + const body = + (await response.json()) as Project; + + assert.equal( + body.nome, + "Novo Projeto HTTP", + ); + + assert.equal(body.status, "ativo"); + }, + ); - assert.equal(res.status, 400); - const body = (await res.json()) as { error: string }; - assert.match(body.error, /nome.*obrigat[óo]rio/i); - }); - - await t.test("POST /api/v1/projects - retorna 409 se nome já estiver em uso por projeto ativo", async () => { - const res = await fetch(baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nome: "Novo Projeto HTTP", - cliente: "Outro Cliente", - }), - }); + await context.test( + "POST /api/v1/projects - retorna 400 se nome estiver vazio", + async () => { + const response = await fetch( + baseUrl, + { + method: "POST", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + nome: "", + cliente: "PRO4TECH", + }), + }, + ); + + assert.equal(response.status, 400); + + const body = + (await response.json()) as { + error: string; + }; + + assert.match( + body.error, + /nome.*obrigat[óo]rio/i, + ); + }, + ); - assert.equal(res.status, 409); - const body = (await res.json()) as { error: string }; - assert.equal(body.error, "Já existe um projeto ativo com este nome."); - }); - - await t.test("GET /api/v1/projects - lista projetos com status 200", async () => { - const res = await fetch(baseUrl); - assert.equal(res.status, 200); - const body = (await res.json()) as PaginatedProjects; - assert.ok(Array.isArray(body.items)); - assert.equal(body.total, 1); - }); - - await t.test("GET /api/v1/projects/:id - retorna 200 para projeto existente", async () => { - const res = await fetch(`${baseUrl}/a0000000-0000-4000-8000-000000000001`); - assert.equal(res.status, 200); - const body = (await res.json()) as ProjectWithStats; - assert.equal(body.nome, "Novo Projeto HTTP"); - }); - - await t.test("GET /api/v1/projects/:id - retorna 400 para UUID inválido", async () => { - const res = await fetch(`${baseUrl}/uuid-invalido`); - assert.equal(res.status, 400); - }); - - await t.test("GET /api/v1/projects/:id - retorna 404 para ID inexistente", async () => { - const res = await fetch(`${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff`); - assert.equal(res.status, 404); - }); - - await t.test("prévia informa contagens e retorna 404 para projeto ausente", async () => { - const response = await fetch(`${baseUrl}/a0000000-0000-4000-8000-000000000001/archive-impact`); - assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { projeto: 1, epicos: 2, features: 3, pbis: 4 }); - assert.equal((await fetch(`${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff/archive-impact`)).status, 404); - assert.equal((await fetch(`${baseUrl}/invalid/archive-impact`)).status, 400); - }); - - await t.test("arquivamento exige confirmação e contagens válidas", async () => { - for (const body of [{}, { confirmado: false }, { confirmado: true }, { confirmado: true, impacto: { projeto: 1, epicos: -1, features: 0, pbis: 0 } }]) { - const response = await fetch(`${baseUrl}/a0000000-0000-4000-8000-000000000001/archive`, { - method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), - }); - assert.equal(response.status, 400); - assert.equal(mockRepo.projects[0].status, "ativo"); - } - }); + await context.test( + "POST /api/v1/projects - retorna 409 se nome já estiver em uso por projeto ativo", + async () => { + const response = await fetch( + baseUrl, + { + method: "POST", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + nome: "Novo Projeto HTTP", + cliente: "Outro Cliente", + }), + }, + ); + + assert.equal(response.status, 409); + + const body = + (await response.json()) as { + error: string; + }; + + assert.equal( + body.error, + "Já existe um projeto ativo com este nome.", + ); + }, + ); - await t.test("não permite contornar a confirmação via cadastro ou edição", async () => { - for (const [path, method] of [["", "POST"], ["/a0000000-0000-4000-8000-000000000001", "PATCH"]]) { - const response = await fetch(`${baseUrl}${path}`, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nome: "Outro", cliente: "Teste", status: "arquivado" }) }); - assert.equal(response.status, 400); - } - assert.equal((await fetch(`${baseUrl}?status=inexistente`)).status, 400); - }); - - await t.test("PATCH /api/v1/projects/:id/archive - arquiva projeto", async () => { - const res = await fetch(`${baseUrl}/a0000000-0000-4000-8000-000000000001/archive`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ confirmado: true, impacto: { projeto: 1, epicos: 0, features: 0, pbis: 0 }, justificativa: "Projeto concluído" }), - }); + await context.test( + "GET /api/v1/projects - lista projetos com status 200", + async () => { + const response = await fetch(baseUrl); + + assert.equal(response.status, 200); + + const body = + (await response.json()) as PaginatedProjects; + + assert.ok( + Array.isArray(body.items), + ); + + assert.equal(body.total, 1); + }, + ); - assert.equal(res.status, 200); - const body = (await res.json()) as ProjectWithStats; - assert.equal(body.status, "arquivado"); - }); -}); + await context.test( + "GET /api/v1/projects/:id - retorna 200 para projeto existente", + async () => { + const response = await fetch( + `${baseUrl}/a0000000-0000-4000-8000-000000000001`, + ); + + assert.equal(response.status, 200); + + const body = + (await response.json()) as ProjectWithStats; + + assert.equal( + body.nome, + "Novo Projeto HTTP", + ); + }, + ); + + await context.test( + "GET /api/v1/projects/:id - retorna 400 para UUID inválido", + async () => { + const response = await fetch( + `${baseUrl}/uuid-invalido`, + ); + + assert.equal(response.status, 400); + }, + ); + + await context.test( + "GET /api/v1/projects/:id - retorna 404 para ID inexistente", + async () => { + const response = await fetch( + `${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff`, + ); + + assert.equal(response.status, 404); + }, + ); + + await context.test( + "GET /api/v1/projects/:id/backlog-tree - retorna a hierarquia isolada do projeto", + async () => { + const id = + "a0000000-0000-4000-8000-000000000001"; + + const response = await fetch( + `${baseUrl}/${id}/backlog-tree`, + ); + + assert.equal(response.status, 200); + + const body = + (await response.json()) as ProjectBacklogTree; + + assert.equal( + body.project.id, + id, + ); + + assert.equal( + body.epics[0].features[0].pbis[0] + .codigo, + "PBI-01.4.1", + ); + + assert.deepEqual( + body.technologies, + [ + { + id: "tech-react", + nome: "React", + }, + ], + ); + + assert.equal( + ( + await fetch( + `${baseUrl}/invalid/backlog-tree`, + ) + ).status, + 400, + ); + + assert.equal( + ( + await fetch( + `${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff/backlog-tree`, + ) + ).status, + 404, + ); + }, + ); + + await context.test( + "prévia informa contagens e retorna 404 para projeto ausente", + async () => { + const response = await fetch( + `${baseUrl}/a0000000-0000-4000-8000-000000000001/archive-impact`, + ); + + assert.equal(response.status, 200); + + assert.deepEqual( + await response.json(), + { + projeto: 1, + epicos: 2, + features: 3, + pbis: 4, + }, + ); + + assert.equal( + ( + await fetch( + `${baseUrl}/ffffffff-ffff-4fff-8fff-ffffffffffff/archive-impact`, + ) + ).status, + 404, + ); + + assert.equal( + ( + await fetch( + `${baseUrl}/invalid/archive-impact`, + ) + ).status, + 400, + ); + }, + ); + + await context.test( + "arquivamento exige confirmação e contagens válidas", + async () => { + const requests = [ + {}, + { confirmado: false }, + { confirmado: true }, + { + confirmado: true, + impacto: { + projeto: 1, + epicos: -1, + features: 0, + pbis: 0, + }, + }, + ]; + + for (const body of requests) { + const response = await fetch( + `${baseUrl}/a0000000-0000-4000-8000-000000000001/archive`, + { + method: "PATCH", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify(body), + }, + ); + + assert.equal( + response.status, + 400, + ); + + assert.equal( + mockRepo.projects[0].status, + "ativo", + ); + } + }, + ); + + await context.test( + "não permite contornar a confirmação via cadastro ou edição", + async () => { + const requests: Array< + [string, string] + > = [ + ["", "POST"], + [ + "/a0000000-0000-4000-8000-000000000001", + "PATCH", + ], + ]; + + for (const [path, method] of requests) { + const response = await fetch( + `${baseUrl}${path}`, + { + method, + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + nome: "Outro", + cliente: "Teste", + status: "arquivado", + }), + }, + ); + + assert.equal( + response.status, + 400, + ); + } + + assert.equal( + ( + await fetch( + `${baseUrl}?status=inexistente`, + ) + ).status, + 400, + ); + }, + ); + + await context.test( + "PATCH /api/v1/projects/:id/archive - arquiva projeto", + async () => { + const response = await fetch( + `${baseUrl}/a0000000-0000-4000-8000-000000000001/archive`, + { + method: "PATCH", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + confirmado: true, + impacto: { + projeto: 1, + epicos: 0, + features: 0, + pbis: 0, + }, + justificativa: + "Projeto concluído", + }), + }, + ); + + assert.equal(response.status, 200); + + const body = + (await response.json()) as ProjectWithStats; + + assert.equal( + body.status, + "arquivado", + ); + }, + ); + }, +); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.routes.ts b/backend/src/modules/projects/projects.routes.ts index 5edc2f8..3657b5f 100644 --- a/backend/src/modules/projects/projects.routes.ts +++ b/backend/src/modules/projects/projects.routes.ts @@ -4,7 +4,10 @@ import { requireRole } from "../../middleware/requireRole.js"; export const projectsRouter = Router(); -const canWrite = requireRole("admin", "po"); +const canWrite = requireRole( + "admin", + "po", +); /** * @swagger @@ -60,7 +63,11 @@ const canWrite = requireRole("admin", "po"); * 403: * description: Sem permissão */ -projectsRouter.post("/", canWrite, projectsController.create); +projectsRouter.post( + "/", + canWrite, + projectsController.create, +); /** * @swagger @@ -125,7 +132,38 @@ projectsRouter.post("/", canWrite, projectsController.create); * 401: * description: Não autenticado */ -projectsRouter.get("/", projectsController.list); +projectsRouter.get( + "/", + projectsController.list, +); + +/** + * @swagger + * /api/v1/projects/{id}/backlog-tree: + * get: + * summary: Obter a árvore completa do backlog de um projeto + * tags: [Projects] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Projeto com épicos, features, PBIs e tecnologias associadas + * 400: + * description: ID inválido + * 404: + * description: Projeto não encontrado + */ +projectsRouter.get( + "/:id/backlog-tree", + projectsController.backlogTree, +); /** * @swagger @@ -155,7 +193,10 @@ projectsRouter.get("/", projectsController.list); * 404: * description: Projeto não encontrado */ -projectsRouter.get("/:id", projectsController.getById); +projectsRouter.get( + "/:id", + projectsController.getById, +); /** * @swagger @@ -216,7 +257,11 @@ projectsRouter.get("/:id", projectsController.getById); * 404: * description: Projeto não encontrado */ -projectsRouter.put("/:id", canWrite, projectsController.update); +projectsRouter.put( + "/:id", + canWrite, + projectsController.update, +); /** * @swagger @@ -277,7 +322,11 @@ projectsRouter.put("/:id", canWrite, projectsController.update); * 404: * description: Projeto não encontrado */ -projectsRouter.patch("/:id", canWrite, projectsController.update); +projectsRouter.patch( + "/:id", + canWrite, + projectsController.update, +); /** * @swagger @@ -319,5 +368,14 @@ projectsRouter.patch("/:id", canWrite, projectsController.update); * 404: * description: Projeto não encontrado */ -projectsRouter.patch("/:id/archive", canWrite, projectsController.archive); -projectsRouter.get("/:id/archive-impact", canWrite, projectsController.archiveImpact); +projectsRouter.patch( + "/:id/archive", + canWrite, + projectsController.archive, +); + +projectsRouter.get( + "/:id/archive-impact", + canWrite, + projectsController.archiveImpact, +); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.service.test.ts b/backend/src/modules/projects/projects.service.test.ts index ea0490c..02d972a 100644 --- a/backend/src/modules/projects/projects.service.test.ts +++ b/backend/src/modules/projects/projects.service.test.ts @@ -1,69 +1,201 @@ import test from "node:test"; import assert from "node:assert/strict"; import { ProjectsService } from "./projects.service.js"; -import { ConflictError, ValidationError, NotFoundError } from "../../shared/errors.js"; +import { + ConflictError, + ValidationError, + NotFoundError, +} from "../../shared/errors.js"; import { ProjectsRepository } from "./projects.repository.js"; -import { Project, ProjectWithStats, PaginatedProjects, CreateProjectDTO, UpdateProjectDTO, ProjectQueryDTO } from "./projects.types.js"; - -class InMemoryProjectsRepository extends ProjectsRepository { - private projects: ProjectWithStats[] = []; +import { + Project, + ProjectWithStats, + PaginatedProjects, + CreateProjectDTO, + UpdateProjectDTO, + ProjectQueryDTO, + ProjectBacklogTree, +} from "./projects.types.js"; + +class InMemoryProjectsRepository + extends ProjectsRepository { + private projects: + ProjectWithStats[] = []; constructor() { super(); } - async findActiveByName(nome: string, excludeId?: string): Promise { - const trimmed = nome.trim().toLowerCase(); - const found = this.projects.find( - (p) => - p.nome.trim().toLowerCase() === trimmed && - p.status !== "arquivado" && - (!excludeId || p.id !== excludeId), - ); - return found ? { ...found } : null; + async findActiveByName( + nome: string, + excludeId?: string, + ): Promise { + const trimmed = + nome.trim().toLowerCase(); + + const found = + this.projects.find( + (project) => + project.nome + .trim() + .toLowerCase() + === trimmed + && project.status + !== "arquivado" + && ( + !excludeId + || project.id + !== excludeId + ), + ); + + return found + ? { ...found } + : null; } - async findById(id: string): Promise { - const found = this.projects.find((p) => p.id === id); - return found ? { ...found } : null; + async findById( + id: string, + ): Promise { + const found = + this.projects.find( + (project) => + project.id === id, + ); + + return found + ? { ...found } + : null; } - async create(data: CreateProjectDTO, _usuarioId?: string | null): Promise { - const newProject: ProjectWithStats = { - id: "11111111-1111-4111-8111-111111111111", - nome: data.nome.trim(), - cliente: data.cliente.trim(), - descricao: data.descricao?.trim() ?? null, - status: data.status ?? "ativo", - data_inicio: data.data_inicio ?? new Date().toISOString(), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - epicos_count: 0, - documentos_count: 0, + async findBacklogTree( + id: string, + ): Promise { + const project = + this.projects.find( + (item) => + item.id === id, + ); + + if (!project) { + return null; + } + + return { + project: { + id: project.id, + nome: project.nome, + status: project.status, + }, + epics: [], + technologies: [], }; - this.projects.push(newProject); - return { ...newProject }; } - async findAll(query: ProjectQueryDTO): Promise { - let filtered = [...this.projects]; + async create( + data: CreateProjectDTO, + _usuarioId?: string | null, + ): Promise { + const newProject: + ProjectWithStats = { + id: + "11111111-1111-4111-8111-111111111111", + + nome: + data.nome.trim(), + + cliente: + data.cliente.trim(), + + descricao: + data.descricao?.trim() + ?? null, - if (query.status && query.status !== "todos") { - filtered = filtered.filter((p) => p.status === query.status); + status: + data.status ?? "ativo", + + data_inicio: + data.data_inicio + ?? new Date() + .toISOString(), + + created_at: + new Date() + .toISOString(), + + updated_at: + new Date() + .toISOString(), + + epicos_count: 0, + documentos_count: 0, + }; + + this.projects.push( + newProject, + ); + + return { + ...newProject, + }; + } + + async findAll( + query: ProjectQueryDTO, + ): Promise { + let filtered = [ + ...this.projects, + ]; + + if ( + query.status + && query.status !== "todos" + ) { + filtered = + filtered.filter( + (project) => + project.status + === query.status, + ); } - if (query.busca && query.busca.trim().length > 0) { - const q = query.busca.toLowerCase(); - filtered = filtered.filter( - (p) => - p.nome.toLowerCase().includes(q) || - p.cliente.toLowerCase().includes(q) || - (p.descricao && p.descricao.toLowerCase().includes(q)), - ); + if ( + query.busca + && query.busca + .trim() + .length > 0 + ) { + const search = + query.busca + .toLowerCase(); + + filtered = + filtered.filter( + (project) => + project.nome + .toLowerCase() + .includes(search) + || project.cliente + .toLowerCase() + .includes(search) + || ( + project.descricao + && project.descricao + .toLowerCase() + .includes(search) + ), + ); } - const total = filtered.length; - const paginated = filtered.slice(query.offset, query.offset + query.limit); + const total = + filtered.length; + + const paginated = + filtered.slice( + query.offset, + query.offset + + query.limit, + ); return { items: paginated, @@ -73,187 +205,496 @@ class InMemoryProjectsRepository extends ProjectsRepository { }; } - async update(id: string, data: UpdateProjectDTO, _usuarioId?: string | null): Promise { - const index = this.projects.findIndex((p) => p.id === id); - if (index === -1) return null; - - const current = this.projects[index]; - const updated: ProjectWithStats = { - ...current, - ...(data.nome !== undefined ? { nome: data.nome.trim() } : {}), - ...(data.cliente !== undefined ? { cliente: data.cliente.trim() } : {}), - ...(data.descricao !== undefined ? { descricao: data.descricao?.trim() ?? null } : {}), - ...(data.status !== undefined ? { status: data.status } : {}), - ...(data.data_inicio !== undefined ? { data_inicio: data.data_inicio ?? null } : {}), - updated_at: new Date().toISOString(), - }; + async update( + id: string, + data: UpdateProjectDTO, + _usuarioId?: string | null, + ): Promise { + const index = + this.projects.findIndex( + (project) => + project.id === id, + ); - this.projects[index] = updated; - return { ...updated }; - } + if (index === -1) { + return null; + } - async archive(id: string, _usuarioId?: string | null, _justificativa?: string): Promise { - const index = this.projects.findIndex((p) => p.id === id); - if (index === -1) return null; + const current = + this.projects[index]; + + const updated: + ProjectWithStats = { + ...current, + + ...(data.nome !== undefined + ? { + nome: + data.nome.trim(), + } + : {}), + + ...(data.cliente + !== undefined + ? { + cliente: + data.cliente.trim(), + } + : {}), + + ...(data.descricao + !== undefined + ? { + descricao: + data.descricao + ?.trim() + ?? null, + } + : {}), + + ...(data.status + !== undefined + ? { + status: + data.status, + } + : {}), + + ...(data.data_inicio + !== undefined + ? { + data_inicio: + data.data_inicio + ?? null, + } + : {}), + + updated_at: + new Date() + .toISOString(), + }; + + this.projects[index] = + updated; - const updated: ProjectWithStats = { - ...this.projects[index], - status: "arquivado", - updated_at: new Date().toISOString(), + return { + ...updated, }; + } + + async archive( + id: string, + _usuarioId?: string | null, + _justificativa?: string, + ): Promise { + const index = + this.projects.findIndex( + (project) => + project.id === id, + ); + + if (index === -1) { + return null; + } + + const updated: + ProjectWithStats = { + ...this.projects[index], + + status: "arquivado", + + updated_at: + new Date() + .toISOString(), + }; + + this.projects[index] = + updated; - this.projects[index] = updated; - return { ...updated }; + return { + ...updated, + }; } } -test("PBI-01.1.1 Cenário 1: deve criar projeto com dados válidos e status 'ativo'", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - const result = await service.create({ - nome: "Plataforma Sinapse", - cliente: "PRO4TECH", - descricao: "Base Inteligente de Requisitos", - }); - - assert.equal(result.nome, "Plataforma Sinapse"); - assert.equal(result.cliente, "PRO4TECH"); - assert.equal(result.descricao, "Base Inteligente de Requisitos"); - assert.equal(result.status, "ativo"); - assert.ok(result.id); -}); - -test("PBI-01.1.1 Cenário 2: deve impedir criação de projeto sem nome", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - await assert.rejects( - async () => { +test( + "PBI-01.1.1 Cenário 1: deve criar projeto com dados válidos e status 'ativo'", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + const result = await service.create({ - nome: " ", - cliente: "PRO4TECH", + nome: + "Plataforma Sinapse", + + cliente: + "PRO4TECH", + + descricao: + "Base Inteligente de Requisitos", }); - }, - (err: Error) => { - assert.ok(err instanceof ValidationError); - assert.match(err.message, /nome.*obrigat[óo]rio/i); - return true; - }, - ); -}); - -test("PBI-01.1.1: deve impedir criação de projeto sem cliente", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - await assert.rejects( - async () => { + + assert.equal( + result.nome, + "Plataforma Sinapse", + ); + + assert.equal( + result.cliente, + "PRO4TECH", + ); + + assert.equal( + result.descricao, + "Base Inteligente de Requisitos", + ); + + assert.equal( + result.status, + "ativo", + ); + + assert.ok(result.id); + }, +); + +test( + "PBI-01.1.1 Cenário 2: deve impedir criação de projeto sem nome", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + await assert.rejects( + async () => { + await service.create({ + nome: " ", + cliente: "PRO4TECH", + }); + }, + + (error: Error) => { + assert.ok( + error + instanceof ValidationError, + ); + + assert.match( + error.message, + /nome.*obrigat[óo]rio/i, + ); + + return true; + }, + ); + }, +); + +test( + "PBI-01.1.1: deve impedir criação de projeto sem cliente", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + await assert.rejects( + async () => { + await service.create({ + nome: + "Projeto Válido", + cliente: "", + }); + }, + + (error: Error) => { + assert.ok( + error + instanceof ValidationError, + ); + + assert.match( + error.message, + /cliente.*obrigat[óo]rio/i, + ); + + return true; + }, + ); + }, +); + +test( + "PBI-01.1.1 Cenário 3: deve impedir criação com nome duplicado de projeto ativo (case-insensitive)", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + await service.create({ + nome: "Sinapse Memory", + cliente: "PRO4TECH", + }); + + await assert.rejects( + async () => { + await service.create({ + nome: + " sinapse memory ", + + cliente: + "Outro Cliente", + }); + }, + + (error: Error) => { + assert.ok( + error + instanceof ConflictError, + ); + + assert.equal( + error.message, + "Já existe um projeto ativo com este nome.", + ); + + return true; + }, + ); + }, +); + +test( + "deve permitir criar projeto com mesmo nome se o anterior estiver arquivado", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + const initial = await service.create({ - nome: "Projeto Válido", - cliente: "", + nome: + "Projeto Antigo", + + cliente: + "Cliente A", }); - }, - (err: Error) => { - assert.ok(err instanceof ValidationError); - assert.match(err.message, /cliente.*obrigat[óo]rio/i); - return true; - }, - ); -}); - -test("PBI-01.1.1 Cenário 3: deve impedir criação com nome duplicado de projeto ativo (case-insensitive)", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - await service.create({ - nome: "Sinapse Memory", - cliente: "PRO4TECH", - }); - - await assert.rejects( - async () => { + + await service.archive( + initial.id, + "user-1", + "Projeto arquivado por fim de contrato", + ); + + const recreated = await service.create({ - nome: " sinapse memory ", - cliente: "Outro Cliente", + nome: + "Projeto Antigo", + + cliente: + "Cliente B", + }); + + assert.equal( + recreated.nome, + "Projeto Antigo", + ); + + assert.equal( + recreated.status, + "ativo", + ); + }, +); + +test( + "deve listar projetos com paginação e filtro por busca", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + await service.create({ + nome: "Alpha Core", + cliente: "Cliente 1", + }); + + await service.create({ + nome: "Beta App", + cliente: "Cliente 2", + descricao: "Mobile", + }); + + await service.create({ + nome: "Gamma Web", + cliente: "Cliente 1", + }); + + const listAll = + await service.list({ + limit: 10, + offset: 0, + }); + + assert.equal( + listAll.total, + 3, + ); + + assert.equal( + listAll.items.length, + 3, + ); + + const listSearch = + await service.list({ + busca: "beta", + limit: 10, + offset: 0, }); - }, - (err: Error) => { - assert.ok(err instanceof ConflictError); - assert.equal(err.message, "Já existe um projeto ativo com este nome."); - return true; - }, - ); -}); - -test("deve permitir criar projeto com mesmo nome se o anterior estiver arquivado", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - const initial = await service.create({ - nome: "Projeto Antigo", - cliente: "Cliente A", - }); - - await service.archive(initial.id, "user-1", "Projeto arquivado por fim de contrato"); - - // Agora deve ser possível criar com o mesmo nome - const recreated = await service.create({ - nome: "Projeto Antigo", - cliente: "Cliente B", - }); - - assert.equal(recreated.nome, "Projeto Antigo"); - assert.equal(recreated.status, "ativo"); -}); - -test("deve listar projetos com paginação e filtro por busca", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - await service.create({ nome: "Alpha Core", cliente: "Cliente 1" }); - await service.create({ nome: "Beta App", cliente: "Cliente 2", descricao: "Mobile" }); - await service.create({ nome: "Gamma Web", cliente: "Cliente 1" }); - - const listAll = await service.list({ limit: 10, offset: 0 }); - assert.equal(listAll.total, 3); - assert.equal(listAll.items.length, 3); - - const listSearch = await service.list({ busca: "beta", limit: 10, offset: 0 }); - assert.equal(listSearch.total, 1); - assert.equal(listSearch.items[0].nome, "Beta App"); -}); - -test("deve consultar projeto por ID e lançar NotFound se não existir", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - const created = await service.create({ nome: "Projeto X", cliente: "Cliente X" }); - const found = await service.getById(created.id); - assert.equal(found.id, created.id); - - await assert.rejects( - async () => { - await service.getById("99999999-9999-4999-8999-999999999999"); - }, - (err: Error) => { - assert.ok(err instanceof NotFoundError); - return true; - }, - ); -}); - -test("deve validar formato UUID no getById", async () => { - const repo = new InMemoryProjectsRepository(); - const service = new ProjectsService(repo); - - await assert.rejects( - async () => { - await service.getById("id-invalido"); - }, - (err: Error) => { - assert.ok(err instanceof ValidationError); - return true; - }, - ); -}); + + assert.equal( + listSearch.total, + 1, + ); + + assert.equal( + listSearch.items[0].nome, + "Beta App", + ); + }, +); + +test( + "deve consultar projeto por ID e lançar NotFound se não existir", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + const created = + await service.create({ + nome: "Projeto X", + cliente: "Cliente X", + }); + + const found = + await service.getById( + created.id, + ); + + assert.equal( + found.id, + created.id, + ); + + await assert.rejects( + async () => { + await service.getById( + "99999999-9999-4999-8999-999999999999", + ); + }, + + (error: Error) => { + assert.ok( + error + instanceof NotFoundError, + ); + + return true; + }, + ); + }, +); + +test( + "deve validar formato UUID no getById", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + await assert.rejects( + async () => { + await service.getById( + "id-invalido", + ); + }, + + (error: Error) => { + assert.ok( + error + instanceof ValidationError, + ); + + return true; + }, + ); + }, +); + +test( + "S1-16: consulta a árvore do projeto e valida projeto ausente ou ID inválido", + async () => { + const repo = + new InMemoryProjectsRepository(); + + const service = + new ProjectsService(repo); + + const created = + await service.create({ + nome: "Projeto árvore", + cliente: "PRO4TECH", + }); + + const tree = + await service + .getBacklogTree( + created.id, + ); + + assert.equal( + tree.project.id, + created.id, + ); + + assert.deepEqual( + tree.epics, + [], + ); + + await assert.rejects( + () => + service.getBacklogTree( + "invalido", + ), + ValidationError, + ); + + await assert.rejects( + () => + service.getBacklogTree( + "99999999-9999-4999-8999-999999999999", + ), + NotFoundError, + ); + }, +); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.service.ts b/backend/src/modules/projects/projects.service.ts index 9e9ba96..f091600 100644 --- a/backend/src/modules/projects/projects.service.ts +++ b/backend/src/modules/projects/projects.service.ts @@ -7,100 +7,225 @@ import { Project, ProjectWithStats, PaginatedProjects, + ProjectBacklogTree, } from "./projects.types.js"; -import { ProjectsRepository, projectsRepository } from "./projects.repository.js"; -import { ConflictError, NotFoundError, ValidationError, validateUuid } from "../../shared/errors.js"; +import { + ProjectsRepository, + projectsRepository, +} from "./projects.repository.js"; +import { + ConflictError, + NotFoundError, + ValidationError, + validateUuid, +} from "../../shared/errors.js"; export class ProjectsService { - constructor(private readonly repository: ProjectsRepository = projectsRepository) {} + constructor( + private readonly repository: + ProjectsRepository = projectsRepository, + ) {} private validateUuid(id: string): void { validateUuid(id, "ID do projeto"); } - async create(input: unknown, usuarioId?: string | null): Promise { - const parseResult = createProjectSchema.safeParse(input); + async create( + input: unknown, + usuarioId?: string | null, + ): Promise { + const parseResult = + createProjectSchema.safeParse(input); + if (!parseResult.success) { - const issue = parseResult.error.issues[0]; - throw new ValidationError(issue.message, parseResult.error.format()); + const issue = + parseResult.error.issues[0]; + + throw new ValidationError( + issue.message, + parseResult.error.format(), + ); } const dto = parseResult.data; - // Cenário 3 — Impedir nome duplicado entre projetos ativos - const existing = await this.repository.findActiveByName(dto.nome); + // Impedir nome duplicado entre projetos ativos. + const existing = + await this.repository.findActiveByName( + dto.nome, + ); + if (existing) { - throw new ConflictError("Já existe um projeto ativo com este nome."); + throw new ConflictError( + "Já existe um projeto ativo com este nome.", + ); } - return await this.repository.create(dto, usuarioId); + return await this.repository.create( + dto, + usuarioId, + ); } - async list(queryInput: unknown): Promise { - const parseResult = projectQuerySchema.safeParse(queryInput); + async list( + queryInput: unknown, + ): Promise { + const parseResult = + projectQuerySchema.safeParse( + queryInput, + ); + if (!parseResult.success) { - const issue = parseResult.error.issues[0]; - throw new ValidationError(issue.message, parseResult.error.format()); + const issue = + parseResult.error.issues[0]; + + throw new ValidationError( + issue.message, + parseResult.error.format(), + ); } - return await this.repository.findAll(parseResult.data); + return await this.repository.findAll( + parseResult.data, + ); } - async getById(id: string): Promise { + async getById( + id: string, + ): Promise { this.validateUuid(id); - const project = await this.repository.findById(id); + const project = + await this.repository.findById(id); + if (!project) { - throw new NotFoundError("Projeto não encontrado."); + throw new NotFoundError( + "Projeto não encontrado.", + ); } return project; } - async archiveImpact(id: string): Promise<{ projeto: number; epicos: number; features: number; pbis: number }> { + async getBacklogTree( + id: string, + ): Promise { + this.validateUuid(id); + + const tree = + await this.repository.findBacklogTree( + id, + ); + + if (!tree) { + throw new NotFoundError( + "Projeto não encontrado.", + ); + } + + return tree; + } + + async archiveImpact( + id: string, + ): Promise<{ + projeto: number; + epicos: number; + features: number; + pbis: number; + }> { this.validateUuid(id); - const impact = await this.repository.archiveImpact(id); - if (!impact) throw new NotFoundError("Projeto não encontrado."); + + const impact = + await this.repository.archiveImpact(id); + + if (!impact) { + throw new NotFoundError( + "Projeto não encontrado.", + ); + } + return impact; } - async update(id: string, input: unknown, usuarioId?: string | null): Promise { + async update( + id: string, + input: unknown, + usuarioId?: string | null, + ): Promise { this.validateUuid(id); - const parseResult = updateProjectSchema.safeParse(input); + const parseResult = + updateProjectSchema.safeParse(input); + if (!parseResult.success) { - const issue = parseResult.error.issues[0]; - throw new ValidationError(issue.message, parseResult.error.format()); + const issue = + parseResult.error.issues[0]; + + throw new ValidationError( + issue.message, + parseResult.error.format(), + ); } const dto = parseResult.data; - // Se estiver atualizando o nome, verificar duplicidade em outros projetos ativos if (dto.nome) { - const existing = await this.repository.findActiveByName(dto.nome, id); + const existing = + await this.repository + .findActiveByName( + dto.nome, + id, + ); + if (existing) { - throw new ConflictError("Já existe um projeto ativo com este nome."); + throw new ConflictError( + "Já existe um projeto ativo com este nome.", + ); } } - const updated = await this.repository.update(id, dto, usuarioId); + const updated = + await this.repository.update( + id, + dto, + usuarioId, + ); + if (!updated) { - throw new NotFoundError("Projeto não encontrado."); + throw new NotFoundError( + "Projeto não encontrado.", + ); } return updated; } - async archive(id: string, usuarioId?: string | null, justificativa?: string, expected?: ArchiveImpact): Promise { + async archive( + id: string, + usuarioId?: string | null, + justificativa?: string, + expected?: ArchiveImpact, + ): Promise { this.validateUuid(id); - const archived = await this.repository.archive(id, usuarioId, justificativa, expected); + const archived = + await this.repository.archive( + id, + usuarioId, + justificativa, + expected, + ); + if (!archived) { - throw new NotFoundError("Projeto não encontrado."); + throw new NotFoundError( + "Projeto não encontrado.", + ); } return archived; } } -export const projectsService = new ProjectsService(); +export const projectsService = + new ProjectsService(); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.types.ts b/backend/src/modules/projects/projects.types.ts index 4354857..69cebdd 100644 --- a/backend/src/modules/projects/projects.types.ts +++ b/backend/src/modules/projects/projects.types.ts @@ -1,68 +1,177 @@ import { z } from "zod"; -export const PROJECT_STATUSES = ["ativo", "em_andamento", "concluido", "arquivado"] as const; -export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; +export const PROJECT_STATUSES = [ + "ativo", + "em_andamento", + "concluido", + "arquivado", +] as const; + +export type ProjectStatus = + (typeof PROJECT_STATUSES)[number]; export const createProjectSchema = z.object({ nome: z - .string({ required_error: "O nome do projeto é obrigatório." }) + .string({ + required_error: + "O nome do projeto é obrigatório.", + }) .trim() - .min(1, "O nome do projeto é obrigatório.") - .max(255, "O nome não pode exceder 255 caracteres."), + .min( + 1, + "O nome do projeto é obrigatório.", + ) + .max( + 255, + "O nome não pode exceder 255 caracteres.", + ), + cliente: z - .string({ required_error: "O nome do cliente é obrigatório." }) + .string({ + required_error: + "O nome do cliente é obrigatório.", + }) + .trim() + .min( + 1, + "O nome do cliente é obrigatório.", + ) + .max( + 255, + "O cliente não pode exceder 255 caracteres.", + ), + + descricao: z + .string() .trim() - .min(1, "O nome do cliente é obrigatório.") - .max(255, "O cliente não pode exceder 255 caracteres."), - descricao: z.string().trim().optional().nullable(), - status: z.enum(["ativo", "em_andamento", "concluido"]).default("ativo"), + .optional() + .nullable(), + + status: z + .enum([ + "ativo", + "em_andamento", + "concluido", + ]) + .default("ativo"), + data_inicio: z .string() - .refine((val) => !isNaN(Date.parse(val)), { - message: "Data de início deve ser uma data válida.", - }) + .refine( + (val) => !isNaN(Date.parse(val)), + { + message: + "Data de início deve ser uma data válida.", + }, + ) .optional() .nullable(), }); -export type CreateProjectDTO = z.infer; +export type CreateProjectDTO = + z.infer; export const updateProjectSchema = z.object({ nome: z .string() .trim() - .min(1, "O nome do projeto não pode ser vazio.") - .max(255, "O nome não pode exceder 255 caracteres.") + .min( + 1, + "O nome do projeto não pode ser vazio.", + ) + .max( + 255, + "O nome não pode exceder 255 caracteres.", + ) .optional(), + cliente: z .string() .trim() - .min(1, "O cliente não pode ser vazio.") - .max(255, "O cliente não pode exceder 255 caracteres.") + .min( + 1, + "O cliente não pode ser vazio.", + ) + .max( + 255, + "O cliente não pode exceder 255 caracteres.", + ) .optional(), - descricao: z.string().trim().optional().nullable(), - status: z.enum(["ativo", "em_andamento", "concluido"]).optional(), + + descricao: z + .string() + .trim() + .optional() + .nullable(), + + status: z + .enum([ + "ativo", + "em_andamento", + "concluido", + ]) + .optional(), + data_inicio: z .string() - .refine((val) => !isNaN(Date.parse(val)), { - message: "Data de início deve ser uma data válida.", - }) + .refine( + (val) => !isNaN(Date.parse(val)), + { + message: + "Data de início deve ser uma data válida.", + }, + ) + .optional() + .nullable(), + + justificativa: z + .string() + .trim() .optional() .nullable(), - justificativa: z.string().trim().optional().nullable(), }); -export type UpdateProjectDTO = z.infer; +export type UpdateProjectDTO = + z.infer; export const projectQuerySchema = z.object({ - status: z.enum([...PROJECT_STATUSES, "todos"]).optional(), - busca: z.string().trim().optional(), - limit: z.coerce.number().int().min(1).max(100).default(50), - offset: z.coerce.number().int().min(0).default(0), - order: z.enum(["created_at_desc", "created_at_asc", "nome_asc", "nome_desc"]).default("created_at_desc"), + status: z + .enum([ + ...PROJECT_STATUSES, + "todos", + ]) + .optional(), + + busca: z + .string() + .trim() + .optional(), + + limit: z.coerce + .number() + .int() + .min(1) + .max(100) + .default(50), + + offset: z.coerce + .number() + .int() + .min(0) + .default(0), + + order: z + .enum([ + "created_at_desc", + "created_at_asc", + "nome_asc", + "nome_desc", + ]) + .default("created_at_desc"), }); -export type ProjectQueryDTO = z.infer; +export type ProjectQueryDTO = + z.infer; export interface Project { id: string; @@ -76,7 +185,8 @@ export interface Project { archived_at?: Date | string | null; } -export interface ProjectWithStats extends Project { +export interface ProjectWithStats + extends Project { epicos_count?: number; documentos_count?: number; } @@ -87,3 +197,42 @@ export interface PaginatedProjects { limit: number; offset: number; } + +export interface BacklogTechnology { + id: string; + nome: string; +} + +export interface BacklogPbiNode { + id: string; + codigo: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; +} + +export interface BacklogFeatureNode { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + pbis: BacklogPbiNode[]; +} + +export interface BacklogEpicNode { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + features: BacklogFeatureNode[]; +} + +export interface ProjectBacklogTree { + project: Pick< + Project, + "id" | "nome" | "status" + >; + + epics: BacklogEpicNode[]; + technologies: BacklogTechnology[]; +} \ No newline at end of file diff --git a/frontend/src/api/api_backlog_tree.ts b/frontend/src/api/api_backlog_tree.ts new file mode 100644 index 0000000..8497a92 --- /dev/null +++ b/frontend/src/api/api_backlog_tree.ts @@ -0,0 +1,258 @@ +import { apiRequest } from "./api_auth"; + +export interface BacklogTechnology { + id: string; + nome: string; +} + +export interface BacklogPbiNode { + id: string; + codigo: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; +} + +export interface BacklogFeatureNode { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + pbis: BacklogPbiNode[]; +} + +export interface BacklogEpicNode { + id: string; + titulo: string; + status: string; + tecnologias: BacklogTechnology[]; + features: BacklogFeatureNode[]; +} + +export interface ProjectBacklogTree { + project: { + id: string; + nome: string; + status: string; + }; + + epics: BacklogEpicNode[]; + technologies: BacklogTechnology[]; +} + +function record( + value: unknown, +): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) + ) { + throw new Error( + "Resposta da árvore do backlog inválida", + ); + } + + return value as Record< + string, + unknown + >; +} + +function text( + value: unknown, + field: string, +): string { + if ( + typeof value !== "string" || + value.length === 0 + ) { + throw new Error( + `Campo ${field} inválido na árvore do backlog`, + ); + } + + return value; +} + +function technologies( + value: unknown, +): BacklogTechnology[] { + if (!Array.isArray(value)) { + throw new Error( + "Tecnologias inválidas na árvore do backlog", + ); + } + + return value.map((item) => { + const technology = record(item); + + return { + id: text( + technology.id, + "tecnologia.id", + ), + + nome: text( + technology.nome, + "tecnologia.nome", + ), + }; + }); +} + +function parsePbi( + value: unknown, +): BacklogPbiNode { + const item = record(value); + + return { + id: text(item.id, "pbi.id"), + + codigo: + typeof item.codigo === "string" + ? item.codigo + : "", + + titulo: text( + item.titulo, + "pbi.titulo", + ), + + status: text( + item.status, + "pbi.status", + ), + + tecnologias: technologies( + item.tecnologias, + ), + }; +} + +function parseFeature( + value: unknown, +): BacklogFeatureNode { + const item = record(value); + + if (!Array.isArray(item.pbis)) { + throw new Error( + "PBIs inválidos na árvore do backlog", + ); + } + + return { + id: text( + item.id, + "feature.id", + ), + + titulo: text( + item.titulo, + "feature.titulo", + ), + + status: text( + item.status, + "feature.status", + ), + + tecnologias: technologies( + item.tecnologias, + ), + + pbis: item.pbis.map(parsePbi), + }; +} + +function parseEpic( + value: unknown, +): BacklogEpicNode { + const item = record(value); + + if (!Array.isArray(item.features)) { + throw new Error( + "Features inválidas na árvore do backlog", + ); + } + + return { + id: text( + item.id, + "epico.id", + ), + + titulo: text( + item.titulo, + "epico.titulo", + ), + + status: text( + item.status, + "epico.status", + ), + + tecnologias: technologies( + item.tecnologias, + ), + + features: + item.features.map( + parseFeature, + ), + }; +} + +export async function getProjectBacklogTree( + projectId: string, + signal: AbortSignal, +): Promise { + const response = + await apiRequest( + `/projects/${encodeURIComponent( + projectId, + )}/backlog-tree`, + { signal }, + ); + + const data = record( + await response.json(), + ); + + const project = record( + data.project, + ); + + if (!Array.isArray(data.epics)) { + throw new Error( + "Épicos inválidos na árvore do backlog", + ); + } + + return { + project: { + id: text( + project.id, + "project.id", + ), + + nome: text( + project.nome, + "project.nome", + ), + + status: text( + project.status, + "project.status", + ), + }, + + epics: + data.epics.map(parseEpic), + + technologies: + technologies( + data.technologies, + ), + }; +} \ No newline at end of file diff --git a/frontend/src/assets/styles/projects.css b/frontend/src/assets/styles/projects.css index d1e061a..4cfcf91 100644 --- a/frontend/src/assets/styles/projects.css +++ b/frontend/src/assets/styles/projects.css @@ -1,35 +1,425 @@ -.projects-page { display: grid; gap: 24px; } -.project-filter { display: grid; gap: 8px; max-width: 260px; } -.project-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-card); } -.search-field { position: relative; display: flex; flex: 1 1 260px; align-items: center; min-width: 0; } -.search-field svg { position: absolute; left: 12px; color: var(--text-muted); pointer-events: none; } -.search-field input { width: 100%; min-height: 42px; padding: 10px 12px 10px 38px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--bg-secondary); color: var(--text-primary); font: inherit; } -.project-filter select { padding: 12px; font: inherit; border: 1px solid var(--border-subtle); border-radius: 8px; color: var(--text-primary); background: var(--bg-secondary); } -.archive-dialog { padding: 28px; max-width: 560px; width: calc(100% - 32px); border: 1px solid var(--border-subtle); border-radius: 12px; color: var(--text-primary); background: var(--bg-secondary); } -.archive-dialog::backdrop { background: #0009; } -.archive-dialog p { margin: 20px 0; line-height: 1.6; } -.projects-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; } -.projects-heading h2 { font-size: 1.8rem; } -.projects-heading p, .project-card p, .projects-state p, .project-form > p { color: var(--text-secondary); } -.projects-eyebrow { color: var(--accent-secondary) !important; font-size: .8rem; } -.projects-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); gap: 20px; } -.project-card { padding: 24px; display: grid; gap: 12px; align-content: start; overflow-wrap: anywhere; } -.project-card .badge, .project-card button { justify-self: start; } -.project-card h3 { font-size: 1.3rem; } -.project-card dt { color: var(--text-secondary); margin-top: 16px; } -.project-description { white-space: pre-wrap; } -.project-excerpt { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } -.projects-state { padding: 40px 24px; display: grid; justify-items: center; gap: 16px; text-align: center; } -.project-form { max-width: 720px; padding: 28px; display: grid; gap: 20px; } -.project-field { display: grid; gap: 8px; } -.project-field input, .project-field textarea { width: 100%; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 12px; font: inherit; } -.project-field input[type="checkbox"] { width: auto; padding: 0; accent-color: var(--accent-primary); } -.project-field textarea { resize: vertical; } -.project-field textarea:focus-visible { outline: 2px solid var(--accent-secondary); outline-offset: 3px; } -.project-field [aria-invalid="true"] { border-color: #fca5a5; } -.projects-page [role="alert"] { color: #fca5a5; } -.project-actions { display: flex; flex-wrap: wrap; gap: 12px; } -.badge-error { background: rgba(244, 67, 54, 0.2); color: #fca5a5; } +.projects-page { + display: grid; + gap: 24px; +} + +.project-filter { + display: grid; + gap: 8px; + max-width: 260px; +} + +.project-filters { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 12px; + padding: 16px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-card); +} + +.search-field { + position: relative; + display: flex; + flex: 1 1 260px; + align-items: center; + min-width: 0; +} + +.search-field svg { + position: absolute; + left: 12px; + color: var(--text-muted); + pointer-events: none; +} + +.search-field input { + width: 100%; + min-height: 42px; + padding: 10px 12px 10px 38px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-primary); + font: inherit; +} + +.project-filter select { + padding: 12px; + font: inherit; + border: 1px solid var(--border-subtle); + border-radius: 8px; + color: var(--text-primary); + background: var(--bg-secondary); +} + +.archive-dialog { + padding: 28px; + max-width: 560px; + width: calc(100% - 32px); + border: 1px solid var(--border-subtle); + border-radius: 12px; + color: var(--text-primary); + background: var(--bg-secondary); +} + +.archive-dialog::backdrop { + background: #0009; +} + +.archive-dialog p { + margin: 20px 0; + line-height: 1.6; +} + +.projects-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.projects-heading h2 { + font-size: 1.8rem; +} + +.projects-heading p, +.project-card p, +.projects-state p, +.project-form > p { + color: var(--text-secondary); +} + +.projects-eyebrow { + color: var(--accent-secondary) !important; + font-size: 0.8rem; +} + +.projects-grid { + display: grid; + grid-template-columns: + repeat( + auto-fit, + minmax(min(100%, 290px), 1fr) + ); + gap: 20px; +} + +.project-card { + padding: 24px; + display: grid; + gap: 12px; + align-content: start; + overflow-wrap: anywhere; +} + +.project-card .badge, +.project-card button { + justify-self: start; +} + +.project-card h3 { + font-size: 1.3rem; +} + +.project-card dt { + color: var(--text-secondary); + margin-top: 16px; +} + +.project-description { + white-space: pre-wrap; +} + +.project-excerpt { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.projects-state { + padding: 40px 24px; + display: grid; + justify-items: center; + gap: 16px; + text-align: center; +} + +.project-form { + max-width: 720px; + padding: 28px; + display: grid; + gap: 20px; +} + +.project-field { + display: grid; + gap: 8px; +} + +.project-field input, +.project-field textarea { + width: 100%; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 12px; + font: inherit; +} + +.project-field input[type="checkbox"] { + width: auto; + padding: 0; + accent-color: var(--accent-primary); +} + +.project-field textarea { + resize: vertical; +} + +.project-field textarea:focus-visible { + outline: 2px solid var(--accent-secondary); + outline-offset: 3px; +} + +.project-field [aria-invalid="true"] { + border-color: #fca5a5; +} + +.projects-page [role="alert"] { + color: #fca5a5; +} + +.project-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.badge-error { + background: rgba(244, 67, 54, 0.2); + color: #fca5a5; +} + +/* + * Árvore hierárquica, filtros + * e breadcrumb do backlog (S1-16). + */ + +.backlog-tree { + display: grid; + gap: 20px; +} + +.backlog-tree .projects-heading p { + margin: 4px 0 0; + color: var(--text-secondary); +} + +.backlog-tree-filters { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 12px; + padding: 16px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-card); +} + +.backlog-tree-filter-count { + min-height: 42px; + display: inline-flex; + align-items: center; + padding: 0 14px; + border-radius: 9999px; + color: var(--text-primary); + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + font-size: 0.9rem; +} + +.backlog-tree-list, +.backlog-tree-children { + list-style: none; + margin: 0; + padding: 0; +} + +.backlog-tree-list { + display: grid; + gap: 12px; +} + +.backlog-tree-epic { + overflow: hidden; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-card); +} + +.backlog-tree-feature { + margin: 0 14px 12px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-secondary); +} + +.backlog-tree-row, +.backlog-tree-pbi { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + min-height: 58px; + padding: 10px 14px; +} + +.backlog-tree-epic + > .backlog-tree-row { + min-height: 68px; +} + +.backlog-tree-pbi { + margin: 0 14px 10px 38px; + border-left: + 2px solid var(--accent-secondary); + background: var(--bg-tertiary); + border-radius: + 0 + var(--radius-sm) + var(--radius-sm) + 0; +} + +.backlog-tree-children { + display: grid; + gap: 0; + padding-bottom: 2px; +} + +.backlog-tree-toggle { + width: 34px; + height: 34px; + flex: 0 0 34px; + border: 1px solid var(--border-subtle); + border-radius: 50%; + color: var(--text-primary); + background: transparent; + cursor: pointer; + font-size: 1rem; +} + +.backlog-tree-toggle:hover:not(:disabled), +.backlog-tree-toggle:focus-visible { + border-color: var(--accent-secondary); + color: var(--accent-secondary); +} + +.backlog-tree-toggle:disabled { + cursor: default; + opacity: 0.8; +} + +.backlog-tree-marker { + flex: 0 0 auto; + padding: 4px 8px; + border-radius: 9999px; + color: var(--accent-secondary); + background: + color-mix( + in srgb, + var(--accent-secondary) 12%, + transparent + ); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.backlog-tree-link { + min-width: 180px; + flex: 1 1 260px; + padding: 5px 0; + border: 0; + text-align: left; + color: var(--text-primary); + background: transparent; + cursor: pointer; + font: inherit; + font-weight: 700; + overflow-wrap: anywhere; +} + +.backlog-tree-link:hover, +.backlog-tree-link:focus-visible { + color: var(--accent-secondary); + text-decoration: underline; + text-underline-offset: 4px; +} + +.backlog-tree-meta, +.backlog-tree-technologies { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.backlog-tree-technologies > span { + padding: 3px 7px; + border: 1px solid var(--border-subtle); + border-radius: 9999px; + color: var(--text-secondary); + font-size: 0.75rem; +} + +.backlog-tree-empty-branch { + margin: 0 16px 14px 58px; + color: var(--text-secondary); + font-size: 0.88rem; +} + +.backlog-breadcrumb { + flex-wrap: wrap; +} + +.backlog-breadcrumb-segment { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.backlog-breadcrumb strong { + color: var(--text-primary); + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .backlog-tree-row, + .backlog-tree-pbi { + align-items: flex-start; + } + + .backlog-tree-pbi { + margin-left: 20px; + } + + .backlog-tree-meta { + width: 100%; + padding-left: 44px; + } + + .backlog-tree-pbi + .backlog-tree-meta { + padding-left: 0; + } +} @media (max-width: 900px) { .projects-heading { @@ -42,12 +432,17 @@ } } -/* Painel de Qualidade em Tempo Real (S1-14 / PBI-01.3.5) */ +/* + * Painel de Qualidade em Tempo Real + * (S1-14 / PBI-01.3.5). + */ + .quality-panel { padding: 20px 24px; display: grid; gap: 16px; - border-left: 4px solid var(--accent-secondary); + border-left: + 4px solid var(--accent-secondary); background: var(--bg-card); } @@ -111,7 +506,8 @@ .quality-check-item { display: grid; - grid-template-columns: 24px 1fr auto; + grid-template-columns: + 24px 1fr auto; align-items: start; gap: 12px; padding: 12px 14px; @@ -124,17 +520,22 @@ } .quality-check-item.passed { - border-color: rgba(34, 197, 94, 0.3); + border-color: + rgba(34, 197, 94, 0.3); } .quality-check-item.failed { - border-color: rgba(239, 68, 68, 0.4); - background: rgba(239, 68, 68, 0.04); + border-color: + rgba(239, 68, 68, 0.4); + background: + rgba(239, 68, 68, 0.04); } .quality-check-item.warning { - border-color: rgba(245, 158, 11, 0.4); - background: rgba(245, 158, 11, 0.04); + border-color: + rgba(245, 158, 11, 0.4); + background: + rgba(245, 158, 11, 0.04); } .quality-check-indicator { @@ -153,17 +554,20 @@ } .quality-icon.success { - background: rgba(34, 197, 94, 0.2); + background: + rgba(34, 197, 94, 0.2); color: #4ade80; } .quality-icon.warning { - background: rgba(245, 158, 11, 0.2); + background: + rgba(245, 158, 11, 0.2); color: #fbbf24; } .quality-icon.danger { - background: rgba(239, 68, 68, 0.2); + background: + rgba(239, 68, 68, 0.2); color: #f87171; } @@ -194,15 +598,19 @@ } .quality-badge-tag.blocking { - background: rgba(239, 68, 68, 0.15); + background: + rgba(239, 68, 68, 0.15); color: #fca5a5; - border: 1px solid rgba(239, 68, 68, 0.3); + border: + 1px solid rgba(239, 68, 68, 0.3); } .quality-badge-tag.alert { - background: rgba(245, 158, 11, 0.15); + background: + rgba(245, 158, 11, 0.15); color: #fde68a; - border: 1px solid rgba(245, 158, 11, 0.3); + border: + 1px solid rgba(245, 158, 11, 0.3); } .quality-check-message { @@ -218,24 +626,34 @@ white-space: nowrap; } -/* Efeito visual ao navegar para o campo com foco */ .field-target-highlight { - outline: 3px solid var(--accent-primary) !important; + outline: + 3px solid var(--accent-primary) + !important; outline-offset: 3px !important; - animation: fieldPulse 1.8s ease-in-out; - border-color: var(--accent-primary) !important; + animation: + fieldPulse 1.8s ease-in-out; + border-color: + var(--accent-primary) + !important; } @keyframes fieldPulse { 0% { - box-shadow: 0 0 0 0 rgba(36, 190, 219, 0.7); + box-shadow: + 0 0 0 0 + rgba(36, 190, 219, 0.7); } 50% { - box-shadow: 0 0 0 10px rgba(36, 190, 219, 0); + box-shadow: + 0 0 0 10px + rgba(36, 190, 219, 0); } 100% { - box-shadow: 0 0 0 0 rgba(36, 190, 219, 0); + box-shadow: + 0 0 0 0 + rgba(36, 190, 219, 0); } -} +} \ No newline at end of file diff --git a/frontend/src/backlog/Hierarchy.test.tsx b/frontend/src/backlog/Hierarchy.test.tsx new file mode 100644 index 0000000..8c911e7 --- /dev/null +++ b/frontend/src/backlog/Hierarchy.test.tsx @@ -0,0 +1,538 @@ +// @vitest-environment jsdom + +import { + afterEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +import { + BacklogBreadcrumb, +} from "../views/backlog/BacklogBreadcrumb"; + +import { + BacklogTreeView, + filterBacklogTree, +} from "../views/backlog/BacklogTreeView"; + +import type { + ProjectBacklogTree, +} from "../api/api_backlog_tree"; + +const tree: ProjectBacklogTree = { + project: { + id: "project-1", + nome: "Sinapse", + status: "ativo", + }, + + technologies: [ + { + id: "react", + nome: "React", + }, + { + id: "node", + nome: "Node.js", + }, + ], + + epics: [ + { + id: "epic-1", + titulo: "Organizar requisitos", + status: "ativo", + tecnologias: [], + + features: [ + { + id: "feature-1", + titulo: + "Navegação do backlog", + status: "concluido", + + tecnologias: [ + { + id: "react", + nome: "React", + }, + ], + + pbis: [ + { + id: "pbi-1", + codigo: "PBI-01.4.1", + titulo: + "Expandir árvore", + status: "rascunho", + + tecnologias: [ + { + id: "react", + nome: "React", + }, + ], + }, + ], + }, + + { + id: "feature-2", + titulo: "API antiga", + status: "arquivado", + + tecnologias: [ + { + id: "node", + nome: "Node.js", + }, + ], + + pbis: [], + }, + ], + }, + ], +}; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + + window.sessionStorage.clear(); + + window.history.replaceState( + null, + "", + "/", + ); +}); + +function mockTree( + value: ProjectBacklogTree = tree, +) { + vi.stubGlobal( + "fetch", + vi.fn( + async (url: string) => { + if ( + url + === "/api/v1/projects/project-1/backlog-tree" + ) { + return new Response( + JSON.stringify(value), + { + status: 200, + }, + ); + } + + throw new Error( + `Requisição inesperada: ${url}`, + ); + }, + ), + ); +} + +describe( + "S1-16 — árvore e filtros do backlog", + () => { + it( + "expande progressivamente épico e feature até o PBI", + async () => { + mockTree(); + + render( + , + ); + + await screen.findByText( + "Organizar requisitos", + ); + + expect( + screen.queryByText( + "Navegação do backlog", + ), + ).toBeNull(); + + fireEvent.click( + screen.getByRole( + "button", + { + name: + "Expandir épico Organizar requisitos", + }, + ), + ); + + expect( + screen.getByText( + "Navegação do backlog", + ), + ).toBeTruthy(); + + expect( + screen.queryByText( + /Expandir árvore/, + ), + ).toBeNull(); + + fireEvent.click( + screen.getByRole( + "button", + { + name: + "Expandir feature Navegação do backlog", + }, + ), + ); + + expect( + screen.getByText( + /PBI-01\.4\.1 — Expandir árvore/, + ), + ).toBeTruthy(); + }, + ); + + it( + "combina status e tecnologia, preserva ancestrais e persiste na sessão", + async () => { + mockTree(); + + const view = render( + , + ); + + await screen.findByText( + "Organizar requisitos", + ); + + fireEvent.change( + screen.getByLabelText( + "Status", + ), + { + target: { + value: "rascunho", + }, + }, + ); + + fireEvent.change( + screen.getByLabelText( + "Tecnologia", + ), + { + target: { + value: "react", + }, + }, + ); + + expect( + screen.getByText( + "2 filtros ativos", + ), + ).toBeTruthy(); + + expect( + screen.getByText( + "Organizar requisitos", + ), + ).toBeTruthy(); + + expect( + screen.getByText( + "Navegação do backlog", + ), + ).toBeTruthy(); + + expect( + screen.getByText( + /PBI-01\.4\.1 — Expandir árvore/, + ), + ).toBeTruthy(); + + expect( + screen.queryByText( + "API antiga", + ), + ).toBeNull(); + + expect( + JSON.parse( + window.sessionStorage + .getItem( + "sinapse.backlog.filters.project-1", + )!, + ), + ).toEqual({ + status: "rascunho", + technologyId: "react", + }); + + view.unmount(); + + render( + , + ); + + await screen.findByText( + "2 filtros ativos", + ); + + expect( + ( + screen.getByLabelText( + "Status", + ) as HTMLSelectElement + ).value, + ).toBe("rascunho"); + + expect( + ( + screen.getByLabelText( + "Tecnologia", + ) as HTMLSelectElement + ).value, + ).toBe("react"); + }, + ); + + it( + "oferece limpar filtros quando a combinação não tem resultado", + async () => { + mockTree(); + + render( + , + ); + + await screen.findByText( + "Organizar requisitos", + ); + + fireEvent.change( + screen.getByLabelText( + "Status", + ), + { + target: { + value: "concluido", + }, + }, + ); + + fireEvent.change( + screen.getByLabelText( + "Tecnologia", + ), + { + target: { + value: "node", + }, + }, + ); + + expect( + screen.getByText( + "Nenhum item corresponde aos filtros", + ), + ).toBeTruthy(); + + fireEvent.click( + screen.getAllByRole( + "button", + { + name: + "Limpar filtros", + }, + )[0], + ); + + expect( + screen.getByText( + "0 filtros ativos", + ), + ).toBeTruthy(); + + expect( + screen.getByText( + "Organizar requisitos", + ), + ).toBeTruthy(); + }, + ); + + it( + "orienta a criação do primeiro épico em projeto vazio", + async () => { + mockTree({ + ...tree, + epics: [], + technologies: [], + }); + + render( + , + ); + + expect( + await screen.findByText( + "Este projeto ainda não possui épicos", + ), + ).toBeTruthy(); + + expect( + screen.getByRole( + "button", + { + name: + "Criar primeiro épico", + }, + ), + ).toBeTruthy(); + }, + ); + + it( + "mantém apenas correspondências e seus ancestrais na função de filtragem", + () => { + const filtered = + filterBacklogTree( + tree, + { + status: "rascunho", + technologyId: + "react", + }, + ); + + expect( + filtered.epics, + ).toHaveLength(1); + + expect( + filtered.epics[0] + .features, + ).toHaveLength(1); + + expect( + filtered.epics[0] + .features[0] + .pbis, + ).toHaveLength(1); + }, + ); + }, +); + +it( + "S1-16 — breadcrumb apresenta o caminho e navega pelos ancestrais", + async () => { + const onNavigate = vi.fn(); + + render( + , + ); + + fireEvent.click( + screen.getByRole( + "button", + { + name: "Projeto", + }, + ), + ); + + fireEvent.click( + screen.getByRole( + "button", + { + name: "Feature", + }, + ), + ); + + const current = + screen.getByRole( + "button", + { + name: "PBI-01.4.1", + }, + ); + + fireEvent.click(current); + + await waitFor(() => + expect( + onNavigate, + ).toHaveBeenCalledTimes(3), + ); + + expect( + onNavigate, + ).toHaveBeenLastCalledWith( + "/projects/project-1/epics/epic-1/features/feature-1/pbis/pbi-1", + ); + + expect( + current.getAttribute( + "aria-current", + ), + ).toBe("page"); + }, +); \ No newline at end of file diff --git a/frontend/src/views/backlog/BacklogBreadcrumb.tsx b/frontend/src/views/backlog/BacklogBreadcrumb.tsx new file mode 100644 index 0000000..86438aa --- /dev/null +++ b/frontend/src/views/backlog/BacklogBreadcrumb.tsx @@ -0,0 +1,67 @@ +import { navigate } from "../../models/navigation"; + +export interface BacklogBreadcrumbSegment { + label: string; + path?: string; +} + +export function BacklogBreadcrumb({ + segments, + onNavigate = navigate, +}: { + segments: BacklogBreadcrumbSegment[]; + onNavigate?: (path: string) => void; +}) { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/views/backlog/BacklogTreeView.tsx b/frontend/src/views/backlog/BacklogTreeView.tsx new file mode 100644 index 0000000..326d951 --- /dev/null +++ b/frontend/src/views/backlog/BacklogTreeView.tsx @@ -0,0 +1,986 @@ +import { + useEffect, + useMemo, + useState, +} from "react"; + +import { + ApiError, +} from "../../api/api_auth"; + +import { + getProjectBacklogTree, + type BacklogEpicNode, + type BacklogFeatureNode, + type BacklogPbiNode, + type ProjectBacklogTree, +} from "../../api/api_backlog_tree"; + +import { + navigate, +} from "../../models/navigation"; + +export interface BacklogFilters { + status: string; + technologyId: string; +} + +type Result = + | { + state: "loading"; + } + | { + state: "error"; + message: string; + } + | { + state: "ready"; + tree: ProjectBacklogTree; + }; + +const EMPTY_FILTERS: + BacklogFilters = { + status: "", + technologyId: "", + }; + +const VALID_STATUSES = + new Set([ + "", + "rascunho", + "ativo", + "pronto", + "concluido", + "arquivado", + ]); + +function storageKey( + projectId: string, +) { + return ( + "sinapse.backlog.filters." + + projectId + ); +} + +function readFilters( + projectId: string, +): BacklogFilters { + try { + const stored = + window.sessionStorage + .getItem( + storageKey(projectId), + ); + + if (!stored) { + return EMPTY_FILTERS; + } + + const value = + JSON.parse( + stored, + ) as Partial; + + return { + status: + typeof value.status + === "string" + && VALID_STATUSES.has( + value.status, + ) + ? value.status + : "", + + technologyId: + typeof value.technologyId + === "string" + ? value.technologyId + : "", + }; + } catch { + return EMPTY_FILTERS; + } +} + +function matches( + item: { + status: string; + + tecnologias: + Array<{ + id: string; + }>; + }, + + filters: BacklogFilters, +) { + return ( + ( + !filters.status + || item.status + === filters.status + ) + && ( + !filters.technologyId + || item.tecnologias.some( + (technology) => + technology.id + === filters.technologyId, + ) + ) + ); +} + +export function filterBacklogTree( + tree: ProjectBacklogTree, + filters: BacklogFilters, +): ProjectBacklogTree { + if ( + !filters.status + && !filters.technologyId + ) { + return tree; + } + + const epics = + tree.epics.flatMap( + (epic) => { + const features = + epic.features.flatMap( + (feature) => { + const pbis = + feature.pbis.filter( + (pbi) => + matches( + pbi, + filters, + ), + ); + + return ( + matches( + feature, + filters, + ) + || pbis.length > 0 + ) + ? [ + { + ...feature, + pbis, + }, + ] + : []; + }, + ); + + return ( + matches( + epic, + filters, + ) + || features.length > 0 + ) + ? [ + { + ...epic, + features, + }, + ] + : []; + }, + ); + + return { + ...tree, + epics, + }; +} + +const statusLabels: + Record = { + rascunho: "Rascunho", + ativo: "Ativo", + pronto: "Pronto", + concluido: "Concluído", + arquivado: "Arquivado", + }; + +function Technologies({ + values, +}: { + values: + Array<{ + id: string; + nome: string; + }>; +}) { + if (values.length === 0) { + return null; + } + + return ( + + {values.map( + (item) => ( + + {item.nome} + + ), + )} + + ); +} + +function ItemMeta({ + item, +}: { + item: { + status: string; + + tecnologias: + Array<{ + id: string; + nome: string; + }>; + }; +}) { + return ( + + + {statusLabels[item.status] + ?? item.status} + + + + + ); +} + +function TreePbi({ + projectId, + epicId, + featureId, + pbi, +}: { + projectId: string; + epicId: string; + featureId: string; + pbi: BacklogPbiNode; +}) { + const path = + `/projects/${projectId}` + + `/epics/${epicId}` + + `/features/${featureId}` + + `/pbis/${pbi.id}`; + + return ( +
  • + + + + + +
  • + ); +} + +function TreeFeature({ + projectId, + epicId, + feature, + open, + forcedOpen, + onToggle, +}: { + projectId: string; + epicId: string; + feature: BacklogFeatureNode; + open: boolean; + forcedOpen: boolean; + onToggle: () => void; +}) { + const isOpen = + forcedOpen || open; + + const path = + `/projects/${projectId}` + + `/epics/${epicId}` + + `/features/${feature.id}`; + + return ( +
  • +
    + + + + + + + +
    + + {isOpen && ( + feature.pbis.length > 0 + ? ( +
      + {feature.pbis.map( + (pbi) => ( + + ), + )} +
    + ) + : ( +

    + Nenhum PBI corresponde + nesta feature. +

    + ) + )} +
  • + ); +} + +function TreeEpic({ + projectId, + epic, + open, + forcedOpen, + expandedFeatures, + onToggle, + onToggleFeature, +}: { + projectId: string; + epic: BacklogEpicNode; + open: boolean; + forcedOpen: boolean; + expandedFeatures: Set; + onToggle: () => void; + onToggleFeature: + (id: string) => void; +}) { + const isOpen = + forcedOpen || open; + + return ( +
  • +
    + + + + + + + +
    + + {isOpen && ( + epic.features.length > 0 + ? ( +
      + {epic.features.map( + (feature) => ( + + onToggleFeature( + feature.id, + ) + } + /> + ), + )} +
    + ) + : ( +

    + Nenhuma feature + corresponde neste épico. +

    + ) + )} +
  • + ); +} + +export function BacklogTreeView({ + projectId, + canCreate, +}: { + projectId: string; + canCreate: boolean; +}) { + const [ + result, + setResult, + ] = useState({ + state: "loading", + }); + + const [ + attempt, + setAttempt, + ] = useState(0); + + const [ + filters, + setFilters, + ] = useState( + () => readFilters(projectId), + ); + + const [ + expandedEpics, + setExpandedEpics, + ] = useState>( + () => new Set(), + ); + + const [ + expandedFeatures, + setExpandedFeatures, + ] = useState>( + () => new Set(), + ); + + useEffect(() => { + const controller = + new AbortController(); + + setResult({ + state: "loading", + }); + + getProjectBacklogTree( + projectId, + controller.signal, + ) + .then((tree) => { + if ( + !controller.signal.aborted + ) { + setResult({ + state: "ready", + tree, + }); + } + }) + .catch((error) => { + if ( + controller.signal.aborted + ) { + return; + } + + setResult({ + state: "error", + + message: + error instanceof ApiError + && error.status === 404 + ? "Projeto não encontrado." + : "Não foi possível carregar a árvore do backlog.", + }); + }); + + return () => + controller.abort(); + }, [ + projectId, + attempt, + ]); + + useEffect(() => { + try { + window.sessionStorage + .setItem( + storageKey(projectId), + JSON.stringify(filters), + ); + } catch { + // A navegação continua funcional + // quando o navegador bloqueia + // sessionStorage. + } + }, [ + filters, + projectId, + ]); + + const filteredTree = + useMemo( + () => + result.state === "ready" + ? filterBacklogTree( + result.tree, + filters, + ) + : null, + [ + result, + filters, + ], + ); + + const activeFilterCount = + Number( + Boolean(filters.status), + ) + + Number( + Boolean( + filters.technologyId, + ), + ); + + const filtersActive = + activeFilterCount > 0; + + const toggle = ( + set: Set, + id: string, + ) => { + const next = + new Set(set); + + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + + return next; + }; + + const clearFilters = () => + setFilters( + EMPTY_FILTERS, + ); + + return ( +
    +
    +
    +

    + ÁRVORE DO PROJETO +

    + +

    + Backlog hierárquico +

    + +

    + Expanda épicos e features + para navegar até os PBIs. +

    +
    + + {canCreate && ( + + )} +
    + + {result.state === "ready" + && ( +
    + + + + + + {activeFilterCount} + {" "} + {activeFilterCount === 1 + ? "filtro ativo" + : "filtros ativos"} + + + {filtersActive && ( + + )} +
    + )} + + {result.state === "loading" + && ( +
    + Carregando árvore + do backlog… +
    + )} + + {result.state === "error" + && ( +
    +

    + {result.message} +

    + + +
    + )} + + {result.state === "ready" + && result.tree.epics + .length === 0 + && ( +
    +

    + Este projeto ainda + não possui épicos +

    + +

    + Crie o primeiro épico + para começar a organizar + features e PBIs. +

    + + {canCreate && ( + + )} +
    + )} + + {result.state === "ready" + && result.tree.epics + .length > 0 + && filteredTree?.epics + .length === 0 + && ( +
    +

    + Nenhum item corresponde + aos filtros +

    + +

    + Altere a combinação + de status e tecnologia + ou limpe os critérios. +

    + + +
    + )} + + {filteredTree + && filteredTree.epics + .length > 0 + && ( +
      + {filteredTree.epics.map( + (epic) => ( + + setExpandedEpics( + (current) => + toggle( + current, + epic.id, + ), + ) + } + onToggleFeature={ + (id) => + setExpandedFeatures( + (current) => + toggle( + current, + id, + ), + ) + } + /> + ), + )} +
    + )} +
    + ); +} \ No newline at end of file diff --git a/frontend/src/views/backlog/EpicsView.tsx b/frontend/src/views/backlog/EpicsView.tsx index cced8e3..46714e3 100644 --- a/frontend/src/views/backlog/EpicsView.tsx +++ b/frontend/src/views/backlog/EpicsView.tsx @@ -1,160 +1,797 @@ import { ReadOnlyContext } from "../../models/ReadOnlyContext"; import { ItemArchiveView } from "./ItemArchiveView"; -import { useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { ApiError } from "../../api/api_auth"; import { navigate } from "../../models/navigation"; -import { createEpic, completeEpic, updateEpic, getEpic, listEpics, camposFaltantesDe, type Epic, type EpicInput } from "../../api/api_backlog"; +import { + createEpic, + completeEpic, + updateEpic, + getEpic, + listEpics, + camposFaltantesDe, + type Epic, + type EpicInput, +} from "../../api/api_backlog"; import { descreverCamposFaltantes } from "../../models/fields"; import { useUnsavedChangesGuard } from "../../viewmodels/useUnsavedChangesGuard"; import { CriteriaEditor } from "./CriteriaView"; +import { BacklogBreadcrumb } from "./BacklogBreadcrumb"; import "../../assets/styles/projects.css"; -type ListResult = { state: "loading" } | { state: "error"; message: string } | { state: "ready"; epics: Epic[] }; -const emptyInput: EpicInput = { projeto_id: "", titulo: "", descricao: "", objetivo: "", escopo_macro: "", resultado_esperado: "" }; +type ListResult = + | { state: "loading" } + | { state: "error"; message: string } + | { state: "ready"; epics: Epic[] }; -export function EpicList({ projetoId, canCreate: allowedToCreate }: { projetoId: string; canCreate: boolean }) { - const inheritedReadOnly = useContext(ReadOnlyContext); - const canCreate = allowedToCreate && !inheritedReadOnly; - const [result, setResult] = useState({ state: "loading" }); - const [attempt, setAttempt] = useState(0); - const [status, setStatus] = useState(""); +const emptyInput: EpicInput = { + projeto_id: "", + titulo: "", + descricao: "", + objetivo: "", + escopo_macro: "", + resultado_esperado: "", +}; + +export function EpicList({ + projetoId, + canCreate: allowedToCreate, +}: { + projetoId: string; + canCreate: boolean; +}) { + const inheritedReadOnly = + useContext(ReadOnlyContext); + + const canCreate = + allowedToCreate + && !inheritedReadOnly; + + const [ + result, + setResult, + ] = useState({ + state: "loading", + }); + + const [ + attempt, + setAttempt, + ] = useState(0); + + const [ + status, + setStatus, + ] = useState(""); useEffect(() => { - const controller = new AbortController(); - setResult({ state: "loading" }); - listEpics(projetoId, controller.signal, status) - .then((epics) => { if (!controller.signal.aborted) setResult({ state: "ready", epics }); }) + const controller = + new AbortController(); + + setResult({ + state: "loading", + }); + + listEpics( + projetoId, + controller.signal, + status, + ) + .then((epics) => { + if ( + !controller.signal.aborted + ) { + setResult({ + state: "ready", + epics, + }); + } + }) .catch((error) => { - if (controller.signal.aborted) return; - setResult({ state: "error", message: error instanceof ApiError && error.status === 401 ? "É necessário entrar para acessar os épicos." : "Não foi possível carregar os épicos." }); + if ( + controller.signal.aborted + ) { + return; + } + + setResult({ + state: "error", + + message: + error instanceof ApiError + && error.status === 401 + ? "É necessário entrar para acessar os épicos." + : "Não foi possível carregar os épicos.", + }); }); - return () => controller.abort(); - }, [projetoId, attempt, status]); + + return () => + controller.abort(); + }, [ + projetoId, + attempt, + status, + ]); return (
    -

    Épicos do projeto

    Épicos

    - {canCreate && } +
    +

    + Épicos do projeto +

    + +

    Épicos

    +
    + + {canCreate && ( + + )}
    - - {result.state === "loading" &&
    Carregando épicos…
    } - {result.state === "error" &&

    {result.message}

    -
    } - {result.state === "ready" && (result.epics.length === 0 - ?

    Nenhum épico cadastrado

    Crie o primeiro épico para começar a especificar este projeto.

    - {canCreate && }
    - :
    {result.epics.map((epic) => ( -
    - {epic.status} -

    {epic.titulo}

    -

    {epic.objetivo || "Sem objetivo registrado."}

    - -
    - ))}
    )} + + + + {result.state === "loading" + && ( +
    + Carregando épicos… +
    + )} + + {result.state === "error" + && ( +
    +

    + {result.message} +

    + + +
    + )} + + {result.state === "ready" + && ( + result.epics.length === 0 + ? ( +
    +

    + Nenhum épico cadastrado +

    + +

    + Crie o primeiro épico + para começar a especificar + este projeto. +

    + + {canCreate && ( + + )} +
    + ) + : ( +
    + {result.epics.map( + (epic) => ( +
    + + {epic.status} + + +

    + {epic.titulo} +

    + +

    + {epic.objetivo + || "Sem objetivo registrado."} +

    + + +
    + ), + )} +
    + ) + )}
    ); } -export function EpicForm({ projetoId }: { projetoId: string }) { - const [values, setValues] = useState({ ...emptyInput, projeto_id: projetoId }); - const [busy, setBusy] = useState(false); - const [message, setMessage] = useState(""); - const submitting = useRef(false); - const mounted = useRef(true); - useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); - const isDirty = [values.titulo, values.descricao, values.objetivo, values.escopo_macro, values.resultado_esperado].some((value) => value.trim().length > 0); - const { confirmLeave } = useUnsavedChangesGuard(isDirty); +export function EpicForm({ + projetoId, +}: { + projetoId: string; +}) { + const [ + values, + setValues, + ] = useState({ + ...emptyInput, + projeto_id: projetoId, + }); + + const [ + busy, + setBusy, + ] = useState(false); + + const [ + message, + setMessage, + ] = useState(""); + + const submitting = + useRef(false); + + const mounted = + useRef(true); + + useEffect(() => { + mounted.current = true; + + return () => { + mounted.current = false; + }; + }, []); + + const isDirty = [ + values.titulo, + values.descricao, + values.objetivo, + values.escopo_macro, + values.resultado_esperado, + ].some( + (value) => + value.trim().length > 0, + ); + + const { confirmLeave } = + useUnsavedChangesGuard( + isDirty, + ); return (
    -

    Épicos / Novo épico

    Criar épico

    -

    Apenas o título é obrigatório para salvar como rascunho. Os demais campos do guia são exigidos para concluir.

    -
    { - event.preventDefault(); - if (submitting.current) return; - if (!values.titulo.trim()) { setMessage("Informe o título do épico."); return; } - submitting.current = true; setBusy(true); setMessage(""); - try { - const epic = await createEpic({ ...values, projeto_id: projetoId }); - if (mounted.current) navigate(`/projects/${projetoId}/epics/${epic.id}`); - } catch (error) { - if (!mounted.current) return; - setMessage(error instanceof ApiError && error.status === 404 ? "Projeto não encontrado." : "Não foi possível criar o épico. Tente novamente."); - } finally { - submitting.current = false; - if (mounted.current) setBusy(false); - } - }}> +
    +
    +

    + Épicos / Novo épico +

    + +

    Criar épico

    + +

    + Apenas o título é obrigatório + para salvar como rascunho. + Os demais campos do guia + são exigidos para concluir. +

    +
    +
    + + { + event.preventDefault(); + + if (submitting.current) { + return; + } + + if (!values.titulo.trim()) { + setMessage( + "Informe o título do épico.", + ); + + return; + } + + submitting.current = true; + setBusy(true); + setMessage(""); + + try { + const epic = + await createEpic({ + ...values, + projeto_id: + projetoId, + }); + + if (mounted.current) { + navigate( + `/projects/${projetoId}/epics/${epic.id}`, + ); + } + } catch (error) { + if (!mounted.current) { + return; + } + + setMessage( + error instanceof ApiError + && error.status === 404 + ? "Projeto não encontrado." + : "Não foi possível criar o épico. Tente novamente.", + ); + } finally { + submitting.current = + false; + + if (mounted.current) { + setBusy(false); + } + } + }} + > {([ - ["titulo", "Título", "input"], ["objetivo", "Objetivo", "textarea"], ["descricao", "Descrição", "textarea"], - ["escopo_macro", "Escopo macro", "textarea"], ["resultado_esperado", "Resultado esperado", "textarea"], - ] as const).map(([field, label, kind]) => ( -
    - - {kind === "textarea" - ?