Compare commits
2 Commits
1d675ba666
...
d999695913
| Author | SHA1 | Date |
|---|---|---|
|
|
d999695913 | |
|
|
bd77cd9113 |
|
|
@ -41,12 +41,20 @@ const edgeTypes = {
|
|||
// Generate edges with proportional Sankey-style widths
|
||||
function generateEdges(
|
||||
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[] {
|
||||
const edges: FlowEdge[] = []
|
||||
|
||||
const flowValues: number[] = []
|
||||
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
|
||||
const data = node.data as FunnelNodeData
|
||||
const rate = data.inflowRate || 1
|
||||
|
|
@ -63,6 +71,48 @@ function generateEdges(
|
|||
const MIN_WIDTH = 3
|
||||
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) => {
|
||||
if (node.type !== 'funnel') return
|
||||
const data = node.data as FunnelNodeData
|
||||
|
|
@ -253,9 +303,46 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
|
||||
// Adjust allocation percentage inline from edge +/- buttons
|
||||
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) => {
|
||||
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 allocKey = edgeType === 'overflow' ? 'overflowAllocations' : 'spendingAllocations'
|
||||
const allocs = [...data[allocKey]]
|
||||
|
|
@ -301,7 +388,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
|
||||
// Smart edge regeneration
|
||||
const allocationsKey = useMemo(() => {
|
||||
const funnelKeys = nodes
|
||||
const funnels = nodes
|
||||
.filter(n => n.type === 'funnel')
|
||||
.map(n => {
|
||||
const d = n.data as FunnelNodeData
|
||||
|
|
@ -313,17 +400,17 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
rate: d.inflowRate,
|
||||
}
|
||||
})
|
||||
const sourceKeys = nodes
|
||||
const sources = nodes
|
||||
.filter(n => n.type === 'source')
|
||||
.map(n => {
|
||||
const d = n.data as SourceNodeData
|
||||
return {
|
||||
id: n.id,
|
||||
targets: d.targetAllocations,
|
||||
allocations: d.targetAllocations,
|
||||
rate: d.flowRate,
|
||||
}
|
||||
})
|
||||
return JSON.stringify({ funnelKeys, sourceKeys })
|
||||
return JSON.stringify({ funnels, sources })
|
||||
}, [nodes])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -335,14 +422,14 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
(params: Connection) => {
|
||||
if (!params.source || !params.target) return
|
||||
|
||||
const isSourceOut = params.sourceHandle === 'source-out'
|
||||
const isOverflow = params.sourceHandle?.startsWith('outflow')
|
||||
const isSpending = params.sourceHandle === 'spending-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) {
|
||||
setNodes((nds) => nds.map((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
|
||||
|
||||
// Source edges: reconnect source allocation
|
||||
if (oldEdge.id?.startsWith('source-')) {
|
||||
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
|
||||
|
|
@ -465,7 +552,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
...node,
|
||||
data: {
|
||||
...data,
|
||||
targetAllocations: (data.targetAllocations || []).map(a =>
|
||||
targetAllocations: data.targetAllocations.map(a =>
|
||||
a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a
|
||||
),
|
||||
},
|
||||
|
|
@ -541,12 +628,14 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
if (change.type === 'remove') {
|
||||
const edge = edgesRef.current.find(e => e.id === change.id)
|
||||
if (edge?.data) {
|
||||
const allocData = edge.data as AllocationEdgeData
|
||||
|
||||
// Source edge removal
|
||||
if (edge.id?.startsWith('source-')) {
|
||||
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 filtered = data.targetAllocations.filter(a => a.targetId !== edge.target)
|
||||
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||
return {
|
||||
...node,
|
||||
|
|
@ -558,7 +647,6 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
},
|
||||
}
|
||||
}))
|
||||
onEdgesChange([change])
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -577,7 +665,6 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
}
|
||||
}
|
||||
|
||||
const allocData = edge.data as AllocationEdgeData
|
||||
if (allocData.edgeType === 'overflow') {
|
||||
const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target)
|
||||
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||
|
|
@ -675,8 +762,8 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
position: pos,
|
||||
data: {
|
||||
label: 'New Source',
|
||||
flowRate: 500,
|
||||
sourceType: 'recurring',
|
||||
sourceType: 'unconfigured',
|
||||
flowRate: 0,
|
||||
targetAllocations: [],
|
||||
} as SourceNodeData,
|
||||
},
|
||||
|
|
@ -690,8 +777,8 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
setNodes((nds) => nds.map((node) => {
|
||||
if (node.type === 'source') {
|
||||
const data = node.data as SourceNodeData
|
||||
const filtered = (data.targetAllocations || []).filter(a => !deletedIds.has(a.targetId))
|
||||
if (filtered.length === data.targetAllocations?.length) return node
|
||||
const filtered = data.targetAllocations.filter(a => !deletedIds.has(a.targetId))
|
||||
if (filtered.length === data.targetAllocations.length) return node
|
||||
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||
return {
|
||||
...node,
|
||||
|
|
@ -741,41 +828,13 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
}))
|
||||
}, [setNodes])
|
||||
|
||||
// Simulation
|
||||
// Simulation — real flow logic (inflow → overflow → spending → outcomes)
|
||||
useEffect(() => {
|
||||
if (!isSimulating) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setNodes((nds) =>
|
||||
nds.map((node) => {
|
||||
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)
|
||||
setNodes((nds) => simulateTick(nds as FlowNode[]))
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isSimulating, setNodes])
|
||||
|
|
@ -848,31 +907,33 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
{/* Top-right Controls */}
|
||||
<Panel position="top-right" className="m-4 flex gap-2">
|
||||
{mode === 'space' && (
|
||||
<button
|
||||
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>
|
||||
<>
|
||||
<button
|
||||
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>
|
||||
<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
|
||||
onClick={() => setIsSimulating(!isSimulating)}
|
||||
className={`px-4 py-2 rounded-lg font-medium shadow-sm transition-all text-sm ${
|
||||
|
|
|
|||
|
|
@ -1,21 +1,52 @@
|
|||
'use client'
|
||||
|
||||
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 { OutcomeNodeData } from '@/lib/types'
|
||||
import type { OutcomeNodeData, OutcomePhase, PhaseTask } from '@/lib/types'
|
||||
import { useConnectionState } from '../ConnectionContext'
|
||||
|
||||
const PHASE_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4']
|
||||
|
||||
function OutcomeNode({ data, selected, id }: NodeProps) {
|
||||
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 [expandedPhaseIdx, setExpandedPhaseIdx] = useState<number | null>(null)
|
||||
const [isEditingPhases, setIsEditingPhases] = useState(false)
|
||||
const connectingFrom = useConnectionState()
|
||||
const { setNodes } = useReactFlow()
|
||||
|
||||
const progress = fundingTarget > 0 ? Math.min(100, (fundingReceived / fundingTarget) * 100) : 0
|
||||
const isFunded = 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
|
||||
const isTargetHighlighted = useMemo(() => {
|
||||
if (!connectingFrom || connectingFrom.nodeId === id) return false
|
||||
|
|
@ -128,6 +159,37 @@ function OutcomeNode({ data, selected, id }: NodeProps) {
|
|||
</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">
|
||||
<span className="text-[8px] text-slate-400">Double-click for details</span>
|
||||
</div>
|
||||
|
|
@ -245,8 +307,205 @@ function OutcomeNode({ data, selected, id }: NodeProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Milestones */}
|
||||
{nodeData.milestones && nodeData.milestones.length > 0 && (
|
||||
{/* Phase Accordion */}
|
||||
{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">
|
||||
<span className="text-[10px] text-emerald-600 uppercase tracking-wide block mb-2">Milestones</span>
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -1,136 +1,218 @@
|
|||
'use client'
|
||||
|
||||
import { memo, useState, useCallback } from 'react'
|
||||
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 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) {
|
||||
const nodeData = data as SourceNodeData
|
||||
const { label, flowRate, sourceType, targetAllocations = [] } = nodeData
|
||||
const { setNodes } = useReactFlow()
|
||||
|
||||
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 [editRate, setEditRate] = useState(String(flowRate))
|
||||
const [editType, setEditType] = useState(sourceType)
|
||||
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)
|
||||
setEditRate(String(flowRate))
|
||||
setEditType(sourceType)
|
||||
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, flowRate, sourceType])
|
||||
}, [label, sourceType, flowRate, nodeData])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setNodes((nds) => nds.map((node) => {
|
||||
if (node.id !== id) return node
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...(node.data as SourceNodeData),
|
||||
label: editLabel.trim() || 'Source',
|
||||
flowRate: Math.max(0, Number(editRate) || 0),
|
||||
sourceType: editType,
|
||||
},
|
||||
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, editRate, editType, setNodes])
|
||||
}, [id, editLabel, editSourceType, editFlowRate, editCurrency, editDefaultAmount, editWalletAddress, editChainId, editSafeAddress, did, setNodes])
|
||||
|
||||
const typeLabels = {
|
||||
'recurring': 'Recurring',
|
||||
'one-time': 'One-time',
|
||||
'treasury': 'Treasury',
|
||||
}
|
||||
const handleClose = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
}, [])
|
||||
|
||||
const typeColors = {
|
||||
'recurring': 'bg-emerald-100 text-emerald-700',
|
||||
'one-time': 'bg-blue-100 text-blue-700',
|
||||
'treasury': 'bg-violet-100 text-violet-700',
|
||||
}
|
||||
// Allocation bar segments
|
||||
const allocTotal = useMemo(() =>
|
||||
targetAllocations.reduce((s, a) => s + a.percentage, 0),
|
||||
[targetAllocations]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
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
|
||||
${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}
|
||||
>
|
||||
{/* 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="w-6 h-6 bg-emerald-500 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">
|
||||
<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>
|
||||
<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>
|
||||
<span className="font-semibold text-slate-800 text-sm truncate">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Flow rate */}
|
||||
<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]}`}>
|
||||
{typeLabels[sourceType]}
|
||||
<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>
|
||||
|
||||
<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 */}
|
||||
{targetAllocations.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[8px] text-emerald-600 uppercase w-10">Flow</span>
|
||||
<div className="flex-1 flex h-2 rounded overflow-hidden">
|
||||
{targetAllocations.map((alloc, idx) => (
|
||||
<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={idx}
|
||||
className="transition-all"
|
||||
key={alloc.targetId}
|
||||
className="h-full transition-all duration-300"
|
||||
style={{
|
||||
backgroundColor: alloc.color || SOURCE_COLORS[idx % SOURCE_COLORS.length],
|
||||
width: `${alloc.percentage}%`,
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Flow indicator */}
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<svg className="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 16l6-6h-4V4h-4v6H6z"/>
|
||||
</svg>
|
||||
<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>
|
||||
{/* Hint */}
|
||||
{isUnconfigured && (
|
||||
<div className="text-center">
|
||||
<span className="text-[8px] text-slate-400">Double-click to configure</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<span className="text-[8px] text-slate-400">Double-click to edit</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 funnel top */}
|
||||
{/* Bottom handle - connects to funnels */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
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>
|
||||
|
||||
|
|
@ -138,64 +220,166 @@ function SourceNode({ data, selected, id }: NodeProps) {
|
|||
{isEditing && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={() => setIsEditing(false)}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<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()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-slate-800">Edit Source</h3>
|
||||
<button onClick={() => setIsEditing(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<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>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 uppercase tracking-wide block mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editLabel}
|
||||
onChange={(e) => setEditLabel(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 uppercase tracking-wide block mb-1">Flow Rate ($/mo)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editRate}
|
||||
onChange={(e) => setEditRate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800 font-mono"
|
||||
min="0"
|
||||
/>
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,21 +7,22 @@ export const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9']
|
|||
|
||||
// Demo preset: Source → Treasury → 3 sub-funnels → 7 outcomes
|
||||
export const demoNodes: FlowNode[] = [
|
||||
// Revenue source (top)
|
||||
// Source node (top, above Treasury)
|
||||
{
|
||||
id: 'revenue',
|
||||
id: 'source-donations',
|
||||
type: 'source',
|
||||
position: { x: 660, y: -200 },
|
||||
position: { x: 650, y: -250 },
|
||||
data: {
|
||||
label: 'Revenue Stream',
|
||||
flowRate: 5000,
|
||||
sourceType: 'recurring',
|
||||
label: 'Donations',
|
||||
sourceType: 'card',
|
||||
flowRate: 1500,
|
||||
targetAllocations: [
|
||||
{ targetId: 'treasury', percentage: 100, color: '#10b981' },
|
||||
{ targetId: 'treasury', percentage: 100, color: SOURCE_COLORS[0] },
|
||||
],
|
||||
transakConfig: { fiatCurrency: 'USD', defaultAmount: 100 },
|
||||
} as SourceNodeData,
|
||||
},
|
||||
// Main Treasury Funnel
|
||||
// Main Treasury Funnel (top center)
|
||||
{
|
||||
id: 'treasury',
|
||||
type: 'funnel',
|
||||
|
|
@ -114,6 +115,32 @@ export const demoNodes: FlowNode[] = [
|
|||
fundingReceived: 22000,
|
||||
fundingTarget: 30000,
|
||||
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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
* 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 {
|
||||
tickDivisor: number // inflowRate divided by this per tick
|
||||
|
|
@ -114,6 +114,17 @@ export function simulateTick(
|
|||
.filter((n) => n.type === 'funnel')
|
||||
.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
|
||||
const overflowIncoming = new Map<string, number>()
|
||||
const spendingIncoming = new Map<string, number>()
|
||||
|
|
@ -125,8 +136,8 @@ export function simulateTick(
|
|||
const src = node.data as FunnelNodeData
|
||||
const data: FunnelNodeData = { ...src }
|
||||
|
||||
// 1. Natural inflow
|
||||
let value = data.currentValue + data.inflowRate / tickDivisor
|
||||
// 1. Natural inflow + source node inflow
|
||||
let value = data.currentValue + data.inflowRate / tickDivisor + (sourceInflow.get(node.id) ?? 0)
|
||||
|
||||
// 2. Overflow received from upstream funnels
|
||||
value += overflowIncoming.get(node.id) ?? 0
|
||||
|
|
@ -209,10 +220,26 @@ export function simulateTick(
|
|||
)
|
||||
|
||||
let newStatus = data.status
|
||||
if (data.fundingTarget > 0 && newReceived >= data.fundingTarget && newStatus !== 'blocked') {
|
||||
newStatus = 'completed'
|
||||
} else if (newReceived > 0 && newStatus === 'not-started') {
|
||||
newStatus = 'in-progress'
|
||||
if (newStatus === 'blocked') {
|
||||
// Don't auto-change blocked status
|
||||
} else if (data.phases && data.phases.length > 0) {
|
||||
// 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 {
|
||||
|
|
|
|||
33
lib/types.ts
33
lib/types.ts
|
|
@ -85,7 +85,7 @@ export interface FundingSource {
|
|||
lastUsedAt?: number;
|
||||
}
|
||||
|
||||
// ─── Source Node Types ───────────────────────────────────────
|
||||
// ─── Source Node Types ──────────────────────────────────────
|
||||
|
||||
export interface SourceAllocation {
|
||||
targetId: string
|
||||
|
|
@ -95,12 +95,34 @@ export interface SourceAllocation {
|
|||
|
||||
export interface SourceNodeData {
|
||||
label: string
|
||||
flowRate: number // tokens per month flowing out
|
||||
sourceType: 'recurring' | 'one-time' | 'treasury'
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
|
||||
|
|
@ -147,6 +169,7 @@ export interface OutcomeNodeData {
|
|||
fundingReceived: number
|
||||
fundingTarget: number
|
||||
status: 'not-started' | 'in-progress' | 'completed' | 'blocked'
|
||||
phases?: OutcomePhase[] // ordered by fundingThreshold ascending
|
||||
// Integration metadata
|
||||
source?: IntegrationSource
|
||||
// Optional detail fields
|
||||
|
|
@ -160,11 +183,11 @@ export type FlowNode = Node<FunnelNodeData | OutcomeNodeData | SourceNodeData>
|
|||
export interface AllocationEdgeData {
|
||||
allocation: number // percentage 0-100
|
||||
color: string
|
||||
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
|
||||
edgeType: 'overflow' | 'spending' | 'source' // overflow = sideways, spending = downward, source = top-down from source
|
||||
sourceId: string
|
||||
targetId: string
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue