From 74cdb7b064a9d02229de792d90b38f7229126cd4 Mon Sep 17 00:00:00 2001 From: antopoid Date: Sun, 22 Feb 2026 20:00:49 +0100 Subject: [PATCH] feat: results views (summary, amortization, comparison, charts) and main page --- src/app/page.tsx | 124 ++++++++ src/components/AmortizationTable.tsx | 383 ++++++++++++++++++++++ src/components/Charts.tsx | 453 +++++++++++++++++++++++++++ src/components/ComparisonTable.tsx | 196 ++++++++++++ src/components/ResultsSummary.tsx | 269 ++++++++++++++++ 5 files changed, 1425 insertions(+) create mode 100644 src/app/page.tsx create mode 100644 src/components/AmortizationTable.tsx create mode 100644 src/components/Charts.tsx create mode 100644 src/components/ComparisonTable.tsx create mode 100644 src/components/ResultsSummary.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..c1842df --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,124 @@ +'use client'; + +import React, { useState } from 'react'; +import { SimulationProvider } from '@/contexts/SimulationContext'; +import Header from '@/components/Header'; +import { ProjectForm, CurrentCostsForm, ProjectedCostsForm, ResaleForm } from '@/components/InputForms'; +import LoansForm from '@/components/LoansForm'; +import ResultsSummary from '@/components/ResultsSummary'; +import AmortizationTable from '@/components/AmortizationTable'; +import ComparisonTable from '@/components/ComparisonTable'; +import Charts from '@/components/Charts'; +import { Tabs } from '@/components/ui'; + +// ─── Tab icons ────────────────────────────────────────────────────────────── + +function SummaryIcon() { + return ( + + + + ); +} + +function TableIcon() { + return ( + + + + ); +} + +function CompareIcon() { + return ( + + + + ); +} + +function ChartIcon() { + return ( + + + + ); +} + +// ─── Tabs definition ──────────────────────────────────────────────────────── + +const RESULT_TABS = [ + { id: 'summary', label: 'Résumé', icon: }, + { id: 'amortization', label: 'Amortissement', icon: }, + { id: 'comparison', label: 'Comparaison', icon: }, + { id: 'charts', label: 'Graphiques', icon: }, +]; + +// ─── Page Content ─────────────────────────────────────────────────────────── + +function SimulationPage() { + const [activeTab, setActiveTab] = useState('summary'); + + return ( +
+
+ +
+
+ {/* ─── Left panel: Inputs ─── */} + + + {/* ─── Right panel: Results ─── */} +
+
+ +
+ +
+ {activeTab === 'summary' && } + {activeTab === 'amortization' && } + {activeTab === 'comparison' && } + {activeTab === 'charts' && } +
+
+
+
+ + {/* Footer */} +
+
+
+

+ SimO — Simulateur de prêt immobilier gratuit et open source. + Les résultats sont donnés à titre indicatif. +

+

+ Calculs basés sur des formules d'amortissement standard. + Consultez un professionnel pour votre projet. +

