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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-29 19:44:59 +00:00
parent 9ca745756e
commit a4882977fb
1 changed files with 284 additions and 71 deletions

View File

@ -1,9 +1,9 @@
'use client' 'use client'
import { memo, useState, useCallback, useRef, useEffect } from 'react' 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 { NodeProps } from '@xyflow/react'
import type { FunnelNodeData } from '@/lib/types' import type { FunnelNodeData, OutcomeNodeData } from '@/lib/types'
// Colors // Colors
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1'] 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 nodeData = data as FunnelNodeData
const { label, currentValue, maxCapacity, overflowAllocations = [], spendingAllocations = [] } = nodeData const { label, currentValue, maxCapacity, overflowAllocations = [], spendingAllocations = [] } = nodeData
const { getNode, setNodes, setEdges, getNodes } = useReactFlow()
const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold) const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold)
const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold) const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [draggingPie, setDraggingPie] = useState<{ type: 'overflow' | 'spending', index: number } | null>(null) const [draggingPie, setDraggingPie] = useState<{ type: 'overflow' | 'spending', index: number } | null>(null)
const [localOverflow, setLocalOverflow] = useState(overflowAllocations) const [localOverflow, setLocalOverflow] = useState(overflowAllocations)
const [localSpending, setLocalSpending] = useState(spendingAllocations) const [localSpending, setLocalSpending] = useState(spendingAllocations)
const [showAddOutflow, setShowAddOutflow] = useState(false)
const [showAddOutcome, setShowAddOutcome] = useState(false)
const [newItemName, setNewItemName] = useState('')
const sliderRef = useRef<HTMLDivElement>(null) const sliderRef = useRef<HTMLDivElement>(null)
const overflowPieRef = useRef<SVGSVGElement>(null) const overflowPieRef = useRef<SVGSVGElement>(null)
@ -45,6 +50,9 @@ function FunnelNode({ data, selected, id }: NodeProps) {
const handleCloseEdit = useCallback(() => { const handleCloseEdit = useCallback(() => {
setIsEditing(false) setIsEditing(false)
setShowAddOutflow(false)
setShowAddOutcome(false)
setNewItemName('')
}, []) }, [])
// Threshold slider drag // Threshold slider drag
@ -99,13 +107,8 @@ function FunnelNode({ data, selected, id }: NodeProps) {
if (draggingPie.type === 'overflow') { if (draggingPie.type === 'overflow') {
setLocalOverflow(prev => { setLocalOverflow(prev => {
const newAllocs = [...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) { if (newAllocs.length > 1) {
const otherIdx = (draggingPie.index + 1) % newAllocs.length 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)) const newCurrent = Math.max(5, Math.min(95, percentage))
newAllocs[draggingPie.index] = { ...newAllocs[draggingPie.index], percentage: newCurrent } 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) } 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]) }, [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 // Pie chart rendering helper
const renderPieChart = (allocations: typeof overflowAllocations, colors: string[], type: 'overflow' | 'spending', size: number) => { const renderPieChart = (allocations: typeof overflowAllocations, colors: string[], type: 'overflow' | 'spending', size: number) => {
if (allocations.length === 0) return null if (allocations.length === 0) return null
@ -167,11 +290,11 @@ function FunnelNode({ data, selected, id }: NodeProps) {
fill={alloc.color || colors[idx % colors.length]} fill={alloc.color || colors[idx % colors.length]}
stroke="white" stroke="white"
strokeWidth="2" strokeWidth="2"
className={isEditing ? 'cursor-grab hover:opacity-80' : ''} className="cursor-grab hover:opacity-80"
onMouseDown={isEditing ? (e) => { onMouseDown={(e) => {
e.stopPropagation() e.stopPropagation()
setDraggingPie({ type, index: idx }) setDraggingPie({ type, index: idx })
} : undefined} }}
/> />
) )
}) })
@ -317,7 +440,7 @@ function FunnelNode({ data, selected, id }: NodeProps) {
{/* Simplified allocation bars */} {/* Simplified allocation bars */}
<div className="flex items-center justify-between mt-3 gap-2"> <div className="flex items-center justify-between mt-3 gap-2">
{/* Outflow (left side) */} {/* Outflow (sides) */}
<div className="flex flex-col items-center flex-1"> <div className="flex flex-col items-center flex-1">
<span className="text-[8px] text-amber-600 uppercase mb-1">Out</span> <span className="text-[8px] text-amber-600 uppercase mb-1">Out</span>
{hasOverflow ? ( {hasOverflow ? (
@ -327,7 +450,7 @@ function FunnelNode({ data, selected, id }: NodeProps) {
)} )}
</div> </div>
{/* Outcomes (bottom indicator) */} {/* Outcomes (bottom) */}
<div className="flex flex-col items-center flex-1"> <div className="flex flex-col items-center flex-1">
<span className="text-[8px] text-blue-600 uppercase mb-1">Spend</span> <span className="text-[8px] text-blue-600 uppercase mb-1">Spend</span>
{hasSpending ? ( {hasSpending ? (
@ -374,7 +497,7 @@ function FunnelNode({ data, selected, id }: NodeProps) {
onClick={handleCloseEdit} onClick={handleCloseEdit}
> >
<div <div
className="bg-white rounded-2xl shadow-2xl p-6 min-w-[400px] max-w-lg" className="bg-white rounded-2xl shadow-2xl p-6 min-w-[480px] max-w-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@ -455,70 +578,160 @@ function FunnelNode({ data, selected, id }: NodeProps) {
</div> </div>
</div> </div>
{/* Pie Charts Row */} {/* Allocations Section */}
<div className="flex gap-6 justify-center"> <div className="grid grid-cols-2 gap-4">
{/* Outflows Pie */} {/* Outflows Column */}
{localOverflow.length > 0 && ( <div className="bg-amber-50 rounded-xl p-4">
<div className="flex flex-col items-center"> <div className="flex items-center justify-between mb-3">
<span className="text-xs text-amber-600 font-medium uppercase tracking-wide mb-2"> <span className="text-sm font-semibold text-amber-700"> Outflows</span>
Outflows (to Funnels) <button
</span> onClick={() => setShowAddOutflow(true)}
<svg ref={overflowPieRef} width={120} height={120} className="cursor-pointer"> className="w-6 h-6 bg-amber-500 text-white rounded-full flex items-center justify-center hover:bg-amber-600 transition-colors"
{renderPieChart(localOverflow, OVERFLOW_COLORS, 'overflow', 120)} >
<circle cx={60} cy={60} r={25} fill="white" /> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<text x={60} y={64} textAnchor="middle" className="text-xs fill-slate-500 font-medium"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</text> </button>
</svg>
<div className="mt-2 space-y-1">
{localOverflow.map((alloc, idx) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: alloc.color || OVERFLOW_COLORS[idx] }}
/>
<span className="text-slate-600 truncate max-w-[80px]">{alloc.targetId}</span>
<span className="text-amber-600 font-mono">{alloc.percentage}%</span>
</div>
))}
</div>
</div> </div>
)}
{/* Spending Pie */} {localOverflow.length > 0 ? (
{localSpending.length > 0 && ( <>
<div className="flex flex-col items-center"> <svg ref={overflowPieRef} width={100} height={100} className="mx-auto cursor-pointer">
<span className="text-xs text-blue-600 font-medium uppercase tracking-wide mb-2"> {renderPieChart(localOverflow, OVERFLOW_COLORS, 'overflow', 100)}
Spending (to Outcomes) <circle cx={50} cy={50} r={20} fill="white" />
</span> </svg>
<svg ref={spendingPieRef} width={120} height={120} className="cursor-pointer"> <div className="mt-3 space-y-1">
{renderPieChart(localSpending, SPENDING_COLORS, 'spending', 120)} {localOverflow.map((alloc, idx) => (
<circle cx={60} cy={60} r={25} fill="white" /> <div key={idx} className="flex items-center gap-2 text-xs group">
<text x={60} y={64} textAnchor="middle" className="text-xs fill-slate-500 font-medium"> <div
className="w-3 h-3 rounded flex-shrink-0"
</text> style={{ backgroundColor: alloc.color || OVERFLOW_COLORS[idx] }}
</svg> />
<div className="mt-2 space-y-1"> <span className="text-slate-600 truncate flex-1">{alloc.targetId}</span>
{localSpending.map((alloc, idx) => ( <span className="text-amber-600 font-mono">{alloc.percentage}%</span>
<div key={idx} className="flex items-center gap-2 text-xs"> <button
<div onClick={() => handleRemoveOutflow(idx)}
className="w-3 h-3 rounded" className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600"
style={{ backgroundColor: alloc.color || SPENDING_COLORS[idx] }} >
/> <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<span className="text-slate-600 truncate max-w-[80px]">{alloc.targetId}</span> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<span className="text-blue-600 font-mono">{alloc.percentage}%</span> </svg>
</div> </button>
))} </div>
))}
</div>
</>
) : (
<p className="text-xs text-amber-600/60 text-center py-4">No outflows yet</p>
)}
{/* Add Outflow Form */}
{showAddOutflow && (
<div className="mt-3 p-2 bg-white rounded-lg border border-amber-200">
<input
type="text"
value={newItemName}
onChange={(e) => 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
/>
<div className="flex gap-1">
<button
onClick={handleAddOutflow}
className="flex-1 text-xs px-2 py-1 bg-amber-500 text-white rounded hover:bg-amber-600"
>
Add
</button>
<button
onClick={() => { setShowAddOutflow(false); setNewItemName(''); }}
className="text-xs px-2 py-1 text-slate-500 hover:text-slate-700"
>
Cancel
</button>
</div>
</div> </div>
)}
</div>
{/* Spending/Outcomes Column */}
<div className="bg-blue-50 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-blue-700"> Outcomes</span>
<button
onClick={() => setShowAddOutcome(true)}
className="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center hover:bg-blue-600 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div> </div>
)}
{localSpending.length > 0 ? (
<>
<svg ref={spendingPieRef} width={100} height={100} className="mx-auto cursor-pointer">
{renderPieChart(localSpending, SPENDING_COLORS, 'spending', 100)}
<circle cx={50} cy={50} r={20} fill="white" />
</svg>
<div className="mt-3 space-y-1">
{localSpending.map((alloc, idx) => (
<div key={idx} className="flex items-center gap-2 text-xs group">
<div
className="w-3 h-3 rounded flex-shrink-0"
style={{ backgroundColor: alloc.color || SPENDING_COLORS[idx] }}
/>
<span className="text-slate-600 truncate flex-1">{alloc.targetId}</span>
<span className="text-blue-600 font-mono">{alloc.percentage}%</span>
<button
onClick={() => handleRemoveSpending(idx)}
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
</>
) : (
<p className="text-xs text-blue-600/60 text-center py-4">No outcomes yet</p>
)}
{/* Add Outcome Form */}
{showAddOutcome && (
<div className="mt-3 p-2 bg-white rounded-lg border border-blue-200">
<input
type="text"
value={newItemName}
onChange={(e) => 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
/>
<div className="flex gap-1">
<button
onClick={handleAddOutcome}
className="flex-1 text-xs px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Add
</button>
<button
onClick={() => { setShowAddOutcome(false); setNewItemName(''); }}
className="text-xs px-2 py-1 text-slate-500 hover:text-slate-700"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div> </div>
{(localOverflow.length > 0 || localSpending.length > 0) && ( <p className="text-center text-[10px] text-slate-400 mt-4">
<p className="text-center text-[10px] text-slate-400 mt-4"> Drag pie slices to adjust Click + to add new items
Drag pie slices to adjust allocations </p>
</p>
)}
{/* Close button */} {/* Close button */}
<div className="flex justify-center mt-6"> <div className="flex justify-center mt-6">