rfunds-online/components/mortgage/MortgageControls.tsx

285 lines
9.7 KiB
TypeScript

'use client'
import { useCallback } from 'react'
import type { MortgageSimulatorConfig } from '@/lib/mortgage-types'
interface Props {
config: MortgageSimulatorConfig
onChange: (config: MortgageSimulatorConfig) => void
currentMonth: number
maxMonth: number
onMonthChange: (month: number) => void
playing: boolean
onPlayToggle: () => void
speed: number
onSpeedChange: (speed: number) => void
}
function formatCurrency(n: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
}
export default function MortgageControls({
config, onChange, currentMonth, maxMonth,
onMonthChange, playing, onPlayToggle, speed, onSpeedChange,
}: Props) {
const update = useCallback((partial: Partial<MortgageSimulatorConfig>) => {
onChange({ ...config, ...partial })
}, [config, onChange])
const totalPrincipal = config.propertyValue * (1 - config.downPaymentPercent / 100)
const numTranches = Math.ceil(totalPrincipal / config.trancheSize)
const years = Math.floor(currentMonth / 12)
const months = currentMonth % 12
return (
<div className="flex flex-col gap-5 text-sm">
{/* Property */}
<Section title="Property">
<Slider
label="Value"
value={config.propertyValue}
min={100_000} max={2_000_000} step={25_000}
format={formatCurrency}
onChange={v => update({ propertyValue: v })}
/>
<Slider
label="Down Payment"
value={config.downPaymentPercent}
min={0} max={50} step={5}
format={v => `${v}% (${formatCurrency(config.propertyValue * v / 100)})`}
onChange={v => update({ downPaymentPercent: v })}
/>
<div className="text-slate-400 text-xs mt-1">
Loan: {formatCurrency(totalPrincipal)}
</div>
</Section>
{/* Tranches */}
<Section title="Tranches">
<Slider
label="Size"
value={config.trancheSize}
min={1_000} max={25_000} step={1_000}
format={formatCurrency}
onChange={v => update({ trancheSize: v })}
/>
<div className="text-slate-400 text-xs mt-1">
= {numTranches} lenders
</div>
<Slider
label="Funded"
value={config.fundingPercent}
min={50} max={100} step={5}
format={v => `${v}% (${Math.round(numTranches * v / 100)} / ${numTranches})`}
onChange={v => update({ fundingPercent: v })}
/>
</Section>
{/* Rate */}
<Section title="Interest">
<Slider
label="Base Rate"
value={config.interestRate * 100}
min={1} max={12} step={0.25}
format={v => `${v.toFixed(2)}%`}
onChange={v => update({ interestRate: v / 100 })}
/>
<Slider
label="Rate Spread"
value={config.rateVariation * 100}
min={0} max={3} step={0.25}
format={v => v === 0 ? 'None' : `+/- ${v.toFixed(2)}%`}
onChange={v => update({ rateVariation: v / 100 })}
/>
</Section>
{/* Term & Tiers */}
<Section title="Lending Terms">
<Slider
label="Max Term"
value={config.termYears}
min={5} max={30} step={5}
format={v => `${v} years`}
onChange={v => update({ termYears: v })}
/>
<div className="flex items-center gap-2 mt-2 mb-2">
<button
onClick={() => update({ useVariableTerms: !config.useVariableTerms })}
className={`px-2 py-1 rounded text-xs transition-colors ${
config.useVariableTerms
? 'bg-emerald-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{config.useVariableTerms ? 'Variable Tiers' : 'Uniform Term'}
</button>
</div>
{config.useVariableTerms && (
<div className="space-y-2 mt-2">
{config.lendingTiers.map((tier, i) => (
<div key={tier.label} className="flex items-center gap-2 text-xs">
<span className="w-10 text-slate-300 font-mono">{tier.label}</span>
<span className="text-slate-400">{(tier.rate * 100).toFixed(1)}%</span>
<input
type="range"
min={0} max={50} step={5}
value={tier.allocation * 100}
onChange={e => {
const newTiers = [...config.lendingTiers]
newTiers[i] = { ...tier, allocation: Number(e.target.value) / 100 }
update({ lendingTiers: newTiers })
}}
className="flex-1 accent-emerald-500 h-1"
/>
<span className="w-8 text-right text-slate-400">{(tier.allocation * 100).toFixed(0)}%</span>
</div>
))}
</div>
)}
</Section>
{/* Overpayment */}
<Section title="Overpayment">
<Slider
label="Extra / mo"
value={config.overpayment}
min={0} max={2000} step={50}
format={formatCurrency}
onChange={v => update({ overpayment: v })}
/>
{config.overpayment > 0 && (
<div className="flex gap-1 mt-2">
{(['extra_principal', 'community_fund', 'split'] as const).map(target => (
<button
key={target}
onClick={() => update({ overpaymentTarget: target })}
className={`px-2 py-1 rounded text-xs transition-colors ${
config.overpaymentTarget === target
? 'bg-emerald-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{target === 'extra_principal' ? 'Principal' : target === 'community_fund' ? 'Community' : 'Split'}
</button>
))}
</div>
)}
</Section>
{/* Reinvestment */}
<Section title="Reinvestment">
<Slider
label="Reinvestors"
value={config.reinvestorPercent}
min={0} max={80} step={10}
format={v => `${v}%`}
onChange={v => update({ reinvestorPercent: v })}
/>
{config.reinvestorPercent > 0 && (
<>
<div className="text-slate-400 text-xs mt-1 mb-2">
Relend rates:
</div>
<div className="flex gap-1">
{[[0.03, 0.05], [0.02, 0.04], [0.03], [0.05]].map((rates, i) => {
const label = rates.map(r => `${(r * 100).toFixed(0)}%`).join(' / ')
const isActive = JSON.stringify(config.reinvestmentRates) === JSON.stringify(rates)
return (
<button
key={i}
onClick={() => update({ reinvestmentRates: rates })}
className={`px-2 py-1 rounded text-xs transition-colors ${
isActive
? 'bg-purple-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{label}
</button>
)
})}
</div>
</>
)}
</Section>
{/* Timeline */}
<Section title="Timeline">
<div className="flex items-center gap-2 mb-2">
<button
onClick={onPlayToggle}
className="w-8 h-8 rounded bg-slate-700 hover:bg-slate-600 flex items-center justify-center text-lg transition-colors"
>
{playing ? '\u23F8' : '\u25B6'}
</button>
<button
onClick={() => onMonthChange(0)}
className="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-xs transition-colors"
>
Reset
</button>
<div className="flex gap-1 ml-auto">
{[1, 2, 4].map(s => (
<button
key={s}
onClick={() => onSpeedChange(s)}
className={`px-2 py-1 rounded text-xs transition-colors ${
speed === s ? 'bg-sky-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
{s}x
</button>
))}
</div>
</div>
<input
type="range"
min={0} max={maxMonth}
value={currentMonth}
onChange={e => onMonthChange(Number(e.target.value))}
className="w-full accent-sky-500"
/>
<div className="text-slate-400 text-xs text-center mt-1">
Month {currentMonth} ({years}y {months}m)
</div>
</Section>
</div>
)
}
// ─── Sub-components ──────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">{title}</h3>
{children}
</div>
)
}
function Slider({
label, value, min, max, step, format, onChange,
}: {
label: string; value: number; min: number; max: number; step: number
format: (v: number) => string; onChange: (v: number) => void
}) {
return (
<div className="mb-2">
<div className="flex justify-between mb-1">
<span className="text-slate-300">{label}</span>
<span className="text-white font-mono">{format(value)}</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={e => onChange(Number(e.target.value))}
className="w-full accent-sky-500"
/>
</div>
)
}