feat: jerarquía, vínculos y suscriptores en el detalle (Fase 3)

This commit is contained in:
Nurfog committed 2026-10-14 13:24:17 -03:00
1 parent bbb5fd2b1a
commit 9acaad4f62
7 files changed
+536 -1

No files matched your search

+20 -1
View File
@@ -11,6 +11,7 @@ import { useReference, useReferenceLookups, useWorkflowStates } from "@/lib/stor
import { useAuth } from "@/lib/auth";
import { AppShell } from "@/components/layout/AppShell";
import { ActivityFeed } from "@/components/issues/ActivityFeed";
import { IssueRelations } from "@/components/issues/IssueRelations";
import { EmptyState } from "@/components/ui/EmptyState";
import { TypeIcon } from "@/components/ui/Icons";
@@ -74,7 +75,9 @@ function IssueDetail({ issueId }: { issueId: string }) {
...partial,
...(user ? { actorId: user.id } : {}),
});
setIssue(updated);
// Si cambió el padre, el objeto local puede quedar desfasado.
const fresh = await api.getIssue(issue.id);
setIssue(fresh ?? updated);
// Refrescar la línea de tiempo con los eventos recién creados.
void api.listActivity(issue.id).then(setActivity);
} catch {
@@ -123,6 +126,7 @@ function IssueDetail({ issueId }: { issueId: string }) {
const project = issue.projectId ? projectById.get(issue.projectId) : undefined;
return (
<>
<div className="detail-grid">
{/* Columna principal */}
<div>
@@ -328,5 +332,20 @@ function IssueDetail({ issueId }: { issueId: string }) {
)}
</aside>
</div>
{/* Jerarquía, vínculos y suscriptores (Fase 3) */}
<div className="mt-4">
<IssueRelations
issue={issue}
currentUserId={user?.id ?? null}
users={new Map(users.map((u) => [u.id, u]))}
onChanged={() => {
void api.getIssue(issueId).then((fresh) => {
if (fresh) setIssue(fresh);
});
}}
/>
</div>
</>
);
}
+354
View File
@@ -0,0 +1,354 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { api, ApiError } from "@/lib/api";
import type { ID, Issue, Relation, User } from "@/lib/types";
import { Avatar } from "@/components/ui/Avatar";
interface IssueRelationsProps {
issue: Issue;
currentUserId: ID | null;
users: Map<ID, User>;
/** Refresca el issue (el padre/hijos pueden haber cambiado). */
onChanged: () => void;
}
const KIND_LABEL: Record<string, { outgoing: string; incoming: string }> = {
blocked_by: { outgoing: "Bloqueado por", incoming: "Bloquea a" },
blocks: { outgoing: "Bloquea a", incoming: "Bloqueado por" },
related: { outgoing: "Relacionado con", incoming: "Relacionado con" },
duplicates: { outgoing: "Duplicado de", incoming: "Duplicado por" },
};
/**
* Jerarquía, vínculos y suscriptores de un issue (Fase 3).
*
* Todo pasa por los endpoints dedicados de sam, que validan ciclos,
* existencia y actores. Los errores del backend se muestran tal cual.
*/
export function IssueRelations({ issue, currentUserId, users, onChanged }: IssueRelationsProps) {
const [children, setChildren] = useState<Issue[]>([]);
const [parent, setParent] = useState<Issue | null>(null);
const [relations, setRelations] = useState<Relation[]>([]);
const [relatedIssues, setRelatedIssues] = useState<Map<ID, Issue>>(new Map());
const [subscribers, setSubscribers] = useState<ID[]>([]);
const [error, setError] = useState<string | null>(null);
// Formularios.
const [identifier, setIdentifier] = useState("");
const [kind, setKind] = useState("blocked_by");
const [newChildTitle, setNewChildTitle] = useState("");
const refresh = useCallback(async () => {
setError(null);
try {
const [kids, rels, subs] = await Promise.all([
api.listChildren(issue.id),
api.listRelations(issue.id),
api.listSubscribers(issue.id),
]);
setChildren(kids);
setRelations(rels);
setSubscribers(subs);
// Resolver los issues vinculados + el padre para mostrar identifiers.
const ids = new Set<ID>();
for (const r of rels) {
ids.add(r.fromIssue === issue.id ? r.toIssue : r.fromIssue);
}
if (issue.parentId) ids.add(issue.parentId);
const resolved = new Map<ID, Issue>();
await Promise.all(
[...ids].map(async (id) => {
const found = await api.getIssue(id);
if (found) resolved.set(id, found);
}),
);
setRelatedIssues(resolved);
setParent(issue.parentId ? (resolved.get(issue.parentId) ?? null) : null);
} catch (err) {
setError(err instanceof Error ? err.message : "Error cargando relaciones");
}
}, [issue.id, issue.parentId]);
useEffect(() => {
void refresh();
}, [refresh]);
/** Busca un issue por identifier exacto (ej. "PLAT-42"). */
async function resolveIdentifier(raw: string): Promise<Issue> {
const idf = raw.trim().toUpperCase();
if (!idf) throw new Error("Escribe un identificador (ej. PLAT-42).");
const found = await api.listIssues({ query: idf });
const exact = found.find((i) => i.identifier.toUpperCase() === idf && i.id !== issue.id);
if (!exact) throw new Error(`No existe el issue ${idf}.`);
return exact;
}
async function run(action: () => Promise<unknown>, after?: () => void) {
setError(null);
try {
await action();
after?.();
await refresh();
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error");
}
}
const isSubscribed = currentUserId != null && subscribers.includes(currentUserId);
return (
<div className="row g-3">
{error && (
<div className="col-12">
<div className="alert alert-danger py-2" role="alert">
{error}
</div>
</div>
)}
{/* Jerarquía */}
<div className="col-md-4">
<div className="lp-card p-3 h-100">
<div className="detail-section-title">Jerarquía</div>
{parent ? (
<div className="mb-2" style={{ fontSize: 13 }}>
<span style={{ color: "var(--inv-muted)" }}>Sub-issue de </span>
<Link href={`/issues/${parent.id}`}>
<span className="issue-identifier">{parent.identifier}</span>{" "}
{parent.title}
</Link>
<button
type="button"
className="btn btn-lp btn-sm ms-2"
title="Volver a raíz"
onClick={() => void run(() => api.setParent(issue.id, null, currentUserId ?? undefined))}
>
<i className="bx bx-x" />
</button>
</div>
) : (
<p style={{ fontSize: 13, color: "var(--inv-muted)" }}>Issue raíz.</p>
)}
{children.length > 0 && (
<ul className="list-unstyled mb-2">
{children.map((child) => (
<li key={child.id} className="py-1 d-flex align-items-center gap-2">
<Link
href={`/issues/${child.id}`}
style={{ fontSize: 13 }}
className="text-truncate"
>
<span className="issue-identifier">{child.identifier}</span>{" "}
{child.title}
</Link>
<button
type="button"
className="btn btn-lp btn-sm ms-auto"
title="Sacar de la jerarquía"
onClick={() => void run(() => api.setParent(child.id, null, currentUserId ?? undefined))}
>
<i className="bx bx-x" />
</button>
</li>
))}
</ul>
)}
<form
className="d-flex gap-1 mt-2"
onSubmit={(e) => {
e.preventDefault();
const title = newChildTitle.trim();
if (!title) return;
setNewChildTitle("");
void run(async () => {
const created = await api.createIssue({
title,
projectId: issue.projectId,
...(currentUserId ? { actorId: currentUserId } : {}),
});
await api.setParent(created.id, issue.id, currentUserId ?? undefined);
});
}}
>
<input
className="form-control form-control-sm"
placeholder="Nuevo sub-issue…"
value={newChildTitle}
onChange={(e) => setNewChildTitle(e.target.value)}
/>
<button type="submit" className="btn btn-accent btn-sm" title="Crear sub-issue">
<i className="bx bx-plus" />
</button>
</form>
<form
className="d-flex gap-1 mt-2"
onSubmit={(e) => {
e.preventDefault();
const idf = identifier;
setIdentifier("");
void run(async () => {
const target = await resolveIdentifier(idf);
await api.setParent(target.id, issue.id, currentUserId ?? undefined);
});
}}
>
<input
className="form-control form-control-sm"
placeholder="Añadir existente (PLAT-42)…"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
/>
<button type="submit" className="btn btn-lp btn-sm" title="Mover como sub-issue">
<i className="bx bx-plus" />
</button>
</form>
</div>
</div>
{/* Vínculos */}
<div className="col-md-4">
<div className="lp-card p-3 h-100">
<div className="detail-section-title">Vínculos</div>
{relations.length === 0 && (
<p style={{ fontSize: 13, color: "var(--inv-muted)" }}>Sin vínculos.</p>
)}
<ul className="list-unstyled mb-2">
{relations.map((rel) => {
const outgoing = rel.fromIssue === issue.id;
const otherId = outgoing ? rel.toIssue : rel.fromIssue;
const other = relatedIssues.get(otherId);
const label = outgoing
? (KIND_LABEL[rel.kind]?.outgoing ?? rel.kind)
: (KIND_LABEL[rel.kind]?.incoming ?? rel.kind);
return (
<li key={rel.id} className="py-1 d-flex align-items-center gap-2">
<span style={{ fontSize: 12, color: "var(--inv-muted)" }}>{label}</span>
{other ? (
<Link href={`/issues/${other.id}`} style={{ fontSize: 13 }} className="text-truncate">
<span className="issue-identifier">{other.identifier}</span>{" "}
{other.title}
</Link>
) : (
<span style={{ fontSize: 13 }}>#{otherId}</span>
)}
<button
type="button"
className="btn btn-lp btn-sm ms-auto"
title="Quitar vínculo"
onClick={() =>
void run(() => api.deleteRelation(rel.id, currentUserId ?? undefined))
}
>
<i className="bx bx-x" />
</button>
</li>
);
})}
</ul>
<RelationForm
onAdd={(idf, relKind) =>
void run(async () => {
const target = await resolveIdentifier(idf);
await api.addRelation(issue.id, target.id, relKind, currentUserId ?? undefined);
})
}
/>
</div>
</div>
{/* Suscriptores */}
<div className="col-md-4">
<div className="lp-card p-3 h-100">
<div className="detail-section-title">Suscriptores</div>
{subscribers.length === 0 && (
<p style={{ fontSize: 13, color: "var(--inv-muted)" }}>
Nadie sigue este issue todavía.
</p>
)}
<div className="d-flex flex-wrap gap-2 mb-2">
{subscribers.map((id) => {
const member = users.get(id);
return (
<span key={id} className="d-inline-flex align-items-center gap-1">
<Avatar user={member} />
<span style={{ fontSize: 12 }}>{member?.name ?? id}</span>
</span>
);
})}
</div>
{currentUserId && (
<button
type="button"
className={`btn btn-sm ${isSubscribed ? "btn-lp" : "btn-accent"}`}
onClick={() =>
void run(() =>
isSubscribed
? api.unsubscribe(issue.id, currentUserId, currentUserId)
: api.subscribe(issue.id, currentUserId, currentUserId),
)
}
>
<i className={`bx ${isSubscribed ? "bx-bell-off" : "bx-bell"} me-1`} />
{isSubscribed ? "Dejar de seguir" : "Seguir"}
</button>
)}
</div>
</div>
</div>
);
}
function RelationForm({ onAdd }: { onAdd: (identifier: string, kind: string) => void }) {
const [idf, setIdf] = useState("");
const [relKind, setRelKind] = useState("blocked_by");
return (
<form
className="d-flex gap-1 mt-2"
onSubmit={(e) => {
e.preventDefault();
if (!idf.trim()) return;
onAdd(idf, relKind);
setIdf("");
}}
>
<select
className="form-select form-select-sm"
style={{ maxWidth: 130 }}
value={relKind}
onChange={(e) => setRelKind(e.target.value)}
title="Tipo de vínculo"
>
<option value="blocked_by">Bloqueado por</option>
<option value="blocks">Bloquea a</option>
<option value="related">Relacionado</option>
<option value="duplicates">Duplica a</option>
</select>
<input
className="form-control form-control-sm"
placeholder="PLAT-42…"
value={idf}
onChange={(e) => setIdf(e.target.value)}
/>
<button type="submit" className="btn btn-lp btn-sm" title="Añadir vínculo">
<i className="bx bx-plus" />
</button>
</form>
);
}
+15
View File
@@ -1,6 +1,7 @@
import type {
Activity,
Comment,
Relation,
Cycle,
ID,
Issue,
@@ -45,6 +46,20 @@ export interface ProyectosApi {
// Línea de tiempo (Fase 2 en sam): eventos de creación, cambios y comentarios.
listActivity(issueId: ID): Promise<Activity[]>;
// Relaciones (Fase 3 en sam)
/** Hijos directos (sub-issues). */
listChildren(issueId: ID): Promise<Issue[]>;
/** Fija el padre (null = raíz). Valida ciclos en el backend. */
setParent(issueId: ID, parentId: ID | null, actorId?: ID): Promise<Issue>;
/** Vínculos donde el issue participa (cualquier dirección). */
listRelations(issueId: ID): Promise<Relation[]>;
addRelation(issueId: ID, toIssue: ID, kind: string, actorId?: ID): Promise<Relation>;
deleteRelation(relationId: ID, actorId?: ID): Promise<void>;
/** idUsuarios suscritos al issue. */
listSubscribers(issueId: ID): Promise<ID[]>;
subscribe(issueId: ID, userId: ID, actorId?: ID): Promise<void>;
unsubscribe(issueId: ID, userId: ID, actorId?: ID): Promise<void>;
// Comentarios
listComments(issueId: ID): Promise<Comment[]>;
/** `authorId` es el idUsuario del usuario en sesión. */
+38
View File
@@ -1,6 +1,7 @@
import type {
Activity,
Comment,
Relation,
Cycle,
ID,
Issue,
@@ -161,6 +162,43 @@ export const httpApi: ProyectosApi = {
listActivity: (issueId: ID) => request<Activity[]>(`/api/proyectos/issues/${issueId}/activity`),
listChildren: (issueId: ID) => request<Issue[]>(`/api/proyectos/issues/${issueId}/children`),
setParent: (issueId: ID, parentId: ID | null, actorId?: ID) =>
request<Issue>(`/api/proyectos/issues/${issueId}/parent`, {
method: "PUT",
body: JSON.stringify(actorId ? { parentId, actorId } : { parentId }),
}),
listRelations: (issueId: ID) => request<Relation[]>(`/api/proyectos/issues/${issueId}/relations`),
addRelation: (issueId: ID, toIssue: ID, kind: string, actorId?: ID) =>
request<Relation>(`/api/proyectos/issues/${issueId}/relations`, {
method: "POST",
body: JSON.stringify({ toIssue: Number(toIssue), kind, actorId }),
}),
deleteRelation: (relationId: ID, actorId?: ID) => {
const qs = actorId ? `?actorId=${encodeURIComponent(actorId)}` : "";
return request<void>(`/api/proyectos/relations/${relationId}${qs}`, { method: "DELETE" });
},
listSubscribers: (issueId: ID) => request<ID[]>(`/api/proyectos/issues/${issueId}/subscribers`),
subscribe: (issueId: ID, userId: ID, actorId?: ID) =>
request<void>(`/api/proyectos/issues/${issueId}/subscribers`, {
method: "POST",
body: JSON.stringify({ userId, actorId }),
}),
unsubscribe: (issueId: ID, userId: ID, actorId?: ID) => {
const params = new URLSearchParams({ userId });
if (actorId) params.append("actorId", actorId);
return request<void>(`/api/proyectos/issues/${issueId}/subscribers?${params}`, {
method: "DELETE",
});
},
listComments: (issueId: ID) => request<Comment[]>(`/api/proyectos/issues/${issueId}/comments`),
addComment: (issueId: ID, body: string, authorId: ID) =>
+86
View File
@@ -1,6 +1,7 @@
import type {
Activity,
Comment,
Relation,
Cycle,
ID,
Issue,
@@ -27,6 +28,8 @@ const state = {
issues: structuredClone(seed.issues),
comments: structuredClone(seed.comments),
activity: [] as Activity[],
relations: [] as Relation[],
subscriptions: [] as Array<{ issueId: ID; userId: ID }>,
projects: structuredClone(seed.projects),
cycles: structuredClone(seed.cycles),
users: structuredClone(seed.users),
@@ -212,6 +215,7 @@ export const mockApi: ProyectosApi = {
title: input.title,
description: input.description ?? "",
status,
parentId: null,
priority: input.priority ?? 0,
type: input.type ?? "task",
assigneeId: input.assigneeId ?? null,
@@ -250,6 +254,7 @@ export const mockApi: ProyectosApi = {
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.parentId !== undefined) track("parent", issue.parentId ?? null, patch.parentId);
if (patch.labelIds !== undefined)
track("labels", issue.labelIds.join(","), patch.labelIds.join(","));
@@ -264,6 +269,87 @@ export const mockApi: ProyectosApi = {
return issue;
},
async listChildren(issueId: ID): Promise<Issue[]> {
await delay(60);
return state.issues.filter((i) => (i.parentId ?? null) === issueId);
},
async setParent(issueId: ID, parentId: ID | null, actorId?: ID): Promise<Issue> {
await delay(120);
const issue = state.issues.find((i) => i.id === issueId);
if (!issue) throw new Error(`Issue ${issueId} no encontrado`);
if (parentId !== null) {
if (parentId === issueId) throw new Error("Un issue no puede ser su propio padre.");
if (!state.issues.some((i) => i.id === parentId)) throw new Error("Padre inexistente.");
// Caminata de ancestros (réplica simplificada del backend).
let ancestor: ID | null | undefined = parentId;
while (ancestor != null) {
if (ancestor === issueId) throw new Error("Asignar ese padre crearía un ciclo en la jerarquía.");
ancestor = state.issues.find((i) => i.id === ancestor)?.parentId;
}
}
const old = issue.parentId ?? null;
issue.parentId = parentId;
issue.updatedAt = new Date().toISOString();
if (old !== parentId) logActivity(issueId, actorId ?? null, "parent", old, parentId);
return issue;
},
async listRelations(issueId: ID): Promise<Relation[]> {
await delay(60);
return state.relations.filter((r) => r.fromIssue === issueId || r.toIssue === issueId);
},
async addRelation(issueId: ID, toIssue: ID, kind: string, actorId?: ID): Promise<Relation> {
await delay(120);
if (issueId === toIssue) throw new Error("Un issue no puede vincularse consigo mismo.");
if (!state.issues.some((i) => i.id === issueId && i)) throw new Error("Issue inexistente.");
if (!state.issues.some((i) => i.id === toIssue)) throw new Error("Issue destino inexistente.");
const existing = state.relations.find(
(r) => r.fromIssue === issueId && r.toIssue === toIssue && r.kind === kind,
);
if (existing) return existing;
const rel: Relation = {
id: nextId("rel"),
fromIssue: issueId,
toIssue,
kind,
createdAt: new Date().toISOString(),
};
state.relations.push(rel);
logActivity(issueId, actorId ?? null, "relation_added", null, `${kind}:${toIssue}`);
return rel;
},
async deleteRelation(relationId: ID, actorId?: ID): Promise<void> {
await delay(100);
const rel = state.relations.find((r) => r.id === relationId);
if (!rel) return;
state.relations = state.relations.filter((r) => r.id !== relationId);
logActivity(rel.fromIssue, actorId ?? null, "relation_removed", `${rel.kind}:${rel.toIssue}`, null);
},
async listSubscribers(issueId: ID): Promise<ID[]> {
await delay(60);
return state.subscriptions.filter((s) => s.issueId === issueId).map((s) => s.userId);
},
async subscribe(issueId: ID, userId: ID, actorId?: ID): Promise<void> {
await delay(100);
if (!state.subscriptions.some((s) => s.issueId === issueId && s.userId === userId)) {
state.subscriptions.push({ issueId, userId });
logActivity(issueId, actorId ?? userId, "subscribed", null, userId);
}
},
async unsubscribe(issueId: ID, userId: ID, actorId?: ID): Promise<void> {
await delay(100);
state.subscriptions = state.subscriptions.filter(
(s) => !(s.issueId === issueId && s.userId === userId),
);
logActivity(issueId, actorId ?? userId, "unsubscribed", userId, null);
},
async listActivity(issueId: ID): Promise<Activity[]> {
await delay(60);
return state.activity
+8
View File
@@ -113,6 +113,7 @@ export const issues: Issue[] = [
assigneeId: "u1",
projectId: "p1",
cycleId: "c3",
parentId: null,
labelIds: ["l2"],
createdAt: "2026-10-01T09:15:00Z",
updatedAt: "2026-10-12T15:40:00Z",
@@ -129,6 +130,7 @@ export const issues: Issue[] = [
assigneeId: "u2",
projectId: "p1",
cycleId: "c3",
parentId: null,
labelIds: ["l1", "l4"],
createdAt: "2026-10-03T11:00:00Z",
updatedAt: "2026-10-10T08:20:00Z",
@@ -145,6 +147,7 @@ export const issues: Issue[] = [
assigneeId: "u4",
projectId: "p1",
cycleId: "c3",
parentId: null,
labelIds: ["l3"],
createdAt: "2026-09-28T14:30:00Z",
updatedAt: "2026-10-11T17:05:00Z",
@@ -161,6 +164,7 @@ export const issues: Issue[] = [
assigneeId: "u3",
projectId: "p1",
cycleId: "c3",
parentId: null,
labelIds: ["l2"],
createdAt: "2026-09-20T10:00:00Z",
updatedAt: "2026-10-08T12:00:00Z",
@@ -177,6 +181,7 @@ export const issues: Issue[] = [
assigneeId: null,
projectId: "p2",
cycleId: null,
parentId: null,
labelIds: ["l1", "l3"],
createdAt: "2026-10-06T09:00:00Z",
updatedAt: "2026-10-06T09:00:00Z",
@@ -193,6 +198,7 @@ export const issues: Issue[] = [
assigneeId: "u1",
projectId: "p2",
cycleId: "c4",
parentId: "i5",
labelIds: ["l2"],
createdAt: "2026-10-07T16:45:00Z",
updatedAt: "2026-10-09T10:10:00Z",
@@ -209,6 +215,7 @@ export const issues: Issue[] = [
assigneeId: "u3",
projectId: "p3",
cycleId: "c2",
parentId: null,
labelIds: ["l5"],
createdAt: "2026-08-15T13:20:00Z",
updatedAt: "2026-09-30T09:00:00Z",
@@ -225,6 +232,7 @@ export const issues: Issue[] = [
assigneeId: "u2",
projectId: "p1",
cycleId: "c4",
parentId: null,
labelIds: ["l5"],
createdAt: "2026-10-09T08:00:00Z",
updatedAt: "2026-10-09T08:00:00Z",
+15
View File
@@ -118,6 +118,8 @@ export interface Issue {
assigneeId: ID | null;
projectId: ID | null;
cycleId: ID | null;
/** Id del issue padre (sub-issues, Fase 3 en sam). Null/ausente = raíz. */
parentId: ID | null;
labelIds: ID[];
createdAt: string;
updatedAt: string;
@@ -149,6 +151,19 @@ export interface IssueInput {
assigneeId?: ID | null;
projectId?: ID | null;
cycleId?: ID | null;
parentId?: ID | null;
labelIds?: ID[];
actorId?: ID;
}
/**
* Vínculo tipado entre issues (Fase 3 en sam).
* kind: blocked_by, blocks, related, duplicates.
*/
export interface Relation {
id: ID;
fromIssue: ID;
toIssue: ID;
kind: string;
createdAt: string;
}