← back to StudentLoanTracker
src/app/loans/page.tsx
396 lines
'use client';
import { useState, useCallback } from 'react';
import {
FileText, Upload, PenLine, Plus, Trash2, ShieldCheck, CheckCircle,
AlertCircle, FileSearch, Loader2, X
} from 'lucide-react';
import { parseFile, type ParseResult, type ParsedLoan } from '@/lib/file-intel';
interface LoanEntry {
id: string;
name: string;
type: string;
balance: number;
rate: number;
servicer: string;
status: string;
}
let idCounter = 0;
function makeId(): string {
return 'loan_' + (++idCounter) + '_' + Math.random().toString(36).slice(2, 6);
}
function fromParsed(p: ParsedLoan): LoanEntry {
return {
id: makeId(),
name: p.name,
type: p.loanType,
balance: p.balance,
rate: p.interestRate,
servicer: p.servicer,
status: p.status,
};
}
function newLoan(): LoanEntry {
return { id: makeId(), name: '', type: 'unsubsidized', balance: 0, rate: 5.5, servicer: '', status: '' };
}
export default function LoansPage() {
const [mode, setMode] = useState<'choose' | 'manual' | 'upload' | 'results'>('choose');
const [loans, setLoans] = useState<LoanEntry[]>([newLoan()]);
const [parseResult, setParseResult] = useState<ParseResult | null>(null);
const [parsing, setParsing] = useState(false);
const [dragActive, setDragActive] = useState(false);
const handleFile = useCallback(async (file: File) => {
setParsing(true);
setParseResult(null);
try {
const result = await parseFile(file);
setParseResult(result);
if (result.loans.length > 0) {
setLoans(result.loans.map(fromParsed));
setMode('results');
} else if (result.pslfPayments !== undefined && result.pslfPayments > 0) {
setMode('results');
} else {
setMode('results');
}
} catch (err) {
setParseResult({
classification: { type: 'unknown', confidence: 0, label: 'Error' },
loans: [],
rawText: '',
warnings: [`Failed to parse file: ${err instanceof Error ? err.message : 'Unknown error'}`],
});
setMode('results');
} finally {
setParsing(false);
}
}, []);
function handleFileInput(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) handleFile(file);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
}
function addLoan() { setLoans([...loans, newLoan()]); }
function removeLoan(id: string) { setLoans(loans.filter(l => l.id !== id)); }
function updateLoan(id: string, field: keyof LoanEntry, value: string | number) {
setLoans(loans.map(l => l.id === id ? { ...l, [field]: value } : l));
}
const totalBalance = loans.reduce((sum, l) => sum + l.balance, 0);
const weightedRate = totalBalance > 0
? loans.reduce((sum, l) => sum + l.balance * l.rate, 0) / totalBalance : 0;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="max-w-3xl mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-lg bg-blue-500/10 text-blue-600">
<FileText className="w-5 h-5" />
</div>
<h1 className="text-3xl font-bold">Loan Intake</h1>
</div>
<p className="text-gray-600 text-lg leading-relaxed">
Import your loans to power the calculators, PSLF tracker, and planner.
Upload any student loan document — we'll figure out what it is.
</p>
</div>
{/* ─── Choose Mode ─── */}
{mode === 'choose' && (
<div className="space-y-8">
{/* Upload Drop Zone */}
<div
className={`relative border-2 border-dashed rounded-xl p-12 text-center transition-colors ${
dragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-white hover:border-blue-300'
}`}
onDragOver={(e) => { e.preventDefault(); setDragActive(true); }}
onDragLeave={() => setDragActive(false)}
onDrop={handleDrop}
>
{parsing ? (
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-10 h-10 text-blue-500 animate-spin" />
<p className="text-blue-600 font-medium">Analyzing your document...</p>
<p className="text-gray-400 text-sm">Processing in your browser — nothing uploaded</p>
</div>
) : (
<>
<Upload className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-700 font-medium text-lg mb-2">
Drop any student loan document here
</p>
<p className="text-gray-500 text-sm mb-4">
Supports: StudentAid.gov TXT, CSV, PDF statements, ECF results, 1098-E
</p>
<label className="inline-flex items-center gap-2 px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg cursor-pointer transition-colors">
<FileSearch className="w-5 h-5" />
Browse Files
<input type="file" accept=".txt,.csv,.tsv,.pdf,.doc,.docx" onChange={handleFileInput} className="hidden" />
</label>
<p className="text-xs text-gray-400 mt-4">
All files are processed entirely in your browser. Nothing is uploaded to any server.
</p>
</>
)}
</div>
{/* Or Manual */}
<div className="flex items-center gap-4">
<div className="flex-1 border-t border-gray-200" />
<span className="text-gray-400 text-sm">or</span>
<div className="flex-1 border-t border-gray-200" />
</div>
<button onClick={() => setMode('manual')}
className="w-full p-6 bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-lg transition-all text-left flex items-center gap-4"
>
<PenLine className="w-8 h-8 text-blue-500 shrink-0" />
<div>
<h3 className="font-semibold text-lg">Enter Loans Manually</h3>
<p className="text-gray-600 text-sm">Type in each loan's balance, rate, and servicer.</p>
</div>
</button>
</div>
)}
{/* ─── Parse Results ─── */}
{mode === 'results' && parseResult && (
<div className="space-y-6">
{/* Classification Badge */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<FileSearch className="w-6 h-6 text-blue-500" />
<div>
<h3 className="font-semibold">Document Detected</h3>
<p className="text-sm text-gray-500">{parseResult.classification.label}</p>
</div>
</div>
<div className={`px-3 py-1 rounded-full text-xs font-medium ${
parseResult.classification.confidence > 0.5
? 'bg-green-100 text-green-700'
: parseResult.classification.confidence > 0.2
? 'bg-amber-100 text-amber-700'
: 'bg-red-100 text-red-700'
}`}>
{Math.round(parseResult.classification.confidence * 100)}% confidence
</div>
</div>
{/* Warnings */}
{parseResult.warnings.length > 0 && (
<div className="space-y-2 mt-4">
{parseResult.warnings.map((w, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<AlertCircle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
<span className="text-amber-700">{w}</span>
</div>
))}
</div>
)}
</div>
{/* PSLF Result */}
{parseResult.pslfPayments !== undefined && parseResult.pslfPayments > 0 && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6">
<h3 className="font-semibold text-emerald-800 mb-2">PSLF Payment Count Found</h3>
<div className="text-3xl font-bold text-emerald-700">{parseResult.pslfPayments} / 120</div>
<p className="text-sm text-emerald-600 mt-2">{120 - parseResult.pslfPayments} payments remaining</p>
<a href={`/pslf?payments=${parseResult.pslfPayments}`}
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors"
>
Open PSLF Tracker →
</a>
</div>
)}
{/* Extracted Loans */}
{loans.length > 0 && loans[0].balance > 0 && (
<>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">Extracted Loans ({loans.length})</h3>
<button onClick={() => setMode('manual')} className="text-sm text-blue-600 hover:underline">
Edit manually
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="text-left py-2 text-gray-500 font-medium">Loan</th>
<th className="text-left py-2 text-gray-500 font-medium">Type</th>
<th className="text-right py-2 text-gray-500 font-medium">Balance</th>
<th className="text-right py-2 text-gray-500 font-medium">Rate</th>
<th className="text-left py-2 text-gray-500 font-medium">Servicer</th>
</tr>
</thead>
<tbody>
{loans.map((loan) => (
<tr key={loan.id} className="border-b border-gray-50">
<td className="py-2 font-medium">{loan.name || '—'}</td>
<td className="py-2 text-gray-600">{loan.type}</td>
<td className="py-2 text-right font-mono">${loan.balance.toLocaleString()}</td>
<td className="py-2 text-right">{loan.rate > 0 ? `${loan.rate}%` : '—'}</td>
<td className="py-2 text-gray-600">{loan.servicer || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Summary */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
<h3 className="font-semibold text-blue-800 mb-3">Loan Summary</h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<div className="text-blue-600">Total Balance</div>
<div className="text-xl font-bold text-blue-900">${totalBalance.toLocaleString()}</div>
</div>
<div>
<div className="text-blue-600">Weighted Avg Rate</div>
<div className="text-xl font-bold text-blue-900">{weightedRate.toFixed(2)}%</div>
</div>
<div>
<div className="text-blue-600">Number of Loans</div>
<div className="text-xl font-bold text-blue-900">{loans.length}</div>
</div>
</div>
<a href={`/calculator?balance=${totalBalance}&rate=${(weightedRate / 100).toFixed(4)}`}
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors"
>
Use in Calculator →
</a>
</div>
</>
)}
{/* Actions */}
<div className="flex items-center gap-4">
<button onClick={() => { setMode('choose'); setParseResult(null); }}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors"
>
<Upload className="w-4 h-4" /> Upload Another File
</button>
<button onClick={() => setMode('manual')}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors"
>
<PenLine className="w-4 h-4" /> Edit Manually
</button>
</div>
</div>
)}
{/* ─── Manual Entry ─── */}
{mode === 'manual' && (
<div className="space-y-6">
{loans.map((loan, idx) => (
<div key={loan.id} className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">Loan {idx + 1}</h3>
{loans.length > 1 && (
<button onClick={() => removeLoan(loan.id)} className="text-red-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Name</label>
<input type="text" value={loan.name} placeholder="e.g. Direct Unsub #1"
onChange={(e) => updateLoan(loan.id, 'name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Type</label>
<select value={loan.type} onChange={(e) => updateLoan(loan.id, 'type', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
<option value="subsidized">Direct Subsidized</option>
<option value="unsubsidized">Direct Unsubsidized</option>
<option value="grad_plus">Grad PLUS</option>
<option value="parent_plus">Parent PLUS</option>
<option value="consolidated">Consolidated</option>
<option value="perkins">Perkins</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Balance ($)</label>
<input type="number" value={loan.balance || ''}
onChange={(e) => updateLoan(loan.id, 'balance', Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Rate (%)</label>
<input type="number" step="0.1" value={loan.rate || ''}
onChange={(e) => updateLoan(loan.id, 'rate', Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
))}
<button onClick={addLoan}
className="flex items-center gap-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-300 hover:text-blue-600 transition-colors"
>
<Plus className="w-4 h-4" /> Add Another Loan
</button>
{totalBalance > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
<h3 className="font-semibold text-blue-800 mb-3">Loan Summary</h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<div className="text-blue-600">Total Balance</div>
<div className="text-xl font-bold text-blue-900">${totalBalance.toLocaleString()}</div>
</div>
<div>
<div className="text-blue-600">Weighted Avg Rate</div>
<div className="text-xl font-bold text-blue-900">{weightedRate.toFixed(2)}%</div>
</div>
<div>
<div className="text-blue-600">Number of Loans</div>
<div className="text-xl font-bold text-blue-900">{loans.length}</div>
</div>
</div>
<a href={`/calculator?balance=${totalBalance}&rate=${(weightedRate / 100).toFixed(4)}`}
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors"
>
Use in Calculator →
</a>
</div>
)}
<div className="flex items-center gap-4">
<button onClick={() => setMode('choose')} className="text-sm text-blue-600 hover:underline">← Back</button>
</div>
<div className="flex items-start gap-3 p-4 bg-emerald-50 rounded-lg border border-emerald-200">
<ShieldCheck className="w-5 h-5 text-emerald-600 mt-0.5 shrink-0" />
<p className="text-emerald-800 text-sm">
<strong>Privacy:</strong> Your loan data is stored only in this browser tab.
It is never sent to any server. Close the tab to clear everything.
</p>
</div>
</div>
)}
</div>
);
}