feat: migrar tablero, páginas y configuración a estados dinámicos (Fase 1)

This commit is contained in:
Nurfog committed 2026-10-14 13:14:41 -03:00
1 parent bf6686f9b0
commit e15dc3926e
11 files changed
+408 -21

No files matched your search

+3 -1
View File
@@ -6,6 +6,7 @@ import { api } from "@/lib/api";
import type { Cycle, Issue } from "@/lib/types"; import type { Cycle, Issue } from "@/lib/types";
import { formatShortDate } from "@/lib/format"; import { formatShortDate } from "@/lib/format";
import { percent } from "@/lib/format"; import { percent } from "@/lib/format";
import { useWorkflowStates } from "@/lib/store";
import { AppShell } from "@/components/layout/AppShell"; import { AppShell } from "@/components/layout/AppShell";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { ProgressBar } from "@/components/ui/ProgressBar"; import { ProgressBar } from "@/components/ui/ProgressBar";
@@ -23,6 +24,7 @@ const CICLO_META: Record<Cycle["status"], { label: string; color: string }> = {
* completados por ciclo para mostrar la barra de progreso. * completados por ciclo para mostrar la barra de progreso.
*/ */
export default function CyclesPage() { export default function CyclesPage() {
const { doneKeys } = useWorkflowStates();
const [cycles, setCycles] = useState<Cycle[]>([]); const [cycles, setCycles] = useState<Cycle[]>([]);
const [issues, setIssues] = useState<Issue[]>([]); const [issues, setIssues] = useState<Issue[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -69,7 +71,7 @@ export default function CyclesPage() {
<div className="p-4 d-flex flex-column gap-3"> <div className="p-4 d-flex flex-column gap-3">
{sorted.map((cycle) => { {sorted.map((cycle) => {
const cycleIssues = issues.filter((issue) => issue.cycleId === cycle.id); 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]; const meta = CICLO_META[cycle.status];
return ( return (
+3 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { Issue } from "@/lib/types"; import type { Issue } from "@/lib/types";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useWorkflowStates } from "@/lib/store";
import { AppShell } from "@/components/layout/AppShell"; import { AppShell } from "@/components/layout/AppShell";
import { IssueList } from "@/components/issues/IssueList"; import { IssueList } from "@/components/issues/IssueList";
@@ -17,6 +18,7 @@ export default function InboxPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const userId = user?.id; const userId = user?.id;
const { doneKeys } = useWorkflowStates();
useEffect(() => { useEffect(() => {
// Sin sesión todavía no hay a quién filtrar. // Sin sesión todavía no hay a quién filtrar.
@@ -37,7 +39,7 @@ export default function InboxPage() {
}, [userId]); }, [userId]);
const openIssues = useMemo(() => { 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. // Mayor prioridad primero; dentro de un mismo nivel, lo más reciente.
return [...open].sort((a, b) => { return [...open].sort((a, b) => {
+5 -4
View File
@@ -5,9 +5,9 @@ import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { Comment, Issue, IssueStatus } from "@/lib/types"; 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 { formatLongDate, formatRelative } from "@/lib/format";
import { useReference, useReferenceLookups } from "@/lib/store"; import { useReference, useReferenceLookups, useWorkflowStates } from "@/lib/store";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { AppShell } from "@/components/layout/AppShell"; import { AppShell } from "@/components/layout/AppShell";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
@@ -34,6 +34,7 @@ export default function IssueDetailPage() {
function IssueDetail({ issueId }: { issueId: string }) { function IssueDetail({ issueId }: { issueId: string }) {
const router = useRouter(); const router = useRouter();
const { user } = useAuth(); const { user } = useAuth();
const { meta, order } = useWorkflowStates();
const { users, projects, cycles, labels } = useReference(); const { users, projects, cycles, labels } = useReference();
const { projectById, userById } = useReferenceLookups(); const { projectById, userById } = useReferenceLookups();
@@ -215,9 +216,9 @@ function IssueDetail({ issueId }: { issueId: string }) {
value={issue.status} value={issue.status}
onChange={(event) => patch({ status: event.target.value as IssueStatus })} onChange={(event) => patch({ status: event.target.value as IssueStatus })}
> >
{STATUS_ORDER.map((status) => ( {order.map((status) => (
<option key={status} value={status}> <option key={status} value={status}>
{STATUS_META[status].label} {meta[status]?.label ?? status}
</option> </option>
))} ))}
</select> </select>
+5 -4
View File
@@ -4,7 +4,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { Issue, IssueInput, IssueStatus } from "@/lib/types"; 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 { useIssues } from "@/hooks/useIssues";
import { AppShell } from "@/components/layout/AppShell"; import { AppShell } from "@/components/layout/AppShell";
import { IssueList } from "@/components/issues/IssueList"; import { IssueList } from "@/components/issues/IssueList";
@@ -29,6 +29,7 @@ function IssuesPageInner() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [statusFilter, setStatusFilter] = useState<IssueStatus[]>([]); const [statusFilter, setStatusFilter] = useState<IssueStatus[]>([]);
const { meta, order } = useWorkflowStates();
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@@ -70,7 +71,7 @@ function IssuesPageInner() {
const actions = ( const actions = (
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
{STATUS_ORDER.map((status) => ( {order.map((status) => (
<button <button
key={status} key={status}
type="button" type="button"
@@ -79,9 +80,9 @@ function IssuesPageInner() {
> >
<span <span
className="label-dot" className="label-dot"
style={{ background: STATUS_META[status].color }} style={{ background: meta[status]?.color ?? "#98a2b3" }}
/> />
{STATUS_META[status].label} {meta[status]?.label ?? status}
</button> </button>
))} ))}
</div> </div>
+3 -2
View File
@@ -6,7 +6,7 @@ import { api } from "@/lib/api";
import type { Issue, Project } from "@/lib/types"; import type { Issue, Project } from "@/lib/types";
import { PROJECT_STATUS_META } from "@/lib/meta"; import { PROJECT_STATUS_META } from "@/lib/meta";
import { formatLongDate, percent } from "@/lib/format"; import { formatLongDate, percent } from "@/lib/format";
import { useReferenceLookups } from "@/lib/store"; import { useReferenceLookups, useWorkflowStates } from "@/lib/store";
import { useIssues } from "@/hooks/useIssues"; import { useIssues } from "@/hooks/useIssues";
import { AppShell } from "@/components/layout/AppShell"; import { AppShell } from "@/components/layout/AppShell";
import { IssueList } from "@/components/issues/IssueList"; import { IssueList } from "@/components/issues/IssueList";
@@ -26,6 +26,7 @@ export default function ProjectDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { userById } = useReferenceLookups(); const { userById } = useReferenceLookups();
const { issues, loading: issuesLoading } = useIssues({ projectId: [projectId] }); const { issues, loading: issuesLoading } = useIssues({ projectId: [projectId] });
const { doneKeys } = useWorkflowStates();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -67,7 +68,7 @@ export default function ProjectDetailPage() {
const lead = project.leadId ? userById.get(project.leadId) : undefined; const lead = project.leadId ? userById.get(project.leadId) : undefined;
const meta = PROJECT_STATUS_META[project.status]; 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 ( return (
<AppShell title={project.name} subtitle={project.key}> <AppShell title={project.name} subtitle={project.key}>
+3 -2
View File
@@ -6,7 +6,7 @@ import { api } from "@/lib/api";
import type { Issue, Project } from "@/lib/types"; import type { Issue, Project } from "@/lib/types";
import { PROJECT_STATUS_META } from "@/lib/meta"; import { PROJECT_STATUS_META } from "@/lib/meta";
import { formatShortDate, percent } from "@/lib/format"; import { formatShortDate, percent } from "@/lib/format";
import { useReferenceLookups } from "@/lib/store"; import { useReferenceLookups, useWorkflowStates } from "@/lib/store";
import { AppShell } from "@/components/layout/AppShell"; import { AppShell } from "@/components/layout/AppShell";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
@@ -21,6 +21,7 @@ export default function ProjectsPage() {
const [issues, setIssues] = useState<Issue[]>([]); const [issues, setIssues] = useState<Issue[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { userById } = useReferenceLookups(); const { userById } = useReferenceLookups();
const { doneKeys } = useWorkflowStates();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -45,7 +46,7 @@ export default function ProjectsPage() {
if (!issue.projectId) continue; if (!issue.projectId) continue;
const entry = map.get(issue.projectId) ?? { total: 0, done: 0 }; const entry = map.get(issue.projectId) ?? { total: 0, done: 0 };
entry.total += 1; entry.total += 1;
if (issue.status === "done") entry.done += 1; if (doneKeys.has(issue.status)) entry.done += 1;
map.set(issue.projectId, entry); map.set(issue.projectId, entry);
} }
+376
View File
@@ -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<WorkflowState[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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<unknown>, 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 (
<AppShell title="Configuración" subtitle="Estados del workflow" icon="bx-cog">
<div className="page-pad pt-3" style={{ maxWidth: 860 }}>
{notice && (
<div className="alert alert-success py-2" role="status">
{notice}
</div>
)}
{error && (
<div className="alert alert-danger py-2" role="alert">
{error}
</div>
)}
{loading || refLoading ? (
<div className="skeleton" style={{ height: 220, borderRadius: 10 }} />
) : states.length === 0 ? (
<EmptyState
icon="bx-cog"
title="Sin estados"
description="El backend no devolvió estados."
/>
) : (
<div className="lp-card p-0 overflow-hidden">
<table className="table table-borderless issue-table mb-0">
<thead>
<tr style={{ fontSize: 12, color: "var(--inv-muted)" }}>
<th className="issue-cell">Estado</th>
<th className="issue-cell issue-cell-secondary">Tramo</th>
<th className="issue-cell issue-cell-secondary">Orden</th>
<th className="issue-cell issue-cell-secondary">Terminado</th>
<th className="issue-cell issue-cell-secondary">Activo</th>
<th className="issue-cell" style={{ width: 110 }} />
</tr>
</thead>
<tbody>
{states.map((state) => (
<StateRow
key={state.key}
state={state}
onChanged={refresh}
onError={setError}
onNotice={setNotice}
/>
))}
</tbody>
</table>
</div>
)}
<div className="lp-card p-3 mt-3">
<div className="detail-section-title">Nuevo estado</div>
<form onSubmit={handleCreate} className="d-flex gap-2 flex-wrap align-items-end">
<div>
<label className="form-label" htmlFor="new-key" style={{ fontSize: 12 }}>
Clave
</label>
<input
id="new-key"
className="form-control form-control-sm"
placeholder="qa"
value={newKey}
onChange={(event) => setNewKey(event.target.value)}
pattern="[a-z][a-z0-9_]{0,31}"
title="Minúsculas, números y guiones bajos, empezando por letra"
required
/>
</div>
<div>
<label className="form-label" htmlFor="new-label" style={{ fontSize: 12 }}>
Nombre
</label>
<input
id="new-label"
className="form-control form-control-sm"
placeholder="En QA"
value={newLabel}
onChange={(event) => setNewLabel(event.target.value)}
required
/>
</div>
<div className="form-check mb-1">
<input
id="new-done"
type="checkbox"
className="form-check-input"
checked={newDone}
onChange={(event) => setNewDone(event.target.checked)}
/>
<label className="form-check-label" htmlFor="new-done" style={{ fontSize: 13 }}>
Cuenta como terminado
</label>
</div>
<button type="submit" className="btn btn-accent btn-sm">
<i className="bx bx-plus me-1" />
Crear
</button>
</form>
</div>
</div>
</AppShell>
);
}
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 (
<tr>
<td className="issue-cell">
{editing ? (
<input
className="form-control form-control-sm"
value={label}
onChange={(event) => setLabel(event.target.value)}
/>
) : (
<span className="d-flex align-items-center gap-2">
<i className={`bx ${state.icon}`} style={{ color: state.color, fontSize: 16 }} />
<span className="issue-title">{state.label}</span>
<span className="issue-identifier">{state.key}</span>
</span>
)}
</td>
<td className="issue-cell issue-cell-secondary">
{editing ? (
<input
className="form-control form-control-sm"
value={section}
onChange={(event) => setSection(event.target.value)}
/>
) : (
<span style={{ fontSize: 13, color: "var(--inv-muted)" }}>{state.section}</span>
)}
</td>
<td className="issue-cell issue-cell-secondary">
{editing ? (
<input
type="number"
className="form-control form-control-sm"
style={{ width: 80 }}
value={sortOrder}
onChange={(event) => setSortOrder(Number(event.target.value))}
/>
) : (
<span style={{ fontSize: 13 }}>{state.sortOrder}</span>
)}
</td>
<td className="issue-cell issue-cell-secondary">
<button
type="button"
className={`filter-chip${state.isDone ? " active" : ""}`}
onClick={toggleDone}
title="Si cuenta como terminado (progreso, bandeja)"
>
{state.isDone ? "Sí" : "No"}
</button>
</td>
<td className="issue-cell issue-cell-secondary">
<button
type="button"
className={`filter-chip${state.isActive ? " active" : ""}`}
onClick={toggleActive}
title="Los estados inactivos no aparecen en columnas ni selects"
>
{state.isActive ? "Sí" : "No"}
</button>
</td>
<td className="issue-cell">
<div className="d-flex gap-1 justify-content-end">
{editing ? (
<>
<button type="button" className="btn btn-accent btn-sm" onClick={save}>
Guardar
</button>
<button
type="button"
className="btn btn-lp btn-sm"
onClick={() => {
setEditing(false);
setLabel(state.label);
setColor(state.color);
setSection(state.section);
setSortOrder(state.sortOrder);
}}
>
Cancelar
</button>
</>
) : (
<>
<button
type="button"
className="btn btn-lp btn-sm"
onClick={() => setEditing(true)}
title="Editar nombre, color, tramo y orden"
>
<i className="bx bx-edit" />
</button>
<button
type="button"
className="btn btn-lp btn-sm"
onClick={() => {
const next = prompt("Color (hex):", state.color);
if (next) {
void api
.updateState(state.key, { color: next })
.then(() => {
onNotice("Color actualizado.");
onChanged();
})
.catch((err: unknown) =>
onError(err instanceof Error ? err.message : "Error guardando"),
);
}
}}
title="Cambiar color"
>
<span
className="label-dot"
style={{ background: state.color, width: 12, height: 12 }}
/>
</button>
<button
type="button"
className="btn btn-lp btn-sm"
onClick={remove}
title="Eliminar (solo si ningún issue lo usa)"
>
<i className="bx bx-trash" />
</button>
</>
)}
</div>
</td>
</tr>
);
}
+5 -5
View File
@@ -2,9 +2,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { STATUS_META, STATUS_ORDER } from "@/lib/meta";
import type { Issue, IssueStatus } from "@/lib/types"; 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 { Avatar } from "@/components/ui/Avatar";
import { PriorityIcon, StatusIcon } from "@/components/ui/Icons"; import { PriorityIcon, StatusIcon } from "@/components/ui/Icons";
@@ -25,6 +24,7 @@ interface BoardProps {
export function Board({ issues, loading, onMove }: BoardProps) { export function Board({ issues, loading, onMove }: BoardProps) {
const router = useRouter(); const router = useRouter();
const { userById, projectById, labelById } = useReferenceLookups(); const { userById, projectById, labelById } = useReferenceLookups();
const { meta, order } = useWorkflowStates();
const [draggingId, setDraggingId] = useState<string | null>(null); const [draggingId, setDraggingId] = useState<string | null>(null);
const [overStatus, setOverStatus] = useState<IssueStatus | null>(null); const [overStatus, setOverStatus] = useState<IssueStatus | null>(null);
@@ -40,9 +40,9 @@ export function Board({ issues, loading, onMove }: BoardProps) {
return ( return (
<div className="board"> <div className="board">
{STATUS_ORDER.map((status) => { {order.map((status) => {
const columnIssues = issues.filter((issue) => issue.status === status); const columnIssues = issues.filter((issue) => issue.status === status);
const meta = STATUS_META[status]; const label = meta[status]?.label ?? status;
return ( return (
<div <div
@@ -62,7 +62,7 @@ export function Board({ issues, loading, onMove }: BoardProps) {
> >
<div className="board-column-header"> <div className="board-column-header">
<StatusIcon status={status} /> <StatusIcon status={status} />
<span>{meta.label}</span> <span>{label}</span>
<span className="ms-auto" style={{ color: "var(--lp-text-faint)" }}> <span className="ms-auto" style={{ color: "var(--lp-text-faint)" }}>
{columnIssues.length} {columnIssues.length}
</span> </span>
+3 -2
View File
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { Issue } from "@/lib/types"; import type { Issue } from "@/lib/types";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useReference } from "@/lib/store"; import { useReference, useWorkflowStates } from "@/lib/store";
import { CommandPalette } from "./CommandPalette"; import { CommandPalette } from "./CommandPalette";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
@@ -35,6 +35,7 @@ export function AppShell({ title, subtitle, actions, icon = "bx-briefcase", chil
const pathname = usePathname(); const pathname = usePathname();
const { user, loading: authLoading, logout } = useAuth(); const { user, loading: authLoading, logout } = useAuth();
const { projects } = useReference(); const { projects } = useReference();
const { doneKeys } = useWorkflowStates();
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [issues, setIssues] = useState<Issue[]>([]); const [issues, setIssues] = useState<Issue[]>([]);
@@ -74,7 +75,7 @@ export function AppShell({ title, subtitle, actions, icon = "bx-briefcase", chil
}, [pathname]); }, [pathname]);
const counts = useMemo(() => { const counts = useMemo(() => {
const openIssues = issues.filter((i) => i.status !== "done" && i.status !== "canceled"); const openIssues = issues.filter((i) => !doneKeys.has(i.status));
return { return {
inbox: user ? openIssues.filter((i) => i.assigneeId === user.id).length : 0, inbox: user ? openIssues.filter((i) => i.assigneeId === user.id).length : 0,
issues: openIssues.length, issues: openIssues.length,
+1
View File
@@ -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-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-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-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", id: "new-issue",
icon: "bx-plus-circle", icon: "bx-plus-circle",
+1
View File
@@ -32,6 +32,7 @@ export function Sidebar({ counts, projects, open }: SidebarProps) {
{ href: "/board", icon: "bx-columns", label: "Tablero", count: counts.board }, { href: "/board", icon: "bx-columns", label: "Tablero", count: counts.board },
{ href: "/cycles", icon: "bx-reset", label: "Ciclos", count: counts.cycles }, { href: "/cycles", icon: "bx-reset", label: "Ciclos", count: counts.cycles },
{ href: "/projects", icon: "bx-folder", label: "Proyectos", count: counts.projects }, { href: "/projects", icon: "bx-folder", label: "Proyectos", count: counts.projects },
{ href: "/settings", icon: "bx-cog", label: "Configuración", count: 0 },
]; ];
const isActive = (href: string) => const isActive = (href: string) =>