feat: equipo por proyecto, documentos e imágenes (Fase 6)

This commit is contained in:
Nurfog committed 2026-10-15 09:24:07 -03:00
1 parent b3fb660451
commit 7802938bb9
9 files changed
+672 -9

No files matched your search

+12 -9
View File
@@ -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<string, string> = {
"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." },
+8
View File
@@ -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 }) {
</div>
{/* Jerarquía, vínculos y suscriptores (Fase 3) + chat (Fase 5) */}
<div className="mt-4">
<IssueAttachments
projectId={issue.projectId}
issueId={issue.id}
currentUserId={user?.id ?? null}
/>
</div>
<div className="mt-4">
<div className="row g-3">
<div className="col-12">
+16
View File
@@ -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."
/>
<div className="page-pad pt-0">
<ProjectTeamDocs
projectId={project.id}
workspaceId={project.workspaceId}
currentUserId={memberId}
users={memberMap}
onChanged={() => {
void api.listProjects().then((all) => {
const fresh = all.find((p) => p.id === project.id);
if (fresh) setProject(fresh);
});
}}
/>
</div>
<div className="page-pad pt-0">
<ChatPanel
projectId={project.id}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api, ApiError } from "@/lib/api";
import type { ID, ProjectDocument } from "@/lib/types";
interface IssueAttachmentsProps {
projectId: ID | null;
issueId: ID;
currentUserId: ID | null;
}
/**
* Adjuntos de una tarea (Fase 6): documentos e imágenes con issue_id.
* Reutiliza los endpoints de documentos del proyecto.
*/
export function IssueAttachments({ projectId, issueId, currentUserId }: IssueAttachmentsProps) {
const [docs, setDocs] = useState<ProjectDocument[]>([]);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(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 (
<section className="mt-4">
<div className="detail-section-title">Adjuntos ({docs.length})</div>
{error && (
<div className="alert alert-danger py-2" role="alert">
{error}
</div>
)}
{docs.length > 0 && (
<ul className="list-unstyled mb-2">
{docs.map((doc) => (
<li key={doc.id} className="py-1 d-flex align-items-center gap-2">
<i className="bx bx-file" style={{ color: "var(--inv-muted)" }} />
<span style={{ fontSize: 13 }} className="text-truncate">
{doc.name}
</span>
<button
type="button"
className="btn btn-lp btn-sm ms-auto"
title="Descargar"
onClick={() => void handleDownload(doc)}
>
<i className="bx bx-download" />
</button>
<button
type="button"
className="btn btn-lp btn-sm"
title="Eliminar"
onClick={() => {
if (!window.confirm(`¿Eliminar "${doc.name}"?`)) return;
void api
.deleteDocument(doc.id, currentUserId!)
.then(() => void refresh())
.catch((err: unknown) =>
setError(err instanceof Error ? err.message : "Error eliminando"),
);
}}
>
<i className="bx bx-trash" />
</button>
</li>
))}
</ul>
)}
<input
ref={fileRef}
type="file"
multiple
className="d-none"
onChange={(event) => void handleFiles(event.target.files)}
/>
<button
type="button"
className="btn btn-lp btn-sm"
disabled={uploading || !currentUserId}
onClick={() => fileRef.current?.click()}
>
{uploading ? (
<span className="spinner-border spinner-border-sm" role="status" />
) : (
<>
<i className="bx bx-paperclip me-1" />
Adjuntar
</>
)}
</button>
</section>
);
}
+318
View File
@@ -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<ID, User>;
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<ProjectMember[]>([]);
const [docs, setDocs] = useState<ProjectDocument[]>([]);
const [wsMembers, setWsMembers] = useState<Array<{ userId: ID; name: string }>>([]);
const [error, setError] = useState<string | null>(null);
const [inviteId, setInviteId] = useState("");
const [inviteRole, setInviteRole] = useState("member");
const [uploading, setUploading] = useState(false);
const [preview, setPreview] = useState<Record<ID, string>>({});
const fileRef = useRef<HTMLInputElement>(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<unknown>) {
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 (
<div className="row g-3">
{error && (
<div className="col-12">
<div className="alert alert-danger py-2" role="alert">
{error}
</div>
</div>
)}
{/* Equipo */}
<div className="col-md-6">
<div className="lp-card p-3 h-100">
<div className="detail-section-title">Equipo ({members.length})</div>
<ul className="list-unstyled mb-2">
{members.map((member) => {
const u = users.get(member.userId);
return (
<li key={member.userId} className="py-1 d-flex align-items-center gap-2">
<Avatar user={u} />
<span className="text-truncate" style={{ fontSize: 13 }}>
{u?.name ?? member.name}
</span>
<span className="label-chip ms-auto">
{ROLES.find((r) => r.value === member.role)?.label ?? member.role}
</span>
<button
type="button"
className="btn btn-lp btn-sm"
title="Quitar del equipo"
onClick={() =>
void run(() =>
api.removeProjectMember(projectId, member.userId, currentUserId ?? undefined),
)
}
>
<i className="bx bx-x" />
</button>
</li>
);
})}
{members.length === 0 && (
<li style={{ fontSize: 13, color: "var(--inv-muted)" }}>
Sin equipo. Invita a miembros del workspace.
</li>
)}
</ul>
<div className="d-flex gap-1">
<select
className="form-select form-select-sm"
value={inviteId}
onChange={(event) => setInviteId(event.target.value)}
aria-label="Persona a invitar"
>
<option value="">Invitar del workspace…</option>
{candidates.map((c) => (
<option key={c.userId} value={c.userId}>
{c.name}
</option>
))}
</select>
<select
className="form-select form-select-sm"
style={{ maxWidth: 110 }}
value={inviteRole}
onChange={(event) => setInviteRole(event.target.value)}
aria-label="Rol"
>
{ROLES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
<button
type="button"
className="btn btn-accent btn-sm"
disabled={!inviteId}
onClick={() => {
const id = inviteId;
setInviteId("");
void run(() => api.addProjectMember(projectId, id, inviteRole, currentUserId!));
}}
>
<i className="bx bx-user-plus" />
</button>
</div>
</div>
</div>
{/* Documentos */}
<div className="col-md-6">
<div className="lp-card p-3 h-100">
<div className="detail-section-title">Documentos ({docs.length})</div>
{docs.length === 0 && (
<p style={{ fontSize: 13, color: "var(--inv-muted)" }}>
Sin documentos. Sube archivos del proyecto o imágenes.
</p>
)}
<ul className="list-unstyled mb-2" style={{ maxHeight: 300, overflowY: "auto" }}>
{docs.map((doc) => (
<li key={doc.id} className="py-2 border-bottom" style={{ borderColor: "var(--lp-border)" }}>
{isImage(doc.mime, doc.name) && (
<div className="mb-1">
{preview[doc.id] ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={preview[doc.id]}
alt={doc.name}
className="rounded-2"
style={{ maxWidth: "100%", maxHeight: 160, objectFit: "contain" }}
/>
) : (
<button
type="button"
className="btn btn-lp btn-sm"
onClick={() => void handlePreview(doc)}
>
<i className="bx bx-image me-1" />
Ver imagen
</button>
)}
</div>
)}
<div className="d-flex align-items-center gap-2">
<i className="bx bx-file" style={{ fontSize: 16, color: "var(--inv-muted)" }} />
<span className="text-truncate" style={{ fontSize: 13 }}>
{doc.name}
</span>
<span className="issue-identifier ms-auto">{formatBytes(doc.sizeBytes)}</span>
<button
type="button"
className="btn btn-lp btn-sm"
title="Descargar"
onClick={() => void handleDownload(doc)}
>
<i className="bx bx-download" />
</button>
<button
type="button"
className="btn btn-lp btn-sm"
title="Eliminar"
onClick={() => {
if (!window.confirm(`¿Eliminar "${doc.name}"?`)) return;
void run(() => api.deleteDocument(doc.id, currentUserId!));
}}
>
<i className="bx bx-trash" />
</button>
</div>
</li>
))}
</ul>
<input
ref={fileRef}
type="file"
multiple
className="d-none"
onChange={(event) => void handleFiles(event.target.files)}
/>
<button
type="button"
className="btn btn-accent btn-sm"
disabled={uploading || !currentUserId}
onClick={() => fileRef.current?.click()}
>
{uploading ? (
<span className="spinner-border spinner-border-sm" role="status" />
) : (
<>
<i className="bx bx-upload me-1" />
Subir archivos
</>
)}
</button>
</div>
</div>
</div>
);
}
+14
View File
@@ -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<void>;
removeWorkspaceMember(workspaceId: ID, userId: ID, actorId?: ID): Promise<void>;
// Equipo del proyecto (Fase 6 en sam)
listProjectMembers(projectId: ID, userId: ID): Promise<ProjectMember[]>;
addProjectMember(projectId: ID, userId: ID, role: string, actorId: ID): Promise<void>;
removeProjectMember(projectId: ID, userId: ID, actorId?: ID): Promise<void>;
// Documentos e imágenes (Fase 6 en sam)
listDocuments(projectId: ID, userId: ID, issueId?: ID): Promise<ProjectDocument[]>;
uploadDocument(projectId: ID, file: File, authorId: ID, issueId?: ID): Promise<ProjectDocument>;
/** Descarga el binario (vía proxy, que reenvía el stream). */
downloadDocument(documentId: ID, userId: ID): Promise<Blob>;
deleteDocument(documentId: ID, actorId: ID): Promise<void>;
// Chat (Fase 5 en sam)
listIssueChat(issueId: ID, userId: ID): Promise<ChatMessage[]>;
postIssueChat(issueId: ID, body: string, authorId: ID): Promise<ChatMessage>;
+63
View File
@@ -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<ProjectMember[]>(
`/api/proyectos/projects/${projectId}/members?userId=${encodeURIComponent(userId)}`,
),
addProjectMember: (projectId: ID, userId: ID, role: string, actorId: ID) =>
request<void>(`/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<void>(
`/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<ProjectDocument[]>(
`/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<Blob> => {
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<void>(
`/api/proyectos/documents/${documentId}?actorId=${encodeURIComponent(actorId)}`,
{ method: "DELETE" },
),
listIssueChat: (issueId: ID, userId: ID) =>
request<ChatMessage[]>(
`/api/proyectos/issues/${issueId}/chat?userId=${encodeURIComponent(userId)}`,
+68
View File
@@ -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<ProjectMember[]> {
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<void> {
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<void> {
await delay(100);
state.projectMembers = state.projectMembers.filter(
(m) => !(m.projectId === projectId && m.userId === userId),
);
},
async listDocuments(projectId: ID, _userId: ID, issueId?: ID): Promise<ProjectDocument[]> {
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<ProjectDocument> {
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<Blob> {
await delay(60);
return new Blob(["Documento de ejemplo (modo mock)."], { type: "text/plain" });
},
async deleteDocument(documentId: ID): Promise<void> {
await delay(100);
state.documents = state.documents.filter((d) => d.id !== documentId);
},
async listIssueChat(issueId: ID): Promise<ChatMessage[]> {
await delay(60);
return state.chat
+27
View File
@@ -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;