feat: add source nodes and tiered outcome phases to TBFF canvas
Source nodes represent funding origins (Card/Safe/rIdentity) with configurable flow rates and allocation edges to funnels. Outcome nodes now support phased funding tiers with progressive task unlocking as funding thresholds are reached. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cb2dd9c51f
commit
bd77cd9113
|
|
@ -18,17 +18,19 @@ import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
import FunnelNode from './nodes/FunnelNode'
|
import FunnelNode from './nodes/FunnelNode'
|
||||||
import OutcomeNode from './nodes/OutcomeNode'
|
import OutcomeNode from './nodes/OutcomeNode'
|
||||||
|
import SourceNode from './nodes/SourceNode'
|
||||||
import AllocationEdge from './edges/AllocationEdge'
|
import AllocationEdge from './edges/AllocationEdge'
|
||||||
import StreamEdge from './edges/StreamEdge'
|
import StreamEdge from './edges/StreamEdge'
|
||||||
import IntegrationPanel from './IntegrationPanel'
|
import IntegrationPanel from './IntegrationPanel'
|
||||||
import { ConnectionContext, type ConnectingFrom } from './ConnectionContext'
|
import { ConnectionContext, type ConnectingFrom } from './ConnectionContext'
|
||||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
|
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, SourceNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
|
||||||
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
import { SPENDING_COLORS, OVERFLOW_COLORS, SOURCE_COLORS } from '@/lib/presets'
|
||||||
import { simulateTick } from '@/lib/simulation'
|
import { simulateTick } from '@/lib/simulation'
|
||||||
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
funnel: FunnelNode,
|
funnel: FunnelNode,
|
||||||
outcome: OutcomeNode,
|
outcome: OutcomeNode,
|
||||||
|
source: SourceNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
const edgeTypes = {
|
const edgeTypes = {
|
||||||
|
|
@ -39,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
|
||||||
|
|
@ -61,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
|
||||||
|
|
@ -208,9 +260,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]]
|
||||||
|
|
@ -256,20 +345,29 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
|
|
||||||
// Smart edge regeneration
|
// Smart edge regeneration
|
||||||
const allocationsKey = useMemo(() => {
|
const allocationsKey = useMemo(() => {
|
||||||
return JSON.stringify(
|
const funnels = nodes
|
||||||
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
|
return {
|
||||||
return {
|
id: n.id,
|
||||||
id: n.id,
|
overflow: d.overflowAllocations,
|
||||||
overflow: d.overflowAllocations,
|
spending: d.spendingAllocations,
|
||||||
spending: d.spendingAllocations,
|
streams: d.streamAllocations,
|
||||||
streams: d.streamAllocations,
|
rate: d.inflowRate,
|
||||||
rate: d.inflowRate,
|
}
|
||||||
}
|
})
|
||||||
})
|
const sources = nodes
|
||||||
)
|
.filter(n => n.type === 'source')
|
||||||
|
.map(n => {
|
||||||
|
const d = n.data as SourceNodeData
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
allocations: d.targetAllocations,
|
||||||
|
rate: d.flowRate,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return JSON.stringify({ funnels, sources })
|
||||||
}, [nodes])
|
}, [nodes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -281,11 +379,42 @@ 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'
|
||||||
|
|
||||||
if (!isOverflow && !isSpending && !isStream) return
|
if (!isSourceOut && !isOverflow && !isSpending && !isStream) return
|
||||||
|
|
||||||
|
// Source node connections
|
||||||
|
if (isSourceOut) {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== params.source || node.type !== 'source') return node
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
const existing = data.targetAllocations || []
|
||||||
|
if (existing.some(a => a.targetId === params.target)) return node
|
||||||
|
const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1))
|
||||||
|
const redistributed = existing.map(a => ({
|
||||||
|
...a,
|
||||||
|
percentage: Math.floor(a.percentage * existing.length / (existing.length + 1))
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
targetAllocations: [
|
||||||
|
...redistributed,
|
||||||
|
{
|
||||||
|
targetId: params.target!,
|
||||||
|
percentage: newPct,
|
||||||
|
color: SOURCE_COLORS[existing.length % SOURCE_COLORS.length],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.id !== params.source || node.type !== 'funnel') return node
|
if (node.id !== params.source || node.type !== 'funnel') return node
|
||||||
|
|
@ -371,6 +500,24 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
const newTargetId = newConnection.target
|
const newTargetId = newConnection.target
|
||||||
if (oldTargetId === newTargetId) return
|
if (oldTargetId === newTargetId) return
|
||||||
|
|
||||||
|
// Source edges: reconnect source allocation
|
||||||
|
if (edgeData.edgeType === 'source') {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== oldEdge.source || node.type !== 'source') return node
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
targetAllocations: data.targetAllocations.map(a =>
|
||||||
|
a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Stream edges: reconnect stream allocation
|
// Stream edges: reconnect stream allocation
|
||||||
if (oldEdge.type === 'stream') {
|
if (oldEdge.type === 'stream') {
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
|
@ -438,6 +585,28 @@ 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
|
||||||
|
if (allocData.edgeType === 'source') {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== edge.source || node.type !== 'source') return node
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
const filtered = data.targetAllocations.filter(a => a.targetId !== edge.target)
|
||||||
|
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
targetAllocations: total > 0
|
||||||
|
? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) }))
|
||||||
|
: filtered,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.id !== edge.source || node.type !== 'funnel') return node
|
if (node.id !== edge.source || node.type !== 'funnel') return node
|
||||||
const data = node.data as FunnelNodeData
|
const data = node.data as FunnelNodeData
|
||||||
|
|
@ -453,7 +622,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)
|
||||||
|
|
@ -539,6 +707,26 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
])
|
])
|
||||||
}, [setNodes, screenToFlowPosition])
|
}, [setNodes, screenToFlowPosition])
|
||||||
|
|
||||||
|
// Add source node at viewport center
|
||||||
|
const addSource = useCallback(() => {
|
||||||
|
const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 - 100 })
|
||||||
|
const newId = `source-${Date.now()}`
|
||||||
|
setNodes((nds) => [
|
||||||
|
...nds,
|
||||||
|
{
|
||||||
|
id: newId,
|
||||||
|
type: 'source',
|
||||||
|
position: pos,
|
||||||
|
data: {
|
||||||
|
label: 'New Source',
|
||||||
|
sourceType: 'unconfigured',
|
||||||
|
flowRate: 0,
|
||||||
|
targetAllocations: [],
|
||||||
|
} as SourceNodeData,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}, [setNodes, screenToFlowPosition])
|
||||||
|
|
||||||
// Simulation — real flow logic (inflow → overflow → spending → outcomes)
|
// Simulation — real flow logic (inflow → overflow → spending → outcomes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSimulating) return
|
if (!isSimulating) return
|
||||||
|
|
@ -603,6 +791,12 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
>
|
>
|
||||||
Link Data
|
Link Data
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={addFunnel}
|
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"
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-amber-500 text-white hover:bg-amber-600 transition-all"
|
||||||
|
|
@ -633,6 +827,10 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-3 m-4">
|
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-3 m-4">
|
||||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Types</div>
|
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Types</div>
|
||||||
<div className="space-y-1.5 text-xs">
|
<div className="space-y-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-emerald-500 border border-emerald-700" />
|
||||||
|
<span className="text-slate-600">Sources</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||||
<span className="text-slate-600">Inflows (top)</span>
|
<span className="text-slate-600">Inflows (top)</span>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,392 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { memo, useState, useCallback, useMemo } from 'react'
|
||||||
|
import { Handle, Position, useReactFlow } from '@xyflow/react'
|
||||||
|
import type { NodeProps } from '@xyflow/react'
|
||||||
|
import type { SourceNodeData } from '@/lib/types'
|
||||||
|
import { useAuthStore } from '@/lib/auth'
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const nodeData = data as SourceNodeData
|
||||||
|
const { label, sourceType, flowRate, targetAllocations = [] } = nodeData
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const { setNodes } = useReactFlow()
|
||||||
|
const { isAuthenticated, did, login } = useAuthStore()
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
const [editLabel, setEditLabel] = useState(label)
|
||||||
|
const [editSourceType, setEditSourceType] = useState<SourceType>(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) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditLabel(label)
|
||||||
|
setEditSourceType(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)
|
||||||
|
}, [label, sourceType, flowRate, nodeData])
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== id) return node
|
||||||
|
const updated: SourceNodeData = {
|
||||||
|
...(node.data as SourceNodeData),
|
||||||
|
label: editLabel || 'Source',
|
||||||
|
sourceType: editSourceType,
|
||||||
|
flowRate: editFlowRate,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}, [id, editLabel, editSourceType, editFlowRate, editCurrency, editDefaultAmount, editWalletAddress, editChainId, editSafeAddress, did, setNodes])
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setIsEditing(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Allocation bar segments
|
||||||
|
const allocTotal = useMemo(() =>
|
||||||
|
targetAllocations.reduce((s, a) => s + a.percentage, 0),
|
||||||
|
[targetAllocations]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
bg-white rounded-lg shadow-lg border-2 min-w-[200px] max-w-[240px]
|
||||||
|
transition-all duration-200
|
||||||
|
${selected ? 'border-emerald-500 shadow-emerald-200' : isUnconfigured ? 'border-dashed border-slate-300' : 'border-emerald-300'}
|
||||||
|
`}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<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={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
|
||||||
|
isUnconfigured ? 'bg-slate-400' : 'bg-emerald-500'
|
||||||
|
}`}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{/* Flow rate */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Flow Rate</span>
|
||||||
|
<span className="text-xs font-mono font-medium text-emerald-700">
|
||||||
|
${flowRate.toLocaleString()}/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allocation bars */}
|
||||||
|
{targetAllocations.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Allocations</span>
|
||||||
|
<div className="h-2 bg-slate-100 rounded-full overflow-hidden flex">
|
||||||
|
{targetAllocations.map((alloc, i) => (
|
||||||
|
<div
|
||||||
|
key={alloc.targetId}
|
||||||
|
className="h-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${allocTotal > 0 ? (alloc.percentage / allocTotal) * 100 : 0}%`,
|
||||||
|
backgroundColor: alloc.color,
|
||||||
|
}}
|
||||||
|
title={`${alloc.percentage}% → ${alloc.targetId}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hint */}
|
||||||
|
{isUnconfigured && (
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-[8px] text-slate-400">Double-click to configure</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isUnconfigured && targetAllocations.length === 0 && (
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-[8px] text-slate-400">Drag from handle below to connect to funnels</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom handle - connects to funnels */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="source-out"
|
||||||
|
className={`!w-4 !h-4 !border-2 !border-white !-bottom-2 transition-all ${
|
||||||
|
isUnconfigured ? '!bg-slate-400' : '!bg-emerald-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{isEditing && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source Type Picker */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide block mb-2">Source Type</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{(['card', 'safe_wallet', 'ridentity'] as SourceType[]).map((type) => {
|
||||||
|
const typeMeta = SOURCE_TYPE_META[type]
|
||||||
|
const isSelected = editSourceType === type
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setEditSourceType(type)}
|
||||||
|
className={`flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-emerald-500 bg-emerald-50'
|
||||||
|
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SourceTypeIcon type={type} className="w-6 h-6 text-slate-700" />
|
||||||
|
<span className="text-xs font-medium text-slate-700">{typeMeta.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-500 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="px-4 py-2 text-slate-500 hover:text-slate-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(SourceNode)
|
||||||
|
|
@ -1,11 +1,27 @@
|
||||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
|
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from './types'
|
||||||
|
|
||||||
// Colors for allocations
|
// Colors for allocations
|
||||||
export const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
export const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
||||||
export const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
export const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
||||||
|
export const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9']
|
||||||
|
|
||||||
// Demo preset: Treasury → 3 sub-funnels → 7 outcomes
|
// Demo preset: Source → Treasury → 3 sub-funnels → 7 outcomes
|
||||||
export const demoNodes: FlowNode[] = [
|
export const demoNodes: FlowNode[] = [
|
||||||
|
// Source node (top, above Treasury)
|
||||||
|
{
|
||||||
|
id: 'source-donations',
|
||||||
|
type: 'source',
|
||||||
|
position: { x: 650, y: -250 },
|
||||||
|
data: {
|
||||||
|
label: 'Donations',
|
||||||
|
sourceType: 'card',
|
||||||
|
flowRate: 1500,
|
||||||
|
targetAllocations: [
|
||||||
|
{ targetId: 'treasury', percentage: 100, color: SOURCE_COLORS[0] },
|
||||||
|
],
|
||||||
|
transakConfig: { fiatCurrency: 'USD', defaultAmount: 100 },
|
||||||
|
} as SourceNodeData,
|
||||||
|
},
|
||||||
// Main Treasury Funnel (top center)
|
// Main Treasury Funnel (top center)
|
||||||
{
|
{
|
||||||
id: 'treasury',
|
id: 'treasury',
|
||||||
|
|
@ -94,6 +110,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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* inflow → overflow distribution → spending drain → outcome accumulation
|
* inflow → overflow distribution → spending drain → outcome accumulation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
|
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from './types'
|
||||||
|
|
||||||
export interface SimulationConfig {
|
export interface SimulationConfig {
|
||||||
tickDivisor: number // inflowRate divided by this per tick
|
tickDivisor: number // inflowRate divided by this per tick
|
||||||
|
|
@ -32,6 +32,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>()
|
||||||
|
|
@ -43,8 +54,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
|
||||||
|
|
@ -111,10 +122,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 {
|
||||||
|
|
|
||||||
44
lib/types.ts
44
lib/types.ts
|
|
@ -85,6 +85,43 @@ export interface FundingSource {
|
||||||
lastUsedAt?: number;
|
lastUsedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Source Node Types ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SourceAllocation {
|
||||||
|
targetId: string
|
||||||
|
percentage: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceNodeData {
|
||||||
|
label: string
|
||||||
|
sourceType: 'card' | 'safe_wallet' | 'ridentity' | 'unconfigured'
|
||||||
|
flowRate: number
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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
|
||||||
|
|
@ -126,6 +163,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
|
||||||
|
|
@ -134,16 +172,16 @@ export interface OutcomeNodeData {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData>
|
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