feat: migrar tablero, páginas y configuración a estados dinámicos (Fase 1)
This commit is contained in:
1 parent
bf6686f9b0
commit
e15dc3926e
11 files changed
+408
-21
No files matched your search
@@ -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 (
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
Reference in new issue
Block a user