+
+
+
+
+ ); +} + +// ─── Root page with Provider ──────────────────────────────────────────────── + +export default function Home() { + return ( + + + + ); +} diff --git a/src/components/AmortizationTable.tsx b/src/components/AmortizationTable.tsx new file mode 100644 index 0000000..83301ba --- /dev/null +++ b/src/components/AmortizationTable.tsx @@ -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('yearly'); + const [expandedYear, setExpandedYear] = useState(null); + const [selectedLoan, setSelectedLoan] = useState('all'); + + const activeDuration = results.maxDuration; + + return ( +
+ {/* Controls */} +
+ setViewMode(v as ViewMode)} + /> + + {/* Loan filter */} + +
+ + {/* Table */} +
+
+ {viewMode === 'yearly' ? ( + setExpandedYear(expandedYear === y ? null : y)} + activeDuration={activeDuration} + /> + ) : ( + + )} +
+
+
+ ); +} + +// ─── 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 ( + + + + + + + + + + + + + + {data.map((row) => ( + + onToggleYear(row.year)} + > + + + + + + + + + + {/* Expanded monthly detail */} + {expandedYear === row.year && expandedMonths.length > 0 && ( + + + + )} + + ))} + +
AnnéeIntérêtsCapitalAssuranceRestant dûMensualitéRatio capital
+
+ + + + {row.year} +
+
{formatCurrency(row.interest)}{formatCurrency(row.principal)}{formatCurrency(row.insurance)}{formatCurrency(row.remaining)}{formatCurrency(row.monthlyTotal)} + +
+
+ + + + + + + + + + + + + {expandedMonths.map((m) => ( + + + + + + + + + ))} + +
MoisIntérêtsCapitalAssuranceRestant dûMensualité
+ {MONTH_NAMES[m.month - 1]} + + {formatCurrency(m.interest, 2)} + + {formatCurrency(m.principal, 2)} + + {formatCurrency(m.insurance, 2)} + + {formatCurrency(m.remaining, 2)} + + {formatCurrency(m.payment, 2)} +
+
+
+ ); +} + +// ─── 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 ( +
+ + + + + + + + + + + + + + {pageData.map((row) => ( + + + + + + + + + + ))} + +
MoisAnnéeIntérêtsCapitalAssuranceRestant dûMensualité
{row.month}{row.year}{formatCurrency(row.interest, 2)}{formatCurrency(row.principal, 2)}{formatCurrency(row.insurance, 2)}{formatCurrency(row.remaining)}{formatCurrency(row.payment, 2)}
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page + 1} / {totalPages} + + +
+ )} +
+ ); +} + +// ─── Ratio Bar ────────────────────────────────────────────────────────────── + +function RatioBar({ value }: { value: number }) { + const pct = Math.round(value * 100); + return ( +
+
+
+
+ {pct}% +
+ ); +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +const MONTH_NAMES = [ + 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', + 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre', +]; diff --git a/src/components/Charts.tsx b/src/components/Charts.tsx new file mode 100644 index 0000000..0925278 --- /dev/null +++ b/src/components/Charts.tsx @@ -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 ( +
+
+ {/* 1. Remaining balance over time */} + + + + + {/* 2. Capital vs Interest vs Insurance breakdown */} + + + + + {/* 3. Monthly comparison: rent vs projected */} + + + + + {/* 4. Resale projection */} + + + +
+ + {/* Cost breakdown donut */} + +
+ +
+
+
+ ); +} + +// ─── 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 ( +
+ +
+ ); +} + +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 ( +
+ +
+ ); +} + +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 ( +
+ +
+ ); +} + +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 ( +
+ +
+ ); +} + +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 ( +
+ +
+
+
+ {formatCurrency(results.grandTotal)} +
+
Coût total
+
+
+
+ ); +} + +// ─── Chart Card Wrapper ───────────────────────────────────────────────────── + +function ChartCard({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+
+
{children}
+
+ ); +} + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface ChartProps { + data: ReturnType['results']['yearlySummaries']; + labels: string[]; +} diff --git a/src/components/ComparisonTable.tsx b/src/components/ComparisonTable.tsx new file mode 100644 index 0000000..2621887 --- /dev/null +++ b/src/components/ComparisonTable.tsx @@ -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 ( +
+ {/* Key insights */} +
+ + + y.year === profitYear)?.netResale ?? 0) + : '—' + } + detail={profitYear ? 'Plus-value nette après frais' : 'Sur la période simulée'} + variant={profitYear ? 'green' : 'red'} + /> +
+ + {/* Main comparison table */} +
+
+

+ Comparaison annuelle : propriétaire vs locataire +

+
+
+ + + + + + + + + + + {/* Propriétaire */} + + + + + {/* Locataire */} + + + {/* Comparaison */} + + + {/* Revente */} + + + + + + {data.map((row) => { + const isBreakEven = row.year === breakEvenYear; + return ( + + + + {/* Propriétaire */} + + + + + + {/* Locataire */} + + + + {/* Comparaison */} + + + + {/* Revente */} + + + + ); + })} + +
Année + Propriétaire (mensuel) + + Locataire (mensuel) + + Comparaison + + Revente +
CapitalIntérêtsChargesTotalLoyerTotalDiff/moisRatio cap.Prix+/- value
{row.year} + {formatCurrency(row.monthlyPrincipal)} + + {formatCurrency(row.monthlyInterest)} + + {formatCurrency(row.monthlyCharges + row.monthlyLoanInsurance)} + + {formatCurrency(row.monthlyTotal)} + {formatCurrency(row.currentRent)} + {formatCurrency(row.currentMonthlyTotal)} + 0 ? 'text-red-500' : 'text-emerald-600' + }`}> + {row.difference > 0 ? '+' : ''}{formatCurrency(row.difference)} + + + {formatCurrency(row.resalePrice)}= 0 ? 'text-emerald-600' : 'text-red-500' + }`}> + {row.netResale >= 0 ? '+' : ''}{formatCurrency(row.netResale)} +
+
+
+ + {/* Legend */} +
+

