feat: triage y estimación (Fase 4)
This commit is contained in:
1 parent
9acaad4f62
commit
8f33add874
11 files changed
+208
No files matched your search
@@ -93,6 +93,13 @@ export default function CyclesPage() {
|
||||
</span>
|
||||
<span className="ms-auto" style={{ fontSize: 12.5, color: "var(--lp-text-faint)" }}>
|
||||
{doneCount}/{cycleIssues.length} issues
|
||||
{(() => {
|
||||
const total = cycleIssues.reduce((sum, i) => sum + (i.estimate ?? 0), 0);
|
||||
const done = cycleIssues
|
||||
.filter((i) => doneKeys.has(i.status))
|
||||
.reduce((sum, i) => sum + (i.estimate ?? 0), 0);
|
||||
return total > 0 ? ` · ${done}/${total} pts` : "";
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -240,6 +240,28 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Estimación</span>
|
||||
<select
|
||||
className="prop-select"
|
||||
value={issue.estimate ?? ""}
|
||||
onChange={(event) =>
|
||||
patch(
|
||||
event.target.value === ""
|
||||
? { estimate: null }
|
||||
: { estimate: Number(event.target.value) },
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Sin estimar</option>
|
||||
{[0, 1, 2, 3, 5, 8, 13].map((points) => (
|
||||
<option key={points} value={points}>
|
||||
{points} pts
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">Asignado</span>
|
||||
<select
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import type { Issue, Label, User } from "@/lib/types";
|
||||
import { useReference } from "@/lib/store";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useIssues } from "@/hooks/useIssues";
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { PriorityIcon, TypeIcon } from "@/components/ui/Icons";
|
||||
import { LabelChip } from "@/components/ui/LabelChip";
|
||||
|
||||
/**
|
||||
* Triage (Fase 4): cola de issues nuevos sin revisar.
|
||||
*
|
||||
* Cada fila permite asignar estado y responsable, y el botón "Triar"
|
||||
* marca needsTriage = false. Triar es "ya lo miró un humano", no un
|
||||
* estado más: funciona con cualquier workflow configurado.
|
||||
*/
|
||||
export default function TriagePage() {
|
||||
const { user } = useAuth();
|
||||
const { users, labels } = useReference();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { issues, loading } = useIssues({ needsTriage: true }, refreshKey);
|
||||
const userById = new Map(users.map((u) => [u.id, u]));
|
||||
const labelById = new Map(labels.map((l) => [l.id, l]));
|
||||
|
||||
async function triage(issueId: string) {
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateIssue(issueId, {
|
||||
needsTriage: false,
|
||||
...(user ? { actorId: user.id } : {}),
|
||||
});
|
||||
setRefreshKey((k) => k + 1);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Triage"
|
||||
subtitle={`${issues.length} pendiente${issues.length === 1 ? "" : "s"}`}
|
||||
icon="bx-filter-alt"
|
||||
>
|
||||
<div className="page-pad pt-3" style={{ maxWidth: 900 }}>
|
||||
{error && (
|
||||
<div className="alert alert-danger py-2" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="skeleton" style={{ height: 220, borderRadius: 10 }} />
|
||||
) : issues.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="bx-check-circle"
|
||||
title="Nada por triar"
|
||||
description="Todos los issues ya fueron revisados."
|
||||
/>
|
||||
) : (
|
||||
<div className="d-flex flex-column gap-2">
|
||||
{issues.map((issue) => (
|
||||
<TriageRow
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
userById={userById}
|
||||
labelById={labelById}
|
||||
onTriaged={() => void triage(issue.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function TriageRow({
|
||||
issue,
|
||||
userById,
|
||||
labelById,
|
||||
onTriaged,
|
||||
}: {
|
||||
issue: Issue;
|
||||
userById: Map<string, User>;
|
||||
labelById: Map<string, Label>;
|
||||
onTriaged: () => void;
|
||||
}) {
|
||||
const assignee = issue.assigneeId ? userById.get(issue.assigneeId) : undefined;
|
||||
|
||||
return (
|
||||
<div className="lp-card p-3 d-flex align-items-center gap-3 flex-wrap">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<TypeIcon type={issue.type} />
|
||||
|
||||
<div className="min-w-0 flex-grow-1">
|
||||
<Link href={`/issues/${issue.id}`} className="d-flex gap-2 align-items-baseline">
|
||||
<span className="issue-identifier">{issue.identifier}</span>
|
||||
<span className="issue-title text-truncate">{issue.title}</span>
|
||||
</Link>
|
||||
<div className="d-flex gap-1 mt-1 flex-wrap">
|
||||
{issue.labelIds
|
||||
.map((id) => labelById.get(id))
|
||||
.filter((l): l is NonNullable<typeof l> => Boolean(l))
|
||||
.map((label) => (
|
||||
<LabelChip key={label.id} label={label} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Avatar user={assignee} />
|
||||
|
||||
<button type="button" className="btn btn-accent btn-sm" onClick={onTriaged}>
|
||||
<i className="bx bx-check me-1" />
|
||||
Triar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,14 @@ export function IssueRow({ issue, assignee, labels, showProject = true }: IssueR
|
||||
<span className="issue-identifier">{issue.createdAt.slice(0, 10)}</span>
|
||||
</td>
|
||||
|
||||
<td className="issue-cell issue-cell-secondary" style={{ width: 52 }}>
|
||||
{issue.estimate != null && (
|
||||
<span className="label-chip" title={`${issue.estimate} puntos`}>
|
||||
{issue.estimate}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="issue-cell" style={{ width: 44 }}>
|
||||
<Avatar user={assignee} />
|
||||
</td>
|
||||
|
||||
@@ -82,6 +82,7 @@ export function AppShell({ title, subtitle, actions, icon = "bx-briefcase", chil
|
||||
board: openIssues.length,
|
||||
cycles: openIssues.filter((i) => i.cycleId).length,
|
||||
projects: projects.length,
|
||||
triage: issues.filter((i) => i.needsTriage).length,
|
||||
};
|
||||
}, [issues, projects, user]);
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export function CommandPalette({ open, onClose, issues }: CommandPaletteProps) {
|
||||
{ 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: "nav-triage", icon: "bx-filter-alt", label: "Ir a Triage", hint: "T", run: () => router.push("/triage") },
|
||||
{
|
||||
id: "new-issue",
|
||||
icon: "bx-plus-circle",
|
||||
|
||||
@@ -11,6 +11,7 @@ interface SidebarProps {
|
||||
board: number;
|
||||
cycles: number;
|
||||
projects: number;
|
||||
triage: number;
|
||||
};
|
||||
projects: Array<{ id: string; name: string }>;
|
||||
/** Controla el estado abierto/colapsado del shell. */
|
||||
@@ -33,6 +34,7 @@ export function Sidebar({ counts, projects, open }: SidebarProps) {
|
||||
{ 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 },
|
||||
{ href: "/triage", icon: "bx-filter-alt", label: "Triage", count: counts.triage },
|
||||
];
|
||||
|
||||
const isActive = (href: string) =>
|
||||
|
||||
@@ -95,6 +95,12 @@ function buildQuery(filters?: IssueFilters): string {
|
||||
// El backend llama `q` a la búsqueda de texto.
|
||||
if (filters.query?.trim()) params.append("q", filters.query.trim());
|
||||
|
||||
// Triage y estimación (Fase 4 en sam).
|
||||
if (filters.needsTriage !== undefined)
|
||||
params.append("needsTriage", String(filters.needsTriage));
|
||||
if (filters.estimated !== undefined)
|
||||
params.append("estimated", String(filters.estimated));
|
||||
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@ function matchesFilters(issue: Issue, filters?: IssueFilters): boolean {
|
||||
if (filters.cycleId?.length && !filters.cycleId.includes(issue.cycleId ?? "")) return false;
|
||||
if (filters.labelIds?.length && !filters.labelIds.some((id) => issue.labelIds.includes(id)))
|
||||
return false;
|
||||
if (filters.needsTriage !== undefined && issue.needsTriage !== filters.needsTriage)
|
||||
return false;
|
||||
if (filters.estimated === true && issue.estimate == null) return false;
|
||||
if (filters.estimated === false && issue.estimate != null) return false;
|
||||
|
||||
if (filters.query?.trim()) {
|
||||
const q = filters.query.trim().toLowerCase();
|
||||
@@ -206,6 +210,8 @@ export const mockApi: ProyectosApi = {
|
||||
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}'.`);
|
||||
if (input.estimate != null && input.estimate < 0)
|
||||
throw new Error("La estimación no puede ser negativa.");
|
||||
const project = state.projects.find((p) => p.id === input.projectId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -216,6 +222,8 @@ export const mockApi: ProyectosApi = {
|
||||
description: input.description ?? "",
|
||||
status,
|
||||
parentId: null,
|
||||
needsTriage: true,
|
||||
estimate: input.estimate ?? null,
|
||||
priority: input.priority ?? 0,
|
||||
type: input.type ?? "task",
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
@@ -255,6 +263,9 @@ export const mockApi: ProyectosApi = {
|
||||
if (patch.projectId !== undefined) track("project", issue.projectId, patch.projectId);
|
||||
if (patch.cycleId !== undefined) track("cycle", issue.cycleId, patch.cycleId);
|
||||
if (patch.parentId !== undefined) track("parent", issue.parentId ?? null, patch.parentId);
|
||||
if (patch.needsTriage !== undefined)
|
||||
track("triage", issue.needsTriage ? "pendiente" : "triado", patch.needsTriage ? "pendiente" : "triado");
|
||||
if (patch.estimate !== undefined) track("estimate", issue.estimate, patch.estimate);
|
||||
if (patch.labelIds !== undefined)
|
||||
track("labels", issue.labelIds.join(","), patch.labelIds.join(","));
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ export const issues: Issue[] = [
|
||||
projectId: "p1",
|
||||
cycleId: "c3",
|
||||
parentId: null,
|
||||
needsTriage: false,
|
||||
estimate: 5,
|
||||
labelIds: ["l2"],
|
||||
createdAt: "2026-10-01T09:15:00Z",
|
||||
updatedAt: "2026-10-12T15:40:00Z",
|
||||
@@ -131,6 +133,8 @@ export const issues: Issue[] = [
|
||||
projectId: "p1",
|
||||
cycleId: "c3",
|
||||
parentId: null,
|
||||
needsTriage: false,
|
||||
estimate: 3,
|
||||
labelIds: ["l1", "l4"],
|
||||
createdAt: "2026-10-03T11:00:00Z",
|
||||
updatedAt: "2026-10-10T08:20:00Z",
|
||||
@@ -148,6 +152,8 @@ export const issues: Issue[] = [
|
||||
projectId: "p1",
|
||||
cycleId: "c3",
|
||||
parentId: null,
|
||||
needsTriage: false,
|
||||
estimate: 8,
|
||||
labelIds: ["l3"],
|
||||
createdAt: "2026-09-28T14:30:00Z",
|
||||
updatedAt: "2026-10-11T17:05:00Z",
|
||||
@@ -165,6 +171,8 @@ export const issues: Issue[] = [
|
||||
projectId: "p1",
|
||||
cycleId: "c3",
|
||||
parentId: null,
|
||||
needsTriage: false,
|
||||
estimate: null,
|
||||
labelIds: ["l2"],
|
||||
createdAt: "2026-09-20T10:00:00Z",
|
||||
updatedAt: "2026-10-08T12:00:00Z",
|
||||
@@ -182,6 +190,8 @@ export const issues: Issue[] = [
|
||||
projectId: "p2",
|
||||
cycleId: null,
|
||||
parentId: null,
|
||||
needsTriage: false,
|
||||
estimate: null,
|
||||
labelIds: ["l1", "l3"],
|
||||
createdAt: "2026-10-06T09:00:00Z",
|
||||
updatedAt: "2026-10-06T09:00:00Z",
|
||||
@@ -199,6 +209,8 @@ export const issues: Issue[] = [
|
||||
projectId: "p2",
|
||||
cycleId: "c4",
|
||||
parentId: "i5",
|
||||
needsTriage: false,
|
||||
estimate: 5,
|
||||
labelIds: ["l2"],
|
||||
createdAt: "2026-10-07T16:45:00Z",
|
||||
updatedAt: "2026-10-09T10:10:00Z",
|
||||
@@ -216,6 +228,8 @@ export const issues: Issue[] = [
|
||||
projectId: "p3",
|
||||
cycleId: "c2",
|
||||
parentId: null,
|
||||
needsTriage: false,
|
||||
estimate: null,
|
||||
labelIds: ["l5"],
|
||||
createdAt: "2026-08-15T13:20:00Z",
|
||||
updatedAt: "2026-09-30T09:00:00Z",
|
||||
@@ -233,6 +247,8 @@ export const issues: Issue[] = [
|
||||
projectId: "p1",
|
||||
cycleId: "c4",
|
||||
parentId: null,
|
||||
needsTriage: false,
|
||||
estimate: null,
|
||||
labelIds: ["l5"],
|
||||
createdAt: "2026-10-09T08:00:00Z",
|
||||
updatedAt: "2026-10-09T08:00:00Z",
|
||||
|
||||
@@ -120,6 +120,10 @@ export interface Issue {
|
||||
cycleId: ID | null;
|
||||
/** Id del issue padre (sub-issues, Fase 3 en sam). Null/ausente = raíz. */
|
||||
parentId: ID | null;
|
||||
/** TRUE = pendiente de triage (Fase 4 en sam). */
|
||||
needsTriage: boolean;
|
||||
/** Puntos de historia, null = sin estimar (Fase 4 en sam). */
|
||||
estimate: number | null;
|
||||
labelIds: ID[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -128,6 +132,8 @@ export interface Issue {
|
||||
|
||||
/** Filtros soportados por `listIssues`. */
|
||||
export interface IssueFilters {
|
||||
needsTriage?: boolean;
|
||||
estimated?: boolean;
|
||||
status?: IssueStatus[];
|
||||
priority?: IssuePriority[];
|
||||
assigneeId?: ID[];
|
||||
@@ -152,6 +158,8 @@ export interface IssueInput {
|
||||
projectId?: ID | null;
|
||||
cycleId?: ID | null;
|
||||
parentId?: ID | null;
|
||||
needsTriage?: boolean;
|
||||
estimate?: number | null;
|
||||
labelIds?: ID[];
|
||||
actorId?: ID;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user