3cbf92191e
Check Cross-Plugin Imports / check (push) Has been cancelled
Backend: - Widget total: 0 bug fixed (now returns len(widgets)) - Widget update/delete: now verifies workspace_id + tenant_id (was only tenant_id) - Workspace context: returns all modules with is_visible flag (was only visible modules) - is_workspace_manager() removed (Plan 4.2: no manager checks) - seed_default_workspace: removed hardcoded modules (Plan 4.7: no hardcoded tiles) - Workspace permissions registered in CORE_PERMISSIONS (Plan 2.3) Frontend: - Permission fallback removed: Sidebar/TopBar show nothing while loading (Plan 2.4) - workspaceStore isModuleVisible: fail-closed when isSystemAdmin undefined - WorkspaceManager: AVAILABLE_MODULES replaced with dynamic core+plugin items (Plan 4.4) Tests: - 17 backend tests (removed is_workspace_manager test, adapted widget/context tests) - 13 frontend tests (added undefined-isSystemAdmin test, adapted visibility tests)
246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useWorkspaces, useCreateWorkspace, useUpdateWorkspace, useDeleteWorkspace, useSetWorkspaceModules, useAssignWorkspaceUser, useRemoveWorkspaceUser, type Workspace, type WorkspaceModule } from '@/api/hooks/workspaces';
|
|
import { usePluginStore } from '@/store/pluginStore';
|
|
import { LayoutGrid, Plus, Trash2, Edit, Users, Save, X, Check } from 'lucide-react';
|
|
|
|
// Static core menu items — same as Sidebar.tsx
|
|
const CORE_MENU_ITEMS = [
|
|
{ key: 'dashboard', label: 'Dashboard' },
|
|
{ key: 'contacts', label: 'Kontakte' },
|
|
];
|
|
|
|
export function WorkspaceManager() {
|
|
const { t } = useTranslation();
|
|
const { data: wsData } = useWorkspaces();
|
|
const createWs = useCreateWorkspace();
|
|
const updateWs = useUpdateWorkspace();
|
|
const deleteWs = useDeleteWorkspace();
|
|
const setModules = useSetWorkspaceModules();
|
|
const manifests = usePluginStore(s => s.manifests);
|
|
|
|
// Dynamically build available modules from core menu items + plugin manifests
|
|
const availableModules = useMemo(() => {
|
|
const pluginItems = manifests
|
|
.flatMap(m => m.menu_items || [])
|
|
.map(item => ({
|
|
key: item.path.replace(/^\//, '').split('/')[0],
|
|
label: item.label || item.label_key,
|
|
}));
|
|
// Merge core + plugin, deduplicate by key
|
|
const seen = new Set<string>();
|
|
return [...CORE_MENU_ITEMS, ...pluginItems].filter(m => {
|
|
if (seen.has(m.key)) return false;
|
|
seen.add(m.key);
|
|
return true;
|
|
});
|
|
}, [manifests]);
|
|
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editName, setEditName] = useState('');
|
|
const [editDesc, setEditDesc] = useState('');
|
|
const [editIcon, setEditIcon] = useState('LayoutGrid');
|
|
const [editDefault, setEditDefault] = useState(false);
|
|
|
|
const [moduleWsId, setModuleWsId] = useState<string | null>(null);
|
|
const [moduleConfig, setModuleConfig] = useState<WorkspaceModule[]>([]);
|
|
|
|
const workspaces = wsData?.items || [];
|
|
|
|
const handleCreate = async () => {
|
|
await createWs.mutateAsync({ name: editName, icon: editIcon, description: editDesc, is_default: editDefault });
|
|
setShowCreate(false);
|
|
setEditName(''); setEditDesc(''); setEditDefault(false);
|
|
};
|
|
|
|
const handleSaveEdit = async () => {
|
|
if (!editingId) return;
|
|
await updateWs.mutateAsync({ id: editingId, name: editName, description: editDesc, is_default: editDefault });
|
|
setEditingId(null);
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (confirm('Workspace wirklich löschen?')) {
|
|
await deleteWs.mutateAsync(id);
|
|
}
|
|
};
|
|
|
|
const openModuleEditor = (ws: Workspace) => {
|
|
setModuleWsId(ws.id);
|
|
const existing = ws.modules || [];
|
|
setModuleConfig(availableModules.map(m => {
|
|
const existingMod = existing.find(e => e.module_key === m.key);
|
|
return {
|
|
module_key: m.key,
|
|
is_visible: existingMod?.is_visible ?? false,
|
|
menu_order: existingMod?.menu_order ?? 0,
|
|
config: existingMod?.config || {},
|
|
};
|
|
}));
|
|
};
|
|
|
|
const saveModules = async () => {
|
|
if (!moduleWsId) return;
|
|
await setModules.mutateAsync({ workspaceId: moduleWsId, modules: moduleConfig });
|
|
setModuleWsId(null);
|
|
};
|
|
|
|
const toggleModule = (key: string) => {
|
|
setModuleConfig(prev => prev.map(m =>
|
|
m.module_key === key ? { ...m, is_visible: !m.is_visible } : m
|
|
));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold">{t('workspaces.title', 'Workspaces')}</h2>
|
|
<button
|
|
onClick={() => { setShowCreate(true); setEditName(''); setEditDesc(''); setEditDefault(false); }}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
{t('workspaces.create', 'Neuer Workspace')}
|
|
</button>
|
|
</div>
|
|
|
|
{showCreate && (
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3 bg-gray-50 dark:bg-gray-800/50">
|
|
<h3 className="font-medium">{t('workspaces.createNew', 'Neuen Workspace erstellen')}</h3>
|
|
<input
|
|
type="text"
|
|
placeholder="Name"
|
|
value={editName}
|
|
onChange={e => setEditName(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-sm"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Beschreibung"
|
|
value={editDesc}
|
|
onChange={e => setEditDesc(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-sm"
|
|
/>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} />
|
|
Als Standard-Workspace
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<button onClick={handleCreate} disabled={!editName} className="px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium disabled:opacity-50">
|
|
<Save className="w-4 h-4 inline mr-1" />
|
|
{t('common.save', 'Speichern')}
|
|
</button>
|
|
<button onClick={() => setShowCreate(false)} className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm">
|
|
{t('common.cancel', 'Abbrechen')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{moduleWsId && (
|
|
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-4 space-y-3 bg-blue-50 dark:bg-blue-900/20">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="font-medium">{t('workspaces.moduleConfig', 'Module konfigurieren')}</h3>
|
|
<div className="flex gap-2">
|
|
<button onClick={saveModules} className="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium">
|
|
<Save className="w-4 h-4 inline mr-1" />
|
|
{t('common.save', 'Speichern')}
|
|
</button>
|
|
<button onClick={() => setModuleWsId(null)} className="px-3 py-1.5 border rounded-md text-sm">
|
|
<X className="w-4 h-4 inline" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
{moduleConfig.map(m => {
|
|
const mod = availableModules.find(a => a.key === m.module_key);
|
|
return (
|
|
<label key={m.module_key} className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-md cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<input
|
|
type="checkbox"
|
|
checked={m.is_visible}
|
|
onChange={() => toggleModule(m.module_key)}
|
|
/>
|
|
<span className="text-sm">{mod?.label || m.module_key}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
{workspaces.length === 0 && (
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 py-4 text-center">
|
|
{t('workspaces.empty', 'Keine Workspaces vorhanden. Erstellen Sie einen neuen Workspace.')}
|
|
</p>
|
|
)}
|
|
{workspaces.map(ws => (
|
|
<div key={ws.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
{editingId === ws.id ? (
|
|
<div className="space-y-2">
|
|
<input type="text" value={editName} onChange={e => setEditName(e.target.value)} className="w-full px-3 py-2 border rounded-md text-sm" />
|
|
<input type="text" value={editDesc} onChange={e => setEditDesc(e.target.value)} className="w-full px-3 py-2 border rounded-md text-sm" placeholder="Beschreibung" />
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} />
|
|
Standard
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<button onClick={handleSaveEdit} className="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm">
|
|
<Check className="w-4 h-4 inline mr-1" />
|
|
Speichern
|
|
</button>
|
|
<button onClick={() => setEditingId(null)} className="px-3 py-1.5 border rounded-md text-sm">
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-start gap-3">
|
|
<LayoutGrid className="w-5 h-5 text-gray-400 mt-0.5" />
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{ws.name}</span>
|
|
{ws.is_default && <span className="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">Standard</span>}
|
|
</div>
|
|
{ws.description && <p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{ws.description}</p>}
|
|
<div className="flex items-center gap-3 mt-1 text-xs text-gray-400">
|
|
<span className="flex items-center gap-1"><Users className="w-3 h-3" /> {ws.user_count || 0} User</span>
|
|
<span>{ws.modules?.length || 0} Module</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => openModuleEditor(ws)}
|
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md text-sm"
|
|
title="Module"
|
|
>
|
|
<LayoutGrid className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => { setEditingId(ws.id); setEditName(ws.name); setEditDesc(ws.description || ''); setEditDefault(ws.is_default); }}
|
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md text-sm"
|
|
title="Bearbeiten"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(ws.id)}
|
|
className="p-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md text-sm text-red-600"
|
|
title="Löschen"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|