phase5: workspace frontend — API hooks, useWorkspace hook, WorkspaceSwitcher, Sidebar workspace filter

This commit is contained in:
Agent Zero
2026-07-29 22:12:23 +02:00
parent bd50a85483
commit fca7191269
5 changed files with 276 additions and 2 deletions
+134
View File
@@ -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<string, any>;
}
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<string, any> }[];
widgets: { id: string; widget_key: string; position_x: number; position_y: number; width: number; height: number; config: Record<string, any> }[];
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<WorkspaceContext>({
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'] });
},
});
}
+11 -2
View File
@@ -8,6 +8,7 @@ import { usePluginStore } from '@/store/pluginStore';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import { useMenuOrder } from '@/api/users'; import { useMenuOrder } from '@/api/users';
import { usePermission } from '@/hooks/usePermission'; import { usePermission } from '@/hooks/usePermission';
import { useWorkspace } from '@/hooks/useWorkspace';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
interface NavSingleItem { interface NavSingleItem {
@@ -44,6 +45,7 @@ export function Sidebar() {
const { data: menuOrderData } = useMenuOrder(); const { data: menuOrderData } = useMenuOrder();
const { hasPermission } = usePermission(); const { hasPermission } = usePermission();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const { isModuleVisible } = useWorkspace();
// Use hasPermission directly — permissions are loaded via useUserPermissions hook // Use hasPermission directly — permissions are loaded via useUserPermissions hook
const canAccess = (perm?: string): boolean => { const canAccess = (perm?: string): boolean => {
@@ -78,12 +80,19 @@ export function Sidebar() {
})); }));
const allItems = [...staticItems, ...pluginItems]; 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 // Apply saved menu order if available
const savedOrder = menuOrderData?.menu_order; const savedOrder = menuOrderData?.menu_order;
if (savedOrder && savedOrder.length > 0) { if (savedOrder && savedOrder.length > 0) {
const orderMap = new Map(savedOrder.map((id, idx) => [id, idx])); 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 / // Map path to menu ID: static items use their path without leading /
const aId = a.path.startsWith('/') ? a.path.slice(1) : a.path; const aId = a.path.startsWith('/') ? a.path.slice(1) : a.path;
const bId = b.path.startsWith('/') ? b.path.slice(1) : b.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; if (a.order !== b.order) return a.order - b.order;
return a.label.localeCompare(b.label); return a.label.localeCompare(b.label);
}); });
@@ -10,6 +10,7 @@ import { SearchDropdown } from '@/components/shared/SearchDropdown';
import { SuggestionBadge } from '@/components/ai/SuggestionBadge'; import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code } from 'lucide-react'; import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code } from 'lucide-react';
import { NotificationBell } from '@/components/layout/NotificationBell'; import { NotificationBell } from '@/components/layout/NotificationBell';
import { WorkspaceSwitcher } from '@/components/layout/WorkspaceSwitcher';
import { useWindowStore } from '@/store/windowStore'; import { useWindowStore } from '@/store/windowStore';
import { usePermission } from '@/hooks/usePermission'; import { usePermission } from '@/hooks/usePermission';
@@ -87,6 +88,7 @@ export function TopBar() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<WorkspaceSwitcher />
<NotificationBell /> <NotificationBell />
{/* Minimized windows */} {/* Minimized windows */}
{minimizedWindows.length > 0 && ( {minimizedWindows.length > 0 && (
@@ -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<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 transition-colors"
title={activeWs?.description || activeWs?.name || t('workspaces.select')}
>
<LayoutGrid className="w-4 h-4" />
<span className="max-w-[120px] truncate">{activeWs?.name || t('workspaces.select')}</span>
<ChevronDown className="w-3.5 h-3.5 opacity-60" />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-64 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 py-1">
<div className="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{t('workspaces.myWorkspaces')}
</div>
{myWorkspaces.map(ws => (
<button
key={ws.id}
onClick={() => {
switchWorkspace(ws.id);
setOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 transition-colors"
>
<span className="flex-1 text-left">
<span className="block font-medium">{ws.name}</span>
{ws.description && (
<span className="block text-xs text-gray-500 dark:text-gray-400 truncate">{ws.description}</span>
)}
</span>
{ws.id === activeWorkspaceId && <Check className="w-4 h-4 text-blue-500" />}
</button>
))}
</div>
)}
</div>
);
}
+64
View File
@@ -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<string | null>(() => {
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<string> = 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,
};
}