From bf6686f9b0b597fc15251cba6c23b8aa951f14fa Mon Sep 17 00:00:00 2001 From: Nurfog Date: Wed, 14 Oct 2026 13:12:19 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20estados=20de=20workflow=20din=C3=A1mico?= =?UTF-8?q?s=20desde=20la=20API=20(Fase=201,=20parcial)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/issues/IssueList.tsx | 9 ++-- src/components/ui/Icons.tsx | 22 +++++++--- src/lib/api/client.ts | 7 +++ src/lib/api/http.ts | 22 ++++++++++ src/lib/api/mock.ts | 66 +++++++++++++++++++++++++++-- src/lib/meta.ts | 53 ++++++++++++++++++++--- src/lib/seed.ts | 12 +++++- src/lib/store.tsx | 46 ++++++++++++++++++-- src/lib/types.ts | 39 ++++++++++++++--- 9 files changed, 244 insertions(+), 32 deletions(-) diff --git a/src/components/issues/IssueList.tsx b/src/components/issues/IssueList.tsx index a500062..4f69eec 100644 --- a/src/components/issues/IssueList.tsx +++ b/src/components/issues/IssueList.tsx @@ -1,8 +1,7 @@ "use client"; -import { STATUS_META, STATUS_ORDER } from "@/lib/meta"; import type { Issue } from "@/lib/types"; -import { useReferenceLookups } from "@/lib/store"; +import { useReferenceLookups, useWorkflowStates } from "@/lib/store"; import { EmptyState } from "@/components/ui/EmptyState"; import { StatusIcon } from "@/components/ui/Icons"; import { IssueRow } from "./IssueRow"; @@ -31,6 +30,7 @@ export function IssueList({ emptyDescription = "Crea un nuevo issue para empezar.", }: IssueListProps) { const { projectById, userById, labelById } = useReferenceLookups(); + const { meta, order } = useWorkflowStates(); if (loading) { return ; @@ -81,15 +81,16 @@ export function IssueList({ if (groupBy === "status") { return (
- {STATUS_ORDER.map((status) => { + {order.map((status) => { const groupIssues = issues.filter((issue) => issue.status === status); if (groupIssues.length === 0) return null; + const label = meta[status]?.label ?? status; return (
{header( , - STATUS_META[status].label, + label, groupIssues.length, )} {table(groupIssues)} diff --git a/src/components/ui/Icons.tsx b/src/components/ui/Icons.tsx index d8b510d..0b32ce4 100644 --- a/src/components/ui/Icons.tsx +++ b/src/components/ui/Icons.tsx @@ -1,5 +1,8 @@ -import { PRIORITY_META, STATUS_META, TYPE_META } from "@/lib/meta"; +"use client"; + +import { PRIORITY_META, TYPE_META, UNKNOWN_STATUS_META } from "@/lib/meta"; import type { IssuePriority, IssueStatus, IssueType } from "@/lib/types"; +import { useWorkflowStates } from "@/lib/store"; /** * Iconografía con boxicons, igual que sam-portal y SistemaCalidad. @@ -11,15 +14,20 @@ interface StatusIconProps { size?: number; } -/** Icono de estado del issue. */ +/** + * Icono de estado del issue. + * Resuelve desde los estados configurados; si la clave no existe + * (estado desactivado tras asignarlo), usa el icono neutro. + */ export function StatusIcon({ status, size = 16 }: StatusIconProps) { - const meta = STATUS_META[status]; + const { meta } = useWorkflowStates(); + const m = meta[status] ?? UNKNOWN_STATUS_META; return ( ); } diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 1374e7a..204b783 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -8,6 +8,8 @@ import type { Label, Project, User, + WorkflowState, + WorkflowStateInput, } from "@/lib/types"; /** @@ -27,6 +29,11 @@ export interface ProyectosApi { listLabels(): Promise; listProjects(): Promise; listCycles(): Promise; + /** Estados del workflow (Fase 1: configurables). */ + listStates(includeInactive?: boolean): Promise; + createState(input: WorkflowStateInput & { key: string; label: string }): Promise; + updateState(key: string, patch: WorkflowStateInput): Promise; + deleteState(key: string): Promise; // Issues listIssues(filters?: IssueFilters): Promise; diff --git a/src/lib/api/http.ts b/src/lib/api/http.ts index 155b1f3..f60a5d1 100644 --- a/src/lib/api/http.ts +++ b/src/lib/api/http.ts @@ -8,6 +8,8 @@ import type { Label, Project, User, + WorkflowState, + WorkflowStateInput, } from "@/lib/types"; import type { ProyectosApi } from "./client"; @@ -112,6 +114,26 @@ export const httpApi: ProyectosApi = { listCycles: () => request("/api/proyectos/cycles"), + listStates: (includeInactive?: boolean) => + request( + `/api/proyectos/states${includeInactive ? "?includeInactive=true" : ""}`, + ), + + createState: (input: WorkflowStateInput & { key: string; label: string }) => + request("/api/proyectos/states", { + method: "POST", + body: JSON.stringify(input), + }), + + updateState: (key: string, patch: WorkflowStateInput) => + request(`/api/proyectos/states/${key}`, { + method: "PATCH", + body: JSON.stringify(patch), + }), + + deleteState: (key: string) => + request(`/api/proyectos/states/${key}`, { method: "DELETE" }), + listIssues: (filters) => request(`/api/proyectos/issues${buildQuery(filters)}`), async getIssue(id: ID): Promise { diff --git a/src/lib/api/mock.ts b/src/lib/api/mock.ts index 6bb390e..da94ee9 100644 --- a/src/lib/api/mock.ts +++ b/src/lib/api/mock.ts @@ -8,6 +8,8 @@ import type { Label, Project, User, + WorkflowState, + WorkflowStateInput, } from "@/lib/types"; import * as seed from "@/lib/seed"; import type { ProyectosApi } from "./client"; @@ -27,9 +29,15 @@ const state = { cycles: structuredClone(seed.cycles), users: structuredClone(seed.users), labels: structuredClone(seed.labels), + states: structuredClone(seed.workflowStates), seq: seed.issues.length, }; +/** Claves que cuentan como terminadas (bandera is_done de la API). */ +function doneKeys(): Set { + return new Set(state.states.filter((s) => s.isDone).map((s) => s.key)); +} + /** Simula latencia de red para que los estados de carga sean visibles. */ const delay = (ms = 120) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -111,6 +119,54 @@ export const mockApi: ProyectosApi = { return state.cycles; }, + async listStates(includeInactive = false): Promise { + await delay(60); + const all = [...state.states].sort((a, b) => a.sortOrder - b.sortOrder); + return includeInactive ? all : all.filter((s) => s.isActive); + }, + + async createState(input: WorkflowStateInput & { key: string; label: string }): Promise { + await delay(120); + const key = input.key.trim().toLowerCase(); + if (!/^[a-z][a-z0-9_]{0,31}$/.test(key)) throw new Error("Clave inválida."); + if (state.states.some((s) => s.key === key)) throw new Error(`El estado '${key}' ya existe.`); + const created: WorkflowState = { + key, + label: input.label.trim(), + color: input.color ?? "#6c788b", + icon: input.icon ?? "bx-radio-circle", + sortOrder: input.sortOrder ?? 100, + section: input.section ?? "Sin iniciar", + isDone: input.isDone ?? false, + isActive: true, + }; + state.states.push(created); + return created; + }, + + async updateState(key: string, patch: WorkflowStateInput): Promise { + await delay(120); + const found = state.states.find((s) => s.key === key); + if (!found) throw new Error(`Estado '${key}' no encontrado.`); + Object.assign(found, { + ...(patch.label !== undefined ? { label: patch.label } : {}), + ...(patch.color !== undefined ? { color: patch.color } : {}), + ...(patch.icon !== undefined ? { icon: patch.icon } : {}), + ...(patch.sortOrder !== undefined ? { sortOrder: patch.sortOrder } : {}), + ...(patch.section !== undefined ? { section: patch.section } : {}), + ...(patch.isDone !== undefined ? { isDone: patch.isDone } : {}), + ...(patch.isActive !== undefined ? { isActive: patch.isActive } : {}), + }); + return found; + }, + + async deleteState(key: string): Promise { + await delay(120); + const inUse = state.issues.filter((i) => i.status === key).length; + if (inUse > 0) throw new Error(`El estado tiene ${inUse} issue(s).`); + state.states = state.states.filter((s) => s.key !== key); + }, + async listIssues(filters?: IssueFilters): Promise { await delay(140); return state.issues.filter((issue) => matchesFilters(issue, filters)); @@ -123,6 +179,9 @@ export const mockApi: ProyectosApi = { async createIssue(input: IssueInput): Promise { await delay(120); + const status = input.status ?? "todo"; + const known = state.states.find((s) => s.key === status && s.isActive); + if (!known) throw new Error(`Estado desconocido o inactivo: '${status}'.`); const project = state.projects.find((p) => p.id === input.projectId); const now = new Date().toISOString(); @@ -131,7 +190,7 @@ export const mockApi: ProyectosApi = { identifier: buildIdentifier(project, state.seq + 1), title: input.title, description: input.description ?? "", - status: input.status ?? "todo", + status, priority: input.priority ?? 0, type: input.type ?? "task", assigneeId: input.assigneeId ?? null, @@ -154,8 +213,9 @@ export const mockApi: ProyectosApi = { Object.assign(issue, patch, { updatedAt: new Date().toISOString() }); - // Mantener `completedAt` coherente con el estado final. - const isFinished = issue.status === "done" || issue.status === "canceled"; + // Mantener `completedAt` coherente con el estado final + // (bandera is_done, igual que el backend). + const isFinished = doneKeys().has(issue.status); issue.completedAt = isFinished ? (issue.completedAt ?? new Date().toISOString()) : null; return issue; diff --git a/src/lib/meta.ts b/src/lib/meta.ts index ae8c4ff..5ac1fec 100644 --- a/src/lib/meta.ts +++ b/src/lib/meta.ts @@ -1,11 +1,19 @@ -import type { IssueStatus, IssuePriority, IssueType } from "@/lib/types"; +import type { IssuePriority, IssueType, WorkflowState } from "@/lib/types"; /** Definiciones canónicas para pintar badges y iconos de forma consistente. */ -export const STATUS_META: Record< - IssueStatus, - { label: string; color: string; icon: string; group: string } -> = { +export interface StatusMeta { + label: string; + color: string; + icon: string; +} + +/** + * Fallback estático con los 6 estados históricos. + * Se usa mientras cargan los estados reales o si la API no responde; + * la fuente de verdad es GET /api/proyectos/states (ver useWorkflowStates). + */ +export const STATUS_META: Record = { backlog: { label: "Backlog", color: "#6c788b", icon: "bx-radio-circle", group: "Sin iniciar" }, todo: { label: "Por hacer", color: "#6c788b", icon: "bx-radio-circle", group: "Sin iniciar" }, in_progress: { label: "En curso", color: "#d19b3d", icon: "bx-error-circle", group: "En curso" }, @@ -14,7 +22,7 @@ export const STATUS_META: Record< canceled: { label: "Cancelado", color: "#98a2b3", icon: "bx-x-circle", group: "Completado" }, }; -export const STATUS_ORDER: IssueStatus[] = [ +export const STATUS_ORDER: string[] = [ "backlog", "todo", "in_progress", @@ -23,6 +31,39 @@ export const STATUS_ORDER: IssueStatus[] = [ "canceled", ]; +/** Meta para un estado desconocido (p. ej. desactivado después de asignarlo). */ +export const UNKNOWN_STATUS_META: StatusMeta = { + label: "?", + color: "#98a2b3", + icon: "bx-radio-circle", +}; + +/** + * Construye el mapa de estados desde la API. + * Devuelve meta por clave, orden por sortOrder y el conjunto de claves + * que cuentan como terminadas (isDone). + */ +export function buildStatusMeta(states: WorkflowState[]): { + meta: Record; + order: string[]; + doneKeys: Set; +} { + const active = [...states] + .filter((s) => s.isActive) + .sort((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label)); + + const meta: Record = {}; + for (const s of active) { + meta[s.key] = { label: s.label, color: s.color, icon: s.icon }; + } + + return { + meta, + order: active.map((s) => s.key), + doneKeys: new Set(states.filter((s) => s.isDone).map((s) => s.key)), + }; +} + export const PRIORITY_META: Record< IssuePriority, { label: string; color: string; icon: string; rank: number } diff --git a/src/lib/seed.ts b/src/lib/seed.ts index 11930e8..8310858 100644 --- a/src/lib/seed.ts +++ b/src/lib/seed.ts @@ -1,4 +1,4 @@ -import type { Comment, Cycle, Issue, Label, Project, User } from "@/lib/types"; +import type { Comment, Cycle, Issue, Label, Project, User, WorkflowState } from "@/lib/types"; /** * Datos de ejemplo usados por la capa mock. @@ -24,6 +24,16 @@ export const labels: Label[] = [ { id: "l5", name: "deuda técnica", color: "#f2994a" }, ]; +/** Réplica local de proyectos.workflow_states (ver migración 003). */ +export const workflowStates: WorkflowState[] = [ + { key: "backlog", label: "Backlog", color: "#6c788b", icon: "bx-radio-circle", sortOrder: 10, section: "Sin iniciar", isDone: false, isActive: true }, + { key: "todo", label: "Por hacer", color: "#6c788b", icon: "bx-radio-circle", sortOrder: 20, section: "Sin iniciar", isDone: false, isActive: true }, + { key: "in_progress", label: "En curso", color: "#d19b3d", icon: "bx-error-circle", sortOrder: 30, section: "En curso", isDone: false, isActive: true }, + { key: "in_review", label: "En revisión", color: "#8c6fd1", icon: "bx-time", sortOrder: 40, section: "En curso", isDone: false, isActive: true }, + { key: "done", label: "Completado", color: "#015fc9", icon: "bx-check-circle", sortOrder: 50, section: "Completado", isDone: true, isActive: true }, + { key: "canceled", label: "Cancelado", color: "#98a2b3", icon: "bx-x-circle", sortOrder: 60, section: "Completado", isDone: true, isActive: true }, +]; + export const projects: Project[] = [ { id: "p1", diff --git a/src/lib/store.tsx b/src/lib/store.tsx index 474715f..80281fe 100644 --- a/src/lib/store.tsx +++ b/src/lib/store.tsx @@ -2,11 +2,12 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { api } from "@/lib/api"; -import type { Cycle, Label, Project, User } from "@/lib/types"; +import type { Cycle, Label, Project, User, WorkflowState } from "@/lib/types"; +import { STATUS_META, STATUS_ORDER, buildStatusMeta, type StatusMeta } from "@/lib/meta"; /** * Datos de referencia compartidos por toda la app (usuarios, etiquetas, - * proyectos y ciclos). + * proyectos, ciclos y estados del workflow). * * Se cargan una sola vez en el layout raíz y se exponen por contexto, para * que cada página pueda resolver nombres, colores e iniciales sin repetir @@ -18,6 +19,7 @@ interface ReferenceData { labels: Label[]; projects: Project[]; cycles: Cycle[]; + states: WorkflowState[]; loading: boolean; } @@ -26,6 +28,7 @@ const ReferenceContext = createContext({ labels: [], projects: [], cycles: [], + states: [], loading: true, }); @@ -35,6 +38,7 @@ export function ReferenceProvider({ children }: { children: React.ReactNode }) { labels: [], projects: [], cycles: [], + states: [], }); const [loading, setLoading] = useState(true); @@ -42,15 +46,18 @@ export function ReferenceProvider({ children }: { children: React.ReactNode }) { let cancelled = false; async function load() { - const [users, labels, projects, cycles] = await Promise.all([ + const [users, labels, projects, cycles, states] = await Promise.all([ api.listUsers(), api.listLabels(), api.listProjects(), api.listCycles(), + // Si la API no tiene estados (backend viejo), lista vacía y la UI + // usa el fallback estático de meta.ts. + api.listStates().catch(() => [] as WorkflowState[]), ]); if (!cancelled) { - setData({ users, labels, projects, cycles }); + setData({ users, labels, projects, cycles, states }); setLoading(false); } } @@ -71,6 +78,7 @@ export function useReference(): ReferenceData { return useContext(ReferenceContext); } + /** Helpers de búsqueda por id, memorizados junto a los datos. */ export function useReferenceLookups() { const { users, labels, projects, cycles } = useReference(); @@ -85,3 +93,33 @@ export function useReferenceLookups() { [users, labels, projects, cycles], ); } + +/** + * Estados del workflow como los ve la UI. + * + * Construye el mapa desde la API; si aún no cargan (o la API no los + * expone), usa el fallback estático de meta.ts para no dejar la UI vacía. + */ +export function useWorkflowStates() { + const { states, loading } = useReference(); + + return useMemo(() => { + if (states.length === 0) { + const meta: Record = {}; + for (const [key, m] of Object.entries(STATUS_META)) { + meta[key] = { label: m.label, color: m.color, icon: m.icon }; + } + return { + meta, + order: STATUS_ORDER, + // Fallback histórico: done + canceled cuentan como terminados. + doneKeys: new Set(["done", "canceled"]), + ready: !loading, + }; + } + + const built = buildStatusMeta(states); + return { ...built, ready: true }; + }, [states, loading]); +} + diff --git a/src/lib/types.ts b/src/lib/types.ts index f008b59..a5955c6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -8,13 +8,38 @@ export type ID = string; -export type IssueStatus = - | "backlog" - | "todo" - | "in_progress" - | "in_review" - | "done" - | "canceled"; +/** + * Estado de un issue (Fase 1: configurables). + * + * Ya no es una unión fija: la clave viene de `workflow_states` en el + * backend (GET /api/proyectos/states). Los 6 históricos siguen siendo + * los mismos strings, así que el seed y los datos existentes no cambian. + */ +export type IssueStatus = string; + +/** Estado del workflow tal como lo expone el backend. */ +export interface WorkflowState { + key: string; + label: string; + color: string; + icon: string; + sortOrder: number; + section: string; + isDone: boolean; + isActive: boolean; +} + +/** Payload para crear/actualizar un estado del workflow. */ +export interface WorkflowStateInput { + key?: string; + label?: string; + color?: string; + icon?: string; + sortOrder?: number; + section?: string; + isDone?: boolean; + isActive?: boolean; +} export type IssuePriority = 0 | 1 | 2 | 3 | 4; // 0 = sin prioridad .. 4 = urgente