diff --git a/frontend/package.json b/frontend/package.json index 200c6f3..9708423 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@react-three/drei": "^9.102.3", + "@xyflow/react": "^12.0.0", "@react-three/fiber": "^8.16.2", "@tanstack/react-query": "^5.28.4", "@tanstack/react-table": "^8.14.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18bf798..7bbf31c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import NewProductOrderPage from './pages/NewProductOrder' import NotificationsPage from './pages/Notifications' import PreferencesPage from './pages/Preferences' import TenantsPage from './pages/Tenants' +import WorkflowEditorPage from './pages/WorkflowEditor' function ProtectedRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token) @@ -66,6 +67,14 @@ export default function App() { } /> + + + + } + /> } /> } /> } /> diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts new file mode 100644 index 0000000..f603635 --- /dev/null +++ b/frontend/src/api/workflows.ts @@ -0,0 +1,80 @@ +import api from './client' + +export interface WorkflowDefinition { + id: string + name: string + output_type_id: string | null + config: WorkflowConfig + is_active: boolean + created_at: string +} + +export interface WorkflowConfig { + type: 'still' | 'turntable' | 'multi_angle' | 'custom' + params: WorkflowParams + nodes?: WorkflowNode[] +} + +export interface WorkflowParams { + render_engine?: 'cycles' | 'eevee' + samples?: number + resolution?: [number, number] + fps?: number + duration_s?: number + angles?: number[] +} + +export interface WorkflowNode { + id: string + type: string + position: { x: number; y: number } + data: Record +} + +export interface WorkflowCreate { + name: string + output_type_id?: string | null + config: WorkflowConfig + is_active?: boolean +} + +export interface WorkflowRun { + id: string + workflow_def_id: string | null + order_line_id: string | null + celery_task_id: string | null + status: 'pending' | 'running' | 'completed' | 'failed' + started_at: string | null + completed_at: string | null + error_message: string | null + created_at: string + node_results: WorkflowNodeResult[] +} + +export interface WorkflowNodeResult { + id: string + node_name: string + status: string + output: Record | null + log: string | null + duration_s: number | null + created_at: string +} + +export const getWorkflows = (): Promise => + api.get('/workflows').then(r => r.data) + +export const getWorkflow = (id: string): Promise => + api.get(`/workflows/${id}`).then(r => r.data) + +export const createWorkflow = (data: WorkflowCreate): Promise => + api.post('/workflows', data).then(r => r.data) + +export const updateWorkflow = (id: string, data: Partial): Promise => + api.put(`/workflows/${id}`, data).then(r => r.data) + +export const deleteWorkflow = (id: string): Promise => + api.delete(`/workflows/${id}`).then(() => undefined) + +export const getWorkflowRuns = (workflowId: string): Promise => + api.get(`/workflows/${workflowId}/runs`).then(r => r.data) diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index fb6cceb..83c5b32 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -1,5 +1,5 @@ import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom' -import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2 } from 'lucide-react' +import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal, Building2, GitBranch } from 'lucide-react' import { useAuthStore } from '../../store/auth' import { clsx } from 'clsx' import { useQuery } from '@tanstack/react-query' @@ -120,6 +120,22 @@ export default function Layout() { Admin )} + {(user?.role === 'admin' || user?.role === 'project_manager') && ( + + clsx( + 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', + isActive + ? 'bg-accent-light text-accent' + : 'text-content-secondary hover:bg-surface-hover', + ) + } + > + + Workflows + + )} {user?.role === 'admin' && ( + {hasTarget && ( + + )} +
+ {icon} + {label} +
+ {description &&

{description}

} + {hasSource && ( + + )} + + ) +} + +function InputNode({ selected }: { selected?: boolean }) { + return ( + } + color="green" + description="STEP-Datei Eingang" + selected={selected} + hasTarget={false} + /> + ) +} + +function ConvertNode({ selected }: { selected?: boolean }) { + return ( + } + color="blue" + description="STEP → STL (cadquery)" + selected={selected} + /> + ) +} + +function RenderNode({ data, selected }: { data: { label?: string; params?: WorkflowParams }; selected?: boolean }) { + const params = data.params ?? {} + return ( + } + color="orange" + description={params.render_engine ? `${params.render_engine} · ${params.samples ?? 256} samples` : undefined} + selected={selected} + /> + ) +} + +function RenderFramesNode({ data, selected }: { data: { params?: WorkflowParams }; selected?: boolean }) { + const params = data.params ?? {} + return ( + } + color="orange" + description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : undefined} + selected={selected} + /> + ) +} + +function FFmpegNode({ selected }: { selected?: boolean }) { + return ( + } + color="purple" + description="Frames → MP4" + selected={selected} + /> + ) +} + +function OutputNode({ data, selected }: { data: { label?: string }; selected?: boolean }) { + return ( + } + color="gray" + description="Ergebnis-Datei" + selected={selected} + hasSource={false} + /> + ) +} + +const nodeTypes: NodeTypes = { + inputNode: InputNode as any, + convertNode: ConvertNode as any, + renderNode: RenderNode as any, + renderFramesNode: RenderFramesNode as any, + ffmpegNode: FFmpegNode as any, + outputNode: OutputNode as any, +} + +// ─── Workflow → Graph conversion ───────────────────────────────────────────── + +function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[] } { + const Y = 100 + + if (config.type === 'still') { + const nodes: Node[] = [ + { id: 'input', type: 'inputNode', position: { x: 0, y: Y }, data: { label: 'STEP Input' } }, + { id: 'convert', type: 'convertNode', position: { x: 220, y: Y }, data: { label: 'STL Konvertierung' } }, + { id: 'render', type: 'renderNode', position: { x: 440, y: Y }, data: { label: 'Still Render', params: config.params } }, + { id: 'output', type: 'outputNode', position: { x: 660, y: Y }, data: { label: 'PNG Output' } }, + ] + const edges: Edge[] = [ + { id: 'e1', source: 'input', target: 'convert' }, + { id: 'e2', source: 'convert', target: 'render' }, + { id: 'e3', source: 'render', target: 'output' }, + ] + return { nodes, edges } + } + + if (config.type === 'turntable') { + const nodes: Node[] = [ + { id: 'input', type: 'inputNode', position: { x: 0, y: Y }, data: {} }, + { id: 'convert', type: 'convertNode', position: { x: 220, y: Y }, data: {} }, + { id: 'frames', type: 'renderFramesNode', position: { x: 440, y: Y }, data: { params: config.params } }, + { id: 'ffmpeg', type: 'ffmpegNode', position: { x: 660, y: Y }, data: {} }, + { id: 'output', type: 'outputNode', position: { x: 880, y: Y }, data: { label: 'MP4 Output' } }, + ] + const edges: Edge[] = [ + { id: 'e1', source: 'input', target: 'convert' }, + { id: 'e2', source: 'convert', target: 'frames' }, + { id: 'e3', source: 'frames', target: 'ffmpeg' }, + { id: 'e4', source: 'ffmpeg', target: 'output' }, + ] + return { nodes, edges } + } + + if (config.type === 'multi_angle') { + const angles = config.params.angles ?? [0, 45, 90] + const renderNodes: Node[] = angles.map((angle, i) => ({ + id: `render_${i}`, + type: 'renderNode', + position: { x: 440, y: i * 130 }, + data: { label: `Render ${angle}°`, params: { ...config.params, camera_angle: angle } }, + })) + const nodes: Node[] = [ + { id: 'input', type: 'inputNode', position: { x: 0, y: angles.length * 65 }, data: {} }, + { id: 'convert', type: 'convertNode', position: { x: 220, y: angles.length * 65 }, data: {} }, + ...renderNodes, + { id: 'output', type: 'outputNode', position: { x: 700, y: angles.length * 65 }, data: {} }, + ] + const edges: Edge[] = [ + { id: 'e_in', source: 'input', target: 'convert' }, + ...angles.map((_, i) => ({ id: `e_conv_${i}`, source: 'convert', target: `render_${i}` })), + ...angles.map((_, i) => ({ id: `e_out_${i}`, source: `render_${i}`, target: 'output' })), + ] + return { nodes, edges } + } + + // custom: use nodes from config if present + if (config.nodes && config.nodes.length > 0) { + return { nodes: config.nodes as Node[], edges: [] } + } + + return { nodes: [], edges: [] } +} + +// ─── Config Sidepanel ───────────────────────────────────────────────────────── + +function ConfigSidepanel({ + params, + onChange, +}: { + params: WorkflowParams + onChange: (p: WorkflowParams) => void +}) { + return ( +
+

