209 lines
7.9 KiB
TypeScript
209 lines
7.9 KiB
TypeScript
'use client'
|
|
|
|
import { memo, useState, useCallback } from 'react'
|
|
import { Handle, Position, useReactFlow } from '@xyflow/react'
|
|
import type { NodeProps } from '@xyflow/react'
|
|
import type { SourceNodeData } from '@/lib/types'
|
|
|
|
const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9']
|
|
|
|
function SourceNode({ data, selected, id }: NodeProps) {
|
|
const nodeData = data as SourceNodeData
|
|
const { label, flowRate, sourceType, targetAllocations = [] } = nodeData
|
|
const { setNodes } = useReactFlow()
|
|
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const [editLabel, setEditLabel] = useState(label)
|
|
const [editRate, setEditRate] = useState(String(flowRate))
|
|
const [editType, setEditType] = useState(sourceType)
|
|
|
|
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
setEditLabel(label)
|
|
setEditRate(String(flowRate))
|
|
setEditType(sourceType)
|
|
setIsEditing(true)
|
|
}, [label, flowRate, sourceType])
|
|
|
|
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,
|
|
},
|
|
}
|
|
}))
|
|
setIsEditing(false)
|
|
}, [id, editLabel, editRate, editType, setNodes])
|
|
|
|
const typeLabels = {
|
|
'recurring': 'Recurring',
|
|
'one-time': 'One-time',
|
|
'treasury': 'Treasury',
|
|
}
|
|
|
|
const typeColors = {
|
|
'recurring': 'bg-emerald-100 text-emerald-700',
|
|
'one-time': 'bg-blue-100 text-blue-700',
|
|
'treasury': 'bg-violet-100 text-violet-700',
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={`
|
|
bg-white rounded-xl shadow-lg border-2 min-w-[180px] max-w-[220px]
|
|
transition-all duration-200
|
|
${selected ? 'border-emerald-500 shadow-emerald-200' : 'border-slate-200'}
|
|
`}
|
|
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="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>
|
|
<span className="font-semibold text-slate-800 text-sm truncate">{label}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="p-3 space-y-2">
|
|
<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>
|
|
</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
|
|
key={idx}
|
|
className="transition-all"
|
|
style={{
|
|
backgroundColor: alloc.color || SOURCE_COLORS[idx % SOURCE_COLORS.length],
|
|
width: `${alloc.percentage}%`,
|
|
}}
|
|
/>
|
|
))}
|
|
</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>
|
|
|
|
<div className="text-center">
|
|
<span className="text-[8px] text-slate-400">Double-click to edit</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom handle — connects to funnel top */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Bottom}
|
|
id="source-out"
|
|
className="!w-4 !h-4 !bg-emerald-500 !border-2 !border-white !-bottom-2"
|
|
/>
|
|
</div>
|
|
|
|
{/* Edit Modal */}
|
|
{isEditing && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
onClick={() => setIsEditing(false)}
|
|
>
|
|
<div
|
|
className="bg-white rounded-2xl shadow-2xl p-6 w-80"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<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">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-center mt-6">
|
|
<button
|
|
onClick={handleSave}
|
|
className="px-6 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors font-medium"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default memo(SourceNode)
|