Files
web-cad/frontend/src/pages/Dashboard.tsx
T

544 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 TreeView from '../components/TreeView';
import type { TreeNode } from '../types/ui.types';
import {
getProjects, deleteProject,
getProjectFolders, createProjectFolder, renameProjectFolder, deleteProjectFolder,
moveProjectToFolder, getProjectShares,
type Project, type ProjectFolder, type ProjectShare,
} from '../services/api';
const API_BASE = import.meta.env.VITE_API_BASE || '';
const ROOT_FOLDER_ID = '__root__';
interface DashboardProps {
onOpenProject: (projectId: string) => void;
}
export function Dashboard({ onOpenProject }: DashboardProps) {
const { user, logout, token } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [allProjects, setAllProjects] = useState<Project[]>([]);
const [folders, setFolders] = useState<ProjectFolder[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [shareProjectId, setShareProjectId] = useState<string | null>(null);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(ROOT_FOLDER_ID);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [shares, setShares] = useState<ProjectShare[]>([]);
const [sharesLoading, setSharesLoading] = useState(false);
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; folderId: string | null } | null>(null);
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [showCreateFolder, setShowCreateFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [newFolderParent, setNewFolderParent] = useState<string | null>(null);
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]);
useEffect(() => {
if (selectedFolderId === ROOT_FOLDER_ID) {
setProjects(allProjects);
} else if (selectedFolderId === null) {
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]);
const selectedProject = useMemo(
() => allProjects.find(p => p.id === selectedProjectId) ?? null,
[allProjects, selectedProjectId],
);
const treeNodes = useMemo((): TreeNode[] => {
const rootProjectsCount = allProjects.filter(p => p.folder_id === null).length;
const root: TreeNode = {
id: ROOT_FOLDER_ID,
name: 'Alle Projekte',
count: allProjects.length,
expanded: true,
children: [
{ id: '__unfiled__', name: 'Ohne Ordner', count: rootProjectsCount },
],
};
const buildFolderNode = (folder: ProjectFolder): TreeNode => {
const childFolders = folders.filter(f => f.parent_id === folder.id);
const projCount = allProjects.filter(p => p.folder_id === folder.id).length;
return {
id: folder.id,
name: folder.name,
count: projCount,
expanded: true,
children: childFolders.map(buildFolderNode),
};
};
const topFolders = folders.filter(f => f.parent_id === null);
root.children!.push(...topFolders.map(buildFolderNode));
return [root];
}, [folders, allProjects]);
const folderNameById = useMemo(() => {
const map = new Map<string, string>();
map.set(ROOT_FOLDER_ID, 'Alle Projekte');
map.set('__unfiled__', 'Ohne Ordner');
for (const f of folders) map.set(f.id, f.name);
return map;
}, [folders]);
const handleCreate = async () => {
if (!newName.trim() || !token) return;
try {
const folderId = selectedFolderId === ROOT_FOLDER_ID || selectedFolderId === '__unfiled__'
? 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 loeschen?')) return;
if (!token) return;
try {
await deleteProject(token, id);
if (selectedProjectId === id) setSelectedProjectId(null);
fetchAll();
} catch {
// ignore
}
};
const handleFolderSelect = (id: string) => {
if (id === ROOT_FOLDER_ID) {
setSelectedFolderId(ROOT_FOLDER_ID);
} else if (id === '__unfiled__') {
setSelectedFolderId(null);
} else {
setSelectedFolderId(id);
}
};
const handleCreateFolder = async () => {
if (!newFolderName.trim() || !token) return;
const parentId = newFolderParent === ROOT_FOLDER_ID || newFolderParent === '__unfiled__'
? 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 loeschen? Projekte darin werden nach "Ohne Ordner" verschoben.')) return;
try {
await deleteProjectFolder(token, id);
if (selectedFolderId === id) setSelectedFolderId(ROOT_FOLDER_ID);
fetchAll();
} catch {
// ignore
}
};
const handleProjectCardClick = (projectId: string) => {
setSelectedProjectId(projectId);
};
const handleProjectDragStart = (e: React.DragEvent, projectId: string) => {
setDraggedProjectId(projectId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', `project:${projectId}`);
};
const handleFolderDragOver = (e: React.DragEvent) => {
if (!draggedProjectId) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
(e.currentTarget as HTMLElement).classList.add('tree-drop-target');
};
const handleFolderDragLeave = (e: React.DragEvent) => {
(e.currentTarget as HTMLElement).classList.remove('tree-drop-target');
};
const handleFolderDrop = async (e: React.DragEvent, folderId: string) => {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).classList.remove('tree-drop-target');
if (!draggedProjectId || !token) return;
const targetFolderId = folderId === ROOT_FOLDER_ID || folderId === '__unfiled__' ? null : folderId;
try {
await moveProjectToFolder(token, draggedProjectId, targetFolderId);
fetchAll();
} catch {
// ignore
} finally {
setDraggedProjectId(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 === '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 renderFolderActions = (node: TreeNode) => {
if (node.id === ROOT_FOLDER_ID || node.id === '__unfiled__') return null;
return (
<span className="folder-action-buttons">
<button
className="folder-action-btn"
title="Umbenennen"
onClick={(e) => {
e.stopPropagation();
setRenamingFolderId(node.id);
const folder = folders.find(f => f.id === node.id);
if (folder) setRenameValue(folder.name);
}}
></button>
<button
className="folder-action-btn delete"
title="Loeschen"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(node.id);
}}
></button>
</span>
);
};
return (
<div className="dashboard-page">
<header className="dashboard-header">
<div className="dashboard-brand">
<h1>Web CAD</h1>
<span className="dashboard-user">{user?.name} ({user?.role})</span>
</div>
<div className="dashboard-header-actions">
{token && <NotificationPanel token={token} />}
<button className="dashboard-logout" onClick={logout}>Abmelden</button>
</div>
</header>
<div className="dashboard-3col">
<div className="dashboard-treeview" onContextMenu={(e) => handleContextMenu(e, null)}>
<div className="treeview-header">
<h3>Ordner</h3>
<button
className="treeview-add-folder-btn"
title="Neuer Ordner"
onClick={() => { setNewFolderParent(null); setShowCreateFolder(true); }}
>+</button>
</div>
{showCreateFolder && (
<div className="folder-create-form">
<input
type="text"
placeholder="Ordnername"
value={newFolderName}
onChange={e => setNewFolderName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateFolder(); if (e.key === 'Escape') setShowCreateFolder(false); }}
autoFocus
/>
<div className="folder-create-actions">
<button onClick={handleCreateFolder}>OK</button>
<button onClick={() => { setShowCreateFolder(false); setNewFolderName(''); }}>×</button>
</div>
</div>
)}
<TreeView
nodes={treeNodes}
selectedId={selectedFolderId ?? undefined}
onSelect={handleFolderSelect}
renderActions={renderFolderActions}
/>
{renamingFolderId && (
<div className="folder-rename-form">
<input
type="text"
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameFolder(renamingFolderId); if (e.key === 'Escape') { setRenamingFolderId(null); setRenameValue(''); } }}
onBlur={() => { if (renameValue.trim()) handleRenameFolder(renamingFolderId); else { setRenamingFolderId(null); setRenameValue(''); } }}
autoFocus
/>
</div>
)}
<div className="treeview-drop-overlay">
{folders.map(folder => (
<div
key={folder.id}
className="treeview-drop-zone"
onDragOver={handleFolderDragOver}
onDragLeave={handleFolderDragLeave}
onDrop={(e) => handleFolderDrop(e, folder.id)}
/>
))}
</div>
</div>
<div className="dashboard-content">
<div className="dashboard-section-header">
<h2>{folderNameById.get(selectedFolderId ?? ROOT_FOLDER_ID) ?? 'Projekte'}</h2>
<button className="dashboard-create-btn" onClick={() => setShowCreate(!showCreate)}>
+ Neues Projekt
</button>
</div>
{showCreate && (
<div className="dashboard-create-form">
<input
type="text"
placeholder="Projektname"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }}
autoFocus
/>
<input
type="text"
placeholder="Beschreibung (optional)"
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }}
/>
<button onClick={handleCreate}>Erstellen</button>
<button onClick={() => setShowCreate(false)}>Abbrechen</button>
</div>
)}
{loading ? (
<p className="dashboard-loading">Lade Projekte...</p>
) : projects.length === 0 ? (
<p className="dashboard-empty">Keine Projekte in dieser Ansicht. Erstellen Sie ein neues Projekt.</p>
) : (
<div className="dashboard-project-grid">
{projects.map(project => (
<div
key={project.id}
className={`dashboard-project-card${selectedProjectId === project.id ? ' selected' : ''}`}
onClick={() => handleProjectCardClick(project.id)}
draggable
onDragStart={(e) => handleProjectDragStart(e, project.id)}
onDragEnd={() => setDraggedProjectId(null)}
>
<h3>{project.name}</h3>
{project.description && <p>{project.description}</p>}
<div className="dashboard-project-meta">
<span>{new Date(project.created_at).toLocaleDateString('de-DE')}</span>
<div className="dashboard-project-actions">
<button
className="dashboard-open-btn"
onClick={(e) => { e.stopPropagation(); onOpenProject(project.id); }}
>
Oeffnen
</button>
<button
className="dashboard-share-btn"
onClick={(e) => { e.stopPropagation(); setShareProjectId(project.id); }}
>
Teilen
</button>
<button
className="dashboard-delete-btn"
onClick={(e) => handleDelete(project.id, e)}
>
Loeschen
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="dashboard-info-panel">
{selectedProject ? (
<>
<h3 className="info-panel-title">{selectedProject.name}</h3>
{selectedProject.description && (
<p className="info-panel-description">{selectedProject.description}</p>
)}
<div className="info-panel-section">
<h4>Metadaten</h4>
<dl className="info-panel-meta">
<dt>Erstellt</dt>
<dd>{formatDate(selectedProject.created_at)}</dd>
<dt>Aktualisiert</dt>
<dd>{formatDate(selectedProject.updated_at)}</dd>
<dt>Owner</dt>
<dd>{selectedProject.owner_id}</dd>
<dt>Ordner</dt>
<dd>{selectedProject.folder_id ? (folderNameById.get(selectedProject.folder_id) ?? 'Unbekannt') : 'Ohne Ordner'}</dd>
</dl>
</div>
<div className="info-panel-section">
<h4>Sichtbarkeit / Freigaben</h4>
{sharesLoading ? (
<p className="info-panel-loading">Lade Freigaben...</p>
) : shares.length === 0 ? (
<p className="info-panel-empty">Keine Freigaben</p>
) : (
<ul className="info-panel-shares">
{shares.map(share => (
<li key={share.id} className="info-panel-share-item">
<span className="info-panel-share-email">{share.shared_with_email}</span>
<span className="info-panel-share-perm">{share.permission}</span>
</li>
))}
</ul>
)}
</div>
<button
className="info-panel-open-btn"
onClick={() => onOpenProject(selectedProject.id)}
>
Projekt oeffnen
</button>
</>
) : (
<div className="info-panel-placeholder">
<p>Waehlen Sie ein Projekt aus, um Details zu sehen.</p>
</div>
)}
</div>
</div>
{contextMenu && (
<>
<div className="context-menu-overlay" onClick={() => setContextMenu(null)} />
<div
className="context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
className="context-menu-item"
onClick={() => handleContextAction('new', contextMenu.folderId)}
>Neuer Ordner</button>
{contextMenu.folderId && contextMenu.folderId !== ROOT_FOLDER_ID && contextMenu.folderId !== '__unfiled__' && (
<>
<button
className="context-menu-item"
onClick={() => handleContextAction('rename', contextMenu.folderId)}
>Umbenennen</button>
<button
className="context-menu-item danger"
onClick={() => handleContextAction('delete', contextMenu.folderId)}
>Loeschen</button>
</>
)}
</div>
</>
)}
{token && (
<ShareDialog
token={token}
projectId={shareProjectId ?? ''}
open={!!shareProjectId}
onClose={() => setShareProjectId(null)}
/>
)}
</div>
);
}