feat: workspaces, invitaciones y chat (Fase 5)
This commit is contained in:
1 parent
1c5aa10527
commit
b3fb660451
19 files changed
+1207
-25
No files matched your search
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Issue, IssueStatus } from "@/lib/types";
|
import type { Issue, IssueStatus } from "@/lib/types";
|
||||||
|
import { useWorkspace } from "@/lib/workspace";
|
||||||
import { AppShell } from "@/components/layout/AppShell";
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
import { Board } from "@/components/board/Board";
|
import { Board } from "@/components/board/Board";
|
||||||
|
|
||||||
@@ -15,11 +16,13 @@ import { Board } from "@/components/board/Board";
|
|||||||
export default function BoardPage() {
|
export default function BoardPage() {
|
||||||
const [issues, setIssues] = useState<Issue[]>([]);
|
const [issues, setIssues] = useState<Issue[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { workspaceId } = useWorkspace();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!workspaceId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void api.listIssues().then((result) => {
|
void api.listIssues({ workspaceId }).then((result) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIssues(result);
|
setIssues(result);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -29,7 +32,7 @@ export default function BoardPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [workspaceId]);
|
||||||
|
|
||||||
const handleMove = useCallback(async (issueId: string, status: IssueStatus) => {
|
const handleMove = useCallback(async (issueId: string, status: IssueStatus) => {
|
||||||
// Actualización optimista: reflejar el cambio al instante.
|
// Actualización optimista: reflejar el cambio al instante.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Issue } from "@/lib/types";
|
import type { Issue } from "@/lib/types";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useWorkspace } from "@/lib/workspace";
|
||||||
import { useWorkflowStates } from "@/lib/store";
|
import { useWorkflowStates } from "@/lib/store";
|
||||||
import { AppShell } from "@/components/layout/AppShell";
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
import { IssueList } from "@/components/issues/IssueList";
|
import { IssueList } from "@/components/issues/IssueList";
|
||||||
@@ -19,14 +20,15 @@ export default function InboxPage() {
|
|||||||
|
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
const { doneKeys } = useWorkflowStates();
|
const { doneKeys } = useWorkflowStates();
|
||||||
|
const { workspaceId } = useWorkspace();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Sin sesión todavía no hay a quién filtrar.
|
// Sin sesión todavía no hay a quién filtrar.
|
||||||
if (!userId) return;
|
if (!userId || !workspaceId) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void api.listIssues({ assigneeId: [userId] }).then((result) => {
|
void api.listIssues({ assigneeId: [userId], workspaceId }).then((result) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIssues(result);
|
setIssues(result);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -36,7 +38,7 @@ export default function InboxPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [userId]);
|
}, [userId, workspaceId]);
|
||||||
|
|
||||||
const openIssues = useMemo(() => {
|
const openIssues = useMemo(() => {
|
||||||
const open = issues.filter((issue) => !doneKeys.has(issue.status));
|
const open = issues.filter((issue) => !doneKeys.has(issue.status));
|
||||||
|
|||||||
@@ -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 { 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";
|
||||||
|
|
||||||
@@ -355,7 +356,19 @@ function IssueDetail({ issueId }: { issueId: string }) {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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">
|
<div className="mt-4">
|
||||||
<IssueRelations
|
<IssueRelations
|
||||||
issue={issue}
|
issue={issue}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Suspense, useEffect, useState } from "react";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Issue, IssueInput, IssueStatus } from "@/lib/types";
|
import type { Issue, IssueInput, IssueStatus } from "@/lib/types";
|
||||||
import { useWorkflowStates } from "@/lib/store";
|
import { useWorkflowStates } from "@/lib/store";
|
||||||
|
import { useWorkspace } from "@/lib/workspace";
|
||||||
import { useIssues } from "@/hooks/useIssues";
|
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";
|
||||||
@@ -30,6 +31,7 @@ function IssuesPageInner() {
|
|||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState<IssueStatus[]>([]);
|
const [statusFilter, setStatusFilter] = useState<IssueStatus[]>([]);
|
||||||
const { meta, order } = useWorkflowStates();
|
const { meta, order } = useWorkflowStates();
|
||||||
|
const { workspaceId } = useWorkspace();
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -44,7 +46,7 @@ function IssuesPageInner() {
|
|||||||
}, [searchParams, router]);
|
}, [searchParams, router]);
|
||||||
|
|
||||||
const { issues, loading } = useIssues(
|
const { issues, loading } = useIssues(
|
||||||
statusFilter.length ? { status: statusFilter } : undefined,
|
{ ...(statusFilter.length ? { status: statusFilter } : {}), ...(workspaceId ? { workspaceId } : {}) },
|
||||||
refreshKey,
|
refreshKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -61,7 +63,11 @@ function IssuesPageInner() {
|
|||||||
const trimmed = title.trim();
|
const trimmed = title.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
const input: IssueInput = { title: trimmed, status: "todo" };
|
const input: IssueInput = {
|
||||||
|
title: trimmed,
|
||||||
|
status: "todo",
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
};
|
||||||
await api.createIssue(input);
|
await api.createIssue(input);
|
||||||
|
|
||||||
setTitle("");
|
setTitle("");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "boxicons/css/boxicons.min.css";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AuthProvider } from "@/lib/auth";
|
import { AuthProvider } from "@/lib/auth";
|
||||||
import { ReferenceProvider } from "@/lib/store";
|
import { ReferenceProvider } from "@/lib/store";
|
||||||
|
import { WorkspaceProvider } from "@/lib/workspace";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -30,7 +31,9 @@ export default function RootLayout({
|
|||||||
<html lang="es">
|
<html lang="es">
|
||||||
<body>
|
<body>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<WorkspaceProvider>
|
||||||
<ReferenceProvider>{children}</ReferenceProvider>
|
<ReferenceProvider>{children}</ReferenceProvider>
|
||||||
|
</WorkspaceProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import { api } from "@/lib/api";
|
|||||||
import type { Issue, Project } from "@/lib/types";
|
import type { Issue, Project } from "@/lib/types";
|
||||||
import { PROJECT_STATUS_META } from "@/lib/meta";
|
import { PROJECT_STATUS_META } from "@/lib/meta";
|
||||||
import { formatLongDate, percent } from "@/lib/format";
|
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 { 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 { 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";
|
||||||
@@ -25,7 +28,14 @@ export default function ProjectDetailPage() {
|
|||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { userById } = useReferenceLookups();
|
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();
|
const { doneKeys } = useWorkflowStates();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -126,8 +136,17 @@ export default function ProjectDetailPage() {
|
|||||||
loading={issuesLoading}
|
loading={issuesLoading}
|
||||||
groupBy="status"
|
groupBy="status"
|
||||||
emptyTitle="Este proyecto aún no tiene issues"
|
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>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,8 @@ import type { Issue, Project } from "@/lib/types";
|
|||||||
import { PROJECT_STATUS_META } from "@/lib/meta";
|
import { PROJECT_STATUS_META } from "@/lib/meta";
|
||||||
import { formatShortDate, percent } from "@/lib/format";
|
import { formatShortDate, percent } from "@/lib/format";
|
||||||
import { useReferenceLookups, useWorkflowStates } from "@/lib/store";
|
import { useReferenceLookups, useWorkflowStates } from "@/lib/store";
|
||||||
|
import { useWorkspace } from "@/lib/workspace";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
import { AppShell } from "@/components/layout/AppShell";
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
@@ -22,11 +24,16 @@ export default function ProjectsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { userById } = useReferenceLookups();
|
const { userById } = useReferenceLookups();
|
||||||
const { doneKeys } = useWorkflowStates();
|
const { doneKeys } = useWorkflowStates();
|
||||||
|
const { workspaceId } = useWorkspace();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!workspaceId) return;
|
||||||
let cancelled = false;
|
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) {
|
if (!cancelled) {
|
||||||
setProjects(projectData);
|
setProjects(projectData);
|
||||||
setIssues(issueData);
|
setIssues(issueData);
|
||||||
@@ -37,7 +44,33 @@ export default function ProjectsPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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 progressByProject = useMemo(() => {
|
||||||
const map = new Map<string, { total: number; done: number }>();
|
const map = new Map<string, { total: number; done: number }>();
|
||||||
@@ -63,7 +96,53 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</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
|
<EmptyState
|
||||||
icon="bx-folder"
|
icon="bx-folder"
|
||||||
title="No hay proyectos"
|
title="No hay proyectos"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api, ApiError } from "@/lib/api";
|
|||||||
import type { Issue, Label, User } from "@/lib/types";
|
import type { Issue, Label, User } from "@/lib/types";
|
||||||
import { useReference } from "@/lib/store";
|
import { useReference } from "@/lib/store";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useWorkspace } from "@/lib/workspace";
|
||||||
import { useIssues } from "@/hooks/useIssues";
|
import { useIssues } from "@/hooks/useIssues";
|
||||||
import { AppShell } from "@/components/layout/AppShell";
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
@@ -26,7 +27,11 @@ export default function TriagePage() {
|
|||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 userById = new Map(users.map((u) => [u.id, u]));
|
||||||
const labelById = new Map(labels.map((l) => [l.id, l]));
|
const labelById = new Map(labels.map((l) => [l.id, l]));
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { api } from "@/lib/api";
|
|||||||
import type { Issue } from "@/lib/types";
|
import type { Issue } from "@/lib/types";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
import { useReference, useWorkflowStates } from "@/lib/store";
|
import { useReference, useWorkflowStates } from "@/lib/store";
|
||||||
|
import { useWorkspace } from "@/lib/workspace";
|
||||||
import { CommandPalette } from "./CommandPalette";
|
import { CommandPalette } from "./CommandPalette";
|
||||||
import { Sidebar } from "./Sidebar";
|
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 { user, loading: authLoading, logout } = useAuth();
|
||||||
const { projects } = useReference();
|
const { projects } = useReference();
|
||||||
const { doneKeys } = useWorkflowStates();
|
const { doneKeys } = useWorkflowStates();
|
||||||
|
const { workspaceId, workspace } = useWorkspace();
|
||||||
|
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [issues, setIssues] = useState<Issue[]>([]);
|
const [issues, setIssues] = useState<Issue[]>([]);
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
||||||
// Cargar issues solo cuando hay sesión (los endpoints exigen API key y
|
// Cargar issues del workspace activo (los endpoints exigen API key y
|
||||||
// los datos no tienen sentido anónimos).
|
// los datos no tienen sentido anónimos ni sin workspace).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user || !workspaceId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void api.listIssues().then((result) => {
|
void api.listIssues({ workspaceId }).then((result) => {
|
||||||
if (!cancelled) setIssues(result);
|
if (!cancelled) setIssues(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [user]);
|
}, [user, workspaceId]);
|
||||||
|
|
||||||
// Atajo global Cmd/Ctrl + K para abrir la palette.
|
// Atajo global Cmd/Ctrl + K para abrir la palette.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useWorkspace } from "@/lib/workspace";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
/** Contadores de issues por sección, para mostrar junto a cada enlace. */
|
/** 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) {
|
export function Sidebar({ counts, projects, open }: SidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { workspaces, workspaceId, select } = useWorkspace();
|
||||||
|
|
||||||
const mainLinks = [
|
const mainLinks = [
|
||||||
{ href: "/inbox", icon: "bx-inbox", label: "Bandeja", count: counts.inbox },
|
{ href: "/inbox", icon: "bx-inbox", label: "Bandeja", count: counts.inbox },
|
||||||
@@ -56,6 +58,50 @@ export function Sidebar({ counts, projects, open }: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="nav-list">
|
<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) => (
|
{mainLinks.map((link) => (
|
||||||
<li key={link.href}>
|
<li key={link.href}>
|
||||||
<Link href={link.href} className={isActive(link.href) ? "active" : ""}>
|
<Link href={link.href} className={isActive(link.href) ? "active" : ""}>
|
||||||
|
|||||||
+22
-1
@@ -1,7 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
Activity,
|
Activity,
|
||||||
|
ChatMessage,
|
||||||
Comment,
|
Comment,
|
||||||
|
ProjectInput,
|
||||||
Relation,
|
Relation,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMember,
|
||||||
Cycle,
|
Cycle,
|
||||||
ID,
|
ID,
|
||||||
Issue,
|
Issue,
|
||||||
@@ -29,7 +33,8 @@ export interface ProyectosApi {
|
|||||||
// Datos de referencia
|
// Datos de referencia
|
||||||
listUsers(): Promise<User[]>;
|
listUsers(): Promise<User[]>;
|
||||||
listLabels(): Promise<Label[]>;
|
listLabels(): Promise<Label[]>;
|
||||||
listProjects(): Promise<Project[]>;
|
listProjects(workspaceId?: ID): Promise<Project[]>;
|
||||||
|
createProject(input: ProjectInput): Promise<Project>;
|
||||||
listCycles(): Promise<Cycle[]>;
|
listCycles(): Promise<Cycle[]>;
|
||||||
/** Estados del workflow (Fase 1: configurables). */
|
/** Estados del workflow (Fase 1: configurables). */
|
||||||
listStates(includeInactive?: boolean): Promise<WorkflowState[]>;
|
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.
|
// Línea de tiempo (Fase 2 en sam): eventos de creación, cambios y comentarios.
|
||||||
listActivity(issueId: ID): Promise<Activity[]>;
|
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)
|
// Relaciones (Fase 3 en sam)
|
||||||
/** Hijos directos (sub-issues). */
|
/** Hijos directos (sub-issues). */
|
||||||
listChildren(issueId: ID): Promise<Issue[]>;
|
listChildren(issueId: ID): Promise<Issue[]>;
|
||||||
|
|||||||
+82
-1
@@ -1,7 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
Activity,
|
Activity,
|
||||||
|
ChatMessage,
|
||||||
Comment,
|
Comment,
|
||||||
|
ProjectInput,
|
||||||
Relation,
|
Relation,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMember,
|
||||||
Cycle,
|
Cycle,
|
||||||
ID,
|
ID,
|
||||||
Issue,
|
Issue,
|
||||||
@@ -84,6 +88,7 @@ function buildQuery(filters?: IssueFilters): string {
|
|||||||
};
|
};
|
||||||
|
|
||||||
append("status", filters.status);
|
append("status", filters.status);
|
||||||
|
if (filters.workspaceId) params.append("workspaceId", filters.workspaceId);
|
||||||
append("assigneeId", filters.assigneeId);
|
append("assigneeId", filters.assigneeId);
|
||||||
append("projectId", filters.projectId);
|
append("projectId", filters.projectId);
|
||||||
append("cycleId", filters.cycleId);
|
append("cycleId", filters.cycleId);
|
||||||
@@ -118,7 +123,16 @@ export const httpApi: ProyectosApi = {
|
|||||||
|
|
||||||
listLabels: () => request<Label[]>("/api/proyectos/labels"),
|
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"),
|
listCycles: () => request<Cycle[]>("/api/proyectos/cycles"),
|
||||||
|
|
||||||
@@ -168,6 +182,73 @@ export const httpApi: ProyectosApi = {
|
|||||||
|
|
||||||
listActivity: (issueId: ID) => request<Activity[]>(`/api/proyectos/issues/${issueId}/activity`),
|
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`),
|
listChildren: (issueId: ID) => request<Issue[]>(`/api/proyectos/issues/${issueId}/children`),
|
||||||
|
|
||||||
setParent: (issueId: ID, parentId: ID | null, actorId?: ID) =>
|
setParent: (issueId: ID, parentId: ID | null, actorId?: ID) =>
|
||||||
|
|||||||
+165
-2
@@ -1,7 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
Activity,
|
Activity,
|
||||||
|
ChatMessage,
|
||||||
Comment,
|
Comment,
|
||||||
|
ProjectInput,
|
||||||
Relation,
|
Relation,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMember,
|
||||||
Cycle,
|
Cycle,
|
||||||
ID,
|
ID,
|
||||||
Issue,
|
Issue,
|
||||||
@@ -30,6 +34,14 @@ const state = {
|
|||||||
activity: [] as Activity[],
|
activity: [] as Activity[],
|
||||||
relations: [] as Relation[],
|
relations: [] as Relation[],
|
||||||
subscriptions: [] as Array<{ issueId: ID; userId: ID }>,
|
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),
|
projects: structuredClone(seed.projects),
|
||||||
cycles: structuredClone(seed.cycles),
|
cycles: structuredClone(seed.cycles),
|
||||||
users: structuredClone(seed.users),
|
users: structuredClone(seed.users),
|
||||||
@@ -78,6 +90,7 @@ function buildIdentifier(project: Project | undefined, seq: number): string {
|
|||||||
function matchesFilters(issue: Issue, filters?: IssueFilters): boolean {
|
function matchesFilters(issue: Issue, filters?: IssueFilters): boolean {
|
||||||
if (!filters) return true;
|
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.status?.length && !filters.status.includes(issue.status)) return false;
|
||||||
if (filters.priority?.length && !filters.priority.includes(issue.priority)) return false;
|
if (filters.priority?.length && !filters.priority.includes(issue.priority)) return false;
|
||||||
if (filters.assigneeId?.length && !filters.assigneeId.includes(issue.assigneeId ?? ""))
|
if (filters.assigneeId?.length && !filters.assigneeId.includes(issue.assigneeId ?? ""))
|
||||||
@@ -137,9 +150,154 @@ export const mockApi: ProyectosApi = {
|
|||||||
return state.labels;
|
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);
|
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[]> {
|
async listCycles(): Promise<Cycle[]> {
|
||||||
@@ -222,6 +380,11 @@ export const mockApi: ProyectosApi = {
|
|||||||
description: input.description ?? "",
|
description: input.description ?? "",
|
||||||
status,
|
status,
|
||||||
parentId: null,
|
parentId: null,
|
||||||
|
workspaceId:
|
||||||
|
input.workspaceId ??
|
||||||
|
(input.projectId
|
||||||
|
? (state.projects.find((p) => p.id === input.projectId)?.workspaceId ?? "w1")
|
||||||
|
: "w1"),
|
||||||
needsTriage: true,
|
needsTriage: true,
|
||||||
estimate: input.estimate ?? null,
|
estimate: input.estimate ?? null,
|
||||||
priority: input.priority ?? 0,
|
priority: input.priority ?? 0,
|
||||||
|
|||||||
+23
-1
@@ -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.
|
* 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 },
|
{ 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[] = [
|
export const projects: Project[] = [
|
||||||
{
|
{
|
||||||
id: "p1",
|
id: "p1",
|
||||||
@@ -44,6 +55,7 @@ export const projects: Project[] = [
|
|||||||
leadId: "u1",
|
leadId: "u1",
|
||||||
startDate: "2026-09-01",
|
startDate: "2026-09-01",
|
||||||
targetDate: "2026-12-15",
|
targetDate: "2026-12-15",
|
||||||
|
workspaceId: "w1",
|
||||||
createdAt: "2026-08-20T10:00:00Z",
|
createdAt: "2026-08-20T10:00:00Z",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,6 +67,7 @@ export const projects: Project[] = [
|
|||||||
leadId: "u2",
|
leadId: "u2",
|
||||||
startDate: "2026-10-15",
|
startDate: "2026-10-15",
|
||||||
targetDate: "2027-02-01",
|
targetDate: "2027-02-01",
|
||||||
|
workspaceId: "w1",
|
||||||
createdAt: "2026-09-02T10:00:00Z",
|
createdAt: "2026-09-02T10:00:00Z",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,6 +79,7 @@ export const projects: Project[] = [
|
|||||||
leadId: "u3",
|
leadId: "u3",
|
||||||
startDate: "2026-05-01",
|
startDate: "2026-05-01",
|
||||||
targetDate: "2026-09-30",
|
targetDate: "2026-09-30",
|
||||||
|
workspaceId: "w1",
|
||||||
createdAt: "2026-04-10T10:00:00Z",
|
createdAt: "2026-04-10T10:00:00Z",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -114,6 +128,7 @@ export const issues: Issue[] = [
|
|||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
cycleId: "c3",
|
cycleId: "c3",
|
||||||
parentId: null,
|
parentId: null,
|
||||||
|
workspaceId: "w1",
|
||||||
needsTriage: false,
|
needsTriage: false,
|
||||||
estimate: 5,
|
estimate: 5,
|
||||||
labelIds: ["l2"],
|
labelIds: ["l2"],
|
||||||
@@ -133,6 +148,7 @@ export const issues: Issue[] = [
|
|||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
cycleId: "c3",
|
cycleId: "c3",
|
||||||
parentId: null,
|
parentId: null,
|
||||||
|
workspaceId: "w1",
|
||||||
needsTriage: false,
|
needsTriage: false,
|
||||||
estimate: 3,
|
estimate: 3,
|
||||||
labelIds: ["l1", "l4"],
|
labelIds: ["l1", "l4"],
|
||||||
@@ -152,6 +168,7 @@ export const issues: Issue[] = [
|
|||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
cycleId: "c3",
|
cycleId: "c3",
|
||||||
parentId: null,
|
parentId: null,
|
||||||
|
workspaceId: "w1",
|
||||||
needsTriage: false,
|
needsTriage: false,
|
||||||
estimate: 8,
|
estimate: 8,
|
||||||
labelIds: ["l3"],
|
labelIds: ["l3"],
|
||||||
@@ -171,6 +188,7 @@ export const issues: Issue[] = [
|
|||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
cycleId: "c3",
|
cycleId: "c3",
|
||||||
parentId: null,
|
parentId: null,
|
||||||
|
workspaceId: "w1",
|
||||||
needsTriage: false,
|
needsTriage: false,
|
||||||
estimate: null,
|
estimate: null,
|
||||||
labelIds: ["l2"],
|
labelIds: ["l2"],
|
||||||
@@ -190,6 +208,7 @@ export const issues: Issue[] = [
|
|||||||
projectId: "p2",
|
projectId: "p2",
|
||||||
cycleId: null,
|
cycleId: null,
|
||||||
parentId: null,
|
parentId: null,
|
||||||
|
workspaceId: "w1",
|
||||||
needsTriage: false,
|
needsTriage: false,
|
||||||
estimate: null,
|
estimate: null,
|
||||||
labelIds: ["l1", "l3"],
|
labelIds: ["l1", "l3"],
|
||||||
@@ -209,6 +228,7 @@ export const issues: Issue[] = [
|
|||||||
projectId: "p2",
|
projectId: "p2",
|
||||||
cycleId: "c4",
|
cycleId: "c4",
|
||||||
parentId: "i5",
|
parentId: "i5",
|
||||||
|
workspaceId: "w1",
|
||||||
needsTriage: false,
|
needsTriage: false,
|
||||||
estimate: 5,
|
estimate: 5,
|
||||||
labelIds: ["l2"],
|
labelIds: ["l2"],
|
||||||
@@ -228,6 +248,7 @@ export const issues: Issue[] = [
|
|||||||
projectId: "p3",
|
projectId: "p3",
|
||||||
cycleId: "c2",
|
cycleId: "c2",
|
||||||
parentId: null,
|
parentId: null,
|
||||||
|
workspaceId: "w1",
|
||||||
needsTriage: false,
|
needsTriage: false,
|
||||||
estimate: null,
|
estimate: null,
|
||||||
labelIds: ["l5"],
|
labelIds: ["l5"],
|
||||||
@@ -247,6 +268,7 @@ export const issues: Issue[] = [
|
|||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
cycleId: "c4",
|
cycleId: "c4",
|
||||||
parentId: null,
|
parentId: null,
|
||||||
|
workspaceId: "w1",
|
||||||
needsTriage: false,
|
needsTriage: false,
|
||||||
estimate: null,
|
estimate: null,
|
||||||
labelIds: ["l5"],
|
labelIds: ["l5"],
|
||||||
|
|||||||
@@ -59,8 +59,57 @@ export interface Label {
|
|||||||
color: string;
|
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 {
|
export interface Project {
|
||||||
id: ID;
|
id: ID;
|
||||||
|
workspaceId: ID;
|
||||||
name: string;
|
name: string;
|
||||||
/** Identificador corto usado como prefijo de los issues, ej. "PLAT". */
|
/** Identificador corto usado como prefijo de los issues, ej. "PLAT". */
|
||||||
key: string;
|
key: string;
|
||||||
@@ -120,6 +169,8 @@ export interface Issue {
|
|||||||
cycleId: ID | null;
|
cycleId: ID | null;
|
||||||
/** Id del issue padre (sub-issues, Fase 3 en sam). Null/ausente = raíz. */
|
/** Id del issue padre (sub-issues, Fase 3 en sam). Null/ausente = raíz. */
|
||||||
parentId: ID | null;
|
parentId: ID | null;
|
||||||
|
/** Workspace dueño (Fase 5 en sam). */
|
||||||
|
workspaceId: ID;
|
||||||
/** TRUE = pendiente de triage (Fase 4 en sam). */
|
/** TRUE = pendiente de triage (Fase 4 en sam). */
|
||||||
needsTriage: boolean;
|
needsTriage: boolean;
|
||||||
/** Puntos de historia, null = sin estimar (Fase 4 en sam). */
|
/** Puntos de historia, null = sin estimar (Fase 4 en sam). */
|
||||||
@@ -132,6 +183,7 @@ export interface Issue {
|
|||||||
|
|
||||||
/** Filtros soportados por `listIssues`. */
|
/** Filtros soportados por `listIssues`. */
|
||||||
export interface IssueFilters {
|
export interface IssueFilters {
|
||||||
|
workspaceId?: ID;
|
||||||
needsTriage?: boolean;
|
needsTriage?: boolean;
|
||||||
estimated?: boolean;
|
estimated?: boolean;
|
||||||
status?: IssueStatus[];
|
status?: IssueStatus[];
|
||||||
@@ -158,6 +210,7 @@ export interface IssueInput {
|
|||||||
projectId?: ID | null;
|
projectId?: ID | null;
|
||||||
cycleId?: ID | null;
|
cycleId?: ID | null;
|
||||||
parentId?: ID | null;
|
parentId?: ID | null;
|
||||||
|
workspaceId?: ID | null;
|
||||||
needsTriage?: boolean;
|
needsTriage?: boolean;
|
||||||
estimate?: number | null;
|
estimate?: number | null;
|
||||||
labelIds?: ID[];
|
labelIds?: ID[];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user