Compare commits
2 Commits
1d675ba666
...
d999695913
| Author | SHA1 | Date |
|---|---|---|
|
|
d999695913 | |
|
|
bd77cd9113 |
|
|
@ -41,12 +41,20 @@ const edgeTypes = {
|
||||||
// Generate edges with proportional Sankey-style widths
|
// Generate edges with proportional Sankey-style widths
|
||||||
function generateEdges(
|
function generateEdges(
|
||||||
nodes: FlowNode[],
|
nodes: FlowNode[],
|
||||||
onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => void
|
onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending' | 'source', delta: number) => void
|
||||||
): FlowEdge[] {
|
): FlowEdge[] {
|
||||||
const edges: FlowEdge[] = []
|
const edges: FlowEdge[] = []
|
||||||
|
|
||||||
const flowValues: number[] = []
|
const flowValues: number[] = []
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
|
if (node.type === 'source') {
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
const rate = data.flowRate || 1
|
||||||
|
data.targetAllocations?.forEach((alloc) => {
|
||||||
|
flowValues.push((alloc.percentage / 100) * rate)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (node.type !== 'funnel') return
|
if (node.type !== 'funnel') return
|
||||||
const data = node.data as FunnelNodeData
|
const data = node.data as FunnelNodeData
|
||||||
const rate = data.inflowRate || 1
|
const rate = data.inflowRate || 1
|
||||||
|
|
@ -63,6 +71,48 @@ function generateEdges(
|
||||||
const MIN_WIDTH = 3
|
const MIN_WIDTH = 3
|
||||||
const MAX_WIDTH = 24
|
const MAX_WIDTH = 24
|
||||||
|
|
||||||
|
// Source node edges
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type !== 'source') return
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
const rate = data.flowRate || 1
|
||||||
|
const allocCount = data.targetAllocations?.length ?? 0
|
||||||
|
|
||||||
|
data.targetAllocations?.forEach((alloc) => {
|
||||||
|
const flowValue = (alloc.percentage / 100) * rate
|
||||||
|
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
id: `source-${node.id}-${alloc.targetId}`,
|
||||||
|
source: node.id,
|
||||||
|
target: alloc.targetId,
|
||||||
|
sourceHandle: 'source-out',
|
||||||
|
animated: true,
|
||||||
|
style: {
|
||||||
|
stroke: alloc.color,
|
||||||
|
strokeWidth,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
color: alloc.color,
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
allocation: alloc.percentage,
|
||||||
|
color: alloc.color,
|
||||||
|
edgeType: 'source' as const,
|
||||||
|
sourceId: node.id,
|
||||||
|
targetId: alloc.targetId,
|
||||||
|
siblingCount: allocCount,
|
||||||
|
onAdjust,
|
||||||
|
},
|
||||||
|
type: 'allocation',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
if (node.type !== 'funnel') return
|
if (node.type !== 'funnel') return
|
||||||
const data = node.data as FunnelNodeData
|
const data = node.data as FunnelNodeData
|
||||||
|
|
@ -253,9 +303,46 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
|
|
||||||
// Adjust allocation percentage inline from edge +/- buttons
|
// Adjust allocation percentage inline from edge +/- buttons
|
||||||
const onAdjustAllocation = useCallback(
|
const onAdjustAllocation = useCallback(
|
||||||
(sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => {
|
(sourceId: string, targetId: string, edgeType: 'overflow' | 'spending' | 'source', delta: number) => {
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.id !== sourceId || node.type !== 'funnel') return node
|
if (node.id !== sourceId) return node
|
||||||
|
|
||||||
|
// Source node allocation adjustment
|
||||||
|
if (edgeType === 'source' && node.type === 'source') {
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
const allocs = [...data.targetAllocations]
|
||||||
|
if (allocs.length <= 1) return node
|
||||||
|
|
||||||
|
const idx = allocs.findIndex(a => a.targetId === targetId)
|
||||||
|
if (idx === -1) return node
|
||||||
|
|
||||||
|
const current = allocs[idx].percentage
|
||||||
|
const newPct = Math.max(5, Math.min(95, current + delta))
|
||||||
|
const actualDelta = newPct - current
|
||||||
|
if (actualDelta === 0) return node
|
||||||
|
|
||||||
|
const siblings = allocs.filter((_, i) => i !== idx)
|
||||||
|
const siblingTotal = siblings.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
|
||||||
|
const updated = allocs.map((a, i) => {
|
||||||
|
if (i === idx) return { ...a, percentage: newPct }
|
||||||
|
const share = siblingTotal > 0 ? a.percentage / siblingTotal : 1 / siblings.length
|
||||||
|
return { ...a, percentage: Math.max(1, Math.round(a.percentage - actualDelta * share)) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const sum = updated.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
if (sum !== 100 && updated.length > 1) {
|
||||||
|
const diff = 100 - sum
|
||||||
|
const largestSibIdx = updated.reduce((best, a, i) =>
|
||||||
|
i !== idx && a.percentage > updated[best].percentage ? i : best, idx === 0 ? 1 : 0)
|
||||||
|
updated[largestSibIdx] = { ...updated[largestSibIdx], percentage: updated[largestSibIdx].percentage + diff }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...node, data: { ...data, targetAllocations: updated } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funnel node allocation adjustment
|
||||||
|
if (node.type !== 'funnel') return node
|
||||||
const data = node.data as FunnelNodeData
|
const data = node.data as FunnelNodeData
|
||||||
const allocKey = edgeType === 'overflow' ? 'overflowAllocations' : 'spendingAllocations'
|
const allocKey = edgeType === 'overflow' ? 'overflowAllocations' : 'spendingAllocations'
|
||||||
const allocs = [...data[allocKey]]
|
const allocs = [...data[allocKey]]
|
||||||
|
|
@ -301,7 +388,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
|
|
||||||
// Smart edge regeneration
|
// Smart edge regeneration
|
||||||
const allocationsKey = useMemo(() => {
|
const allocationsKey = useMemo(() => {
|
||||||
const funnelKeys = nodes
|
const funnels = nodes
|
||||||
.filter(n => n.type === 'funnel')
|
.filter(n => n.type === 'funnel')
|
||||||
.map(n => {
|
.map(n => {
|
||||||
const d = n.data as FunnelNodeData
|
const d = n.data as FunnelNodeData
|
||||||
|
|
@ -313,17 +400,17 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
rate: d.inflowRate,
|
rate: d.inflowRate,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const sourceKeys = nodes
|
const sources = nodes
|
||||||
.filter(n => n.type === 'source')
|
.filter(n => n.type === 'source')
|
||||||
.map(n => {
|
.map(n => {
|
||||||
const d = n.data as SourceNodeData
|
const d = n.data as SourceNodeData
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
targets: d.targetAllocations,
|
allocations: d.targetAllocations,
|
||||||
rate: d.flowRate,
|
rate: d.flowRate,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return JSON.stringify({ funnelKeys, sourceKeys })
|
return JSON.stringify({ funnels, sources })
|
||||||
}, [nodes])
|
}, [nodes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -335,14 +422,14 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
(params: Connection) => {
|
(params: Connection) => {
|
||||||
if (!params.source || !params.target) return
|
if (!params.source || !params.target) return
|
||||||
|
|
||||||
|
const isSourceOut = params.sourceHandle === 'source-out'
|
||||||
const isOverflow = params.sourceHandle?.startsWith('outflow')
|
const isOverflow = params.sourceHandle?.startsWith('outflow')
|
||||||
const isSpending = params.sourceHandle === 'spending-out'
|
const isSpending = params.sourceHandle === 'spending-out'
|
||||||
const isStream = params.sourceHandle === 'stream-out'
|
const isStream = params.sourceHandle === 'stream-out'
|
||||||
const isSourceOut = params.sourceHandle === 'source-out'
|
|
||||||
|
|
||||||
if (!isOverflow && !isSpending && !isStream && !isSourceOut) return
|
if (!isSourceOut && !isOverflow && !isSpending && !isStream) return
|
||||||
|
|
||||||
// Handle source node connections
|
// Source node connections
|
||||||
if (isSourceOut) {
|
if (isSourceOut) {
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.id !== params.source || node.type !== 'source') return node
|
if (node.id !== params.source || node.type !== 'source') return node
|
||||||
|
|
@ -457,7 +544,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
if (oldTargetId === newTargetId) return
|
if (oldTargetId === newTargetId) return
|
||||||
|
|
||||||
// Source edges: reconnect source allocation
|
// Source edges: reconnect source allocation
|
||||||
if (oldEdge.id?.startsWith('source-')) {
|
if (edgeData.edgeType === 'source') {
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.id !== oldEdge.source || node.type !== 'source') return node
|
if (node.id !== oldEdge.source || node.type !== 'source') return node
|
||||||
const data = node.data as SourceNodeData
|
const data = node.data as SourceNodeData
|
||||||
|
|
@ -465,7 +552,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
...node,
|
...node,
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
targetAllocations: (data.targetAllocations || []).map(a =>
|
targetAllocations: data.targetAllocations.map(a =>
|
||||||
a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a
|
a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -541,12 +628,14 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
if (change.type === 'remove') {
|
if (change.type === 'remove') {
|
||||||
const edge = edgesRef.current.find(e => e.id === change.id)
|
const edge = edgesRef.current.find(e => e.id === change.id)
|
||||||
if (edge?.data) {
|
if (edge?.data) {
|
||||||
|
const allocData = edge.data as AllocationEdgeData
|
||||||
|
|
||||||
// Source edge removal
|
// Source edge removal
|
||||||
if (edge.id?.startsWith('source-')) {
|
if (allocData.edgeType === 'source') {
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.id !== edge.source || node.type !== 'source') return node
|
if (node.id !== edge.source || node.type !== 'source') return node
|
||||||
const data = node.data as SourceNodeData
|
const data = node.data as SourceNodeData
|
||||||
const filtered = (data.targetAllocations || []).filter(a => a.targetId !== edge.target)
|
const filtered = data.targetAllocations.filter(a => a.targetId !== edge.target)
|
||||||
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
|
|
@ -558,7 +647,6 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
onEdgesChange([change])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -577,7 +665,6 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allocData = edge.data as AllocationEdgeData
|
|
||||||
if (allocData.edgeType === 'overflow') {
|
if (allocData.edgeType === 'overflow') {
|
||||||
const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target)
|
const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target)
|
||||||
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
|
@ -675,8 +762,8 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
position: pos,
|
position: pos,
|
||||||
data: {
|
data: {
|
||||||
label: 'New Source',
|
label: 'New Source',
|
||||||
flowRate: 500,
|
sourceType: 'unconfigured',
|
||||||
sourceType: 'recurring',
|
flowRate: 0,
|
||||||
targetAllocations: [],
|
targetAllocations: [],
|
||||||
} as SourceNodeData,
|
} as SourceNodeData,
|
||||||
},
|
},
|
||||||
|
|
@ -690,8 +777,8 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.type === 'source') {
|
if (node.type === 'source') {
|
||||||
const data = node.data as SourceNodeData
|
const data = node.data as SourceNodeData
|
||||||
const filtered = (data.targetAllocations || []).filter(a => !deletedIds.has(a.targetId))
|
const filtered = data.targetAllocations.filter(a => !deletedIds.has(a.targetId))
|
||||||
if (filtered.length === data.targetAllocations?.length) return node
|
if (filtered.length === data.targetAllocations.length) return node
|
||||||
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
|
|
@ -741,41 +828,13 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
}))
|
}))
|
||||||
}, [setNodes])
|
}, [setNodes])
|
||||||
|
|
||||||
// Simulation
|
// Simulation — real flow logic (inflow → overflow → spending → outcomes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSimulating) return
|
if (!isSimulating) return
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setNodes((nds) =>
|
setNodes((nds) => simulateTick(nds as FlowNode[]))
|
||||||
nds.map((node) => {
|
}, 1000)
|
||||||
if (node.type === 'funnel') {
|
|
||||||
const data = node.data as FunnelNodeData
|
|
||||||
const change = (Math.random() - 0.45) * 300
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else if (node.type === 'outcome') {
|
|
||||||
const data = node.data as OutcomeNodeData
|
|
||||||
const change = Math.random() * 80
|
|
||||||
const newReceived = Math.min(data.fundingTarget * 1.05, data.fundingReceived + change)
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
fundingReceived: newReceived,
|
|
||||||
status: newReceived >= data.fundingTarget ? 'completed' :
|
|
||||||
data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return node
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}, 500)
|
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [isSimulating, setNodes])
|
}, [isSimulating, setNodes])
|
||||||
|
|
@ -848,31 +907,33 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
{/* Top-right Controls */}
|
{/* Top-right Controls */}
|
||||||
<Panel position="top-right" className="m-4 flex gap-2">
|
<Panel position="top-right" className="m-4 flex gap-2">
|
||||||
{mode === 'space' && (
|
{mode === 'space' && (
|
||||||
<button
|
<>
|
||||||
onClick={() => setShowIntegrations(true)}
|
<button
|
||||||
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-indigo-500 text-white hover:bg-indigo-600 transition-all"
|
onClick={() => setShowIntegrations(true)}
|
||||||
>
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-indigo-500 text-white hover:bg-indigo-600 transition-all"
|
||||||
Link Data
|
>
|
||||||
</button>
|
Link Data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addSource}
|
||||||
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-emerald-500 text-white hover:bg-emerald-600 transition-all"
|
||||||
|
>
|
||||||
|
+ Source
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addFunnel}
|
||||||
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-amber-500 text-white hover:bg-amber-600 transition-all"
|
||||||
|
>
|
||||||
|
+ Funnel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addOutcome}
|
||||||
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-pink-500 text-white hover:bg-pink-600 transition-all"
|
||||||
|
>
|
||||||
|
+ Outcome
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={addSource}
|
|
||||||
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-emerald-500 text-white hover:bg-emerald-600 transition-all"
|
|
||||||
>
|
|
||||||
+ Source
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={addFunnel}
|
|
||||||
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-amber-500 text-white hover:bg-amber-600 transition-all"
|
|
||||||
>
|
|
||||||
+ Funnel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={addOutcome}
|
|
||||||
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-pink-500 text-white hover:bg-pink-600 transition-all"
|
|
||||||
>
|
|
||||||
+ Outcome
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSimulating(!isSimulating)}
|
onClick={() => setIsSimulating(!isSimulating)}
|
||||||
className={`px-4 py-2 rounded-lg font-medium shadow-sm transition-all text-sm ${
|
className={`px-4 py-2 rounded-lg font-medium shadow-sm transition-all text-sm ${
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,52 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { memo, useState, useCallback, useMemo } from 'react'
|
import { memo, useState, useCallback, useMemo } 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 { OutcomeNodeData } from '@/lib/types'
|
import type { OutcomeNodeData, OutcomePhase, PhaseTask } from '@/lib/types'
|
||||||
import { useConnectionState } from '../ConnectionContext'
|
import { useConnectionState } from '../ConnectionContext'
|
||||||
|
|
||||||
|
const PHASE_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4']
|
||||||
|
|
||||||
function OutcomeNode({ data, selected, id }: NodeProps) {
|
function OutcomeNode({ data, selected, id }: NodeProps) {
|
||||||
const nodeData = data as OutcomeNodeData
|
const nodeData = data as OutcomeNodeData
|
||||||
const { label, description, fundingReceived, fundingTarget, status } = nodeData
|
const { label, description, fundingReceived, fundingTarget, status, phases } = nodeData
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
const [expandedPhaseIdx, setExpandedPhaseIdx] = useState<number | null>(null)
|
||||||
|
const [isEditingPhases, setIsEditingPhases] = useState(false)
|
||||||
const connectingFrom = useConnectionState()
|
const connectingFrom = useConnectionState()
|
||||||
|
const { setNodes } = useReactFlow()
|
||||||
|
|
||||||
const progress = fundingTarget > 0 ? Math.min(100, (fundingReceived / fundingTarget) * 100) : 0
|
const progress = fundingTarget > 0 ? Math.min(100, (fundingReceived / fundingTarget) * 100) : 0
|
||||||
const isFunded = fundingReceived >= fundingTarget
|
const isFunded = fundingReceived >= fundingTarget
|
||||||
const isPartial = fundingReceived > 0 && fundingReceived < fundingTarget
|
const isPartial = fundingReceived > 0 && fundingReceived < fundingTarget
|
||||||
|
|
||||||
|
// Phase computations
|
||||||
|
const sortedPhases = useMemo(() =>
|
||||||
|
phases ? [...phases].sort((a, b) => a.fundingThreshold - b.fundingThreshold) : [],
|
||||||
|
[phases]
|
||||||
|
)
|
||||||
|
const currentPhaseIdx = useMemo(() => {
|
||||||
|
if (sortedPhases.length === 0) return -1
|
||||||
|
let idx = -1
|
||||||
|
for (let i = 0; i < sortedPhases.length; i++) {
|
||||||
|
if (fundingReceived >= sortedPhases[i].fundingThreshold) idx = i
|
||||||
|
}
|
||||||
|
// If all unlocked, return last; otherwise return last unlocked
|
||||||
|
return idx
|
||||||
|
}, [sortedPhases, fundingReceived])
|
||||||
|
|
||||||
|
// Auto-expand active phase in modal
|
||||||
|
const autoExpandIdx = useMemo(() => {
|
||||||
|
if (sortedPhases.length === 0) return null
|
||||||
|
// Find first unlocked phase with incomplete tasks
|
||||||
|
for (let i = 0; i <= currentPhaseIdx; i++) {
|
||||||
|
if (sortedPhases[i].tasks.some(t => !t.completed)) return i
|
||||||
|
}
|
||||||
|
// If all unlocked tasks done, show last unlocked
|
||||||
|
return currentPhaseIdx >= 0 ? currentPhaseIdx : 0
|
||||||
|
}, [sortedPhases, currentPhaseIdx])
|
||||||
|
|
||||||
// Handle highlighting: outcome targets glow when dragging from spending or stream handles
|
// Handle highlighting: outcome targets glow when dragging from spending or stream handles
|
||||||
const isTargetHighlighted = useMemo(() => {
|
const isTargetHighlighted = useMemo(() => {
|
||||||
if (!connectingFrom || connectingFrom.nodeId === id) return false
|
if (!connectingFrom || connectingFrom.nodeId === id) return false
|
||||||
|
|
@ -128,6 +159,37 @@ function OutcomeNode({ data, selected, id }: NodeProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Phase Indicator (compact) */}
|
||||||
|
{sortedPhases.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Phases</span>
|
||||||
|
<span className="text-[9px] text-slate-500 font-medium">
|
||||||
|
{currentPhaseIdx >= 0 ? `Phase ${currentPhaseIdx + 1}` : 'Locked'} of {sortedPhases.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden flex gap-px">
|
||||||
|
{sortedPhases.map((phase, i) => {
|
||||||
|
const isUnlocked = fundingReceived >= phase.fundingThreshold
|
||||||
|
const allDone = phase.tasks.every(t => t.completed)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-full transition-all duration-300 rounded-full"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: isUnlocked
|
||||||
|
? (allDone ? '#10b981' : PHASE_COLORS[i % PHASE_COLORS.length])
|
||||||
|
: '#e2e8f0',
|
||||||
|
}}
|
||||||
|
title={`${phase.label}: $${phase.fundingThreshold.toLocaleString()} ${isUnlocked ? '(unlocked)' : '(locked)'}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<span className="text-[8px] text-slate-400">Double-click for details</span>
|
<span className="text-[8px] text-slate-400">Double-click for details</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -245,8 +307,205 @@ function OutcomeNode({ data, selected, id }: NodeProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Milestones */}
|
{/* Phase Accordion */}
|
||||||
{nodeData.milestones && nodeData.milestones.length > 0 && (
|
{sortedPhases.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Phases</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditingPhases(!isEditingPhases)}
|
||||||
|
className="text-[10px] text-blue-500 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
{isEditingPhases ? 'Done Editing' : 'Edit Phases'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Phase tier segmented bar */}
|
||||||
|
<div className="h-2 bg-slate-100 rounded-full overflow-hidden flex gap-px mb-3">
|
||||||
|
{sortedPhases.map((phase, i) => {
|
||||||
|
const isUnlocked = fundingReceived >= phase.fundingThreshold
|
||||||
|
const allDone = phase.tasks.every(t => t.completed)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-full transition-all duration-300 rounded-full"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: isUnlocked
|
||||||
|
? (allDone ? '#10b981' : PHASE_COLORS[i % PHASE_COLORS.length])
|
||||||
|
: '#e2e8f0',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedPhases.map((phase, phaseIdx) => {
|
||||||
|
const isUnlocked = fundingReceived >= phase.fundingThreshold
|
||||||
|
const allDone = phase.tasks.every(t => t.completed)
|
||||||
|
const completedCount = phase.tasks.filter(t => t.completed).length
|
||||||
|
const isOpen = expandedPhaseIdx === phaseIdx || (expandedPhaseIdx === null && autoExpandIdx === phaseIdx)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={phaseIdx}
|
||||||
|
className={`rounded-xl border overflow-hidden transition-all ${
|
||||||
|
isUnlocked ? 'border-slate-200 bg-white' : 'border-slate-100 bg-slate-50 opacity-70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Phase header */}
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 p-3 text-left"
|
||||||
|
onClick={() => setExpandedPhaseIdx(isOpen ? null : phaseIdx)}
|
||||||
|
>
|
||||||
|
{/* Status icon */}
|
||||||
|
{isUnlocked ? (
|
||||||
|
allDone ? (
|
||||||
|
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="w-4 h-4 rounded-full flex-shrink-0" style={{ backgroundColor: PHASE_COLORS[phaseIdx % PHASE_COLORS.length] }} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className={`text-xs font-medium block truncate ${isUnlocked ? 'text-slate-800' : 'text-slate-500'}`}>
|
||||||
|
{phase.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-400">
|
||||||
|
${phase.fundingThreshold.toLocaleString()} threshold
|
||||||
|
{isUnlocked && ` · ${completedCount}/${phase.tasks.length} tasks`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Chevron */}
|
||||||
|
<svg className={`w-4 h-4 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Phase content */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-3 pb-3 border-t border-slate-100">
|
||||||
|
{/* Funding progress for this phase */}
|
||||||
|
<div className="flex items-center gap-2 py-2">
|
||||||
|
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (fundingReceived / phase.fundingThreshold) * 100)}%`,
|
||||||
|
backgroundColor: isUnlocked ? '#10b981' : PHASE_COLORS[phaseIdx % PHASE_COLORS.length],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-mono text-slate-500">
|
||||||
|
${Math.floor(Math.min(fundingReceived, phase.fundingThreshold)).toLocaleString()} / ${phase.fundingThreshold.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{phase.tasks.map((task, taskIdx) => (
|
||||||
|
<label
|
||||||
|
key={taskIdx}
|
||||||
|
className={`flex items-center gap-2 text-xs cursor-pointer group ${
|
||||||
|
!isUnlocked ? 'pointer-events-none' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={task.completed}
|
||||||
|
disabled={!isUnlocked}
|
||||||
|
onChange={() => {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== id) return node
|
||||||
|
const d = node.data as OutcomeNodeData
|
||||||
|
const updatedPhases = [...(d.phases || [])]
|
||||||
|
const pIdx = updatedPhases.findIndex(p => p.label === phase.label && p.fundingThreshold === phase.fundingThreshold)
|
||||||
|
if (pIdx === -1) return node
|
||||||
|
const updatedTasks = [...updatedPhases[pIdx].tasks]
|
||||||
|
updatedTasks[taskIdx] = { ...updatedTasks[taskIdx], completed: !updatedTasks[taskIdx].completed }
|
||||||
|
updatedPhases[pIdx] = { ...updatedPhases[pIdx], tasks: updatedTasks }
|
||||||
|
return { ...node, data: { ...d, phases: updatedPhases } }
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
<span className={task.completed ? 'text-slate-500 line-through' : 'text-slate-700'}>
|
||||||
|
{task.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit mode: add task */}
|
||||||
|
{isEditingPhases && isUnlocked && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const taskLabel = prompt('New task label:')
|
||||||
|
if (!taskLabel) return
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== id) return node
|
||||||
|
const d = node.data as OutcomeNodeData
|
||||||
|
const updatedPhases = [...(d.phases || [])]
|
||||||
|
const pIdx = updatedPhases.findIndex(p => p.label === phase.label && p.fundingThreshold === phase.fundingThreshold)
|
||||||
|
if (pIdx === -1) return node
|
||||||
|
updatedPhases[pIdx] = {
|
||||||
|
...updatedPhases[pIdx],
|
||||||
|
tasks: [...updatedPhases[pIdx].tasks, { label: taskLabel, completed: false }],
|
||||||
|
}
|
||||||
|
return { ...node, data: { ...d, phases: updatedPhases } }
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
className="text-[10px] text-blue-500 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
+ Add Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Phase button (edit mode) */}
|
||||||
|
{isEditingPhases && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const phaseLabel = prompt('Phase label:', `Phase ${sortedPhases.length + 1}`)
|
||||||
|
if (!phaseLabel) return
|
||||||
|
const threshold = prompt('Funding threshold ($):', String((sortedPhases.at(-1)?.fundingThreshold ?? 0) + 10000))
|
||||||
|
if (!threshold) return
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== id) return node
|
||||||
|
const d = node.data as OutcomeNodeData
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...d,
|
||||||
|
phases: [
|
||||||
|
...(d.phases || []),
|
||||||
|
{ label: phaseLabel, fundingThreshold: Number(threshold), tasks: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
className="w-full mt-2 px-3 py-2 border border-dashed border-slate-300 rounded-lg text-xs text-slate-500 hover:border-blue-400 hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add Phase
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legacy Milestones (backward compat when no phases) */}
|
||||||
|
{(!phases || phases.length === 0) && nodeData.milestones && nodeData.milestones.length > 0 && (
|
||||||
<div className="mb-4 p-3 bg-emerald-50 rounded-xl">
|
<div className="mb-4 p-3 bg-emerald-50 rounded-xl">
|
||||||
<span className="text-[10px] text-emerald-600 uppercase tracking-wide block mb-2">Milestones</span>
|
<span className="text-[10px] text-emerald-600 uppercase tracking-wide block mb-2">Milestones</span>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
|
||||||
|
|
@ -1,136 +1,218 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { memo, useState, useCallback } from 'react'
|
import { memo, useState, useCallback, useMemo } from 'react'
|
||||||
import { Handle, Position, useReactFlow } from '@xyflow/react'
|
import { Handle, Position, useReactFlow } from '@xyflow/react'
|
||||||
import type { NodeProps } from '@xyflow/react'
|
import type { NodeProps } from '@xyflow/react'
|
||||||
import type { SourceNodeData } from '@/lib/types'
|
import type { SourceNodeData } from '@/lib/types'
|
||||||
|
import { useAuthStore } from '@/lib/auth'
|
||||||
|
|
||||||
const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9']
|
const CHAIN_OPTIONS = [
|
||||||
|
{ id: 1, name: 'Ethereum' },
|
||||||
|
{ id: 10, name: 'Optimism' },
|
||||||
|
{ id: 100, name: 'Gnosis' },
|
||||||
|
{ id: 137, name: 'Polygon' },
|
||||||
|
{ id: 8453, name: 'Base' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CURRENCIES = ['USD', 'EUR', 'GBP']
|
||||||
|
|
||||||
|
type SourceType = SourceNodeData['sourceType']
|
||||||
|
|
||||||
|
const SOURCE_TYPE_META: Record<SourceType, { label: string; color: string }> = {
|
||||||
|
card: { label: 'Credit Card', color: 'violet' },
|
||||||
|
safe_wallet: { label: 'Safe / Wallet', color: 'cyan' },
|
||||||
|
ridentity: { label: 'rIdentity', color: 'purple' },
|
||||||
|
unconfigured: { label: 'Unconfigured', color: 'slate' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceTypeIcon({ type, className = 'w-4 h-4' }: { type: SourceType; className?: string }) {
|
||||||
|
switch (type) {
|
||||||
|
case 'card':
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'safe_wallet':
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'ridentity':
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default: // unconfigured
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function SourceNode({ data, selected, id }: NodeProps) {
|
function SourceNode({ data, selected, id }: NodeProps) {
|
||||||
const nodeData = data as SourceNodeData
|
const nodeData = data as SourceNodeData
|
||||||
const { label, flowRate, sourceType, targetAllocations = [] } = nodeData
|
const { label, sourceType, flowRate, targetAllocations = [] } = nodeData
|
||||||
const { setNodes } = useReactFlow()
|
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const { setNodes } = useReactFlow()
|
||||||
|
const { isAuthenticated, did, login } = useAuthStore()
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
const [editLabel, setEditLabel] = useState(label)
|
const [editLabel, setEditLabel] = useState(label)
|
||||||
const [editRate, setEditRate] = useState(String(flowRate))
|
const [editSourceType, setEditSourceType] = useState<SourceType>(sourceType)
|
||||||
const [editType, setEditType] = useState(sourceType)
|
const [editFlowRate, setEditFlowRate] = useState(flowRate)
|
||||||
|
const [editCurrency, setEditCurrency] = useState(nodeData.transakConfig?.fiatCurrency || 'USD')
|
||||||
|
const [editDefaultAmount, setEditDefaultAmount] = useState(nodeData.transakConfig?.defaultAmount?.toString() || '')
|
||||||
|
const [editWalletAddress, setEditWalletAddress] = useState(nodeData.walletAddress || '')
|
||||||
|
const [editChainId, setEditChainId] = useState(nodeData.chainId || 1)
|
||||||
|
const [editSafeAddress, setEditSafeAddress] = useState(nodeData.safeAddress || '')
|
||||||
|
|
||||||
|
const meta = SOURCE_TYPE_META[sourceType]
|
||||||
|
const isUnconfigured = sourceType === 'unconfigured'
|
||||||
|
|
||||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setEditLabel(label)
|
setEditLabel(label)
|
||||||
setEditRate(String(flowRate))
|
setEditSourceType(sourceType)
|
||||||
setEditType(sourceType)
|
setEditFlowRate(flowRate)
|
||||||
|
setEditCurrency(nodeData.transakConfig?.fiatCurrency || 'USD')
|
||||||
|
setEditDefaultAmount(nodeData.transakConfig?.defaultAmount?.toString() || '')
|
||||||
|
setEditWalletAddress(nodeData.walletAddress || '')
|
||||||
|
setEditChainId(nodeData.chainId || 1)
|
||||||
|
setEditSafeAddress(nodeData.safeAddress || '')
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
}, [label, flowRate, sourceType])
|
}, [label, sourceType, flowRate, nodeData])
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.id !== id) return node
|
if (node.id !== id) return node
|
||||||
return {
|
const updated: SourceNodeData = {
|
||||||
...node,
|
...(node.data as SourceNodeData),
|
||||||
data: {
|
label: editLabel || 'Source',
|
||||||
...(node.data as SourceNodeData),
|
sourceType: editSourceType,
|
||||||
label: editLabel.trim() || 'Source',
|
flowRate: editFlowRate,
|
||||||
flowRate: Math.max(0, Number(editRate) || 0),
|
|
||||||
sourceType: editType,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
if (editSourceType === 'card') {
|
||||||
|
updated.transakConfig = {
|
||||||
|
fiatCurrency: editCurrency,
|
||||||
|
defaultAmount: editDefaultAmount ? parseFloat(editDefaultAmount) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (editSourceType === 'safe_wallet') {
|
||||||
|
updated.walletAddress = editWalletAddress
|
||||||
|
updated.chainId = editChainId
|
||||||
|
updated.safeAddress = editSafeAddress
|
||||||
|
}
|
||||||
|
if (editSourceType === 'ridentity') {
|
||||||
|
updated.encryptIdUserId = did || undefined
|
||||||
|
}
|
||||||
|
return { ...node, data: updated }
|
||||||
}))
|
}))
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
}, [id, editLabel, editRate, editType, setNodes])
|
}, [id, editLabel, editSourceType, editFlowRate, editCurrency, editDefaultAmount, editWalletAddress, editChainId, editSafeAddress, did, setNodes])
|
||||||
|
|
||||||
const typeLabels = {
|
const handleClose = useCallback(() => {
|
||||||
'recurring': 'Recurring',
|
setIsEditing(false)
|
||||||
'one-time': 'One-time',
|
}, [])
|
||||||
'treasury': 'Treasury',
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeColors = {
|
// Allocation bar segments
|
||||||
'recurring': 'bg-emerald-100 text-emerald-700',
|
const allocTotal = useMemo(() =>
|
||||||
'one-time': 'bg-blue-100 text-blue-700',
|
targetAllocations.reduce((s, a) => s + a.percentage, 0),
|
||||||
'treasury': 'bg-violet-100 text-violet-700',
|
[targetAllocations]
|
||||||
}
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
bg-white rounded-xl shadow-lg border-2 min-w-[180px] max-w-[220px]
|
bg-white rounded-lg shadow-lg border-2 min-w-[200px] max-w-[240px]
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
${selected ? 'border-emerald-500 shadow-emerald-200' : 'border-slate-200'}
|
${selected ? 'border-emerald-500 shadow-emerald-200' : isUnconfigured ? 'border-dashed border-slate-300' : 'border-emerald-300'}
|
||||||
`}
|
`}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-3 py-2 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-teal-50 rounded-t-[10px]">
|
<div className={`px-3 py-2 border-b border-slate-100 rounded-t-md ${
|
||||||
|
isUnconfigured ? 'bg-slate-50' : 'bg-gradient-to-r from-emerald-50 to-teal-50'
|
||||||
|
}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-6 h-6 bg-emerald-500 rounded flex items-center justify-center flex-shrink-0">
|
<div className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
|
||||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
isUnconfigured ? 'bg-slate-400' : 'bg-emerald-500'
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
}`}>
|
||||||
</svg>
|
<SourceTypeIcon type={sourceType} className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="font-semibold text-slate-800 text-sm truncate block">{label}</span>
|
||||||
|
<span className={`text-[9px] px-1.5 py-0.5 rounded-full font-medium bg-${meta.color}-100 text-${meta.color}-700`}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold text-slate-800 text-sm truncate">{label}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="p-3 space-y-2">
|
<div className="p-3 space-y-2">
|
||||||
|
{/* Flow rate */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wide ${typeColors[sourceType]}`}>
|
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Flow Rate</span>
|
||||||
{typeLabels[sourceType]}
|
<span className="text-xs font-mono font-medium text-emerald-700">
|
||||||
|
${flowRate.toLocaleString()}/mo
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<span className="text-lg font-bold font-mono text-emerald-600">
|
|
||||||
${flowRate.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-slate-400 ml-1">/mo</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Allocation bars */}
|
{/* Allocation bars */}
|
||||||
{targetAllocations.length > 0 && (
|
{targetAllocations.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<span className="text-[8px] text-emerald-600 uppercase w-10">Flow</span>
|
<span className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Allocations</span>
|
||||||
<div className="flex-1 flex h-2 rounded overflow-hidden">
|
<div className="h-2 bg-slate-100 rounded-full overflow-hidden flex">
|
||||||
{targetAllocations.map((alloc, idx) => (
|
{targetAllocations.map((alloc, i) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={alloc.targetId}
|
||||||
className="transition-all"
|
className="h-full transition-all duration-300"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: alloc.color || SOURCE_COLORS[idx % SOURCE_COLORS.length],
|
width: `${allocTotal > 0 ? (alloc.percentage / allocTotal) * 100 : 0}%`,
|
||||||
width: `${alloc.percentage}%`,
|
backgroundColor: alloc.color,
|
||||||
}}
|
}}
|
||||||
|
title={`${alloc.percentage}% → ${alloc.targetId}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between mt-0.5">
|
||||||
|
{targetAllocations.map((alloc) => (
|
||||||
|
<span key={alloc.targetId} className="text-[8px] font-mono" style={{ color: alloc.color }}>
|
||||||
|
{alloc.percentage}%
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Flow indicator */}
|
{/* Hint */}
|
||||||
<div className="flex items-center justify-center gap-1">
|
{isUnconfigured && (
|
||||||
<svg className="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="currentColor">
|
<div className="text-center">
|
||||||
<path d="M12 16l6-6h-4V4h-4v6H6z"/>
|
<span className="text-[8px] text-slate-400">Double-click to configure</span>
|
||||||
</svg>
|
</div>
|
||||||
<span className="text-[9px] text-emerald-500 uppercase font-medium">Outflow</span>
|
)}
|
||||||
<svg className="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 16l6-6h-4V4h-4v6H6z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
{!isUnconfigured && targetAllocations.length === 0 && (
|
||||||
<span className="text-[8px] text-slate-400">Double-click to edit</span>
|
<div className="text-center">
|
||||||
</div>
|
<span className="text-[8px] text-slate-400">Drag from handle below to connect to funnels</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom handle — connects to funnel top */}
|
{/* Bottom handle - connects to funnels */}
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
id="source-out"
|
id="source-out"
|
||||||
className="!w-4 !h-4 !bg-emerald-500 !border-2 !border-white !-bottom-2"
|
className={`!w-4 !h-4 !border-2 !border-white !-bottom-2 transition-all ${
|
||||||
|
isUnconfigured ? '!bg-slate-400' : '!bg-emerald-500'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -138,64 +220,166 @@ function SourceNode({ data, selected, id }: NodeProps) {
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="bg-white rounded-2xl shadow-2xl p-6 w-80"
|
className="bg-white rounded-2xl shadow-2xl p-6 min-w-[400px] max-w-lg max-h-[90vh] overflow-y-auto"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-bold text-slate-800">Edit Source</h3>
|
<div className="flex items-center gap-3">
|
||||||
<button onClick={() => setIsEditing(false)} className="text-slate-400 hover:text-slate-600">
|
<div className="w-8 h-8 bg-emerald-500 rounded-lg flex items-center justify-center">
|
||||||
|
<SourceTypeIcon type={editSourceType} className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-slate-800">Configure Source</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleClose} className="text-slate-400 hover:text-slate-600">
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* Source Type Picker */}
|
||||||
<div>
|
<div className="mb-4">
|
||||||
<label className="text-xs text-slate-500 uppercase tracking-wide block mb-1">Name</label>
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide block mb-2">Source Type</label>
|
||||||
<input
|
<div className="grid grid-cols-3 gap-2">
|
||||||
type="text"
|
{(['card', 'safe_wallet', 'ridentity'] as SourceType[]).map((type) => {
|
||||||
value={editLabel}
|
const typeMeta = SOURCE_TYPE_META[type]
|
||||||
onChange={(e) => setEditLabel(e.target.value)}
|
const isSelected = editSourceType === type
|
||||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800"
|
return (
|
||||||
autoFocus
|
<button
|
||||||
/>
|
key={type}
|
||||||
</div>
|
onClick={() => setEditSourceType(type)}
|
||||||
|
className={`flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 transition-all ${
|
||||||
<div>
|
isSelected
|
||||||
<label className="text-xs text-slate-500 uppercase tracking-wide block mb-1">Flow Rate ($/mo)</label>
|
? 'border-emerald-500 bg-emerald-50'
|
||||||
<input
|
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||||||
type="number"
|
}`}
|
||||||
value={editRate}
|
>
|
||||||
onChange={(e) => setEditRate(e.target.value)}
|
<SourceTypeIcon type={type} className="w-6 h-6 text-slate-700" />
|
||||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800 font-mono"
|
<span className="text-xs font-medium text-slate-700">{typeMeta.label}</span>
|
||||||
min="0"
|
</button>
|
||||||
/>
|
)
|
||||||
</div>
|
})}
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-slate-500 uppercase tracking-wide block mb-1">Type</label>
|
|
||||||
<select
|
|
||||||
value={editType}
|
|
||||||
onChange={(e) => setEditType(e.target.value as SourceNodeData['sourceType'])}
|
|
||||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800"
|
|
||||||
>
|
|
||||||
<option value="recurring">Recurring</option>
|
|
||||||
<option value="one-time">One-time</option>
|
|
||||||
<option value="treasury">Treasury</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center mt-6">
|
{/* Label */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Label</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
className="w-full text-sm px-3 py-2 border border-slate-200 rounded-lg text-slate-800"
|
||||||
|
placeholder="Source name..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flow Rate */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Flow Rate ($/month)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editFlowRate}
|
||||||
|
onChange={(e) => setEditFlowRate(Number(e.target.value))}
|
||||||
|
className="w-full text-sm px-3 py-2 border border-slate-200 rounded-lg text-slate-800 font-mono"
|
||||||
|
min="0"
|
||||||
|
step="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type-specific config */}
|
||||||
|
{editSourceType === 'card' && (
|
||||||
|
<div className="p-3 bg-violet-50 rounded-xl mb-4">
|
||||||
|
<span className="text-[10px] text-violet-600 uppercase tracking-wide block mb-2">Card Settings (Transak)</span>
|
||||||
|
<label className="text-[10px] text-slate-500 block mb-1">Currency</label>
|
||||||
|
<select
|
||||||
|
value={editCurrency}
|
||||||
|
onChange={(e) => setEditCurrency(e.target.value)}
|
||||||
|
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800"
|
||||||
|
>
|
||||||
|
{CURRENCIES.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
<label className="text-[10px] text-slate-500 block mb-1">Default Amount (optional)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editDefaultAmount}
|
||||||
|
onChange={(e) => setEditDefaultAmount(e.target.value)}
|
||||||
|
placeholder="100"
|
||||||
|
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded text-slate-800"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editSourceType === 'safe_wallet' && (
|
||||||
|
<div className="p-3 bg-cyan-50 rounded-xl mb-4">
|
||||||
|
<span className="text-[10px] text-cyan-600 uppercase tracking-wide block mb-2">Safe / Wallet Settings</span>
|
||||||
|
<label className="text-[10px] text-slate-500 block mb-1">Wallet Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editWalletAddress}
|
||||||
|
onChange={(e) => setEditWalletAddress(e.target.value)}
|
||||||
|
placeholder="0x..."
|
||||||
|
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800 font-mono"
|
||||||
|
/>
|
||||||
|
<label className="text-[10px] text-slate-500 block mb-1">Safe Address (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editSafeAddress}
|
||||||
|
onChange={(e) => setEditSafeAddress(e.target.value)}
|
||||||
|
placeholder="0x..."
|
||||||
|
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800 font-mono"
|
||||||
|
/>
|
||||||
|
<label className="text-[10px] text-slate-500 block mb-1">Chain</label>
|
||||||
|
<select
|
||||||
|
value={editChainId}
|
||||||
|
onChange={(e) => setEditChainId(Number(e.target.value))}
|
||||||
|
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded text-slate-800"
|
||||||
|
>
|
||||||
|
{CHAIN_OPTIONS.map((c) => <option key={c.id} value={c.id}>{c.name} ({c.id})</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editSourceType === 'ridentity' && (
|
||||||
|
<div className="p-3 bg-purple-50 rounded-xl mb-4">
|
||||||
|
<span className="text-[10px] text-purple-600 uppercase tracking-wide block mb-2">rIdentity (EncryptID)</span>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<div className="flex items-center gap-2 p-2 bg-white rounded-lg border border-purple-200">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-purple-500" />
|
||||||
|
<span className="text-xs text-purple-700 font-medium">Connected</span>
|
||||||
|
{did && (
|
||||||
|
<span className="text-[9px] text-purple-500 font-mono truncate ml-auto">{did.slice(0, 20)}...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={async () => { try { await login() } catch {} }}
|
||||||
|
className="w-full text-xs px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-500 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Connect with EncryptID
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save / Cancel */}
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="px-6 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors font-medium"
|
className="flex-1 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-500 transition-colors font-medium"
|
||||||
>
|
>
|
||||||
Done
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="px-4 py-2 text-slate-500 hover:text-slate-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,22 @@ export const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9']
|
||||||
|
|
||||||
// Demo preset: Source → Treasury → 3 sub-funnels → 7 outcomes
|
// Demo preset: Source → Treasury → 3 sub-funnels → 7 outcomes
|
||||||
export const demoNodes: FlowNode[] = [
|
export const demoNodes: FlowNode[] = [
|
||||||
// Revenue source (top)
|
// Source node (top, above Treasury)
|
||||||
{
|
{
|
||||||
id: 'revenue',
|
id: 'source-donations',
|
||||||
type: 'source',
|
type: 'source',
|
||||||
position: { x: 660, y: -200 },
|
position: { x: 650, y: -250 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Revenue Stream',
|
label: 'Donations',
|
||||||
flowRate: 5000,
|
sourceType: 'card',
|
||||||
sourceType: 'recurring',
|
flowRate: 1500,
|
||||||
targetAllocations: [
|
targetAllocations: [
|
||||||
{ targetId: 'treasury', percentage: 100, color: '#10b981' },
|
{ targetId: 'treasury', percentage: 100, color: SOURCE_COLORS[0] },
|
||||||
],
|
],
|
||||||
|
transakConfig: { fiatCurrency: 'USD', defaultAmount: 100 },
|
||||||
} as SourceNodeData,
|
} as SourceNodeData,
|
||||||
},
|
},
|
||||||
// Main Treasury Funnel
|
// Main Treasury Funnel (top center)
|
||||||
{
|
{
|
||||||
id: 'treasury',
|
id: 'treasury',
|
||||||
type: 'funnel',
|
type: 'funnel',
|
||||||
|
|
@ -114,6 +115,32 @@ export const demoNodes: FlowNode[] = [
|
||||||
fundingReceived: 22000,
|
fundingReceived: 22000,
|
||||||
fundingTarget: 30000,
|
fundingTarget: 30000,
|
||||||
status: 'in-progress',
|
status: 'in-progress',
|
||||||
|
phases: [
|
||||||
|
{
|
||||||
|
label: 'Phase 1: Research',
|
||||||
|
fundingThreshold: 5000,
|
||||||
|
tasks: [
|
||||||
|
{ label: 'Requirements analysis', completed: true },
|
||||||
|
{ label: 'Architecture design', completed: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Phase 2: Build',
|
||||||
|
fundingThreshold: 20000,
|
||||||
|
tasks: [
|
||||||
|
{ label: 'Core implementation', completed: false },
|
||||||
|
{ label: 'Testing suite', completed: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Phase 3: Deploy',
|
||||||
|
fundingThreshold: 30000,
|
||||||
|
tasks: [
|
||||||
|
{ label: 'Staging deployment', completed: false },
|
||||||
|
{ label: 'Production launch', completed: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
} as OutcomeNodeData,
|
} as OutcomeNodeData,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
* When dynamicOverflow is true, surplus routes to the most underfunded targets by need-weight.
|
* When dynamicOverflow is true, surplus routes to the most underfunded targets by need-weight.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from './types'
|
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from './types'
|
||||||
|
|
||||||
export interface SimulationConfig {
|
export interface SimulationConfig {
|
||||||
tickDivisor: number // inflowRate divided by this per tick
|
tickDivisor: number // inflowRate divided by this per tick
|
||||||
|
|
@ -114,6 +114,17 @@ export function simulateTick(
|
||||||
.filter((n) => n.type === 'funnel')
|
.filter((n) => n.type === 'funnel')
|
||||||
.sort((a, b) => a.position.y - b.position.y)
|
.sort((a, b) => a.position.y - b.position.y)
|
||||||
|
|
||||||
|
// Source nodes: sum allocations into funnel inflow
|
||||||
|
const sourceInflow = new Map<string, number>()
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type !== 'source') return
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
for (const alloc of data.targetAllocations) {
|
||||||
|
const share = (data.flowRate / tickDivisor) * (alloc.percentage / 100)
|
||||||
|
sourceInflow.set(alloc.targetId, (sourceInflow.get(alloc.targetId) ?? 0) + share)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Accumulators for inter-node transfers
|
// Accumulators for inter-node transfers
|
||||||
const overflowIncoming = new Map<string, number>()
|
const overflowIncoming = new Map<string, number>()
|
||||||
const spendingIncoming = new Map<string, number>()
|
const spendingIncoming = new Map<string, number>()
|
||||||
|
|
@ -125,8 +136,8 @@ export function simulateTick(
|
||||||
const src = node.data as FunnelNodeData
|
const src = node.data as FunnelNodeData
|
||||||
const data: FunnelNodeData = { ...src }
|
const data: FunnelNodeData = { ...src }
|
||||||
|
|
||||||
// 1. Natural inflow
|
// 1. Natural inflow + source node inflow
|
||||||
let value = data.currentValue + data.inflowRate / tickDivisor
|
let value = data.currentValue + data.inflowRate / tickDivisor + (sourceInflow.get(node.id) ?? 0)
|
||||||
|
|
||||||
// 2. Overflow received from upstream funnels
|
// 2. Overflow received from upstream funnels
|
||||||
value += overflowIncoming.get(node.id) ?? 0
|
value += overflowIncoming.get(node.id) ?? 0
|
||||||
|
|
@ -209,10 +220,26 @@ export function simulateTick(
|
||||||
)
|
)
|
||||||
|
|
||||||
let newStatus = data.status
|
let newStatus = data.status
|
||||||
if (data.fundingTarget > 0 && newReceived >= data.fundingTarget && newStatus !== 'blocked') {
|
if (newStatus === 'blocked') {
|
||||||
newStatus = 'completed'
|
// Don't auto-change blocked status
|
||||||
} else if (newReceived > 0 && newStatus === 'not-started') {
|
} else if (data.phases && data.phases.length > 0) {
|
||||||
newStatus = 'in-progress'
|
// Phase-aware status
|
||||||
|
const anyUnlocked = data.phases.some(p => newReceived >= p.fundingThreshold)
|
||||||
|
const allUnlocked = data.phases.every(p => newReceived >= p.fundingThreshold)
|
||||||
|
const allTasksDone = data.phases.every(p => p.tasks.every(t => t.completed))
|
||||||
|
|
||||||
|
if (allUnlocked && allTasksDone) {
|
||||||
|
newStatus = 'completed'
|
||||||
|
} else if (anyUnlocked) {
|
||||||
|
newStatus = 'in-progress'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: no phases defined
|
||||||
|
if (data.fundingTarget > 0 && newReceived >= data.fundingTarget) {
|
||||||
|
newStatus = 'completed'
|
||||||
|
} else if (newReceived > 0 && newStatus === 'not-started') {
|
||||||
|
newStatus = 'in-progress'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
33
lib/types.ts
33
lib/types.ts
|
|
@ -85,7 +85,7 @@ export interface FundingSource {
|
||||||
lastUsedAt?: number;
|
lastUsedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Source Node Types ───────────────────────────────────────
|
// ─── Source Node Types ──────────────────────────────────────
|
||||||
|
|
||||||
export interface SourceAllocation {
|
export interface SourceAllocation {
|
||||||
targetId: string
|
targetId: string
|
||||||
|
|
@ -95,12 +95,34 @@ export interface SourceAllocation {
|
||||||
|
|
||||||
export interface SourceNodeData {
|
export interface SourceNodeData {
|
||||||
label: string
|
label: string
|
||||||
flowRate: number // tokens per month flowing out
|
sourceType: 'card' | 'safe_wallet' | 'ridentity' | 'unconfigured'
|
||||||
sourceType: 'recurring' | 'one-time' | 'treasury'
|
flowRate: number
|
||||||
targetAllocations: SourceAllocation[]
|
targetAllocations: SourceAllocation[]
|
||||||
|
// Card-specific
|
||||||
|
transakConfig?: { fiatCurrency: string; defaultAmount?: number }
|
||||||
|
// Safe/Wallet-specific
|
||||||
|
walletAddress?: string
|
||||||
|
chainId?: number
|
||||||
|
safeAddress?: string
|
||||||
|
// rIdentity-specific
|
||||||
|
encryptIdUserId?: string
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Outcome Phase Types ────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PhaseTask {
|
||||||
|
label: string
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutcomePhase {
|
||||||
|
label: string // e.g., "Phase 1: Research"
|
||||||
|
fundingThreshold: number // funding level to unlock this phase
|
||||||
|
tasks: PhaseTask[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ─── Core Flow Types ─────────────────────────────────────────
|
// ─── Core Flow Types ─────────────────────────────────────────
|
||||||
|
|
||||||
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
|
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
|
||||||
|
|
@ -147,6 +169,7 @@ export interface OutcomeNodeData {
|
||||||
fundingReceived: number
|
fundingReceived: number
|
||||||
fundingTarget: number
|
fundingTarget: number
|
||||||
status: 'not-started' | 'in-progress' | 'completed' | 'blocked'
|
status: 'not-started' | 'in-progress' | 'completed' | 'blocked'
|
||||||
|
phases?: OutcomePhase[] // ordered by fundingThreshold ascending
|
||||||
// Integration metadata
|
// Integration metadata
|
||||||
source?: IntegrationSource
|
source?: IntegrationSource
|
||||||
// Optional detail fields
|
// Optional detail fields
|
||||||
|
|
@ -160,11 +183,11 @@ export type FlowNode = Node<FunnelNodeData | OutcomeNodeData | SourceNodeData>
|
||||||
export interface AllocationEdgeData {
|
export interface AllocationEdgeData {
|
||||||
allocation: number // percentage 0-100
|
allocation: number // percentage 0-100
|
||||||
color: string
|
color: string
|
||||||
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
|
edgeType: 'overflow' | 'spending' | 'source' // overflow = sideways, spending = downward, source = top-down from source
|
||||||
sourceId: string
|
sourceId: string
|
||||||
targetId: string
|
targetId: string
|
||||||
siblingCount: number // how many allocations in this group
|
siblingCount: number // how many allocations in this group
|
||||||
onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => void
|
onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending' | 'source', delta: number) => void
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue