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>(new Map()); + const [subscribers, setSubscribers] = useState([]); + const [error, setError] = useState(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(); + 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(); + 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 { + 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, 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 ( +
+ {error && ( +
+
+ {error} +
+
+ )} + + {/* Jerarquía */} +
+
+
Jerarquía
+ + {parent ? ( +
+ Sub-issue de + + {parent.identifier}{" "} + {parent.title} + + +
+ ) : ( +

Issue raíz.

+ )} + + {children.length > 0 && ( +
    + {children.map((child) => ( +
  • + + {child.identifier}{" "} + {child.title} + + +
  • + ))} +
+ )} + +
{ + 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); + }); + }} + > + setNewChildTitle(e.target.value)} + /> + + + +
{ + e.preventDefault(); + const idf = identifier; + setIdentifier(""); + void run(async () => { + const target = await resolveIdentifier(idf); + await api.setParent(target.id, issue.id, currentUserId ?? undefined); + }); + }} + > + setIdentifier(e.target.value)} + /> + + +
+
+ + {/* Vínculos */} +
+
+
Vínculos
+ + {relations.length === 0 && ( +

Sin vínculos.

+ )} + +
    + {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 ( +
  • + {label} + {other ? ( + + {other.identifier}{" "} + {other.title} + + ) : ( + #{otherId} + )} + +
  • + ); + })} +
+ + + void run(async () => { + const target = await resolveIdentifier(idf); + await api.addRelation(issue.id, target.id, relKind, currentUserId ?? undefined); + }) + } + /> +
+
+ + {/* Suscriptores */} +
+
+
Suscriptores
+ + {subscribers.length === 0 && ( +

+ Nadie sigue este issue todavía. +

+ )} + +
+ {subscribers.map((id) => { + const member = users.get(id); + return ( + + + {member?.name ?? id} + + ); + })} +
+ + {currentUserId && ( + + )} +
+
+
+ ); +} + +function RelationForm({ onAdd }: { onAdd: (identifier: string, kind: string) => void }) { + const [idf, setIdf] = useState(""); + const [relKind, setRelKind] = useState("blocked_by"); + + return ( +
{ + e.preventDefault(); + if (!idf.trim()) return; + onAdd(idf, relKind); + setIdf(""); + }} + > + + setIdf(e.target.value)} + /> + + + ); +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index dced9c4..b21f368 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -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; + // Relaciones (Fase 3 en sam) + /** Hijos directos (sub-issues). */ + listChildren(issueId: ID): Promise; + /** Fija el padre (null = raíz). Valida ciclos en el backend. */ + setParent(issueId: ID, parentId: ID | null, actorId?: ID): Promise; + /** Vínculos donde el issue participa (cualquier dirección). */ + listRelations(issueId: ID): Promise; + addRelation(issueId: ID, toIssue: ID, kind: string, actorId?: ID): Promise; + deleteRelation(relationId: ID, actorId?: ID): Promise; + /** idUsuarios suscritos al issue. */ + listSubscribers(issueId: ID): Promise; + subscribe(issueId: ID, userId: ID, actorId?: ID): Promise; + unsubscribe(issueId: ID, userId: ID, actorId?: ID): Promise; + // Comentarios listComments(issueId: ID): Promise; /** `authorId` es el idUsuario del usuario en sesión. */ diff --git a/src/lib/api/http.ts b/src/lib/api/http.ts index 4cc86d3..7777341 100644 --- a/src/lib/api/http.ts +++ b/src/lib/api/http.ts @@ -1,6 +1,7 @@ import type { Activity, Comment, + Relation, Cycle, ID, Issue, @@ -161,6 +162,43 @@ export const httpApi: ProyectosApi = { listActivity: (issueId: ID) => request(`/api/proyectos/issues/${issueId}/activity`), + listChildren: (issueId: ID) => request(`/api/proyectos/issues/${issueId}/children`), + + setParent: (issueId: ID, parentId: ID | null, actorId?: ID) => + request(`/api/proyectos/issues/${issueId}/parent`, { + method: "PUT", + body: JSON.stringify(actorId ? { parentId, actorId } : { parentId }), + }), + + listRelations: (issueId: ID) => request(`/api/proyectos/issues/${issueId}/relations`), + + addRelation: (issueId: ID, toIssue: ID, kind: string, actorId?: ID) => + request(`/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(`/api/proyectos/relations/${relationId}${qs}`, { method: "DELETE" }); + }, + + listSubscribers: (issueId: ID) => request(`/api/proyectos/issues/${issueId}/subscribers`), + + subscribe: (issueId: ID, userId: ID, actorId?: ID) => + request(`/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(`/api/proyectos/issues/${issueId}/subscribers?${params}`, { + method: "DELETE", + }); + }, + listComments: (issueId: ID) => request(`/api/proyectos/issues/${issueId}/comments`), addComment: (issueId: ID, body: string, authorId: ID) => diff --git a/src/lib/api/mock.ts b/src/lib/api/mock.ts index 44091eb..0fc9e29 100644 --- a/src/lib/api/mock.ts +++ b/src/lib/api/mock.ts @@ -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 { + await delay(60); + return state.issues.filter((i) => (i.parentId ?? null) === issueId); + }, + + async setParent(issueId: ID, parentId: ID | null, actorId?: ID): Promise { + 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 { + 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 { + 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 { + 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 { + await delay(60); + return state.subscriptions.filter((s) => s.issueId === issueId).map((s) => s.userId); + }, + + async subscribe(issueId: ID, userId: ID, actorId?: ID): Promise { + 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 { + 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 { await delay(60); return state.activity diff --git a/src/lib/seed.ts b/src/lib/seed.ts index 8310858..b8bb3e9 100644 --- a/src/lib/seed.ts +++ b/src/lib/seed.ts @@ -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", diff --git a/src/lib/types.ts b/src/lib/types.ts index f1c327b..1ec5b7e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; +}