+ Ratio capital : Part de votre mensualité qui constitue du patrimoine (remboursement du capital emprunté). +

+

+ Plus/Moins-value : Gain net si vous revendez, après déduction des frais de vente, du capital restant dû et de l'apport. +

+
+
+ ); +} + +// ─── 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 ( +
+
{label}
+
{value}
+
{detail}
+
+ ); +} + +function RatioBar({ value }: { value: number }) { + const pct = Math.round(value * 100); + return ( +
+
+
+
+ {pct}% +
+ ); +} diff --git a/src/components/ResultsSummary.tsx b/src/components/ResultsSummary.tsx new file mode 100644 index 0000000..c3f5160 --- /dev/null +++ b/src/components/ResultsSummary.tsx @@ -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 ( +
+ {/* Key metrics */} +
+ + + + results.currentMonthlyTotal + ? 'Surcoût vs loyer' + : 'Économie vs loyer' + } + color={results.projectedMonthlyTotal > results.currentMonthlyTotal ? 'red' : 'green'} + /> +
+ + {/* Financing breakdown */} +
+
+

Récapitulatif du financement

+
+
+
+
+ + + + + +
+ +
+ +
+ +
+
+ +
+ + + +
+ +
+
+
Surcoût du crédit
+
+ {formatCurrency(results.totalInterest + results.totalInsurance)} +
+
+ soit {formatPercent((results.totalInterest + results.totalInsurance) / results.totalCredit)} du capital +
+
+
+
+
+
+ + {/* Per-loan breakdown */} +
+
+

Détail par prêt

+
+
+ + + + + + + + + + + + + + + {results.loanResults.map((lr) => ( + + + + + + + + + + + ))} + + + + + + + + + + + +
PrêtCapitalTauxDuréeMensualitéIntérêtsAssuranceCoût total
+ {lr.loan.name} + {lr.loan.isMainLoan && ( + + AUTO + + )} + {formatCurrency(lr.effectiveAmount)}{formatPercent(lr.loan.rate)}{lr.loan.duration} ans + {formatCurrency(lr.monthlyPayment + lr.monthlyInsurance)} + + {formatCurrency(lr.totalInterest)} + + {formatCurrency(lr.totalInsurance)} + + {formatCurrency(lr.totalCost)} +
Total{formatCurrency(results.totalCapital)}{results.maxDuration} ans{formatCurrency(results.monthlyPaymentYear1)}{formatCurrency(results.totalInterest)}{formatCurrency(results.totalInsurance)}{formatCurrency(results.grandTotal)}
+
+
+ + {/* Monthly cost breakdown for year 1 */} +
+
+

Décomposition mensuelle (année 1)

+
+
+ {results.yearlySummaries[0] && ( + + )} +
+
+
+ ); +} + +// ─── 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 ( +
+
{label}
+
{value}
+
{sub}
+
+ ); +} + +function Row({ + label, + value, + bold, + muted, + className, +}: { + label: string; + value: string; + bold?: boolean; + muted?: boolean; + className?: string; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function CostBreakdownBar({ summary }: { summary: ReturnType['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 ( +
+ {/* Bar */} +
+ {items.map((item) => { + const pct = total > 0 ? (item.value / total) * 100 : 0; + if (pct < 0.5) return null; + return ( +
+ {pct > 10 && ( + {pct.toFixed(0)}% + )} +
+ ); + })} +
+ + {/* Legend */} +
+ {items.map((item) => ( +
+
+ {item.label} + {formatCurrency(item.value)} +
+ ))} +
+ + {/* Total */} +
+ Total mensuel + {formatCurrency(total)} +
+
+ ); +}