feat: results views (summary, amortization, comparison, charts) and main page
This commit is contained in:
383
src/components/AmortizationTable.tsx
Normal file
383
src/components/AmortizationTable.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useSimulation } from '@/contexts/SimulationContext';
|
||||
import { Toggle } from './ui';
|
||||
import { formatCurrency, formatPercent } from '@/lib/formatters';
|
||||
|
||||
type ViewMode = 'yearly' | 'monthly';
|
||||
|
||||
export default function AmortizationTable() {
|
||||
const { results } = useSimulation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('yearly');
|
||||
const [expandedYear, setExpandedYear] = useState<number | null>(null);
|
||||
const [selectedLoan, setSelectedLoan] = useState<string>('all');
|
||||
|
||||
const activeDuration = results.maxDuration;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Toggle
|
||||
options={[
|
||||
{ value: 'yearly', label: 'Annuel' },
|
||||
{ value: 'monthly', label: 'Mensuel' },
|
||||
]}
|
||||
value={viewMode}
|
||||
onChange={(v) => setViewMode(v as ViewMode)}
|
||||
/>
|
||||
|
||||
{/* Loan filter */}
|
||||
<select
|
||||
className="form-input-sm w-auto"
|
||||
value={selectedLoan}
|
||||
onChange={(e) => setSelectedLoan(e.target.value)}
|
||||
>
|
||||
<option value="all">Tous les prêts</option>
|
||||
{results.loanResults.map((lr) => (
|
||||
<option key={lr.loan.id} value={lr.loan.id}>
|
||||
{lr.loan.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
{viewMode === 'yearly' ? (
|
||||
<YearlyTable
|
||||
selectedLoan={selectedLoan}
|
||||
expandedYear={expandedYear}
|
||||
onToggleYear={(y) => setExpandedYear(expandedYear === y ? null : y)}
|
||||
activeDuration={activeDuration}
|
||||
/>
|
||||
) : (
|
||||
<MonthlyTable
|
||||
selectedLoan={selectedLoan}
|
||||
activeDuration={activeDuration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Yearly Table ───────────────────────────────────────────────────────────
|
||||
|
||||
function YearlyTable({
|
||||
selectedLoan,
|
||||
expandedYear,
|
||||
onToggleYear,
|
||||
activeDuration,
|
||||
}: {
|
||||
selectedLoan: string;
|
||||
expandedYear: number | null;
|
||||
onToggleYear: (y: number) => void;
|
||||
activeDuration: number;
|
||||
}) {
|
||||
const { results } = useSimulation();
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (selectedLoan === 'all') {
|
||||
return results.yearlySummaries
|
||||
.filter((y) => y.year <= activeDuration)
|
||||
.map((y) => ({
|
||||
year: y.year,
|
||||
interest: y.totalInterest,
|
||||
principal: y.totalPrincipal,
|
||||
insurance: y.totalInsurance,
|
||||
remaining: y.totalRemaining,
|
||||
payment: y.totalPayment,
|
||||
monthlyTotal: y.monthlyTotal,
|
||||
capitalRatio: y.capitalRatio,
|
||||
}));
|
||||
}
|
||||
|
||||
const lr = results.loanResults.find((l) => l.loan.id === selectedLoan);
|
||||
if (!lr) return [];
|
||||
|
||||
return lr.yearly
|
||||
.filter((y) => y.year <= lr.loan.duration)
|
||||
.map((y) => ({
|
||||
year: y.year,
|
||||
interest: y.interest,
|
||||
principal: y.principal,
|
||||
insurance: y.insurance,
|
||||
remaining: y.remaining,
|
||||
payment: y.payment,
|
||||
monthlyTotal: y.payment / 12,
|
||||
capitalRatio: y.payment > 0 ? y.principal / y.payment : 0,
|
||||
}));
|
||||
}, [results, selectedLoan, activeDuration]);
|
||||
|
||||
// Get monthly details for expanded year
|
||||
const expandedMonths = useMemo(() => {
|
||||
if (expandedYear === null) return [];
|
||||
|
||||
if (selectedLoan === 'all') {
|
||||
// Aggregate monthly data across all loans for the given year
|
||||
const months: { month: number; interest: number; principal: number; insurance: number; remaining: number; payment: number }[] = [];
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
let interest = 0, principal = 0, insurance = 0, remaining = 0, payment = 0;
|
||||
for (const lr of results.loanResults) {
|
||||
const row = lr.monthly.find((r) => r.year === expandedYear && r.monthInYear === m);
|
||||
if (row) {
|
||||
interest += row.interest;
|
||||
principal += row.principal;
|
||||
insurance += row.insurance;
|
||||
remaining += row.remaining;
|
||||
payment += row.payment;
|
||||
}
|
||||
}
|
||||
if (interest > 0 || principal > 0 || insurance > 0) {
|
||||
months.push({ month: m, interest, principal, insurance, remaining, payment });
|
||||
}
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
const lr = results.loanResults.find((l) => l.loan.id === selectedLoan);
|
||||
if (!lr) return [];
|
||||
return lr.monthly
|
||||
.filter((r) => r.year === expandedYear)
|
||||
.map((r) => ({
|
||||
month: r.monthInYear,
|
||||
interest: r.interest,
|
||||
principal: r.principal,
|
||||
insurance: r.insurance,
|
||||
remaining: r.remaining,
|
||||
payment: r.payment,
|
||||
}));
|
||||
}, [results, selectedLoan, expandedYear]);
|
||||
|
||||
return (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-16">Année</th>
|
||||
<th className="text-right">Intérêts</th>
|
||||
<th className="text-right">Capital</th>
|
||||
<th className="text-right">Assurance</th>
|
||||
<th className="text-right">Restant dû</th>
|
||||
<th className="text-right">Mensualité</th>
|
||||
<th className="text-right">Ratio capital</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => (
|
||||
<React.Fragment key={row.year}>
|
||||
<tr
|
||||
className="cursor-pointer hover:bg-blue-50/60 dark:hover:bg-blue-950/30"
|
||||
onClick={() => onToggleYear(row.year)}
|
||||
>
|
||||
<td className="font-medium">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg
|
||||
className={`w-3 h-3 text-slate-400 transition-transform ${
|
||||
expandedYear === row.year ? 'rotate-90' : ''
|
||||
}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{row.year}
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-right text-amber-600 tabular-nums">{formatCurrency(row.interest)}</td>
|
||||
<td className="text-right text-blue-600 tabular-nums">{formatCurrency(row.principal)}</td>
|
||||
<td className="text-right text-purple-500 tabular-nums">{formatCurrency(row.insurance)}</td>
|
||||
<td className="text-right tabular-nums font-medium">{formatCurrency(row.remaining)}</td>
|
||||
<td className="text-right tabular-nums">{formatCurrency(row.monthlyTotal)}</td>
|
||||
<td className="text-right">
|
||||
<RatioBar value={row.capitalRatio} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded monthly detail */}
|
||||
{expandedYear === row.year && expandedMonths.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-0 bg-slate-50 dark:bg-slate-800/50">
|
||||
<div className="p-3 animate-fade-in">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-slate-400 dark:text-slate-500">
|
||||
<th className="text-left py-1 px-2 font-medium">Mois</th>
|
||||
<th className="text-right py-1 px-2 font-medium">Intérêts</th>
|
||||
<th className="text-right py-1 px-2 font-medium">Capital</th>
|
||||
<th className="text-right py-1 px-2 font-medium">Assurance</th>
|
||||
<th className="text-right py-1 px-2 font-medium">Restant dû</th>
|
||||
<th className="text-right py-1 px-2 font-medium">Mensualité</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expandedMonths.map((m) => (
|
||||
<tr key={m.month} className="border-t border-slate-200/50 dark:border-slate-700/50">
|
||||
<td className="py-1 px-2 font-medium text-slate-600 dark:text-slate-400">
|
||||
{MONTH_NAMES[m.month - 1]}
|
||||
</td>
|
||||
<td className="text-right py-1 px-2 text-amber-600 tabular-nums">
|
||||
{formatCurrency(m.interest, 2)}
|
||||
</td>
|
||||
<td className="text-right py-1 px-2 text-blue-600 tabular-nums">
|
||||
{formatCurrency(m.principal, 2)}
|
||||
</td>
|
||||
<td className="text-right py-1 px-2 text-purple-500 tabular-nums">
|
||||
{formatCurrency(m.insurance, 2)}
|
||||
</td>
|
||||
<td className="text-right py-1 px-2 tabular-nums font-medium">
|
||||
{formatCurrency(m.remaining, 2)}
|
||||
</td>
|
||||
<td className="text-right py-1 px-2 tabular-nums">
|
||||
{formatCurrency(m.payment, 2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Monthly Table (flat view) ──────────────────────────────────────────────
|
||||
|
||||
function MonthlyTable({
|
||||
selectedLoan,
|
||||
activeDuration,
|
||||
}: {
|
||||
selectedLoan: string;
|
||||
activeDuration: number;
|
||||
}) {
|
||||
const { results } = useSimulation();
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 24; // 2 years per page
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (selectedLoan === 'all') {
|
||||
const totalMonths = activeDuration * 12;
|
||||
const months: { month: number; year: number; interest: number; principal: number; insurance: number; remaining: number; payment: number }[] = [];
|
||||
|
||||
for (let m = 1; m <= totalMonths; m++) {
|
||||
let interest = 0, principal = 0, insurance = 0, remaining = 0, payment = 0;
|
||||
for (const lr of results.loanResults) {
|
||||
const row = lr.monthly.find((r) => r.month === m);
|
||||
if (row) {
|
||||
interest += row.interest;
|
||||
principal += row.principal;
|
||||
insurance += row.insurance;
|
||||
remaining += row.remaining;
|
||||
payment += row.payment;
|
||||
}
|
||||
}
|
||||
months.push({
|
||||
month: m,
|
||||
year: Math.ceil(m / 12),
|
||||
interest, principal, insurance, remaining, payment,
|
||||
});
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
const lr = results.loanResults.find((l) => l.loan.id === selectedLoan);
|
||||
if (!lr) return [];
|
||||
return lr.monthly.map((r) => ({
|
||||
month: r.month,
|
||||
year: r.year,
|
||||
interest: r.interest,
|
||||
principal: r.principal,
|
||||
insurance: r.insurance,
|
||||
remaining: r.remaining,
|
||||
payment: r.payment,
|
||||
}));
|
||||
}, [results, selectedLoan, activeDuration]);
|
||||
|
||||
const totalPages = Math.ceil(data.length / pageSize);
|
||||
const pageData = data.slice(page * pageSize, (page + 1) * pageSize);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mois</th>
|
||||
<th>Année</th>
|
||||
<th className="text-right">Intérêts</th>
|
||||
<th className="text-right">Capital</th>
|
||||
<th className="text-right">Assurance</th>
|
||||
<th className="text-right">Restant dû</th>
|
||||
<th className="text-right">Mensualité</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageData.map((row) => (
|
||||
<tr key={row.month}>
|
||||
<td className="font-medium text-slate-600 dark:text-slate-400">{row.month}</td>
|
||||
<td className="text-slate-400 dark:text-slate-500">{row.year}</td>
|
||||
<td className="text-right text-amber-600 tabular-nums">{formatCurrency(row.interest, 2)}</td>
|
||||
<td className="text-right text-blue-600 tabular-nums">{formatCurrency(row.principal, 2)}</td>
|
||||
<td className="text-right text-purple-500 tabular-nums">{formatCurrency(row.insurance, 2)}</td>
|
||||
<td className="text-right tabular-nums font-medium">{formatCurrency(row.remaining)}</td>
|
||||
<td className="text-right tabular-nums">{formatCurrency(row.payment, 2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 dark:border-slate-700">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 disabled:text-slate-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<span className="text-sm text-slate-500">
|
||||
Page {page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 disabled:text-slate-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ratio Bar ──────────────────────────────────────────────────────────────
|
||||
|
||||
function RatioBar({ value }: { value: number }) {
|
||||
const pct = Math.round(value * 100);
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<div className="w-16 h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-slate-500 w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre',
|
||||
];
|
||||
453
src/components/Charts.tsx
Normal file
453
src/components/Charts.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
type ChartOptions,
|
||||
} from 'chart.js';
|
||||
import { Line, Bar, Doughnut } from 'react-chartjs-2';
|
||||
import { useSimulation } from '@/contexts/SimulationContext';
|
||||
import { formatCurrencyShort, formatCurrency } from '@/lib/formatters';
|
||||
|
||||
// Register ChartJS components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
// ─── Color palette ──────────────────────────────────────────────────────────
|
||||
|
||||
const COLORS = {
|
||||
capital: { bg: 'rgba(37, 99, 235, 0.15)', border: 'rgb(37, 99, 235)' },
|
||||
interest: { bg: 'rgba(245, 158, 11, 0.15)', border: 'rgb(245, 158, 11)' },
|
||||
insurance: { bg: 'rgba(139, 92, 246, 0.15)', border: 'rgb(139, 92, 246)' },
|
||||
charges: { bg: 'rgba(107, 114, 128, 0.15)', border: 'rgb(107, 114, 128)' },
|
||||
rent: { bg: 'rgba(239, 68, 68, 0.15)', border: 'rgb(239, 68, 68)' },
|
||||
projected: { bg: 'rgba(37, 99, 235, 0.15)', border: 'rgb(37, 99, 235)' },
|
||||
resale: { bg: 'rgba(5, 150, 105, 0.15)', border: 'rgb(5, 150, 105)' },
|
||||
negative: { bg: 'rgba(220, 38, 38, 0.15)', border: 'rgb(220, 38, 38)' },
|
||||
};
|
||||
|
||||
const defaultTooltipOptions = {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.9)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#cbd5e1',
|
||||
borderColor: 'rgba(148, 163, 184, 0.3)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 8,
|
||||
padding: 12,
|
||||
bodySpacing: 6,
|
||||
titleSpacing: 4,
|
||||
usePointStyle: true,
|
||||
};
|
||||
|
||||
const defaultLegendOptions = {
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
padding: 16,
|
||||
font: { size: 11 },
|
||||
color: '#64748b',
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Main Charts Component ─────────────────────────────────────────────────
|
||||
|
||||
export default function Charts() {
|
||||
const { results } = useSimulation();
|
||||
const data = results.yearlySummaries.filter((y) => y.year <= results.maxDuration);
|
||||
const labels = data.map((y) => `An ${y.year}`);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* 1. Remaining balance over time */}
|
||||
<ChartCard title="Évolution du capital restant dû">
|
||||
<RemainingBalanceChart data={data} labels={labels} />
|
||||
</ChartCard>
|
||||
|
||||
{/* 2. Capital vs Interest vs Insurance breakdown */}
|
||||
<ChartCard title="Répartition annuelle des paiements">
|
||||
<PaymentBreakdownChart data={data} labels={labels} />
|
||||
</ChartCard>
|
||||
|
||||
{/* 3. Monthly comparison: rent vs projected */}
|
||||
<ChartCard title="Comparaison mensuelle : propriétaire vs locataire">
|
||||
<ComparisonChart data={data} labels={labels} />
|
||||
</ChartCard>
|
||||
|
||||
{/* 4. Resale projection */}
|
||||
<ChartCard title="Projection de revente (plus/moins-value nette)">
|
||||
<ResaleChart data={data} labels={labels} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* Cost breakdown donut */}
|
||||
<ChartCard title="Répartition du coût total du crédit">
|
||||
<div className="max-w-sm mx-auto">
|
||||
<CostDonut />
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Chart Components ───────────────────────────────────────────────────────
|
||||
|
||||
function RemainingBalanceChart({ data, labels }: ChartProps) {
|
||||
const { results } = useSimulation();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
// Stack per-loan remaining balances
|
||||
const datasets = results.loanResults.map((lr, idx) => {
|
||||
const colors = [
|
||||
COLORS.capital,
|
||||
COLORS.interest,
|
||||
COLORS.insurance,
|
||||
COLORS.charges,
|
||||
COLORS.rent,
|
||||
];
|
||||
const color = colors[idx % colors.length];
|
||||
|
||||
return {
|
||||
label: lr.loan.name,
|
||||
data: data.map((y) => {
|
||||
const yearData = lr.yearly.find((yr) => yr.year === y.year);
|
||||
return yearData?.remaining ?? 0;
|
||||
}),
|
||||
borderColor: color.border,
|
||||
backgroundColor: color.bg,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10,
|
||||
borderWidth: 2,
|
||||
};
|
||||
});
|
||||
|
||||
return { labels, datasets };
|
||||
}, [data, labels, results.loanResults]);
|
||||
|
||||
const options: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: defaultTooltipOptions,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
...defaultTooltipOptions,
|
||||
callbacks: {
|
||||
label: (ctx) => `${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y ?? 0)}`,
|
||||
},
|
||||
},
|
||||
legend: defaultLegendOptions,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#94a3b8', font: { size: 10 } },
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' },
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
font: { size: 10 },
|
||||
callback: (v) => formatCurrencyShort(v as number),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-72">
|
||||
<Line data={chartData} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentBreakdownChart({ data, labels }: ChartProps) {
|
||||
const chartData = useMemo(
|
||||
() => ({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Capital',
|
||||
data: data.map((y) => y.totalPrincipal),
|
||||
backgroundColor: COLORS.capital.border,
|
||||
borderRadius: 3,
|
||||
},
|
||||
{
|
||||
label: 'Intérêts',
|
||||
data: data.map((y) => y.totalInterest),
|
||||
backgroundColor: COLORS.interest.border,
|
||||
borderRadius: 3,
|
||||
},
|
||||
{
|
||||
label: 'Assurance',
|
||||
data: data.map((y) => y.totalInsurance),
|
||||
backgroundColor: COLORS.insurance.border,
|
||||
borderRadius: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[data, labels]
|
||||
);
|
||||
|
||||
const options: ChartOptions<'bar'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
...defaultTooltipOptions,
|
||||
callbacks: {
|
||||
label: (ctx) => `${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y ?? 0)}`,
|
||||
},
|
||||
},
|
||||
legend: defaultLegendOptions,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: { display: false },
|
||||
ticks: { color: '#94a3b8', font: { size: 10 } },
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' },
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
font: { size: 10 },
|
||||
callback: (v) => formatCurrencyShort(v as number),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-72">
|
||||
<Bar data={chartData} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonChart({ data, labels }: ChartProps) {
|
||||
const chartData = useMemo(
|
||||
() => ({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Mensualité propriétaire',
|
||||
data: data.map((y) => y.monthlyTotal),
|
||||
borderColor: COLORS.projected.border,
|
||||
backgroundColor: COLORS.projected.bg,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHitRadius: 10,
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: 'Coût locataire',
|
||||
data: data.map((y) => y.currentMonthlyTotal),
|
||||
borderColor: COLORS.rent.border,
|
||||
backgroundColor: COLORS.rent.bg,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHitRadius: 10,
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 3],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[data, labels]
|
||||
);
|
||||
|
||||
const options: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
...defaultTooltipOptions,
|
||||
callbacks: {
|
||||
label: (ctx) => `${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y ?? 0)}/mois`,
|
||||
},
|
||||
},
|
||||
legend: defaultLegendOptions,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#94a3b8', font: { size: 10 } },
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' },
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
font: { size: 10 },
|
||||
callback: (v) => formatCurrencyShort(v as number),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-72">
|
||||
<Line data={chartData} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResaleChart({ data, labels }: ChartProps) {
|
||||
const chartData = useMemo(
|
||||
() => ({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Plus/moins-value nette',
|
||||
data: data.map((y) => y.netResale),
|
||||
borderColor: data.map((y) =>
|
||||
y.netResale >= 0 ? COLORS.resale.border : COLORS.negative.border
|
||||
),
|
||||
backgroundColor: data.map((y) =>
|
||||
y.netResale >= 0 ? COLORS.resale.bg : COLORS.negative.bg
|
||||
),
|
||||
borderWidth: 2,
|
||||
borderRadius: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[data, labels]
|
||||
);
|
||||
|
||||
const options: ChartOptions<'bar'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
...defaultTooltipOptions,
|
||||
callbacks: {
|
||||
label: (ctx) => `Plus/moins-value: ${formatCurrency(ctx.parsed.y ?? 0)}`,
|
||||
},
|
||||
},
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#94a3b8', font: { size: 10 } },
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' },
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
font: { size: 10 },
|
||||
callback: (v) => formatCurrencyShort(v as number),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-72">
|
||||
<Bar data={chartData} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CostDonut() {
|
||||
const { results } = useSimulation();
|
||||
|
||||
const chartData = useMemo(
|
||||
() => ({
|
||||
labels: ['Capital', 'Intérêts', 'Assurance'],
|
||||
datasets: [
|
||||
{
|
||||
data: [results.totalCapital, results.totalInterest, results.totalInsurance],
|
||||
backgroundColor: [
|
||||
COLORS.capital.border,
|
||||
COLORS.interest.border,
|
||||
COLORS.insurance.border,
|
||||
],
|
||||
borderWidth: 0,
|
||||
hoverOffset: 8,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[results]
|
||||
);
|
||||
|
||||
const options: ChartOptions<'doughnut'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
tooltip: {
|
||||
...defaultTooltipOptions,
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
const total = results.grandTotal;
|
||||
const pct = total > 0 ? ((ctx.parsed / total) * 100).toFixed(1) : 0;
|
||||
return `${ctx.label}: ${formatCurrency(ctx.parsed)} (${pct}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: defaultLegendOptions,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Doughnut data={chartData} options={options} />
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-slate-900 dark:text-slate-100 tabular-nums">
|
||||
{formatCurrency(results.grandTotal)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 dark:text-slate-500">Coût total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Chart Card Wrapper ─────────────────────────────────────────────────────
|
||||
|
||||
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">{title}</h3>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ChartProps {
|
||||
data: ReturnType<typeof useSimulation>['results']['yearlySummaries'];
|
||||
labels: string[];
|
||||
}
|
||||
196
src/components/ComparisonTable.tsx
Normal file
196
src/components/ComparisonTable.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useSimulation } from '@/contexts/SimulationContext';
|
||||
import { formatCurrency } from '@/lib/formatters';
|
||||
|
||||
export default function ComparisonTable() {
|
||||
const { results } = useSimulation();
|
||||
const data = results.yearlySummaries.filter((y) => y.year <= Math.max(results.maxDuration, 5));
|
||||
|
||||
// Find break-even year (when projected <= current)
|
||||
const breakEvenYear = data.find((y) => y.difference <= 0)?.year;
|
||||
// Find resale profit year
|
||||
const profitYear = data.find((y) => y.netResale > 0)?.year;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Key insights */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<InsightCard
|
||||
label="Mensualité projetée (an 1)"
|
||||
value={formatCurrency(data[0]?.monthlyTotal ?? 0)}
|
||||
detail="Crédit + charges propriétaire"
|
||||
variant="blue"
|
||||
/>
|
||||
<InsightCard
|
||||
label="Loyer actuel (an 1)"
|
||||
value={formatCurrency(data[0]?.currentMonthlyTotal ?? 0)}
|
||||
detail="Loyer + charges locataire"
|
||||
variant="amber"
|
||||
/>
|
||||
<InsightCard
|
||||
label={profitYear ? `Revente rentable dès l'année ${profitYear}` : 'Revente non rentable'}
|
||||
value={
|
||||
profitYear
|
||||
? formatCurrency(data.find((y) => y.year === profitYear)?.netResale ?? 0)
|
||||
: '—'
|
||||
}
|
||||
detail={profitYear ? 'Plus-value nette après frais' : 'Sur la période simulée'}
|
||||
variant={profitYear ? 'green' : 'red'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main comparison table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
Comparaison annuelle : propriétaire vs locataire
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan={2} className="border-r border-slate-200 dark:border-slate-700">Année</th>
|
||||
<th colSpan={4} className="text-center border-r border-slate-200 dark:border-slate-700 bg-blue-50/50 dark:bg-blue-950/20">
|
||||
Propriétaire (mensuel)
|
||||
</th>
|
||||
<th colSpan={2} className="text-center border-r border-slate-200 dark:border-slate-700 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
Locataire (mensuel)
|
||||
</th>
|
||||
<th colSpan={2} className="text-center border-r border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||||
Comparaison
|
||||
</th>
|
||||
<th colSpan={2} className="text-center bg-emerald-50/50 dark:bg-emerald-950/20">
|
||||
Revente
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
{/* Propriétaire */}
|
||||
<th className="text-right text-blue-600">Capital</th>
|
||||
<th className="text-right text-amber-500">Intérêts</th>
|
||||
<th className="text-right text-slate-400">Charges</th>
|
||||
<th className="text-right border-r border-slate-200 dark:border-slate-700 font-bold">Total</th>
|
||||
{/* Locataire */}
|
||||
<th className="text-right">Loyer</th>
|
||||
<th className="text-right border-r border-slate-200 dark:border-slate-700 font-bold">Total</th>
|
||||
{/* Comparaison */}
|
||||
<th className="text-right">Diff/mois</th>
|
||||
<th className="text-right border-r border-slate-200 dark:border-slate-700">Ratio cap.</th>
|
||||
{/* Revente */}
|
||||
<th className="text-right">Prix</th>
|
||||
<th className="text-right">+/- value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => {
|
||||
const isBreakEven = row.year === breakEvenYear;
|
||||
return (
|
||||
<tr
|
||||
key={row.year}
|
||||
className={isBreakEven ? 'bg-emerald-50/50 dark:bg-emerald-950/20' : ''}
|
||||
>
|
||||
<td className="font-medium border-r border-slate-100 dark:border-slate-800">{row.year}</td>
|
||||
|
||||
{/* Propriétaire */}
|
||||
<td className="text-right text-blue-600 tabular-nums">
|
||||
{formatCurrency(row.monthlyPrincipal)}
|
||||
</td>
|
||||
<td className="text-right text-amber-500 tabular-nums">
|
||||
{formatCurrency(row.monthlyInterest)}
|
||||
</td>
|
||||
<td className="text-right text-slate-400 tabular-nums">
|
||||
{formatCurrency(row.monthlyCharges + row.monthlyLoanInsurance)}
|
||||
</td>
|
||||
<td className="text-right font-semibold border-r border-slate-100 dark:border-slate-800 tabular-nums">
|
||||
{formatCurrency(row.monthlyTotal)}
|
||||
</td>
|
||||
|
||||
{/* Locataire */}
|
||||
<td className="text-right tabular-nums">{formatCurrency(row.currentRent)}</td>
|
||||
<td className="text-right font-semibold border-r border-slate-100 dark:border-slate-800 tabular-nums">
|
||||
{formatCurrency(row.currentMonthlyTotal)}
|
||||
</td>
|
||||
|
||||
{/* Comparaison */}
|
||||
<td className={`text-right font-medium tabular-nums ${
|
||||
row.difference > 0 ? 'text-red-500' : 'text-emerald-600'
|
||||
}`}>
|
||||
{row.difference > 0 ? '+' : ''}{formatCurrency(row.difference)}
|
||||
</td>
|
||||
<td className="text-right border-r border-slate-100 dark:border-slate-800">
|
||||
<RatioBar value={row.capitalRatio} />
|
||||
</td>
|
||||
|
||||
{/* Revente */}
|
||||
<td className="text-right tabular-nums">{formatCurrency(row.resalePrice)}</td>
|
||||
<td className={`text-right font-medium tabular-nums ${
|
||||
row.netResale >= 0 ? 'text-emerald-600' : 'text-red-500'
|
||||
}`}>
|
||||
{row.netResale >= 0 ? '+' : ''}{formatCurrency(row.netResale)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="text-xs text-slate-400 space-y-1 px-1">
|
||||
<p>
|
||||
<strong>Ratio capital :</strong> Part de votre mensualité qui constitue du patrimoine (remboursement du capital emprunté).
|
||||
</p>
|
||||
<p>
|
||||
<strong>Plus/Moins-value :</strong> Gain net si vous revendez, après déduction des frais de vente, du capital restant dû et de l'apport.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function InsightCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
variant,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
variant: 'blue' | 'amber' | 'green' | 'red';
|
||||
}) {
|
||||
const styles = {
|
||||
blue: 'border-blue-200 bg-blue-50/50 dark:border-blue-900 dark:bg-blue-950/30',
|
||||
amber: 'border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/30',
|
||||
green: 'border-emerald-200 bg-emerald-50/50 dark:border-emerald-900 dark:bg-emerald-950/30',
|
||||
red: 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/30',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${styles[variant]}`}>
|
||||
<div className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</div>
|
||||
<div className="text-xl font-bold text-slate-900 dark:text-slate-100 mt-1 tabular-nums">{value}</div>
|
||||
<div className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{detail}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RatioBar({ value }: { value: number }) {
|
||||
const pct = Math.round(value * 100);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 justify-end">
|
||||
<div className="w-12 h-1.5 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full"
|
||||
style={{ width: `${Math.min(100, pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-slate-400 w-7 text-right">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
src/components/ResultsSummary.tsx
Normal file
269
src/components/ResultsSummary.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useSimulation } from '@/contexts/SimulationContext';
|
||||
import { formatCurrency, formatPercent } from '@/lib/formatters';
|
||||
|
||||
export default function ResultsSummary() {
|
||||
const { results, state } = useSimulation();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Key metrics */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<MetricCard
|
||||
label="Mensualité totale"
|
||||
value={formatCurrency(results.projectedMonthlyTotal)}
|
||||
sub="Crédit + charges"
|
||||
color="blue"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Coût total du crédit"
|
||||
value={formatCurrency(results.grandTotal)}
|
||||
sub={`dont ${formatCurrency(results.totalInterest)} d'intérêts`}
|
||||
color="amber"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Loyer actuel"
|
||||
value={formatCurrency(results.currentMonthlyTotal)}
|
||||
sub="Loyer + charges"
|
||||
color="slate"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Différence mensuelle"
|
||||
value={formatCurrency(results.projectedMonthlyTotal - results.currentMonthlyTotal)}
|
||||
sub={
|
||||
results.projectedMonthlyTotal > results.currentMonthlyTotal
|
||||
? 'Surcoût vs loyer'
|
||||
: 'Économie vs loyer'
|
||||
}
|
||||
color={results.projectedMonthlyTotal > results.currentMonthlyTotal ? 'red' : 'green'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Financing breakdown */}
|
||||
<div className="card">
|
||||
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Récapitulatif du financement</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row label="Prix du bien" value={formatCurrency(state.propertyPrice)} />
|
||||
<Row label="Frais de notaire" value={formatCurrency(results.notaryFees)} muted />
|
||||
<Row label="Frais de garantie" value={formatCurrency(results.guaranteeFees)} muted />
|
||||
<Row label="Frais de dossier" value={formatCurrency(state.bankFees)} muted />
|
||||
<Row label="Travaux" value={formatCurrency(state.worksCost)} muted />
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-2">
|
||||
<Row label="Financement total" value={formatCurrency(results.totalFinancing)} bold />
|
||||
</div>
|
||||
<Row label="Apport" value={`- ${formatCurrency(state.downPayment)}`} className="text-emerald-600" />
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-2">
|
||||
<Row label="Crédit total" value={formatCurrency(results.totalCredit)} bold />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row label="Capital emprunté" value={formatCurrency(results.totalCapital)} />
|
||||
<Row label="Total intérêts" value={formatCurrency(results.totalInterest)} className="text-amber-600" />
|
||||
<Row label="Total assurance" value={formatCurrency(results.totalInsurance)} className="text-purple-600" />
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-2">
|
||||
<Row label="Coût total" value={formatCurrency(results.grandTotal)} bold />
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-slate-50 dark:bg-slate-800 p-3">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mb-1">Surcoût du crédit</div>
|
||||
<div className="text-lg font-bold text-slate-900 dark:text-slate-100 tabular-nums">
|
||||
{formatCurrency(results.totalInterest + results.totalInsurance)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
soit {formatPercent((results.totalInterest + results.totalInsurance) / results.totalCredit)} du capital
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-loan breakdown */}
|
||||
<div className="card">
|
||||
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Détail par prêt</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Prêt</th>
|
||||
<th className="text-right">Capital</th>
|
||||
<th className="text-right">Taux</th>
|
||||
<th className="text-right">Durée</th>
|
||||
<th className="text-right">Mensualité</th>
|
||||
<th className="text-right">Intérêts</th>
|
||||
<th className="text-right">Assurance</th>
|
||||
<th className="text-right">Coût total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.loanResults.map((lr) => (
|
||||
<tr key={lr.loan.id}>
|
||||
<td className="font-medium text-slate-800 dark:text-slate-200">
|
||||
{lr.loan.name}
|
||||
{lr.loan.isMainLoan && (
|
||||
<span className="ml-1.5 text-[10px] text-blue-500 bg-blue-50 px-1 py-0.5 rounded">
|
||||
AUTO
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right tabular-nums">{formatCurrency(lr.effectiveAmount)}</td>
|
||||
<td className="text-right tabular-nums">{formatPercent(lr.loan.rate)}</td>
|
||||
<td className="text-right tabular-nums">{lr.loan.duration} ans</td>
|
||||
<td className="text-right tabular-nums font-medium">
|
||||
{formatCurrency(lr.monthlyPayment + lr.monthlyInsurance)}
|
||||
</td>
|
||||
<td className="text-right tabular-nums text-amber-600">
|
||||
{formatCurrency(lr.totalInterest)}
|
||||
</td>
|
||||
<td className="text-right tabular-nums text-purple-600">
|
||||
{formatCurrency(lr.totalInsurance)}
|
||||
</td>
|
||||
<td className="text-right tabular-nums font-semibold">
|
||||
{formatCurrency(lr.totalCost)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-slate-50 dark:bg-slate-800/50 font-semibold">
|
||||
<td>Total</td>
|
||||
<td className="text-right tabular-nums">{formatCurrency(results.totalCapital)}</td>
|
||||
<td></td>
|
||||
<td className="text-right tabular-nums">{results.maxDuration} ans</td>
|
||||
<td className="text-right tabular-nums">{formatCurrency(results.monthlyPaymentYear1)}</td>
|
||||
<td className="text-right tabular-nums text-amber-600">{formatCurrency(results.totalInterest)}</td>
|
||||
<td className="text-right tabular-nums text-purple-600">{formatCurrency(results.totalInsurance)}</td>
|
||||
<td className="text-right tabular-nums">{formatCurrency(results.grandTotal)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly cost breakdown for year 1 */}
|
||||
<div className="card">
|
||||
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-800">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Décomposition mensuelle (année 1)</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{results.yearlySummaries[0] && (
|
||||
<CostBreakdownBar summary={results.yearlySummaries[0]} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub: string;
|
||||
color: 'blue' | 'amber' | 'green' | 'red' | 'slate';
|
||||
}) {
|
||||
const colors = {
|
||||
blue: 'border-blue-200 bg-blue-50/50 dark:border-blue-900 dark:bg-blue-950/30',
|
||||
amber: 'border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/30',
|
||||
green: 'border-emerald-200 bg-emerald-50/50 dark:border-emerald-900 dark:bg-emerald-950/30',
|
||||
red: 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/30',
|
||||
slate: 'border-slate-200 bg-slate-50/50 dark:border-slate-700 dark:bg-slate-800/50',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${colors[color]}`}>
|
||||
<div className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</div>
|
||||
<div className="text-xl font-bold text-slate-900 dark:text-slate-100 mt-1 tabular-nums">{value}</div>
|
||||
<div className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
bold,
|
||||
muted,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
bold?: boolean;
|
||||
muted?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex justify-between ${className ?? ''}`}>
|
||||
<span className={`${muted ? 'text-slate-400 dark:text-slate-500' : 'text-slate-600 dark:text-slate-400'} ${bold ? 'font-semibold text-slate-900 dark:text-slate-100' : ''}`}>
|
||||
{label}
|
||||
</span>
|
||||
<span className={`tabular-nums ${bold ? 'font-semibold text-slate-900 dark:text-slate-100' : ''} ${muted ? 'text-slate-400 dark:text-slate-500' : ''}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CostBreakdownBar({ summary }: { summary: ReturnType<typeof useSimulation>['results']['yearlySummaries'][0] }) {
|
||||
const items = [
|
||||
{ label: 'Capital', value: summary.monthlyPrincipal, color: 'bg-blue-500' },
|
||||
{ label: 'Intérêts', value: summary.monthlyInterest, color: 'bg-amber-400' },
|
||||
{ label: 'Assurance prêt', value: summary.monthlyLoanInsurance, color: 'bg-purple-400' },
|
||||
{ label: 'Charges', value: summary.monthlyCharges, color: 'bg-slate-400' },
|
||||
];
|
||||
|
||||
const total = items.reduce((s, i) => s + i.value, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Bar */}
|
||||
<div className="flex rounded-lg overflow-hidden h-8">
|
||||
{items.map((item) => {
|
||||
const pct = total > 0 ? (item.value / total) * 100 : 0;
|
||||
if (pct < 0.5) return null;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`${item.color} flex items-center justify-center transition-all`}
|
||||
style={{ width: `${pct}%` }}
|
||||
title={`${item.label}: ${formatCurrency(item.value)}`}
|
||||
>
|
||||
{pct > 10 && (
|
||||
<span className="text-xs text-white font-medium">{pct.toFixed(0)}%</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2 mt-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2 text-sm">
|
||||
<div className={`w-2.5 h-2.5 rounded-sm ${item.color}`} />
|
||||
<span className="text-slate-500 dark:text-slate-400">{item.label}</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300 tabular-nums">{formatCurrency(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-slate-700 flex justify-between text-sm font-semibold">
|
||||
<span>Total mensuel</span>
|
||||
<span className="tabular-nums">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user