/** * Dashboard Page - 3-column layout with project folder tree, project cards, and info panel */ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useAuth } from '../contexts/AuthContext'; import { NotificationPanel } from '../components/NotificationPanel'; import { ShareDialog } from '../components/ShareDialog'; import SettingsModal from '../components/SettingsModal'; import { getProjects, deleteProject, updateProject, getProjectFolders, createProjectFolder, renameProjectFolder, deleteProjectFolder, moveProjectToFolder, moveProjectFolder, getProjectShares, getUsers, createProjectShare, getDrawings, getElements, type Project, type ProjectFolder, type ProjectShare, type SimpleUser, } from '../services/api'; import type { CADElement } from '../types/cad.types'; const API_BASE = import.meta.env.VITE_API_BASE || ''; const UNFILED_ID = '__unfiled__'; interface DashboardProps { onOpenProject: (projectId: string) => void; } /** Render a real SVG preview of a project's first drawing elements */ function CanvasPreview({ project, token }: { project: Project; token: string }) { const [elements, setElements] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; async function loadPreview() { try { const drawings = await getDrawings(token, project.id); if (drawings.length === 0) { if (!cancelled) setElements([]); return; } const rawEls = await getElements(token, drawings[0].id); // API may return DBElement with properties_json (string) instead of properties (object) const transformed: CADElement[] = rawEls.map((el: any) => ({ id: el.id, type: el.type, layerId: el.layer_id ?? el.layerId ?? '', x: el.x ?? 0, y: el.y ?? 0, width: el.width ?? 0, height: el.height ?? 0, properties: typeof el.properties_json === 'string' ? JSON.parse(el.properties_json || '{}') : (el.properties ?? {}), })); if (!cancelled) setElements(transformed); } catch { if (!cancelled) setElements([]); } finally { if (!cancelled) setLoading(false); } } loadPreview(); return () => { cancelled = true; }; }, [token, project.id]); // Compute bounding box of all elements for viewBox scaling const bbox = useMemo(() => { if (elements.length === 0) return null; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const el of elements) { minX = Math.min(minX, el.x); minY = Math.min(minY, el.y); maxX = Math.max(maxX, el.x + el.width); maxY = Math.max(maxY, el.y + el.height); } return { minX, minY, w: Math.max(maxX - minX, 10), h: Math.max(maxY - minY, 10) }; }, [elements]); const svgElements = useMemo(() => { if (!bbox || elements.length === 0) return []; return elements.slice(0, 50).map((el) => { const stroke = el.properties.stroke || '#60a5fa'; const fill = el.properties.fill || 'none'; const sw = Math.max((el.properties.strokeWidth || 1), 0.5); const x = el.x - bbox.minX; const y = el.y - bbox.minY; const w = el.width; const h = el.height; switch (el.type) { case 'rect': case 'table': case 'stage': return ; case 'circle': return ; case 'line': case 'leader': return ; case 'text': return {el.properties.text || ''}; case 'polygon': case 'polyline': if (el.properties.points && el.properties.points.length > 1) { const pts = el.properties.points.map(p => `${p.x - bbox.minX},${p.y - bbox.minY}`).join(' '); return ; } return null; case 'chair': return ; case 'arc': return ; default: return ; } }); }, [elements, bbox]); return (
{loading ? (
...
) : elements.length === 0 || !bbox ? ( Leere Zeichnung ) : ( {svgElements} )}
); } export function Dashboard({ onOpenProject }: DashboardProps) { const { user, logout, token } = useAuth(); const [projects, setProjects] = useState([]); const [allProjects, setAllProjects] = useState([]); const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); const [newName, setNewName] = useState(''); const [newDesc, setNewDesc] = useState(''); const [shareProjectId, setShareProjectId] = useState(null); const [selectedFolderId, setSelectedFolderId] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState(null); const [shares, setShares] = useState([]); const [sharesLoading, setSharesLoading] = useState(false); const [draggedProjectId, setDraggedProjectId] = useState(null); const [draggedFolderId, setDraggedFolderId] = useState(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; folderId: string | null } | null>(null); const [renamingFolderId, setRenamingFolderId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [showCreateFolder, setShowCreateFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [newFolderParent, setNewFolderParent] = useState(null); const [editingProjectName, setEditingProjectName] = useState(''); const [editingProjectDesc, setEditingProjectDesc] = useState(''); const [savingProject, setSavingProject] = useState(false); const [dragOverFolderId, setDragOverFolderId] = useState(null); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [mobileTreeOpen, setMobileTreeOpen] = useState(false); const [mobileInfoOpen, setMobileInfoOpen] = useState(false); const [showSettings, setShowSettings] = useState(false); const [users, setUsers] = useState([]); const [usersLoading, setUsersLoading] = useState(false); const fetchAll = useCallback(async () => { if (!token) return; setLoading(true); try { const [projs, fldrs] = await Promise.all([ getProjects(token), getProjectFolders(token), ]); setAllProjects(projs); setFolders(fldrs); } catch { // ignore } finally { setLoading(false); } }, [token]); useEffect(() => { fetchAll(); }, [fetchAll]); // Default: expand all folders on first load or when folders change useEffect(() => { if (folders.length > 0 && expandedFolders.size === 0) { setExpandedFolders(new Set(folders.map(f => f.id))); } }, [folders, expandedFolders.size]); useEffect(() => { if (selectedFolderId === null) { setProjects(allProjects); } else if (selectedFolderId === UNFILED_ID) { setProjects(allProjects.filter(p => p.folder_id === null)); } else { setProjects(allProjects.filter(p => p.folder_id === selectedFolderId)); } }, [allProjects, selectedFolderId]); useEffect(() => { if (!token || !selectedProjectId) { setShares([]); return; } setSharesLoading(true); getProjectShares(token, selectedProjectId) .then(s => setShares(s)) .catch(() => setShares([])) .finally(() => setSharesLoading(false)); }, [token, selectedProjectId]); // Load users for permission selector (admin only) useEffect(() => { if (!token || user?.role !== 'admin') return; setUsersLoading(true); getUsers(token) .then(u => setUsers(u)) .catch(() => setUsers([])) .finally(() => setUsersLoading(false)); }, [token, user?.role]); const selectedProject = useMemo( () => allProjects.find(p => p.id === selectedProjectId) ?? null, [allProjects, selectedProjectId], ); useEffect(() => { if (selectedProject) { setEditingProjectName(selectedProject.name); setEditingProjectDesc(selectedProject.description ?? ''); } }, [selectedProject]); const folderNameById = useMemo(() => { const map = new Map(); map.set(UNFILED_ID, 'Ohne Ordner'); for (const f of folders) map.set(f.id, f.name); return map; }, [folders]); const currentFolderName = useMemo(() => { if (selectedFolderId === null) return 'Alle Projekte'; return folderNameById.get(selectedFolderId) ?? 'Projekte'; }, [selectedFolderId, folderNameById]); const toggleFolder = (id: string) => { setExpandedFolders(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const handleCreate = async () => { if (!newName.trim() || !token) return; try { const folderId = selectedFolderId === null || selectedFolderId === UNFILED_ID ? null : selectedFolderId; const res = await fetch(`${API_BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: newName, description: newDesc || null, folder_id: folderId }), }); if (res.ok) { setNewName(''); setNewDesc(''); setShowCreate(false); fetchAll(); } } catch { // ignore } }; const handleDelete = async (id: string, e: React.MouseEvent) => { e.stopPropagation(); if (!confirm('Projekt wirklich löschen?')) return; if (!token) return; try { await deleteProject(token, id); if (selectedProjectId === id) setSelectedProjectId(null); fetchAll(); } catch { // ignore } }; const handleFolderSelect = (id: string | null) => { setSelectedFolderId(id); }; const handleCreateFolder = async () => { if (!newFolderName.trim() || !token) return; const parentId = newFolderParent === UNFILED_ID ? null : newFolderParent; try { await createProjectFolder(token, newFolderName, parentId); setNewFolderName(''); setShowCreateFolder(false); setNewFolderParent(null); fetchAll(); } catch { // ignore } }; const handleRenameFolder = async (id: string) => { if (!renameValue.trim() || !token) return; try { await renameProjectFolder(token, id, renameValue); setRenamingFolderId(null); setRenameValue(''); fetchAll(); } catch { // ignore } }; const handleDeleteFolder = async (id: string) => { if (!token) return; if (!confirm('Ordner löschen? Projekte darin werden nach "Ohne Ordner" verschoben.')) return; try { await deleteProjectFolder(token, id); if (selectedFolderId === id) setSelectedFolderId(null); fetchAll(); } catch { // ignore } }; const handleProjectCardClick = (projectId: string) => { setSelectedProjectId(projectId); setMobileInfoOpen(true); onOpenProject(projectId); }; const handleSaveProject = async () => { if (!selectedProject || !token) return; if (!editingProjectName.trim()) return; setSavingProject(true); try { await updateProject(token, selectedProject.id, { name: editingProjectName.trim(), description: editingProjectDesc.trim() || null, }); fetchAll(); } catch { // ignore } finally { setSavingProject(false); } }; const handleProjectDragStart = (e: React.DragEvent, projectId: string) => { setDraggedProjectId(projectId); setDraggedFolderId(null); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', `project:${projectId}`); }; const handleFolderDragStart = (e: React.DragEvent, folderId: string) => { setDraggedFolderId(folderId); setDraggedProjectId(null); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', `folder:${folderId}`); }; const handleFolderDragOver = (e: React.DragEvent, folderId: string | null) => { if (!draggedProjectId && !draggedFolderId) return; // Prevent dropping a folder into itself or its descendants if (draggedFolderId) { if (folderId === draggedFolderId) return; let current: string | null = folderId; while (current) { if (current === draggedFolderId) return; // target is a descendant of dragged const f = folders.find(fld => fld.id === current); current = f?.parent_id ?? null; } } e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverFolderId(folderId); }; const handleFolderDragLeave = (folderId: string | null) => { if (dragOverFolderId === folderId) { setDragOverFolderId(null); } }; const handleFolderDrop = async (e: React.DragEvent, folderId: string | null) => { e.preventDefault(); e.stopPropagation(); const targetFolderId = folderId === UNFILED_ID ? null : folderId; if (draggedProjectId && token) { try { await moveProjectToFolder(token, draggedProjectId, targetFolderId); fetchAll(); } catch { // ignore } finally { setDraggedProjectId(null); setDragOverFolderId(null); } } else if (draggedFolderId && token) { // Move folder into target folder (or to root if null) const folder = folders.find(f => f.id === draggedFolderId); if (folder && folder.id !== targetFolderId && folder.parent_id !== targetFolderId) { try { await moveProjectFolder(token, draggedFolderId, targetFolderId, folder.name); // Auto-expand target if it has children now if (targetFolderId) { setExpandedFolders(prev => new Set([...prev, targetFolderId])); } fetchAll(); } catch { // ignore } finally { setDraggedFolderId(null); setDragOverFolderId(null); } } else { setDraggedFolderId(null); setDragOverFolderId(null); } } }; const handleContextMenu = (e: React.MouseEvent, folderId: string | null) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, folderId }); }; const handleContextAction = (action: string, folderId: string | null) => { setContextMenu(null); if (action === 'new') { setNewFolderParent(folderId); setShowCreateFolder(true); } else if (action === 'newSubfolder' && folderId) { setNewFolderParent(folderId); setShowCreateFolder(true); } else if (action === 'rename' && folderId) { const folder = folders.find(f => f.id === folderId); if (folder) { setRenamingFolderId(folderId); setRenameValue(folder.name); } } else if (action === 'delete' && folderId) { handleDeleteFolder(folderId); } }; const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', }); }; const folderIcon = ( ); const renderFolderItem = (folder: ProjectFolder, depth: number): React.ReactNode => { const children = folders.filter(f => f.parent_id === folder.id); const isExpanded = expandedFolders.has(folder.id); const projectCount = allProjects.filter(p => p.folder_id === folder.id).length; const isSelected = selectedFolderId === folder.id; const isDragOver = dragOverFolderId === folder.id; const isRenaming = renamingFolderId === folder.id; const isDragging = draggedFolderId === folder.id; return (
handleFolderDragStart(e, folder.id)} onDragOver={(e) => handleFolderDragOver(e, folder.id)} onDragLeave={() => handleFolderDragLeave(folder.id)} onDrop={(e) => handleFolderDrop(e, folder.id)} onClick={() => handleFolderSelect(folder.id)} onContextMenu={(e) => handleContextMenu(e, folder.id)} style={{ paddingLeft: `${depth * 16 + 8}px` }} > {children.length > 0 ? ( ) : ( )} {folderIcon} {isRenaming ? ( setRenameValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleRenameFolder(folder.id); if (e.key === 'Escape') { setRenamingFolderId(null); setRenameValue(''); } }} onBlur={() => { if (renameValue.trim()) handleRenameFolder(folder.id); else { setRenamingFolderId(null); setRenameValue(''); } }} onClick={e => e.stopPropagation()} autoFocus /> ) : ( {folder.name} )} {projectCount > 0 && ({projectCount})} {!isRenaming && ( )}
{isExpanded && children.map(child => renderFolderItem(child, depth + 1))}
); }; // Shared users from shares data const sharedUserEmails = useMemo(() => shares.map(s => s.shared_with_email), [shares]); return (

Web CAD

{user?.name} ({user?.role})
{token && }
{(mobileTreeOpen || mobileInfoOpen) && (
{ setMobileTreeOpen(false); setMobileInfoOpen(false); }} /> )}
handleContextMenu(e, null)}>

