559 lines
29 KiB
TypeScript
559 lines
29 KiB
TypeScript
'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<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
|
|
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 (
|
|
<>
|
|
<div
|
|
className={`
|
|
bg-white rounded-lg shadow-lg border-2 min-w-[200px] max-w-[240px]
|
|
transition-all duration-200
|
|
${selected ? 'border-pink-500 shadow-pink-200' : 'border-slate-200'}
|
|
`}
|
|
onDoubleClick={handleDoubleClick}
|
|
>
|
|
<Handle
|
|
type="target"
|
|
position={Position.Top}
|
|
className={`!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-top-2 transition-all ${
|
|
isTargetHighlighted ? '!animate-pulse !ring-2 !ring-blue-400 !scale-125' : ''
|
|
}`}
|
|
/>
|
|
|
|
<div className="px-3 py-2 border-b border-slate-100 bg-gradient-to-r from-pink-50 to-purple-50 rounded-t-md">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-6 h-6 bg-pink-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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<span className="font-semibold text-slate-800 text-sm truncate block">{label}</span>
|
|
{nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId ? (
|
|
<a
|
|
href={`https://rvote.online/s/${nodeData.source.rvoteSpaceSlug}/proposal/${nodeData.source.rvoteProposalId}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[9px] text-violet-500 font-medium hover:text-violet-700 hover:underline inline-flex items-center gap-0.5 transition-colors"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
rVote • score +{nodeData.source.rvoteProposalScore ?? 0}
|
|
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
</svg>
|
|
</a>
|
|
) : nodeData.source?.type === 'rvote' ? (
|
|
<span className="text-[9px] text-violet-500 font-medium">
|
|
rVote • score +{nodeData.source.rvoteProposalScore ?? 0}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-3 space-y-2">
|
|
{description && (
|
|
<p className="text-xs text-slate-500 line-clamp-2">{description}</p>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between">
|
|
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wide ${colors.bg} ${colors.text}`}>
|
|
{status.replace('-', ' ')}
|
|
</span>
|
|
{isFunded && (
|
|
<svg className="w-4 h-4 text-emerald-500" 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>
|
|
|
|
<div>
|
|
<div className="flex justify-between items-center mb-1">
|
|
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Funding</span>
|
|
<span className="text-xs font-mono text-slate-700">
|
|
${Math.floor(fundingReceived).toLocaleString()} / ${fundingTarget.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full transition-all duration-500 ${
|
|
isFunded ? 'bg-emerald-500' : isPartial ? 'bg-blue-500' : 'bg-slate-300'
|
|
}`}
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-right mt-0.5">
|
|
<span className="text-[10px] font-medium text-slate-500">{progress.toFixed(0)}%</span>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Detail Modal */}
|
|
{isExpanded && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
onClick={handleCloseExpanded}
|
|
>
|
|
<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-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-bold text-slate-800">{label}</h3>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wide ${colors.bg} ${colors.text}`}>
|
|
{status.replace('-', ' ')}
|
|
</span>
|
|
{nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId && (
|
|
<a
|
|
href={`https://rvote.online/s/${nodeData.source.rvoteSpaceSlug}/proposal/${nodeData.source.rvoteProposalId}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[10px] text-violet-500 font-medium hover:text-violet-700 hover:underline inline-flex items-center gap-0.5"
|
|
>
|
|
rVote
|
|
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
</svg>
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleCloseExpanded}
|
|
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>
|
|
|
|
{/* Description */}
|
|
{description && (
|
|
<div className="mb-4 p-3 bg-slate-50 rounded-xl">
|
|
<span className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Description</span>
|
|
<p className="text-sm text-slate-700">{description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Funding Progress */}
|
|
<div className="mb-4 p-3 bg-gradient-to-r from-pink-50 to-purple-50 rounded-xl">
|
|
<span className="text-[10px] text-slate-500 uppercase tracking-wide block mb-2">Funding Progress</span>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<span className="text-2xl font-bold font-mono text-slate-800">
|
|
${Math.floor(fundingReceived).toLocaleString()}
|
|
</span>
|
|
<span className="text-sm text-slate-500">
|
|
/ ${fundingTarget.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="h-3 bg-white/60 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full transition-all duration-500 rounded-full ${
|
|
isFunded ? 'bg-emerald-500' : isPartial ? 'bg-blue-500' : 'bg-slate-300'
|
|
}`}
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-right mt-1">
|
|
<span className="text-xs font-medium text-slate-600">{progress.toFixed(1)}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* rVote Details */}
|
|
{nodeData.source?.type === 'rvote' && (
|
|
<div className="mb-4 p-3 bg-violet-50 rounded-xl">
|
|
<span className="text-[10px] text-violet-600 uppercase tracking-wide block mb-2">rVote Details</span>
|
|
<div className="space-y-1.5 text-xs">
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-500">Score</span>
|
|
<span className="font-mono text-violet-700 font-medium">+{nodeData.source.rvoteProposalScore ?? 0}</span>
|
|
</div>
|
|
{nodeData.source.rvoteProposalStatus && (
|
|
<div className="flex justify-between">
|
|
<span className="text-slate-500">Status</span>
|
|
<span className="text-violet-700 font-medium capitalize">{nodeData.source.rvoteProposalStatus}</span>
|
|
</div>
|
|
)}
|
|
{nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId && (
|
|
<a
|
|
href={`https://rvote.online/s/${nodeData.source.rvoteSpaceSlug}/proposal/${nodeData.source.rvoteProposalId}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block text-center mt-2 px-3 py-1.5 bg-violet-600 text-white rounded-lg text-xs font-medium hover:bg-violet-500 transition-colors"
|
|
>
|
|
View on rVote
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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">
|
|
{nodeData.milestones.map((milestone, i) => (
|
|
<div key={i} className="flex items-center gap-2 text-xs">
|
|
{milestone.completedAt ? (
|
|
<svg className="w-3.5 h-3.5 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-3.5 h-3.5 rounded-full border-2 border-slate-300 flex-shrink-0" />
|
|
)}
|
|
<span className={milestone.completedAt ? 'text-slate-500 line-through' : 'text-slate-700'}>
|
|
{milestone.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Completion Info */}
|
|
{nodeData.completedAt && (
|
|
<div className="mb-4 p-3 bg-emerald-50 rounded-xl flex items-center gap-2">
|
|
<svg className="w-5 h-5 text-emerald-500" 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>
|
|
<span className="text-sm text-emerald-700 font-medium">
|
|
Completed {new Date(nodeData.completedAt).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Close Button */}
|
|
<div className="flex justify-center mt-4">
|
|
<button
|
|
onClick={handleCloseExpanded}
|
|
className="px-6 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors font-medium"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default memo(OutcomeNode)
|