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";
|
"use client";
|
||||||
|
|
||||||
import { STATUS_META, STATUS_ORDER } from "@/lib/meta";
|
|
||||||
import type { Issue } from "@/lib/types";
|
import type { Issue } from "@/lib/types";
|
||||||
import { useReferenceLookups } from "@/lib/store";
|
import { useReferenceLookups, useWorkflowStates } from "@/lib/store";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import { StatusIcon } from "@/components/ui/Icons";
|
import { StatusIcon } from "@/components/ui/Icons";
|
||||||
import { IssueRow } from "./IssueRow";
|
import { IssueRow } from "./IssueRow";
|
||||||
@@ -31,6 +30,7 @@ export function IssueList({
|
|||||||
emptyDescription = "Crea un nuevo issue para empezar.",
|
emptyDescription = "Crea un nuevo issue para empezar.",
|
||||||
}: IssueListProps) {
|
}: IssueListProps) {
|
||||||
const { projectById, userById, labelById } = useReferenceLookups();
|
const { projectById, userById, labelById } = useReferenceLookups();
|
||||||
|
const { meta, order } = useWorkflowStates();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <IssueListSkeleton />;
|
return <IssueListSkeleton />;
|
||||||
@@ -81,15 +81,16 @@ export function IssueList({
|
|||||||
if (groupBy === "status") {
|
if (groupBy === "status") {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{STATUS_ORDER.map((status) => {
|
{order.map((status) => {
|
||||||
const groupIssues = issues.filter((issue) => issue.status === status);
|
const groupIssues = issues.filter((issue) => issue.status === status);
|
||||||
if (groupIssues.length === 0) return null;
|
if (groupIssues.length === 0) return null;
|
||||||
|
const label = meta[status]?.label ?? status;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section key={status}>
|
<section key={status}>
|
||||||
{header(
|
{header(
|
||||||
<StatusIcon status={status} />,
|
<StatusIcon status={status} />,
|
||||||
STATUS_META[status].label,
|
label,
|
||||||
groupIssues.length,
|
groupIssues.length,
|
||||||
)}
|
)}
|
||||||
{table(groupIssues)}
|
{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 type { IssuePriority, IssueStatus, IssueType } from "@/lib/types";
|
||||||
|
import { useWorkflowStates } from "@/lib/store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iconografía con boxicons, igual que sam-portal y SistemaCalidad.
|
* Iconografía con boxicons, igual que sam-portal y SistemaCalidad.
|
||||||
@@ -11,15 +14,20 @@ interface StatusIconProps {
|
|||||||
size?: number;
|
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) {
|
export function StatusIcon({ status, size = 16 }: StatusIconProps) {
|
||||||
const meta = STATUS_META[status];
|
const { meta } = useWorkflowStates();
|
||||||
|
const m = meta[status] ?? UNKNOWN_STATUS_META;
|
||||||
return (
|
return (
|
||||||
<i
|
<i
|
||||||
className={`bx ${meta.icon}`}
|
className={`bx ${m.icon}`}
|
||||||
style={{ color: meta.color, fontSize: size }}
|
style={{ color: m.color, fontSize: size }}
|
||||||
title={meta.label}
|
title={m.label}
|
||||||
aria-label={meta.label}
|
aria-label={m.label}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
Label,
|
Label,
|
||||||
Project,
|
Project,
|
||||||
User,
|
User,
|
||||||
|
WorkflowState,
|
||||||
|
WorkflowStateInput,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +29,11 @@ export interface ProyectosApi {
|
|||||||
listLabels(): Promise<Label[]>;
|
listLabels(): Promise<Label[]>;
|
||||||
listProjects(): Promise<Project[]>;
|
listProjects(): Promise<Project[]>;
|
||||||
listCycles(): Promise<Cycle[]>;
|
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
|
// Issues
|
||||||
listIssues(filters?: IssueFilters): Promise<Issue[]>;
|
listIssues(filters?: IssueFilters): Promise<Issue[]>;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
Label,
|
Label,
|
||||||
Project,
|
Project,
|
||||||
User,
|
User,
|
||||||
|
WorkflowState,
|
||||||
|
WorkflowStateInput,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import type { ProyectosApi } from "./client";
|
import type { ProyectosApi } from "./client";
|
||||||
|
|
||||||
@@ -112,6 +114,26 @@ export const httpApi: ProyectosApi = {
|
|||||||
|
|
||||||
listCycles: () => request<Cycle[]>("/api/proyectos/cycles"),
|
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)}`),
|
listIssues: (filters) => request<Issue[]>(`/api/proyectos/issues${buildQuery(filters)}`),
|
||||||
|
|
||||||
async getIssue(id: ID): Promise<Issue | null> {
|
async getIssue(id: ID): Promise<Issue | null> {
|
||||||
|
|||||||
+63
-3
@@ -8,6 +8,8 @@ import type {
|
|||||||
Label,
|
Label,
|
||||||
Project,
|
Project,
|
||||||
User,
|
User,
|
||||||
|
WorkflowState,
|
||||||
|
WorkflowStateInput,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import * as seed from "@/lib/seed";
|
import * as seed from "@/lib/seed";
|
||||||
import type { ProyectosApi } from "./client";
|
import type { ProyectosApi } from "./client";
|
||||||
@@ -27,9 +29,15 @@ const state = {
|
|||||||
cycles: structuredClone(seed.cycles),
|
cycles: structuredClone(seed.cycles),
|
||||||
users: structuredClone(seed.users),
|
users: structuredClone(seed.users),
|
||||||
labels: structuredClone(seed.labels),
|
labels: structuredClone(seed.labels),
|
||||||
|
states: structuredClone(seed.workflowStates),
|
||||||
seq: seed.issues.length,
|
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. */
|
/** Simula latencia de red para que los estados de carga sean visibles. */
|
||||||
const delay = (ms = 120) => new Promise((resolve) => setTimeout(resolve, ms));
|
const delay = (ms = 120) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
@@ -111,6 +119,54 @@ export const mockApi: ProyectosApi = {
|
|||||||
return state.cycles;
|
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[]> {
|
async listIssues(filters?: IssueFilters): Promise<Issue[]> {
|
||||||
await delay(140);
|
await delay(140);
|
||||||
return state.issues.filter((issue) => matchesFilters(issue, filters));
|
return state.issues.filter((issue) => matchesFilters(issue, filters));
|
||||||
@@ -123,6 +179,9 @@ export const mockApi: ProyectosApi = {
|
|||||||
|
|
||||||
async createIssue(input: IssueInput): Promise<Issue> {
|
async createIssue(input: IssueInput): Promise<Issue> {
|
||||||
await delay(120);
|
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 project = state.projects.find((p) => p.id === input.projectId);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
@@ -131,7 +190,7 @@ export const mockApi: ProyectosApi = {
|
|||||||
identifier: buildIdentifier(project, state.seq + 1),
|
identifier: buildIdentifier(project, state.seq + 1),
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description ?? "",
|
description: input.description ?? "",
|
||||||
status: input.status ?? "todo",
|
status,
|
||||||
priority: input.priority ?? 0,
|
priority: input.priority ?? 0,
|
||||||
type: input.type ?? "task",
|
type: input.type ?? "task",
|
||||||
assigneeId: input.assigneeId ?? null,
|
assigneeId: input.assigneeId ?? null,
|
||||||
@@ -154,8 +213,9 @@ export const mockApi: ProyectosApi = {
|
|||||||
|
|
||||||
Object.assign(issue, patch, { updatedAt: new Date().toISOString() });
|
Object.assign(issue, patch, { updatedAt: new Date().toISOString() });
|
||||||
|
|
||||||
// Mantener `completedAt` coherente con el estado final.
|
// Mantener `completedAt` coherente con el estado final
|
||||||
const isFinished = issue.status === "done" || issue.status === "canceled";
|
// (bandera is_done, igual que el backend).
|
||||||
|
const isFinished = doneKeys().has(issue.status);
|
||||||
issue.completedAt = isFinished ? (issue.completedAt ?? new Date().toISOString()) : null;
|
issue.completedAt = isFinished ? (issue.completedAt ?? new Date().toISOString()) : null;
|
||||||
|
|
||||||
return issue;
|
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. */
|
/** Definiciones canónicas para pintar badges y iconos de forma consistente. */
|
||||||
|
|
||||||
export const STATUS_META: Record<
|
export interface StatusMeta {
|
||||||
IssueStatus,
|
label: string;
|
||||||
{ label: string; color: string; icon: string; group: 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" },
|
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" },
|
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" },
|
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" },
|
canceled: { label: "Cancelado", color: "#98a2b3", icon: "bx-x-circle", group: "Completado" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STATUS_ORDER: IssueStatus[] = [
|
export const STATUS_ORDER: string[] = [
|
||||||
"backlog",
|
"backlog",
|
||||||
"todo",
|
"todo",
|
||||||
"in_progress",
|
"in_progress",
|
||||||
@@ -23,6 +31,39 @@ export const STATUS_ORDER: IssueStatus[] = [
|
|||||||
"canceled",
|
"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<
|
export const PRIORITY_META: Record<
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
{ label: string; color: string; icon: string; rank: number }
|
{ 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.
|
* Datos de ejemplo usados por la capa mock.
|
||||||
@@ -24,6 +24,16 @@ export const labels: Label[] = [
|
|||||||
{ id: "l5", name: "deuda técnica", color: "#f2994a" },
|
{ 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[] = [
|
export const projects: Project[] = [
|
||||||
{
|
{
|
||||||
id: "p1",
|
id: "p1",
|
||||||
|
|||||||
+42
-4
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { api } from "@/lib/api";
|
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,
|
* 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
|
* 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
|
* que cada página pueda resolver nombres, colores e iniciales sin repetir
|
||||||
@@ -18,6 +19,7 @@ interface ReferenceData {
|
|||||||
labels: Label[];
|
labels: Label[];
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
cycles: Cycle[];
|
cycles: Cycle[];
|
||||||
|
states: WorkflowState[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ const ReferenceContext = createContext<ReferenceData>({
|
|||||||
labels: [],
|
labels: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
cycles: [],
|
cycles: [],
|
||||||
|
states: [],
|
||||||
loading: true,
|
loading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ export function ReferenceProvider({ children }: { children: React.ReactNode }) {
|
|||||||
labels: [],
|
labels: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
cycles: [],
|
cycles: [],
|
||||||
|
states: [],
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -42,15 +46,18 @@ export function ReferenceProvider({ children }: { children: React.ReactNode }) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const [users, labels, projects, cycles] = await Promise.all([
|
const [users, labels, projects, cycles, states] = await Promise.all([
|
||||||
api.listUsers(),
|
api.listUsers(),
|
||||||
api.listLabels(),
|
api.listLabels(),
|
||||||
api.listProjects(),
|
api.listProjects(),
|
||||||
api.listCycles(),
|
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) {
|
if (!cancelled) {
|
||||||
setData({ users, labels, projects, cycles });
|
setData({ users, labels, projects, cycles, states });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +78,7 @@ export function useReference(): ReferenceData {
|
|||||||
return useContext(ReferenceContext);
|
return useContext(ReferenceContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Helpers de búsqueda por id, memorizados junto a los datos. */
|
/** Helpers de búsqueda por id, memorizados junto a los datos. */
|
||||||
export function useReferenceLookups() {
|
export function useReferenceLookups() {
|
||||||
const { users, labels, projects, cycles } = useReference();
|
const { users, labels, projects, cycles } = useReference();
|
||||||
@@ -85,3 +93,33 @@ export function useReferenceLookups() {
|
|||||||
[users, labels, projects, cycles],
|
[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 ID = string;
|
||||||
|
|
||||||
export type IssueStatus =
|
/**
|
||||||
| "backlog"
|
* Estado de un issue (Fase 1: configurables).
|
||||||
| "todo"
|
*
|
||||||
| "in_progress"
|
* Ya no es una unión fija: la clave viene de `workflow_states` en el
|
||||||
| "in_review"
|
* backend (GET /api/proyectos/states). Los 6 históricos siguen siendo
|
||||||
| "done"
|
* los mismos strings, así que el seed y los datos existentes no cambian.
|
||||||
| "canceled";
|
*/
|
||||||
|
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
|
export type IssuePriority = 0 | 1 | 2 | 3 | 4; // 0 = sin prioridad .. 4 = urgente
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user