diff --git a/src/app/cycles/page.tsx b/src/app/cycles/page.tsx index 5ce9dee..ac1813c 100644 --- a/src/app/cycles/page.tsx +++ b/src/app/cycles/page.tsx @@ -6,6 +6,7 @@ import { api } from "@/lib/api"; import type { Cycle, Issue } from "@/lib/types"; import { formatShortDate } from "@/lib/format"; import { percent } from "@/lib/format"; +import { useWorkflowStates } from "@/lib/store"; import { AppShell } from "@/components/layout/AppShell"; import { EmptyState } from "@/components/ui/EmptyState"; import { ProgressBar } from "@/components/ui/ProgressBar"; @@ -23,6 +24,7 @@ const CICLO_META: Record = { * completados por ciclo para mostrar la barra de progreso. */ export default function CyclesPage() { + const { doneKeys } = useWorkflowStates(); const [cycles, setCycles] = useState([]); const [issues, setIssues] = useState([]); const [loading, setLoading] = useState(true); @@ -69,7 +71,7 @@ export default function CyclesPage() {
{sorted.map((cycle) => { const cycleIssues = issues.filter((issue) => issue.cycleId === cycle.id); - const doneCount = cycleIssues.filter((issue) => issue.status === "done").length; + const doneCount = cycleIssues.filter((issue) => doneKeys.has(issue.status)).length; const meta = CICLO_META[cycle.status]; return ( diff --git a/src/app/inbox/page.tsx b/src/app/inbox/page.tsx index 9c6c362..3831c20 100644 --- a/src/app/inbox/page.tsx +++ b/src/app/inbox/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { api } from "@/lib/api"; import type { Issue } from "@/lib/types"; import { useAuth } from "@/lib/auth"; +import { useWorkflowStates } from "@/lib/store"; import { AppShell } from "@/components/layout/AppShell"; import { IssueList } from "@/components/issues/IssueList"; @@ -17,6 +18,7 @@ export default function InboxPage() { const [loading, setLoading] = useState(true); const userId = user?.id; + const { doneKeys } = useWorkflowStates(); useEffect(() => { // Sin sesión todavía no hay a quién filtrar. @@ -37,7 +39,7 @@ export default function InboxPage() { }, [userId]); const openIssues = useMemo(() => { - const open = issues.filter((issue) => issue.status !== "done" && issue.status !== "canceled"); + const open = issues.filter((issue) => !doneKeys.has(issue.status)); // Mayor prioridad primero; dentro de un mismo nivel, lo más reciente. return [...open].sort((a, b) => { diff --git a/src/app/issues/[id]/page.tsx b/src/app/issues/[id]/page.tsx index 81df2e5..5c07829 100644 --- a/src/app/issues/[id]/page.tsx +++ b/src/app/issues/[id]/page.tsx @@ -5,9 +5,9 @@ import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import type { Comment, Issue, IssueStatus } from "@/lib/types"; -import { PRIORITY_META, STATUS_META, STATUS_ORDER, TYPE_META } from "@/lib/meta"; +import { PRIORITY_META, TYPE_META } from "@/lib/meta"; import { formatLongDate, formatRelative } from "@/lib/format"; -import { useReference, useReferenceLookups } from "@/lib/store"; +import { useReference, useReferenceLookups, useWorkflowStates } from "@/lib/store"; import { useAuth } from "@/lib/auth"; import { AppShell } from "@/components/layout/AppShell"; import { Avatar } from "@/components/ui/Avatar"; @@ -34,6 +34,7 @@ export default function IssueDetailPage() { function IssueDetail({ issueId }: { issueId: string }) { const router = useRouter(); const { user } = useAuth(); + const { meta, order } = useWorkflowStates(); const { users, projects, cycles, labels } = useReference(); const { projectById, userById } = useReferenceLookups(); @@ -215,9 +216,9 @@ function IssueDetail({ issueId }: { issueId: string }) { value={issue.status} onChange={(event) => patch({ status: event.target.value as IssueStatus })} > - {STATUS_ORDER.map((status) => ( + {order.map((status) => ( ))} diff --git a/src/app/issues/page.tsx b/src/app/issues/page.tsx index a00784f..ebd5eaf 100644 --- a/src/app/issues/page.tsx +++ b/src/app/issues/page.tsx @@ -4,7 +4,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; import { api } from "@/lib/api"; import type { Issue, IssueInput, IssueStatus } from "@/lib/types"; -import { STATUS_META, STATUS_ORDER } from "@/lib/meta"; +import { useWorkflowStates } from "@/lib/store"; import { useIssues } from "@/hooks/useIssues"; import { AppShell } from "@/components/layout/AppShell"; import { IssueList } from "@/components/issues/IssueList"; @@ -29,6 +29,7 @@ function IssuesPageInner() { const searchParams = useSearchParams(); const [statusFilter, setStatusFilter] = useState([]); + const { meta, order } = useWorkflowStates(); const [refreshKey, setRefreshKey] = useState(0); const [creating, setCreating] = useState(false); const [title, setTitle] = useState(""); @@ -70,7 +71,7 @@ function IssuesPageInner() { const actions = (
- {STATUS_ORDER.map((status) => ( + {order.map((status) => ( ))}
diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index 2741f28..24d9db7 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -6,7 +6,7 @@ import { api } from "@/lib/api"; import type { Issue, Project } from "@/lib/types"; import { PROJECT_STATUS_META } from "@/lib/meta"; import { formatLongDate, percent } from "@/lib/format"; -import { useReferenceLookups } from "@/lib/store"; +import { useReferenceLookups, useWorkflowStates } from "@/lib/store"; import { useIssues } from "@/hooks/useIssues"; import { AppShell } from "@/components/layout/AppShell"; import { IssueList } from "@/components/issues/IssueList"; @@ -26,6 +26,7 @@ export default function ProjectDetailPage() { const [loading, setLoading] = useState(true); const { userById } = useReferenceLookups(); const { issues, loading: issuesLoading } = useIssues({ projectId: [projectId] }); + const { doneKeys } = useWorkflowStates(); useEffect(() => { let cancelled = false; @@ -67,7 +68,7 @@ export default function ProjectDetailPage() { const lead = project.leadId ? userById.get(project.leadId) : undefined; const meta = PROJECT_STATUS_META[project.status]; - const doneCount = issues.filter((issue) => issue.status === "done").length; + const doneCount = issues.filter((issue) => doneKeys.has(issue.status)).length; return ( diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index adf5062..59bfa3d 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -6,7 +6,7 @@ import { api } from "@/lib/api"; import type { Issue, Project } from "@/lib/types"; import { PROJECT_STATUS_META } from "@/lib/meta"; import { formatShortDate, percent } from "@/lib/format"; -import { useReferenceLookups } from "@/lib/store"; +import { useReferenceLookups, useWorkflowStates } from "@/lib/store"; import { AppShell } from "@/components/layout/AppShell"; import { Avatar } from "@/components/ui/Avatar"; import { EmptyState } from "@/components/ui/EmptyState"; @@ -21,6 +21,7 @@ export default function ProjectsPage() { const [issues, setIssues] = useState([]); const [loading, setLoading] = useState(true); const { userById } = useReferenceLookups(); + const { doneKeys } = useWorkflowStates(); useEffect(() => { let cancelled = false; @@ -45,7 +46,7 @@ export default function ProjectsPage() { if (!issue.projectId) continue; const entry = map.get(issue.projectId) ?? { total: 0, done: 0 }; entry.total += 1; - if (issue.status === "done") entry.done += 1; + if (doneKeys.has(issue.status)) entry.done += 1; map.set(issue.projectId, entry); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..f6befa6 --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api, ApiError } from "@/lib/api"; +import type { WorkflowState } from "@/lib/types"; +import { useReference } from "@/lib/store"; +import { AppShell } from "@/components/layout/AppShell"; +import { EmptyState } from "@/components/ui/EmptyState"; + +/** + * Configuración del workflow: CRUD de estados. + * + * Los estados son la única fuente de verdad para columnas, filtros y + * selects (Fase 1). Borrar exige que ningún issue use el estado (409); + * para sacarlo de circulación sin borrarlo se desactiva. + */ +export default function SettingsPage() { + const { loading: refLoading } = useReference(); + const [states, setStates] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + // Formulario de creación. + const [newKey, setNewKey] = useState(""); + const [newLabel, setNewLabel] = useState(""); + const [newDone, setNewDone] = useState(false); + + async function refresh() { + setLoading(true); + setError(null); + try { + const all = await api.listStates(true); + setStates([...all].sort((a, b) => a.sortOrder - b.sortOrder)); + } catch (err) { + setError(err instanceof Error ? err.message : "Error cargando estados"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void refresh(); + }, []); + + async function run(action: () => Promise, okMessage: string) { + setError(null); + setNotice(null); + try { + await action(); + setNotice(okMessage); + await refresh(); + } catch (err) { + // El backend explica el motivo (409 con issues usándolo, etc.). + setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error"); + } + } + + function handleCreate(event: React.FormEvent) { + event.preventDefault(); + const key = newKey.trim().toLowerCase(); + const label = newLabel.trim(); + if (!key || !label) return; + + void run( + () => api.createState({ key, label, isDone: newDone }), + `Estado "${label}" creado.`, + ).then(() => { + setNewKey(""); + setNewLabel(""); + setNewDone(false); + }); + } + + return ( + +
+ {notice && ( +
+ {notice} +
+ )} + {error && ( +
+ {error} +
+ )} + + {loading || refLoading ? ( +
+ ) : states.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {states.map((state) => ( + + ))} + +
EstadoTramoOrdenTerminadoActivo +
+
+ )} + +
+
Nuevo estado
+
+
+ + setNewKey(event.target.value)} + pattern="[a-z][a-z0-9_]{0,31}" + title="Minúsculas, números y guiones bajos, empezando por letra" + required + /> +
+
+ + setNewLabel(event.target.value)} + required + /> +
+
+ setNewDone(event.target.checked)} + /> + +
+ + +
+
+ + ); +} + +function StateRow({ + state, + onChanged, + onError, + onNotice, +}: { + state: WorkflowState; + onChanged: () => void; + onError: (message: string | null) => void; + onNotice: (message: string | null) => void; +}) { + const [editing, setEditing] = useState(false); + const [label, setLabel] = useState(state.label); + const [color, setColor] = useState(state.color); + const [section, setSection] = useState(state.section); + const [sortOrder, setSortOrder] = useState(state.sortOrder); + + async function save() { + onError(null); + onNotice(null); + try { + await api.updateState(state.key, { label, color, section, sortOrder }); + onNotice(`Estado "${label}" actualizado.`); + setEditing(false); + onChanged(); + } catch (err) { + onError(err instanceof Error ? err.message : "Error guardando"); + } + } + + async function toggleDone() { + onError(null); + onNotice(null); + try { + await api.updateState(state.key, { isDone: !state.isDone }); + onChanged(); + } catch (err) { + onError(err instanceof Error ? err.message : "Error guardando"); + } + } + + async function toggleActive() { + onError(null); + onNotice(null); + try { + await api.updateState(state.key, { isActive: !state.isActive }); + onChanged(); + } catch (err) { + onError(err instanceof Error ? err.message : "Error guardando"); + } + } + + async function remove() { + if (!window.confirm(`¿Eliminar el estado "${state.label}"? Solo es posible si ningún issue lo usa.`)) { + return; + } + onError(null); + onNotice(null); + try { + await api.deleteState(state.key); + onNotice(`Estado "${state.label}" eliminado.`); + onChanged(); + } catch (err) { + onError(err instanceof Error ? err.message : "Error eliminando"); + } + } + + return ( + + + {editing ? ( + setLabel(event.target.value)} + /> + ) : ( + + + {state.label} + {state.key} + + )} + + + {editing ? ( + setSection(event.target.value)} + /> + ) : ( + {state.section} + )} + + + {editing ? ( + setSortOrder(Number(event.target.value))} + /> + ) : ( + {state.sortOrder} + )} + + + + + + + + +
+ {editing ? ( + <> + + + + ) : ( + <> + + + + + )} +
+ + + ); +} diff --git a/src/components/board/Board.tsx b/src/components/board/Board.tsx index 775f7ae..7b9d967 100644 --- a/src/components/board/Board.tsx +++ b/src/components/board/Board.tsx @@ -2,9 +2,8 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; -import { STATUS_META, STATUS_ORDER } from "@/lib/meta"; import type { Issue, IssueStatus } from "@/lib/types"; -import { useReferenceLookups } from "@/lib/store"; +import { useReferenceLookups, useWorkflowStates } from "@/lib/store"; import { Avatar } from "@/components/ui/Avatar"; import { PriorityIcon, StatusIcon } from "@/components/ui/Icons"; @@ -25,6 +24,7 @@ interface BoardProps { export function Board({ issues, loading, onMove }: BoardProps) { const router = useRouter(); const { userById, projectById, labelById } = useReferenceLookups(); + const { meta, order } = useWorkflowStates(); const [draggingId, setDraggingId] = useState(null); const [overStatus, setOverStatus] = useState(null); @@ -40,9 +40,9 @@ export function Board({ issues, loading, onMove }: BoardProps) { return (
- {STATUS_ORDER.map((status) => { + {order.map((status) => { const columnIssues = issues.filter((issue) => issue.status === status); - const meta = STATUS_META[status]; + const label = meta[status]?.label ?? status; return (
- {meta.label} + {label} {columnIssues.length} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 5296347..522d82b 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { api } from "@/lib/api"; import type { Issue } from "@/lib/types"; import { useAuth } from "@/lib/auth"; -import { useReference } from "@/lib/store"; +import { useReference, useWorkflowStates } from "@/lib/store"; import { CommandPalette } from "./CommandPalette"; import { Sidebar } from "./Sidebar"; @@ -35,6 +35,7 @@ export function AppShell({ title, subtitle, actions, icon = "bx-briefcase", chil const pathname = usePathname(); const { user, loading: authLoading, logout } = useAuth(); const { projects } = useReference(); + const { doneKeys } = useWorkflowStates(); const [open, setOpen] = useState(true); const [issues, setIssues] = useState([]); @@ -74,7 +75,7 @@ export function AppShell({ title, subtitle, actions, icon = "bx-briefcase", chil }, [pathname]); const counts = useMemo(() => { - const openIssues = issues.filter((i) => i.status !== "done" && i.status !== "canceled"); + const openIssues = issues.filter((i) => !doneKeys.has(i.status)); return { inbox: user ? openIssues.filter((i) => i.assigneeId === user.id).length : 0, issues: openIssues.length, diff --git a/src/components/layout/CommandPalette.tsx b/src/components/layout/CommandPalette.tsx index b0fa81c..3d722fc 100644 --- a/src/components/layout/CommandPalette.tsx +++ b/src/components/layout/CommandPalette.tsx @@ -48,6 +48,7 @@ export function CommandPalette({ open, onClose, issues }: CommandPaletteProps) { { id: "nav-board", icon: "bx-columns", label: "Ir a Tablero", hint: "B", run: () => router.push("/board") }, { id: "nav-cycles", icon: "bx-reset", label: "Ir a Ciclos", hint: "C", run: () => router.push("/cycles") }, { id: "nav-projects", icon: "bx-folder", label: "Ir a Proyectos", hint: "P", run: () => router.push("/projects") }, + { id: "nav-settings", icon: "bx-cog", label: "Ir a Configuración", hint: "S", run: () => router.push("/settings") }, { id: "new-issue", icon: "bx-plus-circle", diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e26925e..d1cdda6 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -32,6 +32,7 @@ export function Sidebar({ counts, projects, open }: SidebarProps) { { href: "/board", icon: "bx-columns", label: "Tablero", count: counts.board }, { href: "/cycles", icon: "bx-reset", label: "Ciclos", count: counts.cycles }, { href: "/projects", icon: "bx-folder", label: "Proyectos", count: counts.projects }, + { href: "/settings", icon: "bx-cog", label: "Configuración", count: 0 }, ]; const isActive = (href: string) =>