diff --git a/src/app/api/proyectos/[...path]/route.ts b/src/app/api/proyectos/[...path]/route.ts index c173388..d7adec9 100644 --- a/src/app/api/proyectos/[...path]/route.ts +++ b/src/app/api/proyectos/[...path]/route.ts @@ -44,7 +44,9 @@ async function proxy(req: NextRequest, path: string[]) { const contentType = req.headers.get("content-type"); if (contentType) headers["Content-Type"] = contentType; - const body = WITH_BODY.has(req.method) ? await req.text() : undefined; + // arrayBuffer, no text(): preserva bytes exactos para multipart/binarios. + // Con text() los archivos llegarían corruptos al backend. + const body = WITH_BODY.has(req.method) ? await req.arrayBuffer() : undefined; try { const upstream = await fetch(target, { @@ -54,14 +56,15 @@ async function proxy(req: NextRequest, path: string[]) { cache: "no-store", }); - // Reenvía la respuesta tal cual (código y cuerpo), sin interpretarla. - const text = await upstream.text(); - return new NextResponse(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") ?? "application/json", - }, - }); + // Reenvía la respuesta tal cual (código, cuerpo y tipo), sin + // interpretarla. arrayBuffer para no corromper descargas binarias. + const buffer = await upstream.arrayBuffer(); + const headersOut: Record = { + "Content-Type": upstream.headers.get("content-type") ?? "application/json", + }; + const disposition = upstream.headers.get("content-disposition"); + if (disposition) headersOut["Content-Disposition"] = disposition; + return new NextResponse(buffer, { status: upstream.status, headers: headersOut }); } catch { return NextResponse.json( { message: "No se pudo contactar con SAM.API." }, diff --git a/src/app/issues/[id]/page.tsx b/src/app/issues/[id]/page.tsx index 9d928a9..13b76ea 100644 --- a/src/app/issues/[id]/page.tsx +++ b/src/app/issues/[id]/page.tsx @@ -12,6 +12,7 @@ import { useAuth } from "@/lib/auth"; import { AppShell } from "@/components/layout/AppShell"; import { ActivityFeed } from "@/components/issues/ActivityFeed"; import { IssueRelations } from "@/components/issues/IssueRelations"; +import { IssueAttachments } from "@/components/issues/IssueAttachments"; import { ChatPanel } from "@/components/chat/ChatPanel"; import { EmptyState } from "@/components/ui/EmptyState"; import { TypeIcon } from "@/components/ui/Icons"; @@ -357,6 +358,13 @@ function IssueDetail({ issueId }: { issueId: string }) { {/* Jerarquía, vínculos y suscriptores (Fase 3) + chat (Fase 5) */} +
+ +
diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index 015839a..b4186bd 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -13,6 +13,7 @@ import { useIssues } from "@/hooks/useIssues"; import { AppShell } from "@/components/layout/AppShell"; import { IssueList } from "@/components/issues/IssueList"; import { ChatPanel } from "@/components/chat/ChatPanel"; +import { ProjectTeamDocs } from "@/components/projects/ProjectTeamDocs"; import { Avatar } from "@/components/ui/Avatar"; import { EmptyState } from "@/components/ui/EmptyState"; import { ProgressBar } from "@/components/ui/ProgressBar"; @@ -139,6 +140,21 @@ export default function ProjectDetailPage() { emptyDescription="Crea un issue nuevo y asígnalo a este proyecto." /> +
+ { + void api.listProjects().then((all) => { + const fresh = all.find((p) => p.id === project.id); + if (fresh) setProject(fresh); + }); + }} + /> +
+
([]); + const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + + const refresh = useCallback(async () => { + if (!projectId || !currentUserId) return; + try { + setDocs(await api.listDocuments(projectId, currentUserId, issueId)); + } catch (err) { + setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error"); + } + }, [projectId, issueId, currentUserId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // Sin proyecto no hay dónde adjuntar (los documentos cuelgan de él). + if (!projectId) return null; + + async function handleFiles(files: FileList | null) { + if (!files || files.length === 0 || !currentUserId) return; + setError(null); + setUploading(true); + try { + for (const file of files) { + if (file.size > 25 * 1024 * 1024) { + setError(`"${file.name}" supera los 25 MB.`); + continue; + } + await api.uploadDocument(projectId!, file, currentUserId, issueId); + } + await refresh(); + } catch (err) { + setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error subiendo"); + } finally { + setUploading(false); + if (fileRef.current) fileRef.current.value = ""; + } + } + + async function handleDownload(doc: ProjectDocument) { + if (!currentUserId) return; + try { + const blob = await api.downloadDocument(doc.id, currentUserId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = doc.name; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } catch (err) { + setError(err instanceof Error ? err.message : "Error descargando"); + } + } + + return ( +
+
Adjuntos ({docs.length})
+ + {error && ( +
+ {error} +
+ )} + + {docs.length > 0 && ( +
    + {docs.map((doc) => ( +
  • + + + {doc.name} + + + +
  • + ))} +
+ )} + + void handleFiles(event.target.files)} + /> + +
+ ); +} diff --git a/src/components/projects/ProjectTeamDocs.tsx b/src/components/projects/ProjectTeamDocs.tsx new file mode 100644 index 0000000..99cb37c --- /dev/null +++ b/src/components/projects/ProjectTeamDocs.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { api, ApiError } from "@/lib/api"; +import type { ID, ProjectDocument, ProjectMember, User } from "@/lib/types"; +import { formatRelative } from "@/lib/format"; +import { Avatar } from "@/components/ui/Avatar"; + +interface ProjectTeamDocsProps { + projectId: ID; + workspaceId: ID; + currentUserId: ID | null; + users: Map; + onChanged: () => void; +} + +const ROLES = [ + { value: "owner", label: "Owner" }, + { value: "admin", label: "Admin" }, + { value: "member", label: "Miembro" }, +]; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function isImage(mime: string, name: string): boolean { + return mime.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(name); +} + +/** + * Equipo y documentos del proyecto (Fase 6). + * + * Equipo: invitar solo a miembros del workspace, con roles. Documentos: + * subida con vista previa para imágenes, descarga y borrado. + */ +export function ProjectTeamDocs({ projectId, workspaceId, currentUserId, users, onChanged }: ProjectTeamDocsProps) { + const [members, setMembers] = useState([]); + const [docs, setDocs] = useState([]); + const [wsMembers, setWsMembers] = useState>([]); + const [error, setError] = useState(null); + const [inviteId, setInviteId] = useState(""); + const [inviteRole, setInviteRole] = useState("member"); + const [uploading, setUploading] = useState(false); + const [preview, setPreview] = useState>({}); + const fileRef = useRef(null); + + const refresh = useCallback(async () => { + if (!currentUserId) return; + setError(null); + try { + const [team, documents, ws] = await Promise.all([ + api.listProjectMembers(projectId, currentUserId), + api.listDocuments(projectId, currentUserId), + api.listWorkspaceMembers(workspaceId, currentUserId), + ]); + setMembers(team); + setDocs(documents); + setWsMembers(ws.map((m) => ({ userId: m.userId, name: m.name }))); + } catch (err) { + setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error"); + } + }, [projectId, workspaceId, currentUserId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + async function run(action: () => Promise) { + if (!currentUserId) return; + setError(null); + try { + await action(); + await refresh(); + onChanged(); + } catch (err) { + setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error"); + } + } + + // Candidatos: miembros del workspace que aún no están en el equipo. + const teamIds = new Set(members.map((m) => m.userId)); + const candidates = wsMembers.filter((m) => !teamIds.has(m.userId)); + + async function handleFiles(files: FileList | null) { + if (!files || files.length === 0 || !currentUserId) return; + setError(null); + setUploading(true); + try { + for (const file of files) { + if (file.size > 25 * 1024 * 1024) { + setError(`"${file.name}" supera los 25 MB.`); + continue; + } + await api.uploadDocument(projectId, file, currentUserId); + } + await refresh(); + onChanged(); + } catch (err) { + setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error subiendo"); + } finally { + setUploading(false); + if (fileRef.current) fileRef.current.value = ""; + } + } + + async function handleDownload(doc: ProjectDocument) { + if (!currentUserId) return; + try { + const blob = await api.downloadDocument(doc.id, currentUserId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = doc.name; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } catch (err) { + setError(err instanceof Error ? err.message : "Error descargando"); + } + } + + async function handlePreview(doc: ProjectDocument) { + if (!currentUserId || preview[doc.id]) return; + try { + const blob = await api.downloadDocument(doc.id, currentUserId); + setPreview((current) => ({ ...current, [doc.id]: URL.createObjectURL(blob) })); + } catch { + // Sin vista previa: queda el botón de descarga. + } + } + + return ( +
+ {error && ( +
+
+ {error} +
+
+ )} + + {/* Equipo */} +
+
+
Equipo ({members.length})
+ +
    + {members.map((member) => { + const u = users.get(member.userId); + return ( +
  • + + + {u?.name ?? member.name} + + + {ROLES.find((r) => r.value === member.role)?.label ?? member.role} + + +
  • + ); + })} + {members.length === 0 && ( +
  • + Sin equipo. Invita a miembros del workspace. +
  • + )} +
+ +
+ + + +
+
+
+ + {/* Documentos */} +
+
+
Documentos ({docs.length})
+ + {docs.length === 0 && ( +

+ Sin documentos. Sube archivos del proyecto o imágenes. +

+ )} + +
    + {docs.map((doc) => ( +
  • + {isImage(doc.mime, doc.name) && ( +
    + {preview[doc.id] ? ( + // eslint-disable-next-line @next/next/no-img-element + {doc.name} + ) : ( + + )} +
    + )} +
    + + + {doc.name} + + {formatBytes(doc.sizeBytes)} + + +
    +
  • + ))} +
+ + void handleFiles(event.target.files)} + /> + +
+
+
+ ); +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index d56751c..eb0907d 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -2,7 +2,9 @@ import type { Activity, ChatMessage, Comment, + ProjectDocument, ProjectInput, + ProjectMember, Relation, Workspace, WorkspaceMember, @@ -61,6 +63,18 @@ export interface ProyectosApi { setMemberRole(workspaceId: ID, userId: ID, role: string, actorId: ID): Promise; removeWorkspaceMember(workspaceId: ID, userId: ID, actorId?: ID): Promise; + // Equipo del proyecto (Fase 6 en sam) + listProjectMembers(projectId: ID, userId: ID): Promise; + addProjectMember(projectId: ID, userId: ID, role: string, actorId: ID): Promise; + removeProjectMember(projectId: ID, userId: ID, actorId?: ID): Promise; + + // Documentos e imágenes (Fase 6 en sam) + listDocuments(projectId: ID, userId: ID, issueId?: ID): Promise; + uploadDocument(projectId: ID, file: File, authorId: ID, issueId?: ID): Promise; + /** Descarga el binario (vía proxy, que reenvía el stream). */ + downloadDocument(documentId: ID, userId: ID): Promise; + deleteDocument(documentId: ID, actorId: ID): Promise; + // Chat (Fase 5 en sam) listIssueChat(issueId: ID, userId: ID): Promise; postIssueChat(issueId: ID, body: string, authorId: ID): Promise; diff --git a/src/lib/api/http.ts b/src/lib/api/http.ts index 14123db..d7ac700 100644 --- a/src/lib/api/http.ts +++ b/src/lib/api/http.ts @@ -2,7 +2,9 @@ import type { Activity, ChatMessage, Comment, + ProjectDocument, ProjectInput, + ProjectMember, Relation, Workspace, WorkspaceMember, @@ -227,6 +229,67 @@ export const httpApi: ProyectosApi = { ); }, + listProjectMembers: (projectId: ID, userId: ID) => + request( + `/api/proyectos/projects/${projectId}/members?userId=${encodeURIComponent(userId)}`, + ), + + addProjectMember: (projectId: ID, userId: ID, role: string, actorId: ID) => + request(`/api/proyectos/projects/${projectId}/members`, { + method: "POST", + body: JSON.stringify({ userId, role, actorId }), + }), + + removeProjectMember: (projectId: ID, userId: ID, actorId?: ID) => { + const qs = actorId ? `?actorId=${encodeURIComponent(actorId)}` : ""; + return request( + `/api/proyectos/projects/${projectId}/members/${encodeURIComponent(userId)}${qs}`, + { method: "DELETE" }, + ); + }, + + listDocuments: (projectId: ID, userId: ID, issueId?: ID) => { + const params = new URLSearchParams({ userId }); + if (issueId) params.append("issueId", issueId); + return request( + `/api/proyectos/projects/${projectId}/documents?${params}`, + ); + }, + + // FormData SIN Content-Type manual: el navegador pone el boundary. + // El proxy reenvía los bytes intactos (arrayBuffer) con la API key. + uploadDocument: async (projectId: ID, file: File, authorId: ID, issueId?: ID) => { + const form = new FormData(); + form.append("file", file); + if (issueId) form.append("issueId", issueId); + form.append("authorId", authorId); + const response = await fetch(`/api/proyectos/projects/${projectId}/documents`, { + method: "POST", + body: form, + cache: "no-store", + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { message?: string } | null; + throw new ApiError(response.status, body?.message ?? `Error ${response.status}`); + } + return (await response.json()) as ProjectDocument; + }, + + downloadDocument: async (documentId: ID, userId: ID): Promise => { + const response = await fetch( + `/api/proyectos/documents/${documentId}/download?userId=${encodeURIComponent(userId)}`, + { cache: "no-store" }, + ); + if (!response.ok) throw new ApiError(response.status, `Error ${response.status}`); + return response.blob(); + }, + + deleteDocument: (documentId: ID, actorId: ID) => + request( + `/api/proyectos/documents/${documentId}?actorId=${encodeURIComponent(actorId)}`, + { method: "DELETE" }, + ), + listIssueChat: (issueId: ID, userId: ID) => request( `/api/proyectos/issues/${issueId}/chat?userId=${encodeURIComponent(userId)}`, diff --git a/src/lib/api/mock.ts b/src/lib/api/mock.ts index bd51418..474bb72 100644 --- a/src/lib/api/mock.ts +++ b/src/lib/api/mock.ts @@ -2,7 +2,9 @@ import type { Activity, ChatMessage, Comment, + ProjectDocument, ProjectInput, + ProjectMember, Relation, Workspace, WorkspaceMember, @@ -42,6 +44,12 @@ const state = { { workspaceId: "w1", userId: "u4", role: "member" }, ] as Array<{ workspaceId: ID; userId: ID; role: string }>, chat: [] as ChatMessage[], + projectMembers: [{ projectId: "p1", userId: "u1", role: "owner" }] as Array<{ + projectId: ID; + userId: ID; + role: string; + }>, + documents: [] as ProjectDocument[], projects: structuredClone(seed.projects), cycles: structuredClone(seed.cycles), users: structuredClone(seed.users), @@ -252,6 +260,66 @@ export const mockApi: ProyectosApi = { ); }, + async listProjectMembers(projectId: ID): Promise { + await delay(60); + return state.projectMembers + .filter((m) => m.projectId === projectId) + .map((m) => { + const u = state.users.find((x) => x.id === m.userId); + return { userId: m.userId, name: u?.name ?? m.userId, email: u?.email ?? "", role: m.role }; + }); + }, + + async addProjectMember(projectId: ID, userId: ID, role: string): Promise { + await delay(100); + const existing = state.projectMembers.find( + (m) => m.projectId === projectId && m.userId === userId, + ); + if (existing) existing.role = role; + else state.projectMembers.push({ projectId, userId, role }); + }, + + async removeProjectMember(projectId: ID, userId: ID): Promise { + await delay(100); + state.projectMembers = state.projectMembers.filter( + (m) => !(m.projectId === projectId && m.userId === userId), + ); + }, + + async listDocuments(projectId: ID, _userId: ID, issueId?: ID): Promise { + await delay(60); + return state.documents + .filter((d) => d.projectId === projectId && (issueId ? d.issueId === issueId : true)) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + }, + + async uploadDocument(projectId: ID, file: File, authorId: ID, issueId?: ID): Promise { + await delay(200); + const doc: ProjectDocument = { + id: nextId("doc"), + projectId, + issueId: issueId ?? null, + name: file.name, + path: `mock/${file.name}`, + mime: file.type || "application/octet-stream", + sizeBytes: file.size, + uploadedBy: authorId, + createdAt: new Date().toISOString(), + }; + state.documents.push(doc); + return doc; + }, + + async downloadDocument(): Promise { + await delay(60); + return new Blob(["Documento de ejemplo (modo mock)."], { type: "text/plain" }); + }, + + async deleteDocument(documentId: ID): Promise { + await delay(100); + state.documents = state.documents.filter((d) => d.id !== documentId); + }, + async listIssueChat(issueId: ID): Promise { await delay(60); return state.chat diff --git a/src/lib/types.ts b/src/lib/types.ts index b6ff47e..fd4a48e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -73,6 +73,33 @@ export interface Workspace { createdAt: string; } +/** + * Miembro del equipo de un proyecto (Fase 6 en sam). Solo pueden + * invitarse miembros del workspace. + */ +export interface ProjectMember { + userId: ID; + name: string; + email: string; + role: string; +} + +/** + * Metadata de un documento o imagen (Fase 6 en sam). El binario vive + * en /app/media/proyectos del backend; aquí solo la ruta. + */ +export interface ProjectDocument { + id: ID; + projectId: ID; + issueId: ID | null; + name: string; + path: string; + mime: string; + sizeBytes: number; + uploadedBy: ID; + createdAt: string; +} + export interface WorkspaceMember { userId: ID; name: string;