import { Head, router } from '@inertiajs/react'; import { Settings } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import BucketCard from '@/components/BucketCard'; import DistributionLines from '@/components/DistributionLines'; import InlineEditInput from '@/components/InlineEditInput'; import InlineEditSelect from '@/components/InlineEditSelect'; import SettingsPanel from '@/components/SettingsPanel'; import { csrfToken } from '@/lib/utils'; import { type Bucket, type DistributionPreview, type Scenario } from '@/types'; interface Props { scenario: Scenario; buckets: { data: Bucket[] }; } const bucketTypeOptions = [ { value: 'need', label: 'Need' }, { value: 'want', label: 'Want' }, ]; const allocationTypeOptions = [ { value: 'fixed_limit', label: 'Fixed Limit' }, { value: 'percentage', label: 'Percentage' }, ]; const centsToDollars = (cents: number): number => cents / 100; const dollarsToCents = (dollars: number): number => Math.round(dollars * 100); const basisPointsToPercent = (bp: number): number => bp / 100; const percentToBasisPoints = (pct: number): number => Math.round(pct * 100); const patchBucket = async (bucketId: string, data: Record): Promise => { const response = await fetch(`/buckets/${bucketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken(), }, body: JSON.stringify(data), }); if (!response.ok) { throw new Error('Failed to update bucket'); } router.reload({ only: ['buckets'] }); }; const defaultFormData = { name: '', type: 'need' as Bucket['type'], allocation_type: 'fixed_limit', allocation_value: '', buffer_multiplier: '0', buffer_mode: 'preset' as 'preset' | 'custom', }; export default function Show({ scenario, buckets }: Props) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [editingBucket, setEditingBucket] = useState(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [formData, setFormData] = useState({ ...defaultFormData }); const [incomeAmount, setIncomeAmount] = useState(''); const [distribution, setDistribution] = useState(null); const [isDistributing, setIsDistributing] = useState(false); const [isSaving, setIsSaving] = useState(false); const [distributionError, setDistributionError] = useState(null); // Sync editingBucket with refreshed props after inline edits. // Only react to buckets changing, not editingBucket — reading .id is stable. useEffect(() => { if (editingBucket) { const updated = buckets.data.find(b => b.id === editingBucket.id); if (updated) { setEditingBucket(updated); } else { setEditingBucket(null); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [buckets]); const containerRef = useRef(null); const incomeRef = useRef(null); const bucketRefs = useRef>(new Map()); const handleDistribute = async () => { const dollars = parseFloat(incomeAmount); if (!dollars || dollars <= 0) return; setIsDistributing(true); setDistributionError(null); try { const response = await fetch(`/scenarios/${scenario.id}/projections/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken(), }, body: JSON.stringify({ amount: dollarsToCents(dollars) }), }); if (response.ok) { setDistribution(await response.json()); } else { setDistributionError('DISTRIBUTION FAILED'); } } catch (error) { setDistributionError('CONNECTION ERROR'); } finally { setIsDistributing(false); } }; const handleSaveDistribution = async () => { const dollars = parseFloat(incomeAmount); if (!dollars || dollars <= 0 || !distribution) return; setIsSaving(true); setDistributionError(null); try { const response = await fetch(`/scenarios/${scenario.id}/projections/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken(), }, body: JSON.stringify({ amount: dollarsToCents(dollars) }), }); if (response.ok) { setDistribution(null); setIncomeAmount(''); router.reload({ only: ['buckets'] }); } else { setDistributionError('SAVE FAILED'); } } catch (error) { setDistributionError('CONNECTION ERROR'); } finally { setIsSaving(false); } }; const handleIncomeChange = (e: React.ChangeEvent) => { setIncomeAmount(e.target.value); setDistribution(null); setDistributionError(null); }; const openCreateModal = () => { setFormData({ ...defaultFormData }); setIsCreateModalOpen(true); }; const handleDelete = async (bucket: Bucket) => { if (!confirm(`Delete "${bucket.name}"?`)) return; try { const response = await fetch(`/buckets/${bucket.id}`, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': csrfToken() }, }); if (response.ok) { setEditingBucket(null); router.reload({ only: ['buckets'] }); } } catch (error) { console.error('Error deleting bucket:', error); } }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); try { const response = await fetch(`/scenarios/${scenario.id}/buckets`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken(), }, body: JSON.stringify({ name: formData.name, type: formData.type, allocation_type: formData.allocation_type, allocation_value: formData.allocation_value ? (formData.allocation_type === 'percentage' ? percentToBasisPoints(parseFloat(formData.allocation_value)) : dollarsToCents(parseFloat(formData.allocation_value))) : null, buffer_multiplier: formData.allocation_type === 'fixed_limit' ? parseFloat(formData.buffer_multiplier) || 0 : 0, }), }); if (response.ok) { setIsCreateModalOpen(false); setFormData({ ...defaultFormData }); router.reload({ only: ['buckets'] }); } else { const errorData = await response.json(); console.error('Failed to create bucket:', errorData); } } catch (error) { console.error('Error creating bucket:', error); } finally { setIsSubmitting(false); } }; const handlePriorityChange = async (bucketId: string, direction: 'up' | 'down') => { const bucket = buckets.data.find(b => b.id === bucketId); if (!bucket) return; const newPriority = direction === 'up' ? bucket.priority - 1 : bucket.priority + 1; if (newPriority < 1 || newPriority > buckets.data.length) return; try { await patchBucket(bucketId, { priority: newPriority }); } catch (error) { console.error('Error updating bucket priority:', error); } }; return ( <>
{/* Header */}

BUCKETS

{/* Buckets + Distribution + Income */}
{/* Left: Buckets */}

BUCKETS

{buckets.data.length === 0 ? (

NO BUCKETS CONFIGURED

Income flows through your pipeline into prioritized buckets.

) : (
{buckets.data.map((bucket) => ( ))}
)}
{/* Middle: Connector area (SVG lines render here) */}
{distribution && ( )} {/* Right: Income */}

INCOME

{distribution && ( )} {distributionError && (

{distributionError}

)}
{/* Create Bucket Modal */} {isCreateModalOpen && (

ADD BUCKET

setFormData({ ...formData, name: e.target.value })} className="w-full bg-black border-2 border-red-500/50 px-3 py-2 text-red-500 font-mono focus:border-red-500 focus:outline-none" placeholder="e.g., Travel Fund" required />
{formData.allocation_type !== 'unlimited' && (
setFormData({ ...formData, allocation_value: e.target.value })} className="w-full bg-black border-2 border-red-500/50 px-3 py-2 text-red-500 font-mono font-digital focus:border-red-500 focus:outline-none" placeholder={formData.allocation_type === 'percentage' ? '25' : '1000'} step={formData.allocation_type === 'percentage' ? '0.01' : '0.01'} min={formData.allocation_type === 'percentage' ? '0.01' : '0'} max={formData.allocation_type === 'percentage' ? '100' : undefined} required />
)} {formData.allocation_type === 'fixed_limit' && (
{formData.buffer_mode === 'custom' && ( setFormData({ ...formData, buffer_multiplier: e.target.value })} className="mt-2 w-full bg-black border-2 border-red-500/50 px-3 py-2 text-red-500 font-mono font-digital focus:border-red-500 focus:outline-none" placeholder="e.g., 0.75" step="0.01" min="0" /> )}
)}
)} {/* Edit Bucket Modal */} {editingBucket && (
{ if (e.target === e.currentTarget) setEditingBucket(null); }} >

EDIT BUCKET

{/* Name */}
NAME patchBucket(editingBucket.id, { name: val })} className="text-red-500 font-mono font-bold" />
{/* Type */}
TYPE patchBucket(editingBucket.id, { type: val })} displayLabel={editingBucket.type_label} disabled={editingBucket.type === 'overflow'} className="text-red-500 font-mono" />
{/* Allocation Type */}
ALLOCATION patchBucket(editingBucket.id, { allocation_type: val, allocation_value: null })} displayLabel={editingBucket.allocation_type_label} disabled={editingBucket.type === 'overflow'} className="text-red-500 font-mono" />
{/* Allocation Value */} {editingBucket.allocation_type !== 'unlimited' && (
VALUE {editingBucket.allocation_type === 'fixed_limit' ? ( patchBucket(editingBucket.id, { allocation_value: dollarsToCents(val) })} formatDisplay={(v) => `$${v.toFixed(2)}`} min={0} step="0.01" /> ) : ( patchBucket(editingBucket.id, { allocation_value: percentToBasisPoints(val) })} formatDisplay={(v) => `${v.toFixed(2)}%`} min={0} step="0.01" /> )}
)} {/* Current Balance */}
CURRENT patchBucket(editingBucket.id, { starting_amount: dollarsToCents(val) })} formatDisplay={(v) => `$${v.toFixed(2)}`} min={0} step="0.01" className="font-digital text-red-500" />
{/* Buffer */} {editingBucket.allocation_type === 'fixed_limit' && (
BUFFER patchBucket(editingBucket.id, { buffer_multiplier: val })} formatDisplay={(v) => v > 0 ? `${v}x` : 'NONE'} min={0} step="0.01" className="font-digital text-red-500" />
)} {/* Priority */}
PRIORITY
#{editingBucket.priority}
{/* Delete */} {editingBucket.type !== 'overflow' && (
)}
)} ); }