feat: estados de workflow dinámicos desde la API (Fase 1, parcial)
This commit is contained in:
1 parent
2b60a17645
commit
bf6686f9b0
9 files changed
+244
-32
No files matched your search
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user