feat: results views (summary, amortization, comparison, charts) and main page

This commit is contained in:
2026-02-22 20:00:49 +01:00
parent 99ab1fc6fb
commit 74cdb7b064
5 changed files with 1425 additions and 0 deletions

View 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 </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 </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 </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
View 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[];
}

View 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 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>
);
}

View 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>
);
}