feat: equipo por proyecto, documentos e imágenes (Fase 6)
This commit is contained in:
1 parent
b3fb660451
commit
7802938bb9
9 files changed
+671
-8
No files matched your search
@@ -44,7 +44,9 @@ async function proxy(req: NextRequest, path: string[]) {
|
|||||||
const contentType = req.headers.get("content-type");
|
const contentType = req.headers.get("content-type");
|
||||||
if (contentType) headers["Content-Type"] = contentType;
|
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 {
|
try {
|
||||||
const upstream = await fetch(target, {
|
const upstream = await fetch(target, {
|
||||||
@@ -54,14 +56,15 @@ async function proxy(req: NextRequest, path: string[]) {
|
|||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reenvía la respuesta tal cual (código y cuerpo), sin interpretarla.
|
// Reenvía la respuesta tal cual (código, cuerpo y tipo), sin
|
||||||
const text = await upstream.text();
|
// interpretarla. arrayBuffer para no corromper descargas binarias.
|
||||||
return new NextResponse(text, {
|
const buffer = await upstream.arrayBuffer();
|
||||||
status: upstream.status,
|
const headersOut: Record<string, string> = {
|
||||||
headers: {
|
|
||||||
"Content-Type": upstream.headers.get("content-type") ?? "application/json",
|
"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 {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "No se pudo contactar con SAM.API." },
|
{ message: "No se pudo contactar con SAM.API." },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 { IssueRelations } from "@/components/issues/IssueRelations";
|
||||||
|
import { IssueAttachments } from "@/components/issues/IssueAttachments";
|
||||||
import { ChatPanel } from "@/components/chat/ChatPanel";
|
import { ChatPanel } from "@/components/chat/ChatPanel";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import { TypeIcon } from "@/components/ui/Icons";
|
import { TypeIcon } from "@/components/ui/Icons";
|
||||||
@@ -357,6 +358,13 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Jerarquía, vínculos y suscriptores (Fase 3) + chat (Fase 5) */}
|
{/* 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="mt-4">
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useIssues } from "@/hooks/useIssues";
|
|||||||
import { AppShell } from "@/components/layout/AppShell";
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
import { IssueList } from "@/components/issues/IssueList";
|
import { IssueList } from "@/components/issues/IssueList";
|
||||||
import { ChatPanel } from "@/components/chat/ChatPanel";
|
import { ChatPanel } from "@/components/chat/ChatPanel";
|
||||||
|
import { ProjectTeamDocs } from "@/components/projects/ProjectTeamDocs";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
import { ProgressBar } from "@/components/ui/ProgressBar";
|
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."
|
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">
|
<div className="page-pad pt-0">
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import type {
|
|||||||
Activity,
|
Activity,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
Comment,
|
Comment,
|
||||||
|
ProjectDocument,
|
||||||
ProjectInput,
|
ProjectInput,
|
||||||
|
ProjectMember,
|
||||||
Relation,
|
Relation,
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceMember,
|
WorkspaceMember,
|
||||||
@@ -61,6 +63,18 @@ export interface ProyectosApi {
|
|||||||
setMemberRole(workspaceId: ID, userId: ID, role: string, actorId: ID): Promise<void>;
|
setMemberRole(workspaceId: ID, userId: ID, role: string, actorId: ID): Promise<void>;
|
||||||
removeWorkspaceMember(workspaceId: ID, userId: ID, 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)
|
// Chat (Fase 5 en sam)
|
||||||
listIssueChat(issueId: ID, userId: ID): Promise<ChatMessage[]>;
|
listIssueChat(issueId: ID, userId: ID): Promise<ChatMessage[]>;
|
||||||
postIssueChat(issueId: ID, body: string, authorId: ID): Promise<ChatMessage>;
|
postIssueChat(issueId: ID, body: string, authorId: ID): Promise<ChatMessage>;
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import type {
|
|||||||
Activity,
|
Activity,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
Comment,
|
Comment,
|
||||||
|
ProjectDocument,
|
||||||
ProjectInput,
|
ProjectInput,
|
||||||
|
ProjectMember,
|
||||||
Relation,
|
Relation,
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceMember,
|
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) =>
|
listIssueChat: (issueId: ID, userId: ID) =>
|
||||||
request<ChatMessage[]>(
|
request<ChatMessage[]>(
|
||||||
`/api/proyectos/issues/${issueId}/chat?userId=${encodeURIComponent(userId)}`,
|
`/api/proyectos/issues/${issueId}/chat?userId=${encodeURIComponent(userId)}`,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import type {
|
|||||||
Activity,
|
Activity,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
Comment,
|
Comment,
|
||||||
|
ProjectDocument,
|
||||||
ProjectInput,
|
ProjectInput,
|
||||||
|
ProjectMember,
|
||||||
Relation,
|
Relation,
|
||||||
Workspace,
|
Workspace,
|
||||||
WorkspaceMember,
|
WorkspaceMember,
|
||||||
@@ -42,6 +44,12 @@ const state = {
|
|||||||
{ workspaceId: "w1", userId: "u4", role: "member" },
|
{ workspaceId: "w1", userId: "u4", role: "member" },
|
||||||
] as Array<{ workspaceId: ID; userId: ID; role: string }>,
|
] as Array<{ workspaceId: ID; userId: ID; role: string }>,
|
||||||
chat: [] as ChatMessage[],
|
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),
|
projects: structuredClone(seed.projects),
|
||||||
cycles: structuredClone(seed.cycles),
|
cycles: structuredClone(seed.cycles),
|
||||||
users: structuredClone(seed.users),
|
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[]> {
|
async listIssueChat(issueId: ID): Promise<ChatMessage[]> {
|
||||||
await delay(60);
|
await delay(60);
|
||||||
return state.chat
|
return state.chat
|
||||||
|
|||||||
@@ -73,6 +73,33 @@ export interface Workspace {
|
|||||||
createdAt: string;
|
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 {
|
export interface WorkspaceMember {
|
||||||
userId: ID;
|
userId: ID;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in new issue
Block a user