feat: línea de tiempo mezclada en el detalle (Fase 2)
This commit is contained in:
1 parent
e15dc3926e
commit
bbb5fd2b1a
6 files changed
+307
-32
No files matched your search
@@ -4,13 +4,13 @@ import Link from "next/link";
|
||||
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 type { Activity, Comment, Issue, IssueStatus } from "@/lib/types";
|
||||
import { PRIORITY_META, TYPE_META } from "@/lib/meta";
|
||||
import { formatLongDate, formatRelative } from "@/lib/format";
|
||||
import { useReference, useReferenceLookups, useWorkflowStates } from "@/lib/store";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { ActivityFeed } from "@/components/issues/ActivityFeed";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { TypeIcon } from "@/components/ui/Icons";
|
||||
|
||||
@@ -36,22 +36,25 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { meta, order } = useWorkflowStates();
|
||||
const { users, projects, cycles, labels } = useReference();
|
||||
const { projectById, userById } = useReferenceLookups();
|
||||
const { projectById } = useReferenceLookups();
|
||||
|
||||
const [issue, setIssue] = useState<Issue | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [activity, setActivity] = useState<Activity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [found, issueComments] = await Promise.all([
|
||||
const [found, issueComments, issueActivity] = await Promise.all([
|
||||
api.getIssue(issueId),
|
||||
api.listComments(issueId),
|
||||
api.listActivity(issueId),
|
||||
]);
|
||||
|
||||
setIssue(found);
|
||||
setComments(issueComments);
|
||||
setActivity(issueActivity);
|
||||
setLoading(false);
|
||||
}, [issueId]);
|
||||
|
||||
@@ -67,8 +70,13 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
||||
setIssue((current) => (current ? { ...current, ...partial } : current));
|
||||
|
||||
try {
|
||||
const updated = await api.updateIssue(issue.id, partial);
|
||||
const updated = await api.updateIssue(issue.id, {
|
||||
...partial,
|
||||
...(user ? { actorId: user.id } : {}),
|
||||
});
|
||||
setIssue(updated);
|
||||
// Refrescar la línea de tiempo con los eventos recién creados.
|
||||
void api.listActivity(issue.id).then(setActivity);
|
||||
} catch {
|
||||
// Revertir si el backend rechaza el cambio.
|
||||
void load();
|
||||
@@ -84,6 +92,7 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
||||
setDraft("");
|
||||
const created = await api.addComment(issueId, body, user.id);
|
||||
setComments((current) => [...current, created]);
|
||||
void api.listActivity(issueId).then(setActivity);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -150,35 +159,21 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comentarios */}
|
||||
{/* Línea de tiempo: comentarios + eventos del log (Fase 2) */}
|
||||
<section>
|
||||
<div className="detail-section-title">Actividad</div>
|
||||
|
||||
{comments.length === 0 && (
|
||||
<p style={{ color: "var(--lp-text-faint)", fontSize: 13 }}>
|
||||
Sin comentarios todavía.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{comments.map((comment) => {
|
||||
const author = userById.get(comment.authorId);
|
||||
return (
|
||||
<div key={comment.id} className="comment">
|
||||
<Avatar user={author} size="lg" />
|
||||
<div className="flex-grow-1">
|
||||
<div className="d-flex align-items-baseline gap-2 mb-1">
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
{author?.name ?? "Desconocido"}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "var(--lp-text-faint)" }}>
|
||||
{formatRelative(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="comment-body">{comment.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ActivityFeed
|
||||
comments={comments}
|
||||
activity={activity}
|
||||
users={new Map(users.map((u) => [u.id, u]))}
|
||||
states={Object.fromEntries(
|
||||
order.map((key) => [key, { label: meta[key]?.label ?? key }]),
|
||||
)}
|
||||
projects={new Map(projects.map((p) => [p.id, p]))}
|
||||
cycles={new Map(cycles.map((c) => [c.id, c]))}
|
||||
labels={new Map(labels.map((l) => [l.id, l]))}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleComment} className="mt-3">
|
||||
<textarea
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { Activity, Comment, Cycle, Label, Project, User } from "@/lib/types";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
|
||||
interface ActivityFeedProps {
|
||||
comments: Comment[];
|
||||
activity: Activity[];
|
||||
users: Map<string, User>;
|
||||
states: Record<string, { label: string }>;
|
||||
projects: Map<string, Project>;
|
||||
cycles: Map<string, Cycle>;
|
||||
labels: Map<string, Label>;
|
||||
}
|
||||
|
||||
type FeedItem =
|
||||
| { kind: "comment"; at: string; comment: Comment }
|
||||
| { kind: "event"; at: string; event: Activity };
|
||||
|
||||
/**
|
||||
* Línea de tiempo mezclada de un issue (Fase 2): comentarios y eventos
|
||||
* del log en orden cronológico, con descripciones legibles.
|
||||
*
|
||||
* Los valores guardados son claves/ids; aquí se resuelven a nombres
|
||||
* usando los datos de referencia (usuarios, estados, proyectos...).
|
||||
*/
|
||||
export function ActivityFeed({ comments, activity, users, states, projects, cycles, labels }: ActivityFeedProps) {
|
||||
const items = useMemo<FeedItem[]>(() => {
|
||||
const list: FeedItem[] = [
|
||||
...comments.map((comment) => ({ kind: "comment" as const, at: comment.createdAt, comment })),
|
||||
...activity
|
||||
// Los comentarios ya se muestran como tales; no duplicarlos.
|
||||
.filter((event) => event.action !== "comment_added")
|
||||
.map((event) => ({ kind: "event" as const, at: event.createdAt, event })),
|
||||
];
|
||||
return list.sort((a, b) => a.at.localeCompare(b.at));
|
||||
}, [comments, activity]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p style={{ color: "var(--inv-muted)", fontSize: 13 }}>
|
||||
Sin actividad todavía.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{items.map((item) =>
|
||||
item.kind === "comment" ? (
|
||||
<CommentRow
|
||||
key={`c-${item.comment.id}`}
|
||||
comment={item.comment}
|
||||
author={users.get(item.comment.authorId)}
|
||||
/>
|
||||
) : (
|
||||
<EventRow
|
||||
key={`e-${item.event.id}`}
|
||||
event={item.event}
|
||||
actor={item.event.actorId ? users.get(item.event.actorId) : undefined}
|
||||
states={states}
|
||||
projects={projects}
|
||||
cycles={cycles}
|
||||
labels={labels}
|
||||
users={users}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentRow({ comment, author }: { comment: Comment; author?: User }) {
|
||||
return (
|
||||
<div className="comment">
|
||||
<Avatar user={author} size="lg" />
|
||||
<div className="flex-grow-1">
|
||||
<div className="d-flex align-items-baseline gap-2 mb-1">
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{author?.name ?? "Desconocido"}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--inv-muted)" }}>
|
||||
{formatRelative(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="comment-body">{comment.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nombre legible de un valor según la acción (id → nombre cuando aplica). */
|
||||
function resolveValue(
|
||||
action: string,
|
||||
value: string | null,
|
||||
ctx: {
|
||||
states: Record<string, { label: string }>;
|
||||
projects: Map<string, Project>;
|
||||
cycles: Map<string, Cycle>;
|
||||
labels: Map<string, Label>;
|
||||
users: Map<string, User>;
|
||||
},
|
||||
): string {
|
||||
if (value == null || value === "") return "—";
|
||||
|
||||
switch (action) {
|
||||
case "status":
|
||||
return ctx.states[value]?.label ?? value;
|
||||
case "assignee":
|
||||
return ctx.users.get(value)?.name ?? value;
|
||||
case "project":
|
||||
return ctx.projects.get(value)?.name ?? value;
|
||||
case "cycle":
|
||||
return ctx.cycles.get(value)?.name ?? value;
|
||||
case "labels":
|
||||
if (value === "") return "ninguna";
|
||||
return value
|
||||
.split(",")
|
||||
.map((id) => ctx.labels.get(id)?.name ?? id)
|
||||
.join(", ");
|
||||
case "priority": {
|
||||
const names: Record<string, string> = {
|
||||
"0": "sin prioridad",
|
||||
"1": "baja",
|
||||
"2": "media",
|
||||
"3": "alta",
|
||||
"4": "urgente",
|
||||
};
|
||||
return names[value] ?? value;
|
||||
}
|
||||
case "parent":
|
||||
return value === "" ? "raíz" : `issue #${value}`;
|
||||
default:
|
||||
return value.length > 120 ? `${value.slice(0, 120)}…` : value;
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_VERB: Record<string, string> = {
|
||||
created: "creó el issue",
|
||||
status: "cambió el estado",
|
||||
assignee: "reasignó el issue",
|
||||
priority: "cambió la prioridad",
|
||||
type: "cambió el tipo",
|
||||
title: "renombró el issue",
|
||||
description: "editó la descripción",
|
||||
project: "movió el issue de proyecto",
|
||||
cycle: "movió el issue de ciclo",
|
||||
labels: "actualizó las etiquetas",
|
||||
parent: "movió el issue en la jerarquía",
|
||||
relation_added: "vinculó el issue",
|
||||
relation_removed: "quitó un vínculo",
|
||||
subscribed: "se suscribió",
|
||||
unsubscribed: "dejó de seguir",
|
||||
};
|
||||
|
||||
function EventRow({
|
||||
event,
|
||||
actor,
|
||||
states,
|
||||
projects,
|
||||
cycles,
|
||||
labels,
|
||||
users,
|
||||
}: {
|
||||
event: Activity;
|
||||
actor?: User;
|
||||
states: Record<string, { label: string }>;
|
||||
projects: Map<string, Project>;
|
||||
cycles: Map<string, Cycle>;
|
||||
labels: Map<string, Label>;
|
||||
users: Map<string, User>;
|
||||
}) {
|
||||
const ctx = { states, projects, cycles, labels, users };
|
||||
const verb = ACTION_VERB[event.action] ?? event.action;
|
||||
|
||||
// Acciones con antes/después; el resto muestra el valor nuevo o nada.
|
||||
const hasTransition = event.oldValue != null || event.newValue != null;
|
||||
const detail =
|
||||
event.action === "created"
|
||||
? ` (${event.newValue})`
|
||||
: hasTransition
|
||||
? `: ${resolveValue(event.action, event.oldValue, ctx)} → ${resolveValue(event.action, event.newValue, ctx)}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="comment" style={{ alignItems: "center" }}>
|
||||
<Avatar user={actor} />
|
||||
<div className="flex-grow-1" style={{ fontSize: 13, color: "var(--inv-muted)" }}>
|
||||
<span style={{ fontWeight: 600, color: "var(--bs-body-color)" }}>
|
||||
{actor?.name ?? "Sistema"}
|
||||
</span>{" "}
|
||||
{verb}
|
||||
{detail}
|
||||
<span style={{ fontSize: 12, color: "var(--inv-muted)" }}>
|
||||
{" "}
|
||||
· {formatRelative(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
Activity,
|
||||
Comment,
|
||||
Cycle,
|
||||
ID,
|
||||
@@ -41,6 +42,9 @@ export interface ProyectosApi {
|
||||
createIssue(input: IssueInput): Promise<Issue>;
|
||||
updateIssue(id: ID, patch: Partial<IssueInput>): Promise<Issue>;
|
||||
|
||||
// Línea de tiempo (Fase 2 en sam): eventos de creación, cambios y comentarios.
|
||||
listActivity(issueId: ID): Promise<Activity[]>;
|
||||
|
||||
// Comentarios
|
||||
listComments(issueId: ID): Promise<Comment[]>;
|
||||
/** `authorId` es el idUsuario del usuario en sesión. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
Activity,
|
||||
Comment,
|
||||
Cycle,
|
||||
ID,
|
||||
@@ -158,6 +159,8 @@ export const httpApi: ProyectosApi = {
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
|
||||
listActivity: (issueId: ID) => request<Activity[]>(`/api/proyectos/issues/${issueId}/activity`),
|
||||
|
||||
listComments: (issueId: ID) => request<Comment[]>(`/api/proyectos/issues/${issueId}/comments`),
|
||||
|
||||
addComment: (issueId: ID, body: string, authorId: ID) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
Activity,
|
||||
Comment,
|
||||
Cycle,
|
||||
ID,
|
||||
@@ -25,6 +26,7 @@ import type { ProyectosApi } from "./client";
|
||||
const state = {
|
||||
issues: structuredClone(seed.issues),
|
||||
comments: structuredClone(seed.comments),
|
||||
activity: [] as Activity[],
|
||||
projects: structuredClone(seed.projects),
|
||||
cycles: structuredClone(seed.cycles),
|
||||
users: structuredClone(seed.users),
|
||||
@@ -34,6 +36,25 @@ const state = {
|
||||
};
|
||||
|
||||
/** Claves que cuentan como terminadas (bandera is_done de la API). */
|
||||
/** Registra un evento en la línea de tiempo (réplica del backend). */
|
||||
function logActivity(
|
||||
issueId: ID,
|
||||
actorId: ID | null,
|
||||
action: string,
|
||||
oldValue: string | null,
|
||||
newValue: string | null,
|
||||
) {
|
||||
state.activity.push({
|
||||
id: nextId("ev"),
|
||||
issueId,
|
||||
actorId,
|
||||
action,
|
||||
oldValue,
|
||||
newValue,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function doneKeys(): Set<string> {
|
||||
return new Set(state.states.filter((s) => s.isDone).map((s) => s.key));
|
||||
}
|
||||
@@ -203,6 +224,7 @@ export const mockApi: ProyectosApi = {
|
||||
};
|
||||
|
||||
state.issues.unshift(issue);
|
||||
logActivity(issue.id, null, "created", null, issue.identifier);
|
||||
return issue;
|
||||
},
|
||||
|
||||
@@ -211,6 +233,26 @@ export const mockApi: ProyectosApi = {
|
||||
const issue = state.issues.find((i) => i.id === id);
|
||||
if (!issue) throw new Error(`Issue ${id} no encontrado`);
|
||||
|
||||
// Solo se registra lo que cambió de verdad (igual que el backend).
|
||||
const actor = (patch as { actorId?: ID }).actorId ?? null;
|
||||
const changed: Array<[string, string | null, string | null]> = [];
|
||||
const track = (action: string, oldV: unknown, newV: unknown) => {
|
||||
const o = oldV == null ? null : String(oldV);
|
||||
const n = newV == null ? null : String(newV);
|
||||
if (o !== n) changed.push([action, o, n]);
|
||||
};
|
||||
|
||||
if (patch.title !== undefined) track("title", issue.title, patch.title);
|
||||
if (patch.description !== undefined) track("description", issue.description, patch.description);
|
||||
if (patch.status !== undefined) track("status", issue.status, patch.status);
|
||||
if (patch.type !== undefined) track("type", issue.type, patch.type);
|
||||
if (patch.priority !== undefined) track("priority", issue.priority, patch.priority);
|
||||
if (patch.assigneeId !== undefined) track("assignee", issue.assigneeId, patch.assigneeId);
|
||||
if (patch.projectId !== undefined) track("project", issue.projectId, patch.projectId);
|
||||
if (patch.cycleId !== undefined) track("cycle", issue.cycleId, patch.cycleId);
|
||||
if (patch.labelIds !== undefined)
|
||||
track("labels", issue.labelIds.join(","), patch.labelIds.join(","));
|
||||
|
||||
Object.assign(issue, patch, { updatedAt: new Date().toISOString() });
|
||||
|
||||
// Mantener `completedAt` coherente con el estado final
|
||||
@@ -218,9 +260,17 @@ export const mockApi: ProyectosApi = {
|
||||
const isFinished = doneKeys().has(issue.status);
|
||||
issue.completedAt = isFinished ? (issue.completedAt ?? new Date().toISOString()) : null;
|
||||
|
||||
for (const [action, o, n] of changed) logActivity(id, actor, action, o, n);
|
||||
return issue;
|
||||
},
|
||||
|
||||
async listActivity(issueId: ID): Promise<Activity[]> {
|
||||
await delay(60);
|
||||
return state.activity
|
||||
.filter((e) => e.issueId === issueId)
|
||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
},
|
||||
|
||||
async listComments(issueId: ID): Promise<Comment[]> {
|
||||
await delay(60);
|
||||
return state.comments.filter((c) => c.issueId === issueId);
|
||||
@@ -236,6 +286,7 @@ export const mockApi: ProyectosApi = {
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
state.comments.push(comment);
|
||||
logActivity(issueId, authorId, "comment_added", null, body.slice(0, 200));
|
||||
return comment;
|
||||
},
|
||||
};
|
||||
+22
-1
@@ -82,6 +82,22 @@ export interface Cycle {
|
||||
status: "upcoming" | "active" | "completed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Evento del log de actividad (Fase 2 en sam).
|
||||
* action: created, status, assignee, priority, type, title, description,
|
||||
* project, cycle, labels, comment_added, parent, relation_added,
|
||||
* relation_removed, subscribed, unsubscribed.
|
||||
*/
|
||||
export interface Activity {
|
||||
id: ID;
|
||||
issueId: ID;
|
||||
actorId: ID | null;
|
||||
action: string;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: ID;
|
||||
issueId: ID;
|
||||
@@ -119,7 +135,11 @@ export interface IssueFilters {
|
||||
query?: string;
|
||||
}
|
||||
|
||||
/** Payload para crear/actualizar un issue. */
|
||||
/**
|
||||
* Payload para crear/actualizar un issue.
|
||||
* actorId es transitorio: el backend lo usa como autor del log y no lo
|
||||
* persiste en el issue (ver BuildPatch en sam).
|
||||
*/
|
||||
export interface IssueInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -130,4 +150,5 @@ export interface IssueInput {
|
||||
projectId?: ID | null;
|
||||
cycleId?: ID | null;
|
||||
labelIds?: ID[];
|
||||
actorId?: ID;
|
||||
}
|
||||
Reference in new issue
Block a user