156 lines
6.5 KiB
TypeScript
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>
|
|
)
|
|
}
|