feat: workspaces, invitaciones y chat (Fase 5)

This commit is contained in:
Nurfog committed 2026-10-14 16:59:31 -03:00
1 parent 1c5aa10527
commit b3fb660451
19 files changed
+1208 -26

No files matched your search

+5 -2
View File
@@ -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<Issue[]>([]);
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.
+5 -3
View File
@@ -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));
+14 -1
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 { 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 }) {
</aside>
</div>
{/* Jerarquía, vínculos y suscriptores (Fase 3) */}
{/* Jerarquía, vínculos y suscriptores (Fase 3) + chat (Fase 5) */}
<div className="mt-4">
<div className="row g-3">
<div className="col-12">
<ChatPanel
issueId={issue.id}
currentUserId={user?.id ?? null}
users={new Map(users.map((u) => [u.id, u]))}
title="Chat del issue"
/>
</div>
</div>
</div>
<div className="mt-4">
<IssueRelations
issue={issue}
+8 -2
View File
@@ -5,6 +5,7 @@ import { Suspense, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { Issue, IssueInput, IssueStatus } from "@/lib/types";
import { useWorkflowStates } from "@/lib/store";
import { useWorkspace } from "@/lib/workspace";
import { useIssues } from "@/hooks/useIssues";
import { AppShell } from "@/components/layout/AppShell";
import { IssueList } from "@/components/issues/IssueList";
@@ -30,6 +31,7 @@ function IssuesPageInner() {
const [statusFilter, setStatusFilter] = useState<IssueStatus[]>([]);
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("");
+4 -1
View File
@@ -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({
<html lang="es">
<body>
<AuthProvider>
<ReferenceProvider>{children}</ReferenceProvider>
<WorkspaceProvider>
<ReferenceProvider>{children}</ReferenceProvider>
</WorkspaceProvider>
</AuthProvider>
</body>
</html>
+22 -3
View File
@@ -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<Project | null>(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."
/>
<div className="page-pad pt-0">
<ChatPanel
projectId={project.id}
currentUserId={memberId}
users={memberMap}
title="Chat del proyecto"
/>
</div>
</AppShell>
);
}
+82 -3
View File
@@ -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<string | null>(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<string, { total: number; done: number }>();
@@ -63,7 +96,53 @@ export default function ProjectsPage() {
</div>
)}
{!loading && projects.length === 0 && (
{!loading && (
<div className="px-4 pt-3">
{!creating ? (
<button type="button" className="filter-chip" onClick={() => setCreating(true)}>
<i className="bx bx-plus" />
Nuevo proyecto
</button>
) : (
<form onSubmit={handleCreateProject} className="lp-card p-3">
{createError && (
<div className="alert alert-danger py-2" role="alert">
{createError}
</div>
)}
<div className="d-flex gap-2 flex-wrap">
<input
className="form-control form-control-sm"
style={{ maxWidth: 260 }}
placeholder="Nombre del proyecto"
value={newName}
onChange={(event) => setNewName(event.target.value)}
required
autoFocus
/>
<input
className="form-control form-control-sm"
style={{ maxWidth: 120 }}
placeholder="CLAVE"
value={newKey}
onChange={(event) => 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
/>
<button type="submit" className="btn btn-accent btn-sm" disabled={!newName.trim() || !newKey.trim()}>
Crear
</button>
<button type="button" className="btn btn-lp btn-sm" onClick={() => setCreating(false)}>
Cancelar
</button>
</div>
</form>
)}
</div>
)}
{!loading && projects.length === 0 && !creating && (
<EmptyState
icon="bx-folder"
title="No hay proyectos"
+6 -1
View File
@@ -6,6 +6,7 @@ import { api, ApiError } from "@/lib/api";
import type { Issue, Label, User } from "@/lib/types";
import { useReference } from "@/lib/store";
import { useAuth } from "@/lib/auth";
import { useWorkspace } from "@/lib/workspace";
import { useIssues } from "@/hooks/useIssues";
import { AppShell } from "@/components/layout/AppShell";
import { Avatar } from "@/components/ui/Avatar";
@@ -26,7 +27,11 @@ export default function TriagePage() {
const [refreshKey, setRefreshKey] = useState(0);
const [error, setError] = useState<string | null>(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]));
+305
View File
@@ -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<WorkspaceMember[]>([]);
const [allUsers, setAllUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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<unknown>, 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 (
<AppShell title="Workspace">
<EmptyState
icon="bx-buildings"
title="Workspace no encontrado"
description="No eres miembro o no existe."
/>
</AppShell>
);
}
return (
<AppShell
title={workspace?.name ?? "Workspace"}
subtitle={workspace?.description}
icon="bx-buildings"
>
<div className="page-pad pt-3" style={{ maxWidth: 760 }}>
{notice && (
<div className="alert alert-success py-2" role="status">
{notice}
</div>
)}
{error && (
<div className="alert alert-danger py-2" role="alert">
{error}
</div>
)}
<div className="detail-section-title">
Miembros ({members.length})
</div>
{loading ? (
<div className="skeleton" style={{ height: 140, borderRadius: 10 }} />
) : (
<div className="lp-card p-0 overflow-hidden mb-3">
<table className="table table-borderless issue-table mb-0">
<tbody>
{members.map((member) => (
<tr key={member.userId}>
<td className="issue-cell">
<span className="d-flex align-items-center gap-2">
<Avatar
user={{
id: member.userId,
name: member.name,
email: member.email,
initials: member.name.slice(0, 2).toUpperCase(),
color: "#015fc9",
}}
/>
<span>
<span className="issue-title">{member.name}</span>
<br />
<span className="issue-identifier">{member.email || member.userId}</span>
</span>
</span>
</td>
<td className="issue-cell issue-cell-secondary" style={{ width: 130 }}>
{isOwner && member.userId !== user?.id ? (
<select
className="prop-select"
value={member.role}
onChange={(event) =>
void run(
() =>
api.setMemberRole(
workspaceId,
member.userId,
event.target.value,
user!.id,
),
"Rol actualizado.",
)
}
>
{ROLES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
) : (
<span className="label-chip">
{ROLES.find((r) => r.value === member.role)?.label ?? member.role}
</span>
)}
</td>
<td className="issue-cell" style={{ width: 60 }}>
{(canManage || member.userId === user?.id) && (
<button
type="button"
className="btn btn-lp btn-sm"
title={member.userId === user?.id ? "Salir del workspace" : "Quitar miembro"}
onClick={() =>
void run(
() =>
api.removeWorkspaceMember(workspaceId, member.userId, user!.id),
"Miembro eliminado.",
)
}
>
<i className="bx bx-x" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{canManage && (
<div className="lp-card p-3 mb-3">
<div className="detail-section-title">Invitar persona</div>
<input
className="form-control form-control-sm mb-2"
placeholder="Buscar por nombre, email o RUT…"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setPicked("");
}}
/>
{query.trim() !== "" && (
<ul className="list-unstyled mb-2" style={{ maxHeight: 180, overflowY: "auto" }}>
{candidates.slice(0, 8).map((u) => (
<li key={u.id}>
<button
type="button"
className={`filter-chip w-100 text-start mb-1${picked === u.id ? " active" : ""}`}
onClick={() => setPicked(u.id)}
>
<Avatar user={u} />
<span className="text-truncate">
{u.name} <span className="issue-identifier">({u.id})</span>
</span>
</button>
</li>
))}
{candidates.length === 0 && (
<li style={{ fontSize: 13, color: "var(--inv-muted)" }}>Sin resultados.</li>
)}
</ul>
)}
<div className="d-flex gap-2 align-items-end">
<select
className="form-select form-select-sm"
style={{ maxWidth: 140 }}
value={role}
onChange={(event) => setRole(event.target.value)}
aria-label="Rol"
>
{ROLES.filter((r) => r.value !== "owner" || isOwner).map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
<button
type="button"
className="btn btn-accent btn-sm"
disabled={!picked}
onClick={() => {
const id = picked;
setPicked("");
setQuery("");
void run(
() => api.addWorkspaceMember(workspaceId, id, role, user!.id),
"Persona invitada.",
);
}}
>
<i className="bx bx-user-plus me-1" />
Invitar
</button>
</div>
</div>
)}
{isOwner && (
<button
type="button"
className="btn btn-lp btn-sm"
onClick={() => {
if (!window.confirm(`¿Eliminar el workspace "${workspace?.name}"? Debe estar vacío.`)) {
return;
}
void (async () => {
try {
await api.deleteWorkspace(workspaceId, user!.id);
refreshWorkspaces();
router.push("/issues");
} catch (err) {
setError(
err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Error",
);
}
})();
}}
>
<i className="bx bx-trash me-1" />
Eliminar workspace
</button>
)}
</div>
</AppShell>
);
}
+99
View File
@@ -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<string | null>(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 (
<AppShell title="Nuevo workspace" subtitle="Espacio de un departamento" icon="bx-buildings">
<div className="page-pad pt-3" style={{ maxWidth: 560 }}>
{error && (
<div className="alert alert-danger py-2" role="alert">
{error}
</div>
)}
<div className="lp-card p-4">
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label" htmlFor="ws-name">
Nombre
</label>
<input
id="ws-name"
className="form-control"
placeholder="Ventas, Académico, Finanzas…"
value={name}
onChange={(event) => setName(event.target.value)}
required
autoFocus
/>
</div>
<div className="mb-4">
<label className="form-label" htmlFor="ws-desc">
Descripción
</label>
<textarea
id="ws-desc"
className="form-control"
rows={3}
placeholder="Para qué es este espacio…"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<button type="submit" className="btn btn-accent" disabled={saving || !name.trim()}>
{saving ? (
<span className="spinner-border spinner-border-sm" role="status" />
) : (
<>
<i className="bx bx-plus me-1" />
Crear workspace
</>
)}
</button>
</form>
</div>
</div>
</AppShell>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import type { ChatMessage, ID, User } from "@/lib/types";
import { formatRelative } from "@/lib/format";
import { Avatar } from "@/components/ui/Avatar";
interface ChatPanelProps {
/** Exactamente uno de los dos: conversación del issue o canal del proyecto. */
issueId?: ID;
projectId?: ID;
currentUserId: ID | null;
users: Map<ID, User>;
title?: string;
}
/** Cada cuánto se refresca solo (respaldo en vivo sin websockets). */
const POLL_MS = 10_000;
/**
* Chat persistente (Fase 5): el respaldo de lo que se hace.
* Polling cada 10 s + refresco al enviar. Solo miembros del workspace
* pueden leer y escribir (lo exige el backend).
*/
export function ChatPanel({ issueId, projectId, currentUserId, users, title = "Chat" }: ChatPanelProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
if (!currentUserId) {
setLoading(false);
return;
}
try {
const list = issueId
? await api.listIssueChat(issueId, currentUserId)
: await api.listProjectChat(projectId!, currentUserId);
setMessages(list);
} catch {
// Sin permiso o sin backend: se muestra vacío, sin romper la página.
} finally {
setLoading(false);
}
}, [issueId, projectId, currentUserId]);
useEffect(() => {
void load();
const timer = setInterval(() => void load(), POLL_MS);
return () => clearInterval(timer);
}, [load]);
// Bajar al último mensaje cuando llegan nuevos.
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [messages.length]);
async function handleSend(event: React.FormEvent) {
event.preventDefault();
const body = draft.trim();
if (!body || !currentUserId || sending) return;
setDraft("");
setSending(true);
try {
const created = issueId
? await api.postIssueChat(issueId, body, currentUserId)
: await api.postProjectChat(projectId!, body, currentUserId);
setMessages((current) => [...current, created]);
} catch {
setDraft(body);
} finally {
setSending(false);
}
}
return (
<div className="lp-card p-3 d-flex flex-column" style={{ minHeight: 280 }}>
<div className="detail-section-title">{title}</div>
<div style={{ maxHeight: 320, overflowY: "auto" }} className="flex-grow-1 mb-2">
{loading ? (
<div className="skeleton" style={{ height: 120, borderRadius: 8 }} />
) : messages.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--inv-muted)" }}>
Sin mensajes. Lo que se converse aquí queda como respaldo.
</p>
) : (
messages.map((msg) => {
const author = users.get(msg.authorId);
const mine = msg.authorId === currentUserId;
return (
<div key={msg.id} className={`d-flex gap-2 mb-2${mine ? " flex-row-reverse" : ""}`}>
<Avatar user={author} />
<div style={{ maxWidth: "80%" }}>
<div
className="px-2 py-1 rounded-2"
style={{
background: mine ? "#e8f0fd" : "#f4f7fb",
fontSize: 13,
whiteSpace: "pre-wrap",
}}
>
{msg.body}
</div>
<div
style={{
fontSize: 11,
color: "var(--inv-muted)",
textAlign: mine ? "right" : "left",
}}
>
{author?.name ?? msg.authorId} · {formatRelative(msg.createdAt)}
</div>
</div>
</div>
);
})
)}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSend} className="d-flex gap-2">
<input
className="form-control form-control-sm"
placeholder="Escribe un mensaje…"
value={draft}
onChange={(event) => setDraft(event.target.value)}
disabled={!currentUserId || sending}
/>
<button
type="submit"
className="btn btn-accent btn-sm"
disabled={!draft.trim() || !currentUserId || sending}
title="Enviar"
>
<i className="bx bx-send" />
</button>
</form>
</div>
);
}
+7 -5
View File
@@ -6,6 +6,7 @@ import { api } from "@/lib/api";
import type { Issue } from "@/lib/types";
import { useAuth } from "@/lib/auth";
import { useReference, useWorkflowStates } from "@/lib/store";
import { useWorkspace } from "@/lib/workspace";
import { CommandPalette } from "./CommandPalette";
import { Sidebar } from "./Sidebar";
@@ -36,25 +37,26 @@ export function AppShell({ title, subtitle, actions, icon = "bx-briefcase", chil
const { user, loading: authLoading, logout } = useAuth();
const { projects } = useReference();
const { doneKeys } = useWorkflowStates();
const { workspaceId, workspace } = useWorkspace();
const [open, setOpen] = useState(true);
const [issues, setIssues] = useState<Issue[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false);
// Cargar issues solo cuando hay sesión (los endpoints exigen API key y
// los datos no tienen sentido anónimos).
// Cargar issues del workspace activo (los endpoints exigen API key y
// los datos no tienen sentido anónimos ni sin workspace).
useEffect(() => {
if (!user) return;
if (!user || !workspaceId) return;
let cancelled = false;
void api.listIssues().then((result) => {
void api.listIssues({ workspaceId }).then((result) => {
if (!cancelled) setIssues(result);
});
return () => {
cancelled = true;
};
}, [user]);
}, [user, workspaceId]);
// Atajo global Cmd/Ctrl + K para abrir la palette.
useEffect(() => {
+46
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useWorkspace } from "@/lib/workspace";
interface SidebarProps {
/** Contadores de issues por sección, para mostrar junto a cada enlace. */
@@ -26,6 +27,7 @@ interface SidebarProps {
*/
export function Sidebar({ counts, projects, open }: SidebarProps) {
const pathname = usePathname();
const { workspaces, workspaceId, select } = useWorkspace();
const mainLinks = [
{ href: "/inbox", icon: "bx-inbox", label: "Bandeja", count: counts.inbox },
@@ -56,6 +58,50 @@ export function Sidebar({ counts, projects, open }: SidebarProps) {
</div>
<ul className="nav-list">
{/* Selector de workspace (Fase 5) */}
<li>
<div className="d-flex align-items-center gap-2 px-2 py-1">
<i className="bx bx-buildings" style={{ fontSize: "1.4rem", minWidth: 30, textAlign: "center", color: "#dbe4f3" }} />
{open && (
<select
className="form-select form-select-sm links_name"
style={{ background: "rgba(255,255,255,0.12)", color: "#fff", border: "none" }}
value={workspaceId ?? ""}
onChange={(event) => select(event.target.value)}
aria-label="Workspace activo"
>
{workspaces.map((w) => (
<option key={w.id} value={w.id} style={{ color: "#16243d" }}>
{w.name}
</option>
))}
</select>
)}
</div>
<span className="tooltip">Workspace</span>
</li>
<li>
{open && (
<Link href="/workspaces/new" className={pathname === "/workspaces/new" ? "active" : ""}>
<i className="bx bx-plus" />
<span className="links_name">Nuevo workspace</span>
</Link>
)}
<span className="tooltip">Nuevo workspace</span>
</li>
{open && workspaceId && (
<li>
<Link
href={`/workspaces/${workspaceId}`}
className={pathname === `/workspaces/${workspaceId}` ? "active" : ""}
>
<i className="bx bx-group" />
<span className="links_name">Miembros</span>
</Link>
<span className="tooltip">Miembros</span>
</li>
)}
{mainLinks.map((link) => (
<li key={link.href}>
<Link href={link.href} className={isActive(link.href) ? "active" : ""}>
+22 -1
View File
@@ -1,7 +1,11 @@
import type {
Activity,
ChatMessage,
Comment,
ProjectInput,
Relation,
Workspace,
WorkspaceMember,
Cycle,
ID,
Issue,
@@ -29,7 +33,8 @@ export interface ProyectosApi {
// Datos de referencia
listUsers(): Promise<User[]>;
listLabels(): Promise<Label[]>;
listProjects(): Promise<Project[]>;
listProjects(workspaceId?: ID): Promise<Project[]>;
createProject(input: ProjectInput): Promise<Project>;
listCycles(): Promise<Cycle[]>;
/** Estados del workflow (Fase 1: configurables). */
listStates(includeInactive?: boolean): Promise<WorkflowState[]>;
@@ -46,6 +51,22 @@ export interface ProyectosApi {
// Línea de tiempo (Fase 2 en sam): eventos de creación, cambios y comentarios.
listActivity(issueId: ID): Promise<Activity[]>;
// Workspaces (Fase 5 en sam)
listWorkspaces(userId: ID): Promise<Workspace[]>;
createWorkspace(name: string, description: string, ownerId: ID): Promise<Workspace>;
updateWorkspace(id: ID, patch: { name?: string; description?: string; actorId: ID }): Promise<Workspace>;
deleteWorkspace(id: ID, actorId: ID): Promise<void>;
listWorkspaceMembers(workspaceId: ID, userId: ID): Promise<WorkspaceMember[]>;
addWorkspaceMember(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>;
// Chat (Fase 5 en sam)
listIssueChat(issueId: ID, userId: ID): Promise<ChatMessage[]>;
postIssueChat(issueId: ID, body: string, authorId: ID): Promise<ChatMessage>;
listProjectChat(projectId: ID, userId: ID): Promise<ChatMessage[]>;
postProjectChat(projectId: ID, body: string, authorId: ID): Promise<ChatMessage>;
// Relaciones (Fase 3 en sam)
/** Hijos directos (sub-issues). */
listChildren(issueId: ID): Promise<Issue[]>;
+82 -1
View File
@@ -1,7 +1,11 @@
import type {
Activity,
ChatMessage,
Comment,
ProjectInput,
Relation,
Workspace,
WorkspaceMember,
Cycle,
ID,
Issue,
@@ -84,6 +88,7 @@ function buildQuery(filters?: IssueFilters): string {
};
append("status", filters.status);
if (filters.workspaceId) params.append("workspaceId", filters.workspaceId);
append("assigneeId", filters.assigneeId);
append("projectId", filters.projectId);
append("cycleId", filters.cycleId);
@@ -118,7 +123,16 @@ export const httpApi: ProyectosApi = {
listLabels: () => request<Label[]>("/api/proyectos/labels"),
listProjects: () => request<Project[]>("/api/proyectos/projects"),
createProject: (input: ProjectInput) =>
request<Project>("/api/proyectos/projects", {
method: "POST",
body: JSON.stringify(input),
}),
listProjects: (workspaceId?: ID) =>
request<Project[]>(
`/api/proyectos/projects${workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : ""}`,
),
listCycles: () => request<Cycle[]>("/api/proyectos/cycles"),
@@ -168,6 +182,73 @@ export const httpApi: ProyectosApi = {
listActivity: (issueId: ID) => request<Activity[]>(`/api/proyectos/issues/${issueId}/activity`),
listWorkspaces: (userId: ID) =>
request<Workspace[]>(`/api/proyectos/workspaces?userId=${encodeURIComponent(userId)}`),
createWorkspace: (name: string, description: string, ownerId: ID) =>
request<Workspace>("/api/proyectos/workspaces", {
method: "POST",
body: JSON.stringify({ name, description, ownerId }),
}),
updateWorkspace: (id: ID, patch: { name?: string; description?: string; actorId: ID }) =>
request<Workspace>(`/api/proyectos/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
deleteWorkspace: (id: ID, actorId: ID) =>
request<void>(`/api/proyectos/workspaces/${id}?actorId=${encodeURIComponent(actorId)}`, {
method: "DELETE",
}),
listWorkspaceMembers: (workspaceId: ID, userId: ID) =>
request<WorkspaceMember[]>(
`/api/proyectos/workspaces/${workspaceId}/members?userId=${encodeURIComponent(userId)}`,
),
addWorkspaceMember: (workspaceId: ID, userId: ID, role: string, actorId: ID) =>
request<void>(`/api/proyectos/workspaces/${workspaceId}/members`, {
method: "POST",
body: JSON.stringify({ userId, role, actorId }),
}),
setMemberRole: (workspaceId: ID, userId: ID, role: string, actorId: ID) =>
request<void>(`/api/proyectos/workspaces/${workspaceId}/members/${encodeURIComponent(userId)}`, {
method: "PATCH",
body: JSON.stringify({ role, actorId }),
}),
removeWorkspaceMember: (workspaceId: ID, userId: ID, actorId?: ID) => {
const qs = actorId ? `?actorId=${encodeURIComponent(actorId)}` : "";
return request<void>(
`/api/proyectos/workspaces/${workspaceId}/members/${encodeURIComponent(userId)}${qs}`,
{ method: "DELETE" },
);
},
listIssueChat: (issueId: ID, userId: ID) =>
request<ChatMessage[]>(
`/api/proyectos/issues/${issueId}/chat?userId=${encodeURIComponent(userId)}`,
),
postIssueChat: (issueId: ID, body: string, authorId: ID) =>
request<ChatMessage>(`/api/proyectos/issues/${issueId}/chat`, {
method: "POST",
body: JSON.stringify({ body, authorId }),
}),
listProjectChat: (projectId: ID, userId: ID) =>
request<ChatMessage[]>(
`/api/proyectos/projects/${projectId}/chat?userId=${encodeURIComponent(userId)}`,
),
postProjectChat: (projectId: ID, body: string, authorId: ID) =>
request<ChatMessage>(`/api/proyectos/projects/${projectId}/chat`, {
method: "POST",
body: JSON.stringify({ body, authorId }),
}),
listChildren: (issueId: ID) => request<Issue[]>(`/api/proyectos/issues/${issueId}/children`),
setParent: (issueId: ID, parentId: ID | null, actorId?: ID) =>
+165 -2
View File
@@ -1,7 +1,11 @@
import type {
Activity,
ChatMessage,
Comment,
ProjectInput,
Relation,
Workspace,
WorkspaceMember,
Cycle,
ID,
Issue,
@@ -30,6 +34,14 @@ const state = {
activity: [] as Activity[],
relations: [] as Relation[],
subscriptions: [] as Array<{ issueId: ID; userId: ID }>,
workspaces: structuredClone(seed.workspaces),
workspaceMembers: [
{ workspaceId: "w1", userId: "u1", role: "owner" },
{ workspaceId: "w1", userId: "u2", role: "admin" },
{ workspaceId: "w1", userId: "u3", role: "member" },
{ workspaceId: "w1", userId: "u4", role: "member" },
] as Array<{ workspaceId: ID; userId: ID; role: string }>,
chat: [] as ChatMessage[],
projects: structuredClone(seed.projects),
cycles: structuredClone(seed.cycles),
users: structuredClone(seed.users),
@@ -78,6 +90,7 @@ function buildIdentifier(project: Project | undefined, seq: number): string {
function matchesFilters(issue: Issue, filters?: IssueFilters): boolean {
if (!filters) return true;
if (filters.workspaceId && issue.workspaceId !== filters.workspaceId) return false;
if (filters.status?.length && !filters.status.includes(issue.status)) return false;
if (filters.priority?.length && !filters.priority.includes(issue.priority)) return false;
if (filters.assigneeId?.length && !filters.assigneeId.includes(issue.assigneeId ?? ""))
@@ -137,9 +150,154 @@ export const mockApi: ProyectosApi = {
return state.labels;
},
async listProjects(): Promise<Project[]> {
async createProject(input: ProjectInput): Promise<Project> {
await delay(120);
const key = input.key.trim().toUpperCase();
if (!/^[A-Z][A-Z0-9]{1,9}$/.test(key)) throw new Error("Clave inválida.");
if (state.projects.some((p) => p.key === key)) throw new Error(`La clave '${key}' ya existe.`);
const project: Project = {
id: nextId("p"),
workspaceId: input.workspaceId ?? "w1",
name: input.name.trim(),
key,
description: input.description ?? "",
status: (input.status ?? "planned") as Project["status"],
leadId: input.leadId ?? null,
startDate: input.startDate ?? null,
targetDate: input.targetDate ?? null,
createdAt: new Date().toISOString(),
};
state.projects.push(project);
return project;
},
async listProjects(workspaceId?: ID): Promise<Project[]> {
await delay(60);
return state.projects;
return workspaceId ? state.projects.filter((p) => p.workspaceId === workspaceId) : state.projects;
},
async listWorkspaces(userId: ID): Promise<Workspace[]> {
await delay(60);
return state.workspaces
.filter((w) => state.workspaceMembers.some((m) => m.workspaceId === w.id && m.userId === userId))
.map((w) => ({
...w,
role: state.workspaceMembers.find((m) => m.workspaceId === w.id && m.userId === userId)?.role ?? "member",
}));
},
async createWorkspace(name: string, description: string, ownerId: ID): Promise<Workspace> {
await delay(120);
if (!name.trim()) throw new Error("El nombre es obligatorio.");
const ws: Workspace = {
id: nextId("w"),
name: name.trim(),
description,
ownerId,
role: "owner",
createdAt: new Date().toISOString(),
};
state.workspaces.push(ws);
state.workspaceMembers.push({ workspaceId: ws.id, userId: ownerId, role: "owner" });
return ws;
},
async updateWorkspace(id: ID, patch: { name?: string; description?: string; actorId: ID }): Promise<Workspace> {
await delay(120);
const ws = state.workspaces.find((w) => w.id === id);
if (!ws) throw new Error("Workspace inexistente.");
if (patch.name !== undefined) ws.name = patch.name;
if (patch.description !== undefined) ws.description = patch.description;
return { ...ws, role: "owner" };
},
async deleteWorkspace(id: ID): Promise<void> {
await delay(120);
state.workspaces = state.workspaces.filter((w) => w.id !== id);
state.workspaceMembers = state.workspaceMembers.filter((m) => m.workspaceId !== id);
},
async listWorkspaceMembers(workspaceId: ID): Promise<WorkspaceMember[]> {
await delay(60);
return state.workspaceMembers
.filter((m) => m.workspaceId === workspaceId)
.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 addWorkspaceMember(workspaceId: ID, userId: ID, role: string): Promise<void> {
await delay(100);
const existing = state.workspaceMembers.find(
(m) => m.workspaceId === workspaceId && m.userId === userId,
);
if (existing) existing.role = role;
else state.workspaceMembers.push({ workspaceId, userId, role });
},
async setMemberRole(workspaceId: ID, userId: ID, role: string): Promise<void> {
await delay(100);
const existing = state.workspaceMembers.find(
(m) => m.workspaceId === workspaceId && m.userId === userId,
);
if (!existing) throw new Error("No es miembro.");
existing.role = role;
},
async removeWorkspaceMember(workspaceId: ID, userId: ID): Promise<void> {
await delay(100);
state.workspaceMembers = state.workspaceMembers.filter(
(m) => !(m.workspaceId === workspaceId && m.userId === userId),
);
},
async listIssueChat(issueId: ID): Promise<ChatMessage[]> {
await delay(60);
return state.chat
.filter((m) => m.issueId === issueId)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
},
async postIssueChat(issueId: ID, body: string, authorId: ID): Promise<ChatMessage> {
await delay(100);
if (!body.trim()) throw new Error("El mensaje no puede estar vacío.");
const issue = state.issues.find((i) => i.id === issueId);
const msg: ChatMessage = {
id: nextId("msg"),
workspaceId: issue?.workspaceId ?? "w1",
projectId: null,
issueId,
authorId,
body: body.trim(),
createdAt: new Date().toISOString(),
};
state.chat.push(msg);
return msg;
},
async listProjectChat(projectId: ID): Promise<ChatMessage[]> {
await delay(60);
return state.chat
.filter((m) => m.projectId === projectId)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
},
async postProjectChat(projectId: ID, body: string, authorId: ID): Promise<ChatMessage> {
await delay(100);
if (!body.trim()) throw new Error("El mensaje no puede estar vacío.");
const project = state.projects.find((p) => p.id === projectId);
const msg: ChatMessage = {
id: nextId("msg"),
workspaceId: project?.workspaceId ?? "w1",
projectId,
issueId: null,
authorId,
body: body.trim(),
createdAt: new Date().toISOString(),
};
state.chat.push(msg);
return msg;
},
async listCycles(): Promise<Cycle[]> {
@@ -222,6 +380,11 @@ export const mockApi: ProyectosApi = {
description: input.description ?? "",
status,
parentId: null,
workspaceId:
input.workspaceId ??
(input.projectId
? (state.projects.find((p) => p.id === input.projectId)?.workspaceId ?? "w1")
: "w1"),
needsTriage: true,
estimate: input.estimate ?? null,
priority: input.priority ?? 0,
+23 -1
View File
@@ -1,4 +1,4 @@
import type { Comment, Cycle, Issue, Label, Project, User, WorkflowState } from "@/lib/types";
import type { Comment, Cycle, Issue, Label, Project, User, WorkflowState, Workspace } from "@/lib/types";
/**
* Datos de ejemplo usados por la capa mock.
@@ -34,6 +34,17 @@ export const workflowStates: WorkflowState[] = [
{ key: "canceled", label: "Cancelado", color: "#98a2b3", icon: "bx-x-circle", sortOrder: 60, section: "Completado", isDone: true, isActive: true },
];
export const workspaces: Workspace[] = [
{
id: "w1",
name: "ICHN",
description: "Espacio inicial de la institución",
ownerId: "u1",
role: "owner",
createdAt: "2026-08-01T10:00:00Z",
},
];
export const projects: Project[] = [
{
id: "p1",
@@ -44,6 +55,7 @@ export const projects: Project[] = [
leadId: "u1",
startDate: "2026-09-01",
targetDate: "2026-12-15",
workspaceId: "w1",
createdAt: "2026-08-20T10:00:00Z",
},
{
@@ -55,6 +67,7 @@ export const projects: Project[] = [
leadId: "u2",
startDate: "2026-10-15",
targetDate: "2027-02-01",
workspaceId: "w1",
createdAt: "2026-09-02T10:00:00Z",
},
{
@@ -66,6 +79,7 @@ export const projects: Project[] = [
leadId: "u3",
startDate: "2026-05-01",
targetDate: "2026-09-30",
workspaceId: "w1",
createdAt: "2026-04-10T10:00:00Z",
},
];
@@ -114,6 +128,7 @@ export const issues: Issue[] = [
projectId: "p1",
cycleId: "c3",
parentId: null,
workspaceId: "w1",
needsTriage: false,
estimate: 5,
labelIds: ["l2"],
@@ -133,6 +148,7 @@ export const issues: Issue[] = [
projectId: "p1",
cycleId: "c3",
parentId: null,
workspaceId: "w1",
needsTriage: false,
estimate: 3,
labelIds: ["l1", "l4"],
@@ -152,6 +168,7 @@ export const issues: Issue[] = [
projectId: "p1",
cycleId: "c3",
parentId: null,
workspaceId: "w1",
needsTriage: false,
estimate: 8,
labelIds: ["l3"],
@@ -171,6 +188,7 @@ export const issues: Issue[] = [
projectId: "p1",
cycleId: "c3",
parentId: null,
workspaceId: "w1",
needsTriage: false,
estimate: null,
labelIds: ["l2"],
@@ -190,6 +208,7 @@ export const issues: Issue[] = [
projectId: "p2",
cycleId: null,
parentId: null,
workspaceId: "w1",
needsTriage: false,
estimate: null,
labelIds: ["l1", "l3"],
@@ -209,6 +228,7 @@ export const issues: Issue[] = [
projectId: "p2",
cycleId: "c4",
parentId: "i5",
workspaceId: "w1",
needsTriage: false,
estimate: 5,
labelIds: ["l2"],
@@ -228,6 +248,7 @@ export const issues: Issue[] = [
projectId: "p3",
cycleId: "c2",
parentId: null,
workspaceId: "w1",
needsTriage: false,
estimate: null,
labelIds: ["l5"],
@@ -247,6 +268,7 @@ export const issues: Issue[] = [
projectId: "p1",
cycleId: "c4",
parentId: null,
workspaceId: "w1",
needsTriage: false,
estimate: null,
labelIds: ["l5"],
+53
View File
@@ -59,8 +59,57 @@ export interface Label {
color: string;
}
/**
* Espacio de trabajo de un departamento (Fase 5 en sam). El usuario
* lo crea e invita a quien necesite.
*/
export interface Workspace {
id: ID;
name: string;
description: string;
ownerId: ID;
/** Rol del usuario en sesión dentro del workspace. */
role: string;
createdAt: string;
}
export interface WorkspaceMember {
userId: ID;
name: string;
email: string;
role: string;
}
/**
* Mensaje de chat (Fase 5 en sam): canal por proyecto o conversación
* por issue. Exactamente uno de los dos tiene valor.
*/
export interface ChatMessage {
id: ID;
workspaceId: ID;
projectId: ID | null;
issueId: ID | null;
authorId: ID;
body: string;
createdAt: string;
}
/** Payload para crear un proyecto (Fase 5 en sam). */
export interface ProjectInput {
name: string;
key: string;
description?: string;
status?: string;
leadId?: ID | null;
startDate?: string | null;
targetDate?: string | null;
workspaceId?: ID;
actorId?: ID;
}
export interface Project {
id: ID;
workspaceId: ID;
name: string;
/** Identificador corto usado como prefijo de los issues, ej. "PLAT". */
key: string;
@@ -120,6 +169,8 @@ export interface Issue {
cycleId: ID | null;
/** Id del issue padre (sub-issues, Fase 3 en sam). Null/ausente = raíz. */
parentId: ID | null;
/** Workspace dueño (Fase 5 en sam). */
workspaceId: ID;
/** TRUE = pendiente de triage (Fase 4 en sam). */
needsTriage: boolean;
/** Puntos de historia, null = sin estimar (Fase 4 en sam). */
@@ -132,6 +183,7 @@ export interface Issue {
/** Filtros soportados por `listIssues`. */
export interface IssueFilters {
workspaceId?: ID;
needsTriage?: boolean;
estimated?: boolean;
status?: IssueStatus[];
@@ -158,6 +210,7 @@ export interface IssueInput {
projectId?: ID | null;
cycleId?: ID | null;
parentId?: ID | null;
workspaceId?: ID | null;
needsTriage?: boolean;
estimate?: number | null;
labelIds?: ID[];
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import type { ID, Workspace } from "@/lib/types";
import { useAuth } from "@/lib/auth";
/**
* Workspace activo (Fase 5).
*
* Cada usuario ve sus workspaces y opera en uno a la vez. El activo se
* guarda en localStorage para persistir entre sesiones. Todos los
* listados (issues, proyectos) se filtran por este id.
*/
const STORAGE_KEY = "proyectos.workspace";
interface WorkspaceContextValue {
workspaces: Workspace[];
/** Id del workspace activo (null = aún cargando o sin workspaces). */
workspaceId: ID | null;
/** Workspace activo resuelto (undefined si no hay). */
workspace?: Workspace;
loading: boolean;
select: (id: ID) => void;
/** Recarga la lista (tras crear un workspace, por ejemplo). */
refresh: () => void;
}
const WorkspaceContext = createContext<WorkspaceContextValue>({
workspaces: [],
workspaceId: null,
workspace: undefined,
loading: true,
select: () => {},
refresh: () => {},
});
export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [workspaceId, setWorkspaceId] = useState<ID | null>(null);
const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const userId = user?.id;
useEffect(() => {
if (!userId) {
setWorkspaces([]);
setWorkspaceId(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
void api
.listWorkspaces(userId)
.then((list) => {
if (cancelled) return;
setWorkspaces(list);
// Restaurar el activo guardado si sigue existiendo.
let saved: string | null = null;
try {
saved = localStorage.getItem(STORAGE_KEY);
} catch {
saved = null;
}
const valid = saved && list.some((w) => w.id === saved);
setWorkspaceId(valid ? saved : (list[0]?.id ?? null));
setLoading(false);
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [userId, refreshKey]);
const select = useCallback((id: ID) => {
setWorkspaceId(id);
try {
localStorage.setItem(STORAGE_KEY, id);
} catch {
// Sin almacenamiento: la selección vive igual en memoria.
}
}, []);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const value = useMemo<WorkspaceContextValue>(() => {
const workspace = workspaces.find((w) => w.id === workspaceId);
// Si el activo desapareció (lo eliminaron), caer al primero.
const effectiveId = workspace ? workspaceId : (workspaces[0]?.id ?? null);
return {
workspaces,
workspaceId: effectiveId,
workspace: workspaces.find((w) => w.id === effectiveId),
loading,
select,
refresh,
};
}, [workspaces, workspaceId, loading, select, refresh]);
return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>;
}
export function useWorkspace(): WorkspaceContextValue {
return useContext(WorkspaceContext);
}