Node-Konfiguration

+ + {/* Render Engine */} +
+ +
+ {(['cycles', 'eevee'] as const).map(eng => ( + + ))} +
+
+ + {/* Samples */} +
+ + onChange({ ...params, samples: Number(e.target.value) })} + className="w-full accent-blue-600" + /> +
+ 1 + 4096 +
+
+ + {/* Resolution */} +
+ +
+ {([[1024, 1024], [2048, 2048], [4096, 4096]] as [number, number][]).map(([w, h]) => ( + + ))} +
+
+ + {/* FPS (only relevant for animation nodes) */} +
+ +
+ {[12, 24, 30, 60].map(fps => ( + + ))} +
+
+ + {/* Duration */} +
+ + onChange({ ...params, duration_s: Number(e.target.value) })} + className="w-full accent-blue-600" + /> +
+
+ ) +} + +// ─── Node Palette ────────────────────────────────────────────────────────────── + +const NODE_PALETTE = [ + { type: 'convertNode', label: 'STEP→STL', icon: }, + { type: 'renderNode', label: 'Still Render', icon: }, + { type: 'renderFramesNode', label: 'Frame Render', icon: }, + { type: 'ffmpegNode', label: 'FFmpeg', icon: }, + { type: 'outputNode', label: 'Output', icon: }, +] + +// ─── New Workflow Modal ─────────────────────────────────────────────────────── + +interface NewWorkflowModalProps { + onClose: () => void + onCreate: (name: string, type: WorkflowConfig['type']) => void + isLoading: boolean +} + +function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProps) { + const [name, setName] = useState('') + const [type, setType] = useState('still') + + return ( +
+
+
+

