From fca7191269eea47cc31dba52ec020c0432bc3ddd Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 29 Jul 2026 22:12:23 +0200 Subject: [PATCH] =?UTF-8?q?phase5:=20workspace=20frontend=20=E2=80=94=20AP?= =?UTF-8?q?I=20hooks,=20useWorkspace=20hook,=20WorkspaceSwitcher,=20Sideba?= =?UTF-8?q?r=20workspace=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/hooks/workspaces.ts | 134 ++++++++++++++++++ frontend/src/components/layout/Sidebar.tsx | 13 +- frontend/src/components/layout/TopBar.tsx | 2 + .../components/layout/WorkspaceSwitcher.tsx | 65 +++++++++ frontend/src/hooks/useWorkspace.ts | 64 +++++++++ 5 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 frontend/src/api/hooks/workspaces.ts create mode 100644 frontend/src/components/layout/WorkspaceSwitcher.tsx create mode 100644 frontend/src/hooks/useWorkspace.ts diff --git a/frontend/src/api/hooks/workspaces.ts b/frontend/src/api/hooks/workspaces.ts new file mode 100644 index 0000000..70339cc --- /dev/null +++ b/frontend/src/api/hooks/workspaces.ts @@ -0,0 +1,134 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiPut, apiDelete } from '@/api/client'; + +export interface WorkspaceModule { + id?: string; + module_key: string; + is_visible: boolean; + menu_order: number; + config: Record; +} + +export interface Workspace { + id: string; + name: string; + icon: string; + description: string | null; + is_default: boolean; + is_active: boolean; + created_by: string | null; + created_at: string | null; + updated_at: string | null; + modules?: WorkspaceModule[]; + user_count?: number; +} + +export interface MyWorkspace extends Workspace { + role: 'member' | 'manager'; + is_user_default: boolean; +} + +export interface WorkspaceContext { + workspace_id: string | null; + name?: string; + icon?: string; + role?: string; + modules: { module_key: string; menu_order: number; config: Record }[]; + widgets: { id: string; widget_key: string; position_x: number; position_y: number; width: number; height: number; config: Record }[]; + error?: string; +} + +export function useWorkspaces() { + return useQuery<{ items: Workspace[]; total: number }>({ + queryKey: ['workspaces'], + queryFn: () => apiGet('/api/v1/workspaces'), + }); +} + +export function useMyWorkspaces() { + return useQuery<{ items: MyWorkspace[]; total: number }>({ + queryKey: ['my-workspaces'], + queryFn: () => apiGet('/api/v1/workspaces/my'), + }); +} + +export function useWorkspaceContext(workspaceId: string | null) { + return useQuery({ + queryKey: ['workspace-context', workspaceId], + queryFn: () => apiGet('/api/v1/workspaces/context', { + headers: workspaceId ? { 'X-Workspace-ID': workspaceId } : {}, + }), + enabled: !!workspaceId, + }); +} + +export function useCreateWorkspace() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string; icon?: string; description?: string; is_default?: boolean }) => + apiPost('/api/v1/workspaces', data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workspaces'] }); + qc.invalidateQueries({ queryKey: ['my-workspaces'] }); + }, + }); +} + +export function useUpdateWorkspace() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: string; name?: string; icon?: string; description?: string; is_default?: boolean; is_active?: boolean }) => + apiPut(`/api/v1/workspaces/${id}`, data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workspaces'] }); + qc.invalidateQueries({ queryKey: ['my-workspaces'] }); + }, + }); +} + +export function useDeleteWorkspace() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiDelete(`/api/v1/workspaces/${id}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workspaces'] }); + qc.invalidateQueries({ queryKey: ['my-workspaces'] }); + }, + }); +} + +export function useSetWorkspaceModules() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ workspaceId, modules }: { workspaceId: string; modules: WorkspaceModule[] }) => + apiPost(`/api/v1/workspaces/${workspaceId}/modules`, { modules }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workspaces'] }); + qc.invalidateQueries({ queryKey: ['my-workspaces'] }); + }, + }); +} + +export function useAssignWorkspaceUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ workspaceId, userId, role }: { workspaceId: string; userId: string; role?: string }) => + apiPost(`/api/v1/workspaces/${workspaceId}/users`, { user_id: userId, role: role || 'member' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workspaces'] }); + qc.invalidateQueries({ queryKey: ['my-workspaces'] }); + }, + }); +} + +export function useRemoveWorkspaceUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ workspaceId, userId }: { workspaceId: string; userId: string }) => + apiDelete(`/api/v1/workspaces/${workspaceId}/users/${userId}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workspaces'] }); + qc.invalidateQueries({ queryKey: ['my-workspaces'] }); + }, + }); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9e2d407..d32a38d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -8,6 +8,7 @@ import { usePluginStore } from '@/store/pluginStore'; import * as LucideIcons from 'lucide-react'; import { useMenuOrder } from '@/api/users'; import { usePermission } from '@/hooks/usePermission'; +import { useWorkspace } from '@/hooks/useWorkspace'; import { useAuthStore } from '@/store/authStore'; interface NavSingleItem { @@ -44,6 +45,7 @@ export function Sidebar() { const { data: menuOrderData } = useMenuOrder(); const { hasPermission } = usePermission(); const user = useAuthStore((state) => state.user); + const { isModuleVisible } = useWorkspace(); // Use hasPermission directly — permissions are loaded via useUserPermissions hook const canAccess = (perm?: string): boolean => { @@ -78,12 +80,19 @@ export function Sidebar() { })); const allItems = [...staticItems, ...pluginItems]; + + // Filter by workspace visibility (in addition to permissions) + // If no workspace is active, isModuleVisible returns true (backward compatible) + const workspaceFiltered = allItems.filter(item => { + const moduleKey = item.path.replace(/^\//, '').split('/')[0]; + return isModuleVisible(moduleKey); + }); // Apply saved menu order if available const savedOrder = menuOrderData?.menu_order; if (savedOrder && savedOrder.length > 0) { const orderMap = new Map(savedOrder.map((id, idx) => [id, idx])); - return allItems.sort((a, b) => { + return workspaceFiltered.sort((a, b) => { // Map path to menu ID: static items use their path without leading / const aId = a.path.startsWith('/') ? a.path.slice(1) : a.path; const bId = b.path.startsWith('/') ? b.path.slice(1) : b.path; @@ -98,7 +107,7 @@ export function Sidebar() { }); } - return allItems.sort((a, b) => { + return workspaceFiltered.sort((a, b) => { if (a.order !== b.order) return a.order - b.order; return a.label.localeCompare(b.label); }); diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 30d73e9..1c209c4 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -10,6 +10,7 @@ import { SearchDropdown } from '@/components/shared/SearchDropdown'; import { SuggestionBadge } from '@/components/ai/SuggestionBadge'; import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code } from 'lucide-react'; import { NotificationBell } from '@/components/layout/NotificationBell'; +import { WorkspaceSwitcher } from '@/components/layout/WorkspaceSwitcher'; import { useWindowStore } from '@/store/windowStore'; import { usePermission } from '@/hooks/usePermission'; @@ -87,6 +88,7 @@ export function TopBar() {
+ {/* Minimized windows */} {minimizedWindows.length > 0 && ( diff --git a/frontend/src/components/layout/WorkspaceSwitcher.tsx b/frontend/src/components/layout/WorkspaceSwitcher.tsx new file mode 100644 index 0000000..ab2ae62 --- /dev/null +++ b/frontend/src/components/layout/WorkspaceSwitcher.tsx @@ -0,0 +1,65 @@ +import { useState, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useWorkspace } from '@/hooks/useWorkspace'; +import { LayoutGrid, ChevronDown, Check } from 'lucide-react'; + +export function WorkspaceSwitcher() { + const { t } = useTranslation(); + const { myWorkspaces, activeWorkspaceId, switchWorkspace, hasWorkspaces } = useWorkspace(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + if (!hasWorkspaces) return null; + + const activeWs = myWorkspaces.find(w => w.id === activeWorkspaceId); + + return ( +
+ + + {open && ( +
+
+ {t('workspaces.myWorkspaces')} +
+ {myWorkspaces.map(ws => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useWorkspace.ts b/frontend/src/hooks/useWorkspace.ts new file mode 100644 index 0000000..a45d657 --- /dev/null +++ b/frontend/src/hooks/useWorkspace.ts @@ -0,0 +1,64 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useMyWorkspaces, useWorkspaceContext, type WorkspaceContext } from '@/api/hooks/workspaces'; + +const STORAGE_KEY = 'leocrm-active-workspace'; + +/** + * Manages the active workspace for the current browser tab. + * + * Uses sessionStorage (per-tab, not shared across tabs) to store the active workspace ID. + * Sends X-Workspace-ID header on workspace-aware requests. + * + * Workspaces are UI/navigation context only — they never affect permissions. + */ +export function useWorkspace() { + const [activeWorkspaceId, setActiveWorkspaceId] = useState(() => { + return sessionStorage.getItem(STORAGE_KEY); + }); + + const { data: myWorkspaces } = useMyWorkspaces(); + const { data: context } = useWorkspaceContext(activeWorkspaceId); + + // Auto-select default workspace if none selected + useEffect(() => { + if (!activeWorkspaceId && myWorkspaces?.items?.length) { + const defaultWs = myWorkspaces.items.find(w => w.is_user_default) || myWorkspaces.items[0]; + if (defaultWs) { + setActiveWorkspaceId(defaultWs.id); + sessionStorage.setItem(STORAGE_KEY, defaultWs.id); + } + } + }, [activeWorkspaceId, myWorkspaces]); + + // Switch workspace (per-tab) + const switchWorkspace = useCallback((workspaceId: string | null) => { + if (workspaceId) { + sessionStorage.setItem(STORAGE_KEY, workspaceId); + } else { + sessionStorage.removeItem(STORAGE_KEY); + } + setActiveWorkspaceId(workspaceId); + }, []); + + // Get visible module keys from workspace context + const visibleModuleKeys: Set = new Set( + context?.modules?.map(m => m.module_key) || [] + ); + + // Check if a module is visible in the current workspace + const isModuleVisible = useCallback((moduleKey: string): boolean => { + // If no workspace context, show all (backward compatible) + if (!context?.workspace_id || !context?.modules?.length) return true; + return visibleModuleKeys.has(moduleKey); + }, [context, visibleModuleKeys]); + + return { + activeWorkspaceId, + activeWorkspace: context, + myWorkspaces: myWorkspaces?.items || [], + switchWorkspace, + isModuleVisible, + visibleModuleKeys, + hasWorkspaces: (myWorkspaces?.items?.length || 0) > 0, + }; +}