rfunds-online/components/mortgage/LenderGrid.tsx

156 lines
6.5 KiB
TypeScript

'use client'
import { useState, useMemo } from 'react'
import type { MortgageTranche } from '@/lib/mortgage-types'
interface Props {
tranches: MortgageTranche[]
onSelectTranche: (tranche: MortgageTranche | null) => void
selectedTrancheId: string | null
}
function formatCurrency(n: number): string {
if (n >= 1000) return `$${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`
return `$${n.toFixed(0)}`
}
export default function LenderGrid({ tranches, onSelectTranche, selectedTrancheId }: Props) {
const [sortBy, setSortBy] = useState<'index' | 'repaid' | 'rate' | 'remaining'>('index')
const [filter, setFilter] = useState<'all' | 'active' | 'repaid' | 'open' | 'for-sale'>('all')
const filtered = useMemo(() => {
let result = [...tranches]
switch (filter) {
case 'active': result = result.filter(t => t.status === 'active' && t.funded); break
case 'repaid': result = result.filter(t => t.status === 'repaid'); break
case 'open': result = result.filter(t => !t.funded); break
case 'for-sale': result = result.filter(t => t.listedForSale); break
}
switch (sortBy) {
case 'repaid': result.sort((a, b) => (b.totalPrincipalPaid / b.principal) - (a.totalPrincipalPaid / a.principal)); break
case 'rate': result.sort((a, b) => b.interestRate - a.interestRate); break
case 'remaining': result.sort((a, b) => a.principalRemaining - b.principalRemaining); break
}
return result
}, [tranches, sortBy, filter])
const repaidCount = tranches.filter(t => t.status === 'repaid').length
const openCount = tranches.filter(t => !t.funded).length
const forSaleCount = tranches.filter(t => t.listedForSale).length
return (
<div className="flex flex-col gap-3">
{/* Header with filters */}
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
Lenders ({tranches.length})
{repaidCount > 0 && <span className="text-emerald-400 ml-1">/ {repaidCount} repaid</span>}
</h3>
<div className="flex gap-1">
{(['all', 'active', 'repaid', 'open', 'for-sale'] as const).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
filter === f ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'
}`}
>
{f === 'for-sale' ? `sale (${forSaleCount})` : f === 'open' ? `open (${openCount})` : f}
</button>
))}
</div>
</div>
{/* Sort */}
<div className="flex gap-1">
<span className="text-xs text-slate-500 mr-1">Sort:</span>
{(['index', 'repaid', 'rate', 'remaining'] as const).map(s => (
<button
key={s}
onClick={() => setSortBy(s)}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
sortBy === s ? 'bg-slate-600 text-white' : 'bg-slate-800 text-slate-500 hover:bg-slate-700'
}`}
>
{s === 'index' ? '#' : s}
</button>
))}
</div>
{/* Grid */}
<div className="grid gap-1.5" style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${tranches.length > 50 ? '56px' : '72px'}, 1fr))`,
}}>
{filtered.map(tranche => {
const repaidPct = tranche.principal > 0
? (tranche.totalPrincipalPaid / tranche.principal) * 100
: 0
const isSelected = tranche.id === selectedTrancheId
const isRepaid = tranche.status === 'repaid'
const isOpen = !tranche.funded
const isForSale = tranche.listedForSale
return (
<button
key={tranche.id}
onClick={() => onSelectTranche(isSelected ? null : tranche)}
className={`
relative rounded p-1.5 text-center transition-all cursor-pointer
${isSelected
? 'ring-2 ring-sky-400 bg-slate-700 scale-105 z-10'
: isOpen
? 'bg-slate-800/40 border border-dashed border-slate-600 hover:border-sky-500 hover:bg-slate-800/60'
: isRepaid
? 'bg-emerald-900/40 hover:bg-emerald-800/50'
: 'bg-slate-800 hover:bg-slate-700'
}
`}
title={isOpen
? `Open tranche: ${formatCurrency(tranche.principal)} @ ${(tranche.interestRate * 100).toFixed(1)}% — ${tranche.tierLabel}`
: `${tranche.lender.name}: ${formatCurrency(tranche.principal)} @ ${(tranche.interestRate * 100).toFixed(1)}%`
}
>
{/* Fill bar background */}
{!isOpen && (
<div
className={`absolute inset-0 rounded opacity-20 transition-all ${
isRepaid ? 'bg-emerald-400' : 'bg-sky-400'
}`}
style={{ clipPath: `inset(${100 - repaidPct}% 0 0 0)` }}
/>
)}
{/* Content */}
<div className="relative">
<div className={`text-xs font-medium truncate ${
isOpen ? 'text-slate-500 italic' : isRepaid ? 'text-emerald-300' : 'text-slate-200'
}`}>
{isOpen ? 'Open' : tranche.lender.name}
</div>
<div className={`text-[10px] font-mono ${isOpen ? 'text-slate-600' : 'text-slate-400'}`}>
{formatCurrency(tranche.principal)}
</div>
<div className={`text-[10px] font-mono ${
isOpen ? 'text-sky-600' : isRepaid ? 'text-emerald-400' : 'text-sky-400'
}`}>
{isOpen ? tranche.tierLabel : isRepaid ? '100%' : `${repaidPct.toFixed(0)}%`}
</div>
{isForSale && (
<div className="absolute -top-0.5 -right-0.5 w-3 h-3 bg-amber-500 rounded-full flex items-center justify-center text-[8px]">
$
</div>
)}
{tranche.transferHistory.length > 0 && (
<div className="absolute -top-0.5 -left-0.5 w-3 h-3 bg-purple-500 rounded-full flex items-center justify-center text-[8px]">
{tranche.transferHistory.length}
</div>
)}
</div>
</button>
)
})}
</div>
</div>
)
}