phase5: workspace frontend — API hooks, useWorkspace hook, WorkspaceSwitcher, Sidebar workspace filter
This commit is contained in:
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user