feat: estados de workflow dinámicos desde la API (Fase 1, parcial)

This commit is contained in:
Nurfog committed 2026-10-14 13:12:19 -03:00
1 parent 2b60a17645
commit bf6686f9b0
9 files changed
+244 -32

No files matched your search

+5 -4
View File
@@ -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 <IssueListSkeleton />;
@@ -81,15 +81,16 @@ export function IssueList({
if (groupBy === "status") {
return (
<div>
{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 (
<section key={status}>
{header(
<StatusIcon status={status} />,
STATUS_META[status].label,
label,
groupIssues.length,
)}
{table(groupIssues)}
+15 -7
View File
@@ -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 (
<i
className={`bx ${meta.icon}`}
style={{ color: meta.color, fontSize: size }}
title={meta.label}
aria-label={meta.label}
className={`bx ${m.icon}`}
style={{ color: m.color, fontSize: size }}
title={m.label}
aria-label={m.label}
/>
);
}
+7
View File
@@ -8,6 +8,8 @@ import type {
Label,
Project,
User,
WorkflowState,
WorkflowStateInput,
} from "@/lib/types";
/**
@@ -27,6 +29,11 @@ export interface ProyectosApi {
listLabels(): Promise<Label[]>;
listProjects(): Promise<Project[]>;
listCycles(): Promise<Cycle[]>;
/** Estados del workflow (Fase 1: configurables). */
listStates(includeInactive?: boolean): Promise<WorkflowState[]>;
createState(input: WorkflowStateInput & { key: string; label: string }): Promise<WorkflowState>;
updateState(key: string, patch: WorkflowStateInput): Promise<WorkflowState>;
deleteState(key: string): Promise<void>;
// Issues
listIssues(filters?: IssueFilters): Promise<Issue[]>;
+22
View File
@@ -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<Cycle[]>("/api/proyectos/cycles"),
listStates: (includeInactive?: boolean) =>
request<WorkflowState[]>(
`/api/proyectos/states${includeInactive ? "?includeInactive=true" : ""}`,
),
createState: (input: WorkflowStateInput & { key: string; label: string }) =>
request<WorkflowState>("/api/proyectos/states", {
method: "POST",
body: JSON.stringify(input),
}),
updateState: (key: string, patch: WorkflowStateInput) =>
request<WorkflowState>(`/api/proyectos/states/${key}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
deleteState: (key: string) =>
request<void>(`/api/proyectos/states/${key}`, { method: "DELETE" }),
listIssues: (filters) => request<Issue[]>(`/api/proyectos/issues${buildQuery(filters)}`),
async getIssue(id: ID): Promise<Issue | null> {
+63 -3
View File
@@ -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<string> {
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<WorkflowState[]> {
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<WorkflowState> {
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<WorkflowState> {
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<void> {
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<Issue[]> {
await delay(140);
return state.issues.filter((issue) => matchesFilters(issue, filters));
@@ -123,6 +179,9 @@ export const mockApi: ProyectosApi = {
async createIssue(input: IssueInput): Promise<Issue> {
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;
+47 -6
View File
@@ -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<string, StatusMeta & { group: string }> = {
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<string, StatusMeta>;
order: string[];
doneKeys: Set<string>;
} {
const active = [...states]
.filter((s) => s.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label));
const meta: Record<string, StatusMeta> = {};
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 }
+11 -1
View File
@@ -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",
+42 -4
View File
@@ -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<ReferenceData>({
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<string, StatusMeta> = {};
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]);
}
+32 -7
View File
@@ -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