'use client' import { memo, useState, useCallback, useMemo } from 'react' import { Handle, Position, useReactFlow } from '@xyflow/react' import type { NodeProps } from '@xyflow/react' 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, phases } = nodeData const [isExpanded, setIsExpanded] = useState(false) const [expandedPhaseIdx, setExpandedPhaseIdx] = useState(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 const handleId = connectingFrom.handleId if (handleId === 'spending-out') return true if (handleId === 'stream-out') return true return false }, [connectingFrom, id]) const statusColors = { 'not-started': { bg: 'bg-slate-100', text: 'text-slate-600', border: 'border-slate-300' }, 'in-progress': { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-300' }, 'completed': { bg: 'bg-emerald-100', text: 'text-emerald-700', border: 'border-emerald-300' }, 'blocked': { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' }, } const colors = statusColors[status] || statusColors['not-started'] const handleDoubleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() setIsExpanded(true) }, []) const handleCloseExpanded = useCallback(() => { setIsExpanded(false) }, []) return ( <>
{label} {nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId ? ( e.stopPropagation()} > rVote • score +{nodeData.source.rvoteProposalScore ?? 0} ) : nodeData.source?.type === 'rvote' ? ( rVote • score +{nodeData.source.rvoteProposalScore ?? 0} ) : null}
{description && (

{description}

)}
{status.replace('-', ' ')} {isFunded && ( )}
Funding ${Math.floor(fundingReceived).toLocaleString()} / ${fundingTarget.toLocaleString()}
{progress.toFixed(0)}%
{/* Phase Indicator (compact) */} {sortedPhases.length > 0 && (
Phases {currentPhaseIdx >= 0 ? `Phase ${currentPhaseIdx + 1}` : 'Locked'} of {sortedPhases.length}
{sortedPhases.map((phase, i) => { const isUnlocked = fundingReceived >= phase.fundingThreshold const allDone = phase.tasks.every(t => t.completed) return (
) })}
)}
Double-click for details
{/* Expanded Detail Modal */} {isExpanded && (
e.stopPropagation()} > {/* Header */}

{label}

{status.replace('-', ' ')} {nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId && ( rVote )}
{/* Description */} {description && (
Description

{description}

)} {/* Funding Progress */}
Funding Progress
${Math.floor(fundingReceived).toLocaleString()} / ${fundingTarget.toLocaleString()}
{progress.toFixed(1)}%
{/* rVote Details */} {nodeData.source?.type === 'rvote' && (
rVote Details
Score +{nodeData.source.rvoteProposalScore ?? 0}
{nodeData.source.rvoteProposalStatus && (
Status {nodeData.source.rvoteProposalStatus}
)} {nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId && ( View on rVote )}
)} {/* Phase Accordion */} {sortedPhases.length > 0 && (
Phases
{/* Phase tier segmented bar */}
{sortedPhases.map((phase, i) => { const isUnlocked = fundingReceived >= phase.fundingThreshold const allDone = phase.tasks.every(t => t.completed) return (
) })}
{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 (
{/* Phase header */} {/* Phase content */} {isOpen && (
{/* Funding progress for this phase */}
${Math.floor(Math.min(fundingReceived, phase.fundingThreshold)).toLocaleString()} / ${phase.fundingThreshold.toLocaleString()}
{/* Tasks */}
{phase.tasks.map((task, taskIdx) => ( ))}
{/* Edit mode: add task */} {isEditingPhases && isUnlocked && (
)}
)}
) })}
{/* Add Phase button (edit mode) */} {isEditingPhases && ( )}
)} {/* Legacy Milestones (backward compat when no phases) */} {(!phases || phases.length === 0) && nodeData.milestones && nodeData.milestones.length > 0 && (
Milestones
{nodeData.milestones.map((milestone, i) => (
{milestone.completedAt ? ( ) : (
)} {milestone.label}
))}
)} {/* Completion Info */} {nodeData.completedAt && (
Completed {new Date(nodeData.completedAt).toLocaleDateString()}
)} {/* Close Button */}
)} ) } export default memo(OutcomeNode)