diff --git a/src/app/issues/[id]/page.tsx b/src/app/issues/[id]/page.tsx
index 171991b..7a57372 100644
--- a/src/app/issues/[id]/page.tsx
+++ b/src/app/issues/[id]/page.tsx
@@ -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 (
+ <>
{/* Columna principal */}
@@ -328,5 +332,20 @@ function IssueDetail({ issueId }: { issueId: string }) {
)}
+
+ {/* Jerarquía, vínculos y suscriptores (Fase 3) */}
+
+ [u.id, u]))}
+ onChanged={() => {
+ void api.getIssue(issueId).then((fresh) => {
+ if (fresh) setIssue(fresh);
+ });
+ }}
+ />
+
+ >
);
}
diff --git a/src/components/issues/IssueRelations.tsx b/src/components/issues/IssueRelations.tsx
new file mode 100644
index 0000000..867303f
--- /dev/null
+++ b/src/components/issues/IssueRelations.tsx
@@ -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
;
+ /** Refresca el issue (el padre/hijos pueden haber cambiado). */
+ onChanged: () => void;
+}
+
+const KIND_LABEL: Record = {
+ 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([]);
+ const [parent, setParent] = useState(null);
+ const [relations, setRelations] = useState([]);
+ const [relatedIssues, setRelatedIssues] = useState