feat: jerarquía, vínculos y suscriptores en el detalle (Fase 3)
This commit is contained in:
1 parent
bbb5fd2b1a
commit
9acaad4f62
7 files changed
+536
-1
No files matched your search
@@ -11,6 +11,7 @@ import { useReference, useReferenceLookups, useWorkflowStates } from "@/lib/stor
|
|||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
import { AppShell } from "@/components/layout/AppShell";
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
import { ActivityFeed } from "@/components/issues/ActivityFeed";
|
import { ActivityFeed } from "@/components/issues/ActivityFeed";
|
||||||
|
import { IssueRelations } from "@/components/issues/IssueRelations";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import { TypeIcon } from "@/components/ui/Icons";
|
import { TypeIcon } from "@/components/ui/Icons";
|
||||||
|
|
||||||
@@ -74,7 +75,9 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
|||||||
...partial,
|
...partial,
|
||||||
...(user ? { actorId: user.id } : {}),
|
...(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.
|
// Refrescar la línea de tiempo con los eventos recién creados.
|
||||||
void api.listActivity(issue.id).then(setActivity);
|
void api.listActivity(issue.id).then(setActivity);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -123,6 +126,7 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
|||||||
const project = issue.projectId ? projectById.get(issue.projectId) : undefined;
|
const project = issue.projectId ? projectById.get(issue.projectId) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="detail-grid">
|
<div className="detail-grid">
|
||||||
{/* Columna principal */}
|
{/* Columna principal */}
|
||||||
<div>
|
<div>
|
||||||
@@ -328,5 +332,20 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Activity,
|
Activity,
|
||||||
Comment,
|
Comment,
|
||||||
|
Relation,
|
||||||
Cycle,
|
Cycle,
|
||||||
ID,
|
ID,
|
||||||
Issue,
|
Issue,
|
||||||
@@ -45,6 +46,20 @@ export interface ProyectosApi {
|
|||||||
// Línea de tiempo (Fase 2 en sam): eventos de creación, cambios y comentarios.
|
// Línea de tiempo (Fase 2 en sam): eventos de creación, cambios y comentarios.
|
||||||
listActivity(issueId: ID): Promise<Activity[]>;
|
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
|
// Comentarios
|
||||||
listComments(issueId: ID): Promise<Comment[]>;
|
listComments(issueId: ID): Promise<Comment[]>;
|
||||||
/** `authorId` es el idUsuario del usuario en sesión. */
|
/** `authorId` es el idUsuario del usuario en sesión. */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Activity,
|
Activity,
|
||||||
Comment,
|
Comment,
|
||||||
|
Relation,
|
||||||
Cycle,
|
Cycle,
|
||||||
ID,
|
ID,
|
||||||
Issue,
|
Issue,
|
||||||
@@ -161,6 +162,43 @@ export const httpApi: ProyectosApi = {
|
|||||||
|
|
||||||
listActivity: (issueId: ID) => request<Activity[]>(`/api/proyectos/issues/${issueId}/activity`),
|
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`),
|
listComments: (issueId: ID) => request<Comment[]>(`/api/proyectos/issues/${issueId}/comments`),
|
||||||
|
|
||||||
addComment: (issueId: ID, body: string, authorId: ID) =>
|
addComment: (issueId: ID, body: string, authorId: ID) =>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Activity,
|
Activity,
|
||||||
Comment,
|
Comment,
|
||||||
|
Relation,
|
||||||
Cycle,
|
Cycle,
|
||||||
ID,
|
ID,
|
||||||
Issue,
|
Issue,
|
||||||
@@ -27,6 +28,8 @@ const state = {
|
|||||||
issues: structuredClone(seed.issues),
|
issues: structuredClone(seed.issues),
|
||||||
comments: structuredClone(seed.comments),
|
comments: structuredClone(seed.comments),
|
||||||
activity: [] as Activity[],
|
activity: [] as Activity[],
|
||||||
|
relations: [] as Relation[],
|
||||||
|
subscriptions: [] as Array<{ issueId: ID; userId: ID }>,
|
||||||
projects: structuredClone(seed.projects),
|
projects: structuredClone(seed.projects),
|
||||||
cycles: structuredClone(seed.cycles),
|
cycles: structuredClone(seed.cycles),
|
||||||
users: structuredClone(seed.users),
|
users: structuredClone(seed.users),
|
||||||
@@ -212,6 +215,7 @@ export const mockApi: ProyectosApi = {
|
|||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description ?? "",
|
description: input.description ?? "",
|
||||||
status,
|
status,
|
||||||
|
parentId: null,
|
||||||
priority: input.priority ?? 0,
|
priority: input.priority ?? 0,
|
||||||
type: input.type ?? "task",
|
type: input.type ?? "task",
|
||||||
assigneeId: input.assigneeId ?? null,
|
assigneeId: input.assigneeId ?? null,
|
||||||
@@ -250,6 +254,7 @@ export const mockApi: ProyectosApi = {
|
|||||||
if (patch.assigneeId !== undefined) track("assignee", issue.assigneeId, patch.assigneeId);
|
if (patch.assigneeId !== undefined) track("assignee", issue.assigneeId, patch.assigneeId);
|
||||||
if (patch.projectId !== undefined) track("project", issue.projectId, patch.projectId);
|
if (patch.projectId !== undefined) track("project", issue.projectId, patch.projectId);
|
||||||
if (patch.cycleId !== undefined) track("cycle", issue.cycleId, patch.cycleId);
|
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)
|
if (patch.labelIds !== undefined)
|
||||||
track("labels", issue.labelIds.join(","), patch.labelIds.join(","));
|
track("labels", issue.labelIds.join(","), patch.labelIds.join(","));
|
||||||
|
|
||||||
@@ -264,6 +269,87 @@ export const mockApi: ProyectosApi = {
|
|||||||
return issue;
|
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[]> {
|
async listActivity(issueId: ID): Promise<Activity[]> {
|
||||||
await delay(60);
|
await delay(60);
|
||||||
return state.activity
|
return state.activity
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export const issues: Issue[] = [
|
|||||||
assigneeId: "u1",
|
assigneeId: "u1",
|
||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
cycleId: "c3",
|
cycleId: "c3",
|
||||||
|
parentId: null,
|
||||||
labelIds: ["l2"],
|
labelIds: ["l2"],
|
||||||
createdAt: "2026-10-01T09:15:00Z",
|
createdAt: "2026-10-01T09:15:00Z",
|
||||||
updatedAt: "2026-10-12T15:40:00Z",
|
updatedAt: "2026-10-12T15:40:00Z",
|
||||||
@@ -129,6 +130,7 @@ export const issues: Issue[] = [
|
|||||||
assigneeId: "u2",
|
assigneeId: "u2",
|
||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
cycleId: "c3",
|
cycleId: "c3",
|
||||||
|
parentId: null,
|
||||||
labelIds: ["l1", "l4"],
|
labelIds: ["l1", "l4"],
|
||||||
createdAt: "2026-10-03T11:00:00Z",
|
createdAt: "2026-10-03T11:00:00Z",
|
||||||
updatedAt: "2026-10-10T08:20:00Z",
|
updatedAt: "2026-10-10T08:20:00Z",
|
||||||
@@ -145,6 +147,7 @@ export const issues: Issue[] = [
|
|||||||
assigneeId: "u4",
|
assigneeId: "u4",
|
||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
cycleId: "c3",
|
cycleId: "c3",
|
||||||
|
parentId: null,
|
||||||
labelIds: ["l3"],
|
labelIds: ["l3"],
|
||||||
createdAt: "2026-09-28T14:30:00Z",
|
createdAt: "2026-09-28T14:30:00Z",
|
||||||
updatedAt: "2026-10-11T17:05:00Z",
|
updatedAt: "2026-10-11T17:05:00Z",
|
||||||
@@ -161,6 +164,7 @@ export const issues: Issue[] = [
|
|||||||
assigneeId: "u3",
|
assigneeId: "u3",
|
||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
cycleId: "c3",
|
cycleId: "c3",
|
||||||
|
parentId: null,
|
||||||
labelIds: ["l2"],
|
labelIds: ["l2"],
|
||||||
createdAt: "2026-09-20T10:00:00Z",
|
createdAt: "2026-09-20T10:00:00Z",
|
||||||
updatedAt: "2026-10-08T12:00:00Z",
|
updatedAt: "2026-10-08T12:00:00Z",
|
||||||
@@ -177,6 +181,7 @@ export const issues: Issue[] = [
|
|||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
projectId: "p2",
|
projectId: "p2",
|
||||||
cycleId: null,
|
cycleId: null,
|
||||||
|
parentId: null,
|
||||||
labelIds: ["l1", "l3"],
|
labelIds: ["l1", "l3"],
|
||||||
createdAt: "2026-10-06T09:00:00Z",
|
createdAt: "2026-10-06T09:00:00Z",
|
||||||
updatedAt: "2026-10-06T09:00:00Z",
|
updatedAt: "2026-10-06T09:00:00Z",
|
||||||
@@ -193,6 +198,7 @@ export const issues: Issue[] = [
|
|||||||
assigneeId: "u1",
|
assigneeId: "u1",
|
||||||
projectId: "p2",
|
projectId: "p2",
|
||||||
cycleId: "c4",
|
cycleId: "c4",
|
||||||
|
parentId: "i5",
|
||||||
labelIds: ["l2"],
|
labelIds: ["l2"],
|
||||||
createdAt: "2026-10-07T16:45:00Z",
|
createdAt: "2026-10-07T16:45:00Z",
|
||||||
updatedAt: "2026-10-09T10:10:00Z",
|
updatedAt: "2026-10-09T10:10:00Z",
|
||||||
@@ -209,6 +215,7 @@ export const issues: Issue[] = [
|
|||||||
assigneeId: "u3",
|
assigneeId: "u3",
|
||||||
projectId: "p3",
|
projectId: "p3",
|
||||||
cycleId: "c2",
|
cycleId: "c2",
|
||||||
|
parentId: null,
|
||||||
labelIds: ["l5"],
|
labelIds: ["l5"],
|
||||||
createdAt: "2026-08-15T13:20:00Z",
|
createdAt: "2026-08-15T13:20:00Z",
|
||||||
updatedAt: "2026-09-30T09:00:00Z",
|
updatedAt: "2026-09-30T09:00:00Z",
|
||||||
@@ -225,6 +232,7 @@ export const issues: Issue[] = [
|
|||||||
assigneeId: "u2",
|
assigneeId: "u2",
|
||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
cycleId: "c4",
|
cycleId: "c4",
|
||||||
|
parentId: null,
|
||||||
labelIds: ["l5"],
|
labelIds: ["l5"],
|
||||||
createdAt: "2026-10-09T08:00:00Z",
|
createdAt: "2026-10-09T08:00:00Z",
|
||||||
updatedAt: "2026-10-09T08:00:00Z",
|
updatedAt: "2026-10-09T08:00:00Z",
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ export interface Issue {
|
|||||||
assigneeId: ID | null;
|
assigneeId: ID | null;
|
||||||
projectId: ID | null;
|
projectId: ID | null;
|
||||||
cycleId: ID | null;
|
cycleId: ID | null;
|
||||||
|
/** Id del issue padre (sub-issues, Fase 3 en sam). Null/ausente = raíz. */
|
||||||
|
parentId: ID | null;
|
||||||
labelIds: ID[];
|
labelIds: ID[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -149,6 +151,19 @@ export interface IssueInput {
|
|||||||
assigneeId?: ID | null;
|
assigneeId?: ID | null;
|
||||||
projectId?: ID | null;
|
projectId?: ID | null;
|
||||||
cycleId?: ID | null;
|
cycleId?: ID | null;
|
||||||
|
parentId?: ID | null;
|
||||||
labelIds?: ID[];
|
labelIds?: ID[];
|
||||||
actorId?: 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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user