Neuer Workflow

+ +
+ +
+
+ + setName(e.target.value)} + autoFocus + /> +
+ +
+ +
+ {([ + { value: 'still', label: 'Still', desc: 'Einzelbild PNG' }, + { value: 'turntable', label: 'Turntable', desc: 'Animations-MP4' }, + { value: 'multi_angle', label: 'Multi-Angle', desc: 'Mehrere Winkel' }, + { value: 'custom', label: 'Custom', desc: 'Freier Editor' }, + ] as { value: WorkflowConfig['type']; label: string; desc: string }[]).map(opt => ( + + ))} +
+
+
+ +
+ + +
+
+
+ ) +} + +// ─── Flow Canvas ────────────────────────────────────────────────────────────── + +interface FlowCanvasProps { + workflow: WorkflowDefinition + onSave: (config: WorkflowConfig) => void + isSaving: boolean +} + +function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { + const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config) + const [nodes, setNodes, onNodesChange] = useNodesState(initNodes) + const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges) + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [params, setParams] = useState(workflow.config.params) + const reactFlowWrapper = useRef(null) + const [reactFlowInstance, setReactFlowInstance] = useState(null) + + const onConnect = useCallback( + (connection: Connection) => setEdges(eds => addEdge(connection, eds)), + [setEdges], + ) + + const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { + if (node.type === 'renderNode' || node.type === 'renderFramesNode') { + setSelectedNodeId(node.id) + const nodeParams = (node.data as any).params as WorkflowParams | undefined + if (nodeParams) setParams(nodeParams) + } else { + setSelectedNodeId(null) + } + }, []) + + const onPaneClick = useCallback(() => { + setSelectedNodeId(null) + }, []) + + const handleParamsChange = useCallback( + (newParams: WorkflowParams) => { + setParams(newParams) + setNodes(nds => + nds.map(n => { + if (n.id === selectedNodeId) { + return { ...n, data: { ...n.data, params: newParams } } + } + return n + }), + ) + }, + [selectedNodeId, setNodes], + ) + + // Drag-drop new nodes from palette + const onDragOver = useCallback((event: DragEvent) => { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + }, []) + + const onDrop = useCallback( + (event: DragEvent) => { + event.preventDefault() + const type = event.dataTransfer.getData('application/reactflow') + if (!type || !reactFlowInstance) return + + const position = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }) + + const newNode: Node = { + id: `${type}_${Date.now()}`, + type, + position, + data: { label: type }, + } + setNodes(nds => [...nds, newNode]) + }, + [reactFlowInstance, setNodes], + ) + + const handleSave = () => { + const updatedConfig: WorkflowConfig = { + ...workflow.config, + params, + nodes: nodes as any, + } + onSave(updatedConfig) + } + + const selectedNode = nodes.find(n => n.id === selectedNodeId) + + return ( +
+ {/* Canvas Toolbar */} +
+ Nodes: + {NODE_PALETTE.map(item => ( +
{ + e.dataTransfer.setData('application/reactflow', item.type) + e.dataTransfer.effectAllowed = 'move' + }} + className="flex items-center gap-1.5 px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 text-xs text-gray-700 cursor-grab hover:bg-gray-100 select-none" + > + {item.icon} + {item.label} +
+ ))} +
+ +
+
+ + {/* Canvas + Sidepanel */} +
+
+ + + + + +
+ + {selectedNode && (selectedNode.type === 'renderNode' || selectedNode.type === 'renderFramesNode') && ( + + )} +
+
+ ) +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export default function WorkflowEditor() { + const queryClient = useQueryClient() + const [selectedId, setSelectedId] = useState(null) + const [showNewModal, setShowNewModal] = useState(false) + + const { data: workflows = [], isLoading } = useQuery({ + queryKey: ['workflows'], + queryFn: getWorkflows, + }) + + const createMutation = useMutation({ + mutationFn: createWorkflow, + onSuccess: wf => { + queryClient.invalidateQueries({ queryKey: ['workflows'] }) + setSelectedId(wf.id) + setShowNewModal(false) + toast.success('Workflow erstellt') + }, + onError: () => toast.error('Fehler beim Erstellen'), + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, config }: { id: string; config: WorkflowConfig }) => + updateWorkflow(id, { config }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workflows'] }) + toast.success('Workflow gespeichert') + }, + onError: () => toast.error('Fehler beim Speichern'), + }) + + const deleteMutation = useMutation({ + mutationFn: deleteWorkflow, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workflows'] }) + setSelectedId(null) + toast.success('Workflow gelöscht') + }, + onError: () => toast.error('Fehler beim Löschen'), + }) + + const handleCreate = (name: string, type: WorkflowConfig['type']) => { + const defaultParams: WorkflowParams = + type === 'turntable' + ? { render_engine: 'cycles', samples: 64, fps: 24, duration_s: 5 } + : type === 'multi_angle' + ? { render_engine: 'cycles', samples: 128, resolution: [2048, 2048], angles: [0, 45, 90] } + : { render_engine: 'cycles', samples: 256, resolution: [2048, 2048] } + + createMutation.mutate({ + name, + config: { type, params: defaultParams }, + is_active: true, + }) + } + + const selectedWorkflow = workflows.find(w => w.id === selectedId) ?? null + + const typeLabel: Record = { + still: 'Still', + turntable: 'Turntable', + multi_angle: 'Multi-Angle', + custom: 'Custom', + } + + const typeBadgeColor: Record = { + still: 'bg-orange-100 text-orange-700', + turntable: 'bg-purple-100 text-purple-700', + multi_angle: 'bg-blue-100 text-blue-700', + custom: 'bg-gray-100 text-gray-600', + } + + return ( +
+ {/* Workflow List Sidebar */} +
+ + + {/* Main Canvas Area */} +
+ {/* Header */} +
+
+

Workflow-Editor

+ {selectedWorkflow && ( +

{selectedWorkflow.name}

+ )} +
+ +
+ + {/* Canvas or Empty State */} + {selectedWorkflow ? ( + updateMutation.mutate({ id: selectedWorkflow.id, config })} + isSaving={updateMutation.isPending} + /> + ) : ( +
+
+ +

Kein Workflow ausgewählt

+

+ Wähle einen Workflow aus der Liste oder erstelle einen neuen. +

+ +
+
+ )} +
+ + {/* New Workflow Modal */} + {showNewModal && ( + setShowNewModal(false)} + onCreate={handleCreate} + isLoading={createMutation.isPending} + /> + )} + + ) +}