From b3fb660451bcbc28c56357545745789691ab5948 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Wed, 14 Oct 2026 16:59:31 -0300 Subject: [PATCH] feat: workspaces, invitaciones y chat (Fase 5) --- src/app/board/page.tsx | 7 +- src/app/inbox/page.tsx | 8 +- src/app/issues/[id]/page.tsx | 15 +- src/app/issues/page.tsx | 10 +- src/app/layout.tsx | 5 +- src/app/projects/[id]/page.tsx | 25 ++- src/app/projects/page.tsx | 85 +++++++- src/app/triage/page.tsx | 7 +- src/app/workspaces/[id]/page.tsx | 305 +++++++++++++++++++++++++++++ src/app/workspaces/new/page.tsx | 99 ++++++++++ src/components/chat/ChatPanel.tsx | 145 ++++++++++++++ src/components/layout/AppShell.tsx | 12 +- src/components/layout/Sidebar.tsx | 46 +++++ src/lib/api/client.ts | 23 ++- src/lib/api/http.ts | 83 +++++++- src/lib/api/mock.ts | 167 +++++++++++++++- src/lib/seed.ts | 24 ++- src/lib/types.ts | 53 +++++ src/lib/workspace.tsx | 115 +++++++++++ 19 files changed, 1208 insertions(+), 26 deletions(-) create mode 100644 src/app/workspaces/[id]/page.tsx create mode 100644 src/app/workspaces/new/page.tsx create mode 100644 src/components/chat/ChatPanel.tsx create mode 100644 src/lib/workspace.tsx diff --git a/src/app/board/page.tsx b/src/app/board/page.tsx index 7e708c4..f0eac81 100644 --- a/src/app/board/page.tsx +++ b/src/app/board/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import type { Issue, IssueStatus } from "@/lib/types"; +import { useWorkspace } from "@/lib/workspace"; import { AppShell } from "@/components/layout/AppShell"; import { Board } from "@/components/board/Board"; @@ -15,11 +16,13 @@ import { Board } from "@/components/board/Board"; export default function BoardPage() { const [issues, setIssues] = useState([]); const [loading, setLoading] = useState(true); + const { workspaceId } = useWorkspace(); useEffect(() => { + if (!workspaceId) return; let cancelled = false; - void api.listIssues().then((result) => { + void api.listIssues({ workspaceId }).then((result) => { if (!cancelled) { setIssues(result); setLoading(false); @@ -29,7 +32,7 @@ export default function BoardPage() { return () => { cancelled = true; }; - }, []); + }, [workspaceId]); const handleMove = useCallback(async (issueId: string, status: IssueStatus) => { // Actualización optimista: reflejar el cambio al instante. diff --git a/src/app/inbox/page.tsx b/src/app/inbox/page.tsx index 3831c20..4a958e2 100644 --- a/src/app/inbox/page.tsx +++ b/src/app/inbox/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { api } from "@/lib/api"; import type { Issue } from "@/lib/types"; import { useAuth } from "@/lib/auth"; +import { useWorkspace } from "@/lib/workspace"; import { useWorkflowStates } from "@/lib/store"; import { AppShell } from "@/components/layout/AppShell"; import { IssueList } from "@/components/issues/IssueList"; @@ -19,14 +20,15 @@ export default function InboxPage() { const userId = user?.id; const { doneKeys } = useWorkflowStates(); + const { workspaceId } = useWorkspace(); useEffect(() => { // Sin sesión todavía no hay a quién filtrar. - if (!userId) return; + if (!userId || !workspaceId) return; let cancelled = false; - void api.listIssues({ assigneeId: [userId] }).then((result) => { + void api.listIssues({ assigneeId: [userId], workspaceId }).then((result) => { if (!cancelled) { setIssues(result); setLoading(false); @@ -36,7 +38,7 @@ export default function InboxPage() { return () => { cancelled = true; }; - }, [userId]); + }, [userId, workspaceId]); const openIssues = useMemo(() => { const open = issues.filter((issue) => !doneKeys.has(issue.status)); diff --git a/src/app/issues/[id]/page.tsx b/src/app/issues/[id]/page.tsx index b341ec3..9d928a9 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 { ChatPanel } from "@/components/chat/ChatPanel"; import { EmptyState } from "@/components/ui/EmptyState"; import { TypeIcon } from "@/components/ui/Icons"; @@ -355,7 +356,19 @@ function IssueDetail({ issueId }: { issueId: string }) { - {/* Jerarquía, vínculos y suscriptores (Fase 3) */} + {/* Jerarquía, vínculos y suscriptores (Fase 3) + chat (Fase 5) */} +
+
+
+ [u.id, u]))} + title="Chat del issue" + /> +
+
+
([]); const { meta, order } = useWorkflowStates(); + const { workspaceId } = useWorkspace(); const [refreshKey, setRefreshKey] = useState(0); const [creating, setCreating] = useState(false); const [title, setTitle] = useState(""); @@ -44,7 +46,7 @@ function IssuesPageInner() { }, [searchParams, router]); const { issues, loading } = useIssues( - statusFilter.length ? { status: statusFilter } : undefined, + { ...(statusFilter.length ? { status: statusFilter } : {}), ...(workspaceId ? { workspaceId } : {}) }, refreshKey, ); @@ -61,7 +63,11 @@ function IssuesPageInner() { const trimmed = title.trim(); if (!trimmed) return; - const input: IssueInput = { title: trimmed, status: "todo" }; + const input: IssueInput = { + title: trimmed, + status: "todo", + ...(workspaceId ? { workspaceId } : {}), + }; await api.createIssue(input); setTitle(""); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index afb54f1..7d11119 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import "boxicons/css/boxicons.min.css"; import "./globals.css"; import { AuthProvider } from "@/lib/auth"; import { ReferenceProvider } from "@/lib/store"; +import { WorkspaceProvider } from "@/lib/workspace"; export const metadata: Metadata = { title: { @@ -30,7 +31,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index 24d9db7..015839a 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -6,10 +6,13 @@ import { api } from "@/lib/api"; import type { Issue, Project } from "@/lib/types"; import { PROJECT_STATUS_META } from "@/lib/meta"; import { formatLongDate, percent } from "@/lib/format"; -import { useReferenceLookups, useWorkflowStates } from "@/lib/store"; +import { useReference, useReferenceLookups, useWorkflowStates } from "@/lib/store"; +import { useWorkspace } from "@/lib/workspace"; +import { useAuth } from "@/lib/auth"; import { useIssues } from "@/hooks/useIssues"; import { AppShell } from "@/components/layout/AppShell"; import { IssueList } from "@/components/issues/IssueList"; +import { ChatPanel } from "@/components/chat/ChatPanel"; import { Avatar } from "@/components/ui/Avatar"; import { EmptyState } from "@/components/ui/EmptyState"; import { ProgressBar } from "@/components/ui/ProgressBar"; @@ -25,7 +28,14 @@ export default function ProjectDetailPage() { const [project, setProject] = useState(null); const [loading, setLoading] = useState(true); const { userById } = useReferenceLookups(); - const { issues, loading: issuesLoading } = useIssues({ projectId: [projectId] }); + const { workspaceId } = useWorkspace(); + const { user } = useAuth(); + const { users } = useReference(); + const memberId = user?.id ?? null; + const memberMap = new Map(users.map((u) => [u.id, u])); + const { issues, loading: issuesLoading } = useIssues( + { projectId: [projectId], ...(workspaceId ? { workspaceId } : {}) }, + ); const { doneKeys } = useWorkflowStates(); useEffect(() => { @@ -126,8 +136,17 @@ export default function ProjectDetailPage() { loading={issuesLoading} groupBy="status" emptyTitle="Este proyecto aún no tiene issues" - emptyDescription="Crea un issue nuevo y asíignalo a este proyecto." + emptyDescription="Crea un issue nuevo y asígnalo a este proyecto." /> + +
+ +
); } diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 59bfa3d..18b7772 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -7,6 +7,8 @@ import type { Issue, Project } from "@/lib/types"; import { PROJECT_STATUS_META } from "@/lib/meta"; import { formatShortDate, percent } from "@/lib/format"; import { useReferenceLookups, useWorkflowStates } from "@/lib/store"; +import { useWorkspace } from "@/lib/workspace"; +import { useAuth } from "@/lib/auth"; import { AppShell } from "@/components/layout/AppShell"; import { Avatar } from "@/components/ui/Avatar"; import { EmptyState } from "@/components/ui/EmptyState"; @@ -22,11 +24,16 @@ export default function ProjectsPage() { const [loading, setLoading] = useState(true); const { userById } = useReferenceLookups(); const { doneKeys } = useWorkflowStates(); + const { workspaceId } = useWorkspace(); useEffect(() => { + if (!workspaceId) return; let cancelled = false; - void Promise.all([api.listProjects(), api.listIssues()]).then(([projectData, issueData]) => { + void Promise.all([ + api.listProjects(workspaceId), + api.listIssues({ workspaceId }), + ]).then(([projectData, issueData]) => { if (!cancelled) { setProjects(projectData); setIssues(issueData); @@ -37,7 +44,33 @@ export default function ProjectsPage() { return () => { cancelled = true; }; - }, []); + }, [workspaceId]); + + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [newKey, setNewKey] = useState(""); + const [createError, setCreateError] = useState(null); + const { user } = useAuth(); + + async function handleCreateProject(event: React.FormEvent) { + event.preventDefault(); + if (!workspaceId || !user) return; + setCreateError(null); + try { + const created = await api.createProject({ + name: newName.trim(), + key: newKey.trim().toUpperCase(), + workspaceId, + actorId: user.id, + }); + setProjects((current) => [...current, created].sort((a, b) => a.name.localeCompare(b.name))); + setNewName(""); + setNewKey(""); + setCreating(false); + } catch (err) { + setCreateError(err instanceof Error ? err.message : "Error creando proyecto"); + } + } const progressByProject = useMemo(() => { const map = new Map(); @@ -63,7 +96,53 @@ export default function ProjectsPage() {
)} - {!loading && projects.length === 0 && ( + {!loading && ( +
+ {!creating ? ( + + ) : ( +
+ {createError && ( +
+ {createError} +
+ )} +
+ setNewName(event.target.value)} + required + autoFocus + /> + setNewKey(event.target.value.toUpperCase())} + pattern="[A-Za-z][A-Za-z0-9]{1,9}" + title="2-10 letras/números, empezando por letra" + required + /> + + +
+
+ )} +
+ )} + + {!loading && projects.length === 0 && !creating && ( (null); - const { issues, loading } = useIssues({ needsTriage: true }, refreshKey); + const { workspaceId } = useWorkspace(); + const { issues, loading } = useIssues( + { needsTriage: true, ...(workspaceId ? { workspaceId } : {}) }, + refreshKey, + ); const userById = new Map(users.map((u) => [u.id, u])); const labelById = new Map(labels.map((l) => [l.id, l])); diff --git a/src/app/workspaces/[id]/page.tsx b/src/app/workspaces/[id]/page.tsx new file mode 100644 index 0000000..741f53d --- /dev/null +++ b/src/app/workspaces/[id]/page.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { api, ApiError } from "@/lib/api"; +import type { User, WorkspaceMember } from "@/lib/types"; +import { useAuth } from "@/lib/auth"; +import { useWorkspace } from "@/lib/workspace"; +import { AppShell } from "@/components/layout/AppShell"; +import { Avatar } from "@/components/ui/Avatar"; +import { EmptyState } from "@/components/ui/EmptyState"; + +const ROLES = [ + { value: "owner", label: "Owner" }, + { value: "admin", label: "Admin" }, + { value: "member", label: "Miembro" }, +]; + +/** + * Detalle del workspace: miembros e invitaciones (Fase 5). + * + * Invitar es buscar un usuario institucional (sige_sam_v3.usuario vía + * la API) y agregarlo con un rol. Solo owner/admin invitan y cambian + * roles; salirse vale siempre. + */ +export default function WorkspaceDetailPage() { + const params = useParams<{ id: string }>(); + const workspaceId = params.id; + const router = useRouter(); + const { user } = useAuth(); + const { workspaces, refresh: refreshWorkspaces } = useWorkspace(); + + const [members, setMembers] = useState([]); + const [allUsers, setAllUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + // Invitación. + const [query, setQuery] = useState(""); + const [picked, setPicked] = useState(""); + const [role, setRole] = useState("member"); + + const workspace = workspaces.find((w) => w.id === workspaceId); + const myRole = workspace?.role; + const canManage = myRole === "owner" || myRole === "admin"; + const isOwner = myRole === "owner"; + + const refresh = useCallback(async () => { + if (!user) return; + setLoading(true); + setError(null); + try { + const [memberList, userList] = await Promise.all([ + api.listWorkspaceMembers(workspaceId, user.id), + api.listUsers(), + ]); + setMembers(memberList); + setAllUsers(userList); + } catch (err) { + setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error"); + } finally { + setLoading(false); + } + }, [workspaceId, user]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + async function run(action: () => Promise, okMessage?: string) { + if (!user) return; + setError(null); + setNotice(null); + try { + await action(); + if (okMessage) setNotice(okMessage); + await refresh(); + refreshWorkspaces(); + } catch (err) { + setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error"); + } + } + + // Candidatos: usuarios institucionales que aún no son miembros. + const memberIds = new Set(members.map((m) => m.userId)); + const candidates = allUsers.filter( + (u) => + !memberIds.has(u.id) && + (query.trim() === "" || + `${u.name} ${u.email} ${u.id}`.toLowerCase().includes(query.trim().toLowerCase())), + ); + + if (!workspace && !loading) { + return ( + + + + ); + } + + return ( + +
+ {notice && ( +
+ {notice} +
+ )} + {error && ( +
+ {error} +
+ )} + +
+ Miembros ({members.length}) +
+ + {loading ? ( +
+ ) : ( +
+ + + {members.map((member) => ( + + + + + + ))} + +
+ + + + {member.name} +
+ {member.email || member.userId} +
+
+
+ {isOwner && member.userId !== user?.id ? ( + + ) : ( + + {ROLES.find((r) => r.value === member.role)?.label ?? member.role} + + )} + + {(canManage || member.userId === user?.id) && ( + + )} +
+
+ )} + + {canManage && ( +
+
Invitar persona
+ { + setQuery(event.target.value); + setPicked(""); + }} + /> + {query.trim() !== "" && ( +
    + {candidates.slice(0, 8).map((u) => ( +
  • + +
  • + ))} + {candidates.length === 0 && ( +
  • Sin resultados.
  • + )} +
+ )} +
+ + +
+
+ )} + + {isOwner && ( + + )} +
+ + ); +} diff --git a/src/app/workspaces/new/page.tsx b/src/app/workspaces/new/page.tsx new file mode 100644 index 0000000..4104029 --- /dev/null +++ b/src/app/workspaces/new/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { api, ApiError } from "@/lib/api"; +import { useAuth } from "@/lib/auth"; +import { useWorkspace } from "@/lib/workspace"; +import { AppShell } from "@/components/layout/AppShell"; + +/** + * Crear workspace (Fase 5). + * + * Cualquier usuario crea su espacio e invita a quien necesite. El + * creador queda como owner automáticamente (lo hace el backend en + * la misma transacción). + */ +export default function NewWorkspacePage() { + const router = useRouter(); + const { user } = useAuth(); + const { refresh, select } = useWorkspace(); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!user || saving) return; + + setError(null); + setSaving(true); + try { + const ws = await api.createWorkspace(name.trim(), description.trim(), user.id); + refresh(); + select(ws.id); + router.push("/issues"); + } catch (err) { + setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error"); + setSaving(false); + } + } + + return ( + +
+ {error && ( +
+ {error} +
+ )} + +
+
+
+ + setName(event.target.value)} + required + autoFocus + /> +
+ +
+ +