feat: equipo por proyecto, documentos e imágenes (Fase 6)
This commit is contained in:
1 parent
b3fb660451
commit
7802938bb9
9 files changed
+672
-9
No files matched your search
@@ -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." },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
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>;
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user