feat: results views (summary, amortization, comparison, charts) and main page
This commit is contained in:
124
src/app/page.tsx
Normal file
124
src/app/page.tsx
Normal file
@@ -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 (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompareIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tabs definition ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RESULT_TABS = [
|
||||||
|
{ id: 'summary', label: 'Résumé', icon: <SummaryIcon /> },
|
||||||
|
{ id: 'amortization', label: 'Amortissement', icon: <TableIcon /> },
|
||||||
|
{ id: 'comparison', label: 'Comparaison', icon: <CompareIcon /> },
|
||||||
|
{ id: 'charts', label: 'Graphiques', icon: <ChartIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Page Content ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SimulationPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState('summary');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="max-w-screen-2xl mx-auto px-4 sm:px-6 py-6">
|
||||||
|
<div className="lg:flex lg:gap-6">
|
||||||
|
{/* ─── Left panel: Inputs ─── */}
|
||||||
|
<aside className="lg:w-[400px] xl:w-[420px] lg:shrink-0 space-y-3 no-print">
|
||||||
|
<ProjectForm />
|
||||||
|
<LoansForm />
|
||||||
|
<CurrentCostsForm />
|
||||||
|
<ProjectedCostsForm />
|
||||||
|
<ResaleForm />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ─── Right panel: Results ─── */}
|
||||||
|
<section className="flex-1 mt-6 lg:mt-0 min-w-0">
|
||||||
|
<div className="sticky top-[57px] z-40 bg-slate-50 dark:bg-slate-950 pb-3 no-print">
|
||||||
|
<Tabs
|
||||||
|
tabs={RESULT_TABS}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1">
|
||||||
|
{activeTab === 'summary' && <ResultsSummary />}
|
||||||
|
{activeTab === 'amortization' && <AmortizationTable />}
|
||||||
|
{activeTab === 'comparison' && <ComparisonTable />}
|
||||||
|
{activeTab === 'charts' && <Charts />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 mt-12 no-print">
|
||||||
|
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 py-6">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-slate-400">
|
||||||
|
<p>
|
||||||
|
SimO — Simulateur de prêt immobilier gratuit et open source.
|
||||||
|
Les résultats sont donnés à titre indicatif.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Calculs basés sur des formules d'amortissement standard.
|
||||||
|
Consultez un professionnel pour votre projet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Root page with Provider ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<SimulationProvider>
|
||||||
|
<SimulationPage />
|
||||||
|
</SimulationProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
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