From a4882977fb57ee8235250ec7eea7b40bd0eb7175 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 29 Jan 2026 19:44:59 +0000 Subject: [PATCH] Add + buttons to create new outflows and outcomes in edit mode Edit modal now includes: - + button next to "Outflows" to create new funnel node - + button next to "Outcomes" to create new outcome node - Hover X button to remove allocations - Auto-redistribute percentages when adding/removing - New nodes positioned relative to current funnel Uses useReactFlow hook to dynamically add nodes to canvas. Co-Authored-By: Claude Opus 4.5 --- components/nodes/FunnelNode.tsx | 355 +++++++++++++++++++++++++------- 1 file changed, 284 insertions(+), 71 deletions(-) diff --git a/components/nodes/FunnelNode.tsx b/components/nodes/FunnelNode.tsx index 3b5cb63..ba47ea6 100644 --- a/components/nodes/FunnelNode.tsx +++ b/components/nodes/FunnelNode.tsx @@ -1,9 +1,9 @@ 'use client' import { memo, useState, useCallback, useRef, useEffect } from 'react' -import { Handle, Position } from '@xyflow/react' +import { Handle, Position, useReactFlow } from '@xyflow/react' import type { NodeProps } from '@xyflow/react' -import type { FunnelNodeData } from '@/lib/types' +import type { FunnelNodeData, OutcomeNodeData } from '@/lib/types' // Colors const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1'] @@ -13,12 +13,17 @@ function FunnelNode({ data, selected, id }: NodeProps) { const nodeData = data as FunnelNodeData const { label, currentValue, maxCapacity, overflowAllocations = [], spendingAllocations = [] } = nodeData + const { getNode, setNodes, setEdges, getNodes } = useReactFlow() + const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold) const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold) const [isEditing, setIsEditing] = useState(false) const [draggingPie, setDraggingPie] = useState<{ type: 'overflow' | 'spending', index: number } | null>(null) const [localOverflow, setLocalOverflow] = useState(overflowAllocations) const [localSpending, setLocalSpending] = useState(spendingAllocations) + const [showAddOutflow, setShowAddOutflow] = useState(false) + const [showAddOutcome, setShowAddOutcome] = useState(false) + const [newItemName, setNewItemName] = useState('') const sliderRef = useRef(null) const overflowPieRef = useRef(null) @@ -45,6 +50,9 @@ function FunnelNode({ data, selected, id }: NodeProps) { const handleCloseEdit = useCallback(() => { setIsEditing(false) + setShowAddOutflow(false) + setShowAddOutcome(false) + setNewItemName('') }, []) // Threshold slider drag @@ -99,13 +107,8 @@ function FunnelNode({ data, selected, id }: NodeProps) { if (draggingPie.type === 'overflow') { setLocalOverflow(prev => { const newAllocs = [...prev] - const total = newAllocs.reduce((sum, a) => sum + a.percentage, 0) - const diff = percentage - newAllocs[draggingPie.index].percentage - - // Redistribute to maintain 100% total if (newAllocs.length > 1) { const otherIdx = (draggingPie.index + 1) % newAllocs.length - const newOther = Math.max(5, newAllocs[otherIdx].percentage - diff) const newCurrent = Math.max(5, Math.min(95, percentage)) newAllocs[draggingPie.index] = { ...newAllocs[draggingPie.index], percentage: newCurrent } newAllocs[otherIdx] = { ...newAllocs[otherIdx], percentage: 100 - newCurrent - newAllocs.filter((_, i) => i !== draggingPie.index && i !== otherIdx).reduce((s, a) => s + a.percentage, 0) } @@ -136,6 +139,126 @@ function FunnelNode({ data, selected, id }: NodeProps) { } }, [draggingPie]) + // Add new outflow funnel + const handleAddOutflow = useCallback(() => { + if (!newItemName.trim()) return + + const currentNode = getNode(id) + if (!currentNode) return + + const newId = `funnel-${Date.now()}` + const newNodeData: FunnelNodeData = { + label: newItemName, + currentValue: 0, + minThreshold: 10000, + maxThreshold: 40000, + maxCapacity: 50000, + inflowRate: 0, + overflowAllocations: [], + spendingAllocations: [], + } + + // Add new funnel node to the right + setNodes((nodes) => [ + ...nodes, + { + id: newId, + type: 'funnel', + position: { x: currentNode.position.x + 250, y: currentNode.position.y }, + data: newNodeData, + }, + ]) + + // Add allocation to local state + const newAllocation = { + targetId: newId, + percentage: localOverflow.length === 0 ? 100 : Math.floor(100 / (localOverflow.length + 1)), + color: OVERFLOW_COLORS[localOverflow.length % OVERFLOW_COLORS.length], + } + + // Redistribute percentages + const newOverflow = localOverflow.map(a => ({ + ...a, + percentage: Math.floor(a.percentage * localOverflow.length / (localOverflow.length + 1)) + })) + newOverflow.push(newAllocation) + + setLocalOverflow(newOverflow) + setShowAddOutflow(false) + setNewItemName('') + }, [newItemName, id, getNode, setNodes, localOverflow]) + + // Add new outcome/deliverable + const handleAddOutcome = useCallback(() => { + if (!newItemName.trim()) return + + const currentNode = getNode(id) + if (!currentNode) return + + const newId = `outcome-${Date.now()}` + const newNodeData: OutcomeNodeData = { + label: newItemName, + description: '', + fundingReceived: 0, + fundingTarget: 20000, + status: 'not-started', + } + + // Add new outcome node below + setNodes((nodes) => [ + ...nodes, + { + id: newId, + type: 'outcome', + position: { x: currentNode.position.x, y: currentNode.position.y + 300 }, + data: newNodeData, + }, + ]) + + // Add allocation to local state + const newAllocation = { + targetId: newId, + percentage: localSpending.length === 0 ? 100 : Math.floor(100 / (localSpending.length + 1)), + color: SPENDING_COLORS[localSpending.length % SPENDING_COLORS.length], + } + + // Redistribute percentages + const newSpending = localSpending.map(a => ({ + ...a, + percentage: Math.floor(a.percentage * localSpending.length / (localSpending.length + 1)) + })) + newSpending.push(newAllocation) + + setLocalSpending(newSpending) + setShowAddOutcome(false) + setNewItemName('') + }, [newItemName, id, getNode, setNodes, localSpending]) + + // Remove allocation + const handleRemoveOutflow = useCallback((index: number) => { + setLocalOverflow(prev => { + const newAllocs = prev.filter((_, i) => i !== index) + // Redistribute percentages + if (newAllocs.length > 0) { + const total = newAllocs.reduce((s, a) => s + a.percentage, 0) + return newAllocs.map(a => ({ ...a, percentage: Math.round(a.percentage / total * 100) })) + } + return newAllocs + }) + }, []) + + const handleRemoveSpending = useCallback((index: number) => { + setLocalSpending(prev => { + const newAllocs = prev.filter((_, i) => i !== index) + // Redistribute percentages + if (newAllocs.length > 0) { + const total = newAllocs.reduce((s, a) => s + a.percentage, 0) + return newAllocs.map(a => ({ ...a, percentage: Math.round(a.percentage / total * 100) })) + } + return newAllocs + }) + }, []) + // Pie chart rendering helper const renderPieChart = (allocations: typeof overflowAllocations, colors: string[], type: 'overflow' | 'spending', size: number) => { if (allocations.length === 0) return null @@ -167,11 +290,11 @@ function FunnelNode({ data, selected, id }: NodeProps) { fill={alloc.color || colors[idx % colors.length]} stroke="white" strokeWidth="2" - className={isEditing ? 'cursor-grab hover:opacity-80' : ''} - onMouseDown={isEditing ? (e) => { + className="cursor-grab hover:opacity-80" + onMouseDown={(e) => { e.stopPropagation() setDraggingPie({ type, index: idx }) - } : undefined} + }} /> ) }) @@ -317,7 +440,7 @@ function FunnelNode({ data, selected, id }: NodeProps) { {/* Simplified allocation bars */}
- {/* Outflow (left side) */} + {/* Outflow (sides) */}
Out {hasOverflow ? ( @@ -327,7 +450,7 @@ function FunnelNode({ data, selected, id }: NodeProps) { )}
- {/* Outcomes (bottom indicator) */} + {/* Outcomes (bottom) */}
Spend {hasSpending ? ( @@ -374,7 +497,7 @@ function FunnelNode({ data, selected, id }: NodeProps) { onClick={handleCloseEdit} >
e.stopPropagation()} >
@@ -455,70 +578,160 @@ function FunnelNode({ data, selected, id }: NodeProps) {
- {/* Pie Charts Row */} -
- {/* Outflows Pie */} - {localOverflow.length > 0 && ( -
- - Outflows (to Funnels) - - - {renderPieChart(localOverflow, OVERFLOW_COLORS, 'overflow', 120)} - - - → - - -
- {localOverflow.map((alloc, idx) => ( -
-
- {alloc.targetId} - {alloc.percentage}% -
- ))} -
+ {/* Allocations Section */} +
+ {/* Outflows Column */} +
+
+ → Outflows +
- )} - {/* Spending Pie */} - {localSpending.length > 0 && ( -
- - Spending (to Outcomes) - - - {renderPieChart(localSpending, SPENDING_COLORS, 'spending', 120)} - - - ↓ - - -
- {localSpending.map((alloc, idx) => ( -
-
- {alloc.targetId} - {alloc.percentage}% -
- ))} + {localOverflow.length > 0 ? ( + <> + + {renderPieChart(localOverflow, OVERFLOW_COLORS, 'overflow', 100)} + + +
+ {localOverflow.map((alloc, idx) => ( +
+
+ {alloc.targetId} + {alloc.percentage}% + +
+ ))} +
+ + ) : ( +

No outflows yet

+ )} + + {/* Add Outflow Form */} + {showAddOutflow && ( +
+ setNewItemName(e.target.value)} + placeholder="New funnel name..." + className="w-full text-xs px-2 py-1 border border-slate-200 rounded mb-2" + autoFocus + /> +
+ + +
+ )} +
+ + {/* Spending/Outcomes Column */} +
+
+ ↓ Outcomes +
- )} + + {localSpending.length > 0 ? ( + <> + + {renderPieChart(localSpending, SPENDING_COLORS, 'spending', 100)} + + +
+ {localSpending.map((alloc, idx) => ( +
+
+ {alloc.targetId} + {alloc.percentage}% + +
+ ))} +
+ + ) : ( +

No outcomes yet

+ )} + + {/* Add Outcome Form */} + {showAddOutcome && ( +
+ setNewItemName(e.target.value)} + placeholder="New outcome name..." + className="w-full text-xs px-2 py-1 border border-slate-200 rounded mb-2" + autoFocus + /> +
+ + +
+
+ )} +
- {(localOverflow.length > 0 || localSpending.length > 0) && ( -

- Drag pie slices to adjust allocations -

- )} +

+ Drag pie slices to adjust • Click + to add new items +

{/* Close button */}