Ordner

{showCreateFolder && (
setNewFolderName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreateFolder(); if (e.key === 'Escape') setShowCreateFolder(false); }} autoFocus />
)} {/* Root: Alle Projekte */}
handleFolderSelect(null)} onDragOver={(e) => handleFolderDragOver(e, null)} onDragLeave={() => handleFolderDragLeave(null)} onDrop={(e) => handleFolderDrop(e, null)} onContextMenu={(e) => handleContextMenu(e, null)} style={{ paddingLeft: '8px' }} > {folderIcon} Alle Projekte ({allProjects.length})
{/* Ohne Ordner node */}
handleFolderSelect(UNFILED_ID)} onDragOver={(e) => handleFolderDragOver(e, UNFILED_ID)} onDragLeave={() => handleFolderDragLeave(UNFILED_ID)} onDrop={(e) => handleFolderDrop(e, UNFILED_ID)} onContextMenu={(e) => handleContextMenu(e, null)} style={{ paddingLeft: '24px' }} > {folderIcon} Ohne Ordner ({allProjects.filter(p => p.folder_id === null).length})
{/* Top-level folders rendered recursively */} {folders.filter(f => !f.parent_id).map(folder => renderFolderItem(folder, 1))}

{currentFolderName}

{showCreate && (
setNewName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }} autoFocus /> setNewDesc(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }} />
)} {loading ? (

Lade Projekte...

) : projects.length === 0 ? (

Keine Projekte in dieser Ansicht. Erstellen Sie ein neues Projekt.

) : (
{projects.map(project => (
handleProjectCardClick(project.id)} draggable onDragStart={(e) => handleProjectDragStart(e, project.id)} onDragEnd={() => { setDraggedProjectId(null); setDragOverFolderId(null); }} >

{project.name}

{project.description &&

{project.description}

}
{new Date(project.created_at).toLocaleDateString('de-DE')}
))}
)}
{selectedProject ? ( <> setEditingProjectName(e.target.value)} placeholder="Projektname" />