Compare commits

...

7 Commits

26 changed files with 5683 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Dependencies
node_modules/
.pnp/
.pnp.js
# Next.js
.next/
out/
# Production
build/
dist/
# Misc
.DS_Store
*.pem
Thumbs.db
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env
.env*.local
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts

10
next.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
trailingSlash: true,
images: {
unoptimized: true,
},
};
export default nextConfig;

1715
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "simo",
"version": "1.0.0",
"description": "Simulateur de prêt immobilier - Gratuit et open source",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.2.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"chart.js": "^4.4.7",
"react-chartjs-2": "^5.2.0",
"lz-string": "^1.5.0",
"chartjs-plugin-datalabels": "^2.2.0"
},
"devDependencies": {
"typescript": "^5.4.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/node": "^20.17.0",
"@types/lz-string": "^1.5.0",
"tailwindcss": "^3.4.17",
"postcss": "^8.4.49",
"autoprefixer": "^10.4.20",
"@tailwindcss/forms": "^0.5.10"
}
}

9
postcss.config.js Normal file
View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
module.exports = config;

226
src/app/globals.css Normal file
View File

@@ -0,0 +1,226 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ─── Base ─────────────────────────────────────────────────────────────────── */
:root {
--color-positive: #059669;
--color-negative: #dc2626;
--color-interest: #f59e0b;
--color-capital: #2563eb;
--color-insurance: #8b5cf6;
--color-charges: #6b7280;
--color-rent: #ef4444;
--color-projected: #2563eb;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-slate-50 text-slate-900 antialiased;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
.dark body,
html.dark body {
@apply bg-slate-950 text-slate-100;
}
/* ─── Form Elements ────────────────────────────────────────────────────────── */
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
}
/* ─── Cards ────────────────────────────────────────────────────────────────── */
@layer components {
.card {
@apply bg-white rounded-xl border border-slate-200 shadow-sm
dark:bg-slate-900 dark:border-slate-800;
}
.card-header {
@apply flex items-center justify-between px-5 py-4 cursor-pointer select-none
border-b border-slate-100 hover:bg-slate-50/50 transition-colors
dark:border-slate-800 dark:hover:bg-slate-800/50;
}
.card-body {
@apply px-5 py-4;
}
/* Form field */
.form-field {
@apply flex flex-col gap-1;
}
.form-label {
@apply text-xs font-medium text-slate-500 uppercase tracking-wide
dark:text-slate-400;
}
.form-input {
@apply block w-full rounded-lg border-slate-300 text-sm
focus:border-blue-500 focus:ring-blue-500
placeholder:text-slate-400 transition-colors
dark:bg-slate-800 dark:border-slate-700 dark:text-slate-100
dark:placeholder:text-slate-500 dark:focus:border-blue-400 dark:focus:ring-blue-400;
}
.form-input-sm {
@apply form-input py-1.5 px-2.5 text-sm;
}
/* Metric cards */
.metric-card {
@apply bg-white rounded-xl border border-slate-200 p-4 shadow-sm
dark:bg-slate-900 dark:border-slate-800;
}
.metric-value {
@apply text-2xl font-bold text-slate-900 tabular-nums
dark:text-slate-100;
}
.metric-label {
@apply text-xs font-medium text-slate-500 mt-1
dark:text-slate-400;
}
/* Table */
.data-table {
@apply w-full text-sm;
}
.data-table thead th {
@apply px-3 py-2.5 text-left text-xs font-semibold text-slate-500
uppercase tracking-wider bg-slate-50 border-b border-slate-200
whitespace-nowrap
dark:bg-slate-800/50 dark:text-slate-400 dark:border-slate-700;
}
.data-table tbody td {
@apply px-3 py-2 tabular-nums border-b border-slate-100
dark:border-slate-800;
}
.data-table tbody tr:hover {
@apply bg-blue-50/40 dark:bg-blue-950/30;
}
/* Tab styles */
.tab-btn {
@apply px-4 py-2.5 text-sm font-medium rounded-lg transition-all
text-slate-500 hover:text-slate-700 hover:bg-slate-100
dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800;
}
.tab-btn-active {
@apply bg-white text-blue-700 shadow-sm border border-slate-200
dark:bg-slate-800 dark:text-blue-400 dark:border-slate-700;
}
/* Badge */
.badge {
@apply inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full;
}
.badge-blue {
@apply badge bg-blue-50 text-blue-700
dark:bg-blue-950/50 dark:text-blue-400;
}
.badge-green {
@apply badge bg-emerald-50 text-emerald-700
dark:bg-emerald-950/50 dark:text-emerald-400;
}
.badge-amber {
@apply badge bg-amber-50 text-amber-700
dark:bg-amber-950/50 dark:text-amber-400;
}
.badge-red {
@apply badge bg-red-50 text-red-700
dark:bg-red-950/50 dark:text-red-400;
}
}
/* ─── Animations ───────────────────────────────────────────────────────────── */
@layer utilities {
.animate-collapsible-open {
animation: collapsible-open 0.25s ease-out;
}
.animate-collapsible-close {
animation: collapsible-close 0.2s ease-in forwards;
}
}
@keyframes collapsible-open {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 1000px;
}
}
@keyframes collapsible-close {
from {
opacity: 1;
max-height: 1000px;
}
to {
opacity: 0;
max-height: 0;
}
}
/* ─── Print ────────────────────────────────────────────────────────────────── */
@media print {
.no-print {
display: none !important;
}
body {
background: white;
color: black;
}
html.dark body {
background: white;
color: black;
}
.card {
break-inside: avoid;
box-shadow: none;
border: 1px solid #e2e8f0;
background: white;
}
}
/* ─── Dark mode utility overrides ──────────────────────────────────────────── */
html.dark select {
@apply bg-slate-800 border-slate-700 text-slate-100;
}
html.dark input[type='checkbox'] {
@apply bg-slate-800 border-slate-600;
}

43
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,43 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
export const metadata: Metadata = {
title: 'SimO — Simulateur de Prêt Immobilier',
description:
'Simulez votre prêt immobilier gratuitement : PTZ, Action Logement, Primo Boost, amortissement détaillé, comparaison loyer/crédit et projection de revente.',
keywords: 'simulateur, prêt immobilier, PTZ, Action Logement, amortissement, crédit immobilier',
openGraph: {
title: 'SimO — Simulateur de Prêt Immobilier',
description: 'Simulez votre prêt immobilier gratuitement avec tous les dispositifs aidés.',
type: 'website',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
// Inline script to prevent FOUC (flash of unstyled content) on dark mode
const themeScript = `
(function() {
try {
var t = localStorage.getItem('simo-theme') || 'system';
var dark = t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
} catch(e) {}
})();
`;
return (
<html lang="fr" className={inter.variable} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className={inter.className}>{children}</body>
</html>
);
}

124
src/app/page.tsx Normal file
View 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>
);
}

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

225
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,225 @@
'use client';
import React, { useState, useCallback } from 'react';
import { useSimulation } from '@/contexts/SimulationContext';
import { encodeState, copyToClipboard, exportCSV } from '@/lib/sharing';
import { formatCurrency } from '@/lib/formatters';
import { useTheme } from '@/hooks/useTheme';
// ─── Icons ──────────────────────────────────────────────────────────────────
function ShareIcon() {
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.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
);
}
function DownloadIcon() {
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
);
}
function PrintIcon() {
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
);
}
function SunIcon() {
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
);
}
function MoonIcon() {
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
);
}
function MonitorIcon() {
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.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
);
}
// ─── Header ─────────────────────────────────────────────────────────────────
export default function Header() {
const { state, results, reset } = useSimulation();
const { theme, cycle } = useTheme();
const [shareStatus, setShareStatus] = useState<'idle' | 'copied' | 'error'>('idle');
const handleShare = useCallback(async () => {
try {
const hash = await encodeState(state);
const url = `${window.location.origin}${window.location.pathname}#${hash}`;
const success = await copyToClipboard(url);
if (success) {
setShareStatus('copied');
setTimeout(() => setShareStatus('idle'), 2500);
} else {
setShareStatus('error');
setTimeout(() => setShareStatus('idle'), 2500);
}
} catch {
setShareStatus('error');
setTimeout(() => setShareStatus('idle'), 2500);
}
}, [state]);
const handleExportCSV = useCallback(() => {
const headers = [
'Année',
'Intérêts (€)',
'Capital (€)',
'Assurance (€)',
'Restant dû (€)',
'Total annuel (€)',
'Mensualité projetée (€)',
'Loyer actuel (€)',
'Différence (€)',
'Prix revente (€)',
'Plus/moins-value (€)',
];
const rows = results.yearlySummaries
.filter((y) => y.year <= results.maxDuration)
.map((y) => [
y.year,
y.totalInterest,
y.totalPrincipal,
y.totalInsurance,
y.totalRemaining,
y.yearlyTotal,
y.monthlyTotal,
y.currentMonthlyTotal,
y.difference,
y.resalePrice,
y.netResale,
]);
exportCSV(headers, rows);
}, [results]);
const handlePrint = useCallback(() => {
window.print();
}, []);
return (
<header className="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 sticky top-0 z-50 no-print">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6">
<div className="flex items-center justify-between h-14">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
<span className="text-white font-bold text-sm">S</span>
</div>
<div>
<h1 className="text-base font-bold text-slate-900 dark:text-slate-100 leading-none">SimO</h1>
<p className="text-[10px] text-slate-400 leading-none mt-0.5">
Simulateur de prêt immobilier
</p>
</div>
</div>
</div>
{/* Monthly payment quick view */}
<div className="hidden md:flex items-center gap-6 text-sm">
<div className="text-center">
<div className="font-semibold text-slate-900 dark:text-slate-100 tabular-nums">
{formatCurrency(results.projectedMonthlyTotal)}
</div>
<div className="text-[10px] text-slate-400 dark:text-slate-500">Mensualité</div>
</div>
<div className="w-px h-6 bg-slate-200 dark:bg-slate-700" />
<div className="text-center">
<div className="font-semibold text-slate-900 dark:text-slate-100 tabular-nums">
{formatCurrency(results.grandTotal)}
</div>
<div className="text-[10px] text-slate-400 dark:text-slate-500">Coût total</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={handleShare}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium
text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100
hover:bg-slate-100 dark:hover:bg-slate-800
rounded-lg transition-colors"
title="Partager la simulation"
>
<ShareIcon />
<span className="hidden sm:inline">
{shareStatus === 'copied' ? 'Lien copié !' : shareStatus === 'error' ? 'Erreur' : 'Partager'}
</span>
</button>
<button
onClick={handleExportCSV}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium
text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100
hover:bg-slate-100 dark:hover:bg-slate-800
rounded-lg transition-colors"
title="Exporter en CSV"
>
<DownloadIcon />
<span className="hidden sm:inline">CSV</span>
</button>
<button
onClick={handlePrint}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium
text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-slate-100
hover:bg-slate-100 dark:hover:bg-slate-800
rounded-lg transition-colors"
title="Imprimer"
>
<PrintIcon />
</button>
<div className="w-px h-6 bg-slate-200 dark:bg-slate-700 mx-1" />
{/* Theme toggle */}
<button
onClick={cycle}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-sm
text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100
hover:bg-slate-100 dark:hover:bg-slate-800
rounded-lg transition-colors"
title={theme === 'light' ? 'Mode clair' : theme === 'dark' ? 'Mode sombre' : 'Système'}
>
{theme === 'light' && <SunIcon />}
{theme === 'dark' && <MoonIcon />}
{theme === 'system' && <MonitorIcon />}
</button>
<button
onClick={reset}
className="text-xs text-slate-400 hover:text-red-500 transition-colors px-2 py-1"
title="Réinitialiser"
>
Réinitialiser
</button>
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,311 @@
'use client';
import React from 'react';
import { useSimulation } from '@/contexts/SimulationContext';
import { CollapsibleSection, NumberInput, RateInput, Toggle } from './ui';
import { formatCurrency } from '@/lib/formatters';
// ─── Icons ──────────────────────────────────────────────────────────────────
function HomeIcon() {
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 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
);
}
function WalletIcon() {
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 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
);
}
function ArrowTrendIcon() {
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
);
}
function CogIcon() {
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
}
// ─── Project Form ───────────────────────────────────────────────────────────
export function ProjectForm() {
const { state, results, setField, setPropertyType } = useSimulation();
return (
<CollapsibleSection
title="Projet immobilier"
icon={<HomeIcon />}
badge={
<span className="badge-blue">{state.propertyType === 'neuf' ? 'Neuf' : 'Ancien'}</span>
}
>
<div className="space-y-4">
{/* Neuf / Ancien toggle */}
<div className="flex justify-center">
<Toggle
options={[
{ value: 'neuf', label: 'Neuf' },
{ value: 'ancien', label: 'Ancien' },
]}
value={state.propertyType}
onChange={(v) => setPropertyType(v as 'neuf' | 'ancien')}
/>
</div>
{/* Price */}
<NumberInput
label="Prix du bien"
value={state.propertyPrice}
onChange={(v) => setField('propertyPrice', v)}
suffix="€"
step={1000}
min={0}
/>
{/* Fees grid */}
<div className="grid grid-cols-2 gap-3">
<RateInput
label="Frais de notaire"
value={state.notaryFeesRate}
onChange={(v) => setField('notaryFeesRate', v)}
hint={formatCurrency(results.notaryFees)}
/>
<RateInput
label="Frais de garantie"
value={state.guaranteeRate}
onChange={(v) => setField('guaranteeRate', v)}
hint={formatCurrency(results.guaranteeFees)}
/>
<NumberInput
label="Frais de dossier"
value={state.bankFees}
onChange={(v) => setField('bankFees', v)}
suffix="€"
step={100}
/>
<NumberInput
label="Travaux"
value={state.worksCost}
onChange={(v) => setField('worksCost', v)}
suffix="€"
step={500}
/>
</div>
{/* Down payment */}
<NumberInput
label="Apport personnel"
value={state.downPayment}
onChange={(v) => setField('downPayment', v)}
suffix="€"
step={1000}
min={0}
/>
{/* Summary */}
<div className="rounded-lg bg-slate-50 dark:bg-slate-800 p-3 space-y-1.5 text-sm">
<div className="flex justify-between text-slate-500 dark:text-slate-400">
<span>Financement total</span>
<span className="font-medium text-slate-700 dark:text-slate-300 tabular-nums">
{formatCurrency(results.totalFinancing)}
</span>
</div>
<div className="flex justify-between font-semibold text-slate-900 dark:text-slate-100">
<span>Crédit nécessaire</span>
<span className="tabular-nums">{formatCurrency(results.totalCredit)}</span>
</div>
</div>
</div>
</CollapsibleSection>
);
}
// ─── Current Costs Form ─────────────────────────────────────────────────────
export function CurrentCostsForm() {
const { state, setField } = useSimulation();
const total =
state.currentRent +
state.currentUtilities +
state.currentCharges +
state.currentHomeInsurance;
return (
<CollapsibleSection
title="Situation actuelle (locataire)"
icon={<WalletIcon />}
badge={<span className="badge-amber">{formatCurrency(total)}/mois</span>}
defaultOpen={false}
>
<div className="space-y-3">
<NumberInput
label="Loyer"
value={state.currentRent}
onChange={(v) => setField('currentRent', v)}
suffix="€/mois"
step={10}
/>
<RateInput
label="Évolution annuelle du loyer"
value={state.rentEvolutionRate}
onChange={(v) => setField('rentEvolutionRate', v)}
/>
<div className="grid grid-cols-2 gap-3">
<NumberInput
label="EDF / Fibre / Eau"
value={state.currentUtilities}
onChange={(v) => setField('currentUtilities', v)}
suffix="€"
step={5}
/>
<NumberInput
label="Charges"
value={state.currentCharges}
onChange={(v) => setField('currentCharges', v)}
suffix="€"
step={5}
/>
</div>
<NumberInput
label="Assurance habitation"
value={state.currentHomeInsurance}
onChange={(v) => setField('currentHomeInsurance', v)}
suffix="€/mois"
step={1}
/>
<div className="rounded-lg bg-amber-50 dark:bg-amber-950/30 p-3">
<div className="flex justify-between text-sm font-semibold text-amber-800 dark:text-amber-300">
<span>Total mensuel actuel</span>
<span className="tabular-nums">{formatCurrency(total)}</span>
</div>
</div>
</div>
</CollapsibleSection>
);
}
// ─── Projected Costs Form ───────────────────────────────────────────────────
export function ProjectedCostsForm() {
const { state, results, setField } = useSimulation();
const chargesTotal =
state.projectedCharges +
state.propertyTax +
state.projectedUtilities +
state.projectedHomeInsurance;
return (
<CollapsibleSection
title="Charges propriétaire"
icon={<HomeIcon />}
badge={<span className="badge-blue">{formatCurrency(chargesTotal)}/mois</span>}
defaultOpen={false}
>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<NumberInput
label="Charges copropriété"
value={state.projectedCharges}
onChange={(v) => setField('projectedCharges', v)}
suffix="€"
step={5}
/>
<NumberInput
label="Taxe foncière"
value={state.propertyTax}
onChange={(v) => setField('propertyTax', v)}
suffix="€/mois"
step={5}
hint="Montant mensuel"
/>
<NumberInput
label="EDF / Fibre / Eau"
value={state.projectedUtilities}
onChange={(v) => setField('projectedUtilities', v)}
suffix="€"
step={5}
/>
<NumberInput
label="Assurance habitation"
value={state.projectedHomeInsurance}
onChange={(v) => setField('projectedHomeInsurance', v)}
suffix="€"
step={1}
/>
</div>
<div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 p-3 space-y-1.5 text-sm">
<div className="flex justify-between text-blue-700 dark:text-blue-300">
<span>Charges mensuelles</span>
<span className="font-medium tabular-nums">{formatCurrency(chargesTotal)}</span>
</div>
<div className="flex justify-between text-blue-700 dark:text-blue-300">
<span>Assurance prêt</span>
<span className="font-medium tabular-nums">
{formatCurrency(
results.loanResults.reduce((s, lr) => s + lr.monthlyInsurance, 0)
)}
</span>
</div>
<div className="border-t border-blue-200 dark:border-blue-800 my-1" />
<div className="flex justify-between font-semibold text-blue-900 dark:text-blue-100">
<span>Total mensuel projeté</span>
<span className="tabular-nums">{formatCurrency(results.projectedMonthlyTotal)}</span>
</div>
</div>
</div>
</CollapsibleSection>
);
}
// ─── Resale Form ────────────────────────────────────────────────────────────
export function ResaleForm() {
const { state, setField } = useSimulation();
return (
<CollapsibleSection
title="Projection de revente"
icon={<ArrowTrendIcon />}
defaultOpen={false}
>
<div className="space-y-3">
<RateInput
label="Décote 1ère année"
value={Math.abs(state.firstYearDepreciation)}
onChange={(v) => setField('firstYearDepreciation', -Math.abs(v))}
hint="Perte de valeur la première année"
/>
<RateInput
label="Évolution annuelle"
value={state.annualAppreciation}
onChange={(v) => setField('annualAppreciation', v)}
hint="Croissance moyenne du prix/an"
/>
<RateInput
label="Frais de vente"
value={state.saleFees}
onChange={(v) => setField('saleFees', v)}
hint="Agence + notaire vendeur"
/>
</div>
</CollapsibleSection>
);
}

View File

@@ -0,0 +1,336 @@
'use client';
import React, { useState } from 'react';
import { useSimulation } from '@/contexts/SimulationContext';
import { CollapsibleSection, NumberInput, RateInput } from './ui';
import { formatCurrency } from '@/lib/formatters';
import { LOAN_PRESETS } from '@/lib/defaults';
import type { LoanInput } from '@/lib/types';
// ─── Icons ──────────────────────────────────────────────────────────────────
function BankIcon() {
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
);
}
function PlusIcon() {
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
);
}
function TrashIcon() {
return (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
className={`w-4 h-4 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
);
}
// ─── Add Loan Menu ──────────────────────────────────────────────────────────
function AddLoanMenu({ onAdd }: { onAdd: (loan: LoanInput) => void }) {
const { state } = useSimulation();
const [open, setOpen] = useState(false);
const availablePresets = LOAN_PRESETS.filter((p) => {
if (p.neufOnly && state.propertyType === 'ancien') return false;
return true;
});
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4
text-sm font-medium text-blue-600 hover:text-blue-700
border-2 border-dashed border-blue-200 dark:border-blue-800 hover:border-blue-300 dark:hover:border-blue-700
rounded-xl transition-colors hover:bg-blue-50/50 dark:hover:bg-blue-950/30"
>
<PlusIcon />
Ajouter un prêt
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-2 z-20
bg-white dark:bg-slate-900 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700
overflow-hidden animate-slide-down">
{availablePresets.map((preset) => (
<button
key={preset.label}
onClick={() => {
onAdd(preset.create());
setOpen(false);
}}
className="w-full text-left px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-800
transition-colors border-b border-slate-100 dark:border-slate-800 last:border-0"
>
<div className="text-sm font-medium text-slate-800 dark:text-slate-200">{preset.label}</div>
<div className="text-xs text-slate-400 mt-0.5">{preset.description}</div>
</button>
))}
</div>
</>
)}
</div>
);
}
// ─── Loan Card ──────────────────────────────────────────────────────────────
function LoanCard({
loan,
effectiveAmount,
}: {
loan: LoanInput;
effectiveAmount: number;
}) {
const { updateLoan, removeLoan, toggleLoan } = useSimulation();
const [expanded, setExpanded] = useState(true);
const amount = loan.isMainLoan ? effectiveAmount : loan.amount;
const monthlyRate = loan.rate / 12;
const months = (loan.duration - loan.deferral) * 12;
let monthlyPayment = 0;
if (months > 0 && amount > 0) {
if (monthlyRate === 0) {
monthlyPayment = amount / months;
} else {
const factor = Math.pow(1 + monthlyRate, months);
monthlyPayment = (amount * monthlyRate * factor) / (factor - 1);
}
}
const monthlyInsurance = amount * loan.insuranceRate / 12;
const totalMonthly = monthlyPayment + monthlyInsurance;
return (
<div
className={`rounded-xl border transition-all ${
loan.enabled
? 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900'
: 'border-slate-100 dark:border-slate-800 bg-slate-50 dark:bg-slate-800/50 opacity-60'
}`}
>
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3">
{/* Enable/Disable checkbox */}
<input
type="checkbox"
checked={loan.enabled}
onChange={() => toggleLoan(loan.id)}
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500
h-4 w-4 cursor-pointer"
/>
{/* Name */}
<button
className="flex-1 text-left"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">{loan.name}</span>
{loan.isMainLoan && (
<span className="ml-2 text-[10px] font-medium text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">
AUTO
</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300 tabular-nums">
{formatCurrency(amount)}
</span>
<ChevronIcon open={expanded} />
</div>
</div>
{!expanded && loan.enabled && (
<div className="text-xs text-slate-400 mt-0.5 tabular-nums">
{formatCurrency(totalMonthly)}/mois &middot; {loan.duration} ans &middot; {(loan.rate * 100).toFixed(2)}%
</div>
)}
</button>
</div>
{/* Body */}
{expanded && loan.enabled && (
<div className="px-4 pb-4 pt-1 border-t border-slate-100 dark:border-slate-800 animate-fade-in">
<div className="space-y-3">
{/* Name input */}
<div className="form-field">
<label className="form-label">Nom du prêt</label>
<input
type="text"
className="form-input-sm"
value={loan.name}
onChange={(e) => updateLoan(loan.id, 'name', e.target.value)}
/>
</div>
{/* Amount (only for non-main loans) */}
{!loan.isMainLoan && (
<NumberInput
label="Montant"
value={loan.amount}
onChange={(v) => updateLoan(loan.id, 'amount', v)}
suffix="€"
step={1000}
min={0}
/>
)}
{loan.isMainLoan && (
<div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 p-2.5 text-sm text-blue-700 dark:text-blue-300">
Montant calculé automatiquement : <strong className="tabular-nums">{formatCurrency(effectiveAmount)}</strong>
<br />
<span className="text-xs text-blue-500">= Crédit total Somme des autres prêts</span>
</div>
)}
{/* Rate + Duration */}
<div className="grid grid-cols-2 gap-3">
<RateInput
label="Taux nominal"
value={loan.rate}
onChange={(v) => updateLoan(loan.id, 'rate', v)}
/>
<NumberInput
label="Durée"
value={loan.duration}
onChange={(v) => updateLoan(loan.id, 'duration', v)}
suffix="ans"
step={1}
min={1}
max={30}
/>
</div>
{/* Deferral + Insurance */}
<div className="grid grid-cols-2 gap-3">
<NumberInput
label="Différé"
value={loan.deferral}
onChange={(v) => updateLoan(loan.id, 'deferral', v)}
suffix="ans"
step={1}
min={0}
max={loan.duration}
hint="Période sans remboursement"
/>
<RateInput
label="Taux assurance"
value={loan.insuranceRate}
onChange={(v) => updateLoan(loan.id, 'insuranceRate', v)}
hint={`${formatCurrency(monthlyInsurance)}/mois`}
/>
</div>
{/* Monthly summary */}
<div className="rounded-lg bg-slate-50 dark:bg-slate-800 p-2.5 text-sm">
<div className="flex justify-between text-slate-600 dark:text-slate-400">
<span>Mensualité hors assurance</span>
<span className="font-medium tabular-nums">{formatCurrency(monthlyPayment, 2)}</span>
</div>
<div className="flex justify-between text-slate-600 dark:text-slate-400 mt-1">
<span>Assurance</span>
<span className="font-medium tabular-nums">{formatCurrency(monthlyInsurance, 2)}</span>
</div>
<div className="border-t border-slate-200 dark:border-slate-700 my-1.5" />
<div className="flex justify-between font-semibold text-slate-900 dark:text-slate-100">
<span>Total mensuel</span>
<span className="tabular-nums">{formatCurrency(totalMonthly, 2)}</span>
</div>
</div>
{/* Delete button */}
{!loan.isMainLoan && (
<button
onClick={() => removeLoan(loan.id)}
className="flex items-center gap-1.5 text-xs text-red-500 hover:text-red-700
transition-colors mt-1"
>
<TrashIcon />
Supprimer ce prêt
</button>
)}
</div>
</div>
)}
</div>
);
}
// ─── Loans Form ─────────────────────────────────────────────────────────────
export default function LoansForm() {
const { state, results, addLoan } = useSimulation();
const totalMonthly = results.loanResults.reduce(
(sum, lr) => sum + lr.monthlyPayment + lr.monthlyInsurance,
0
);
return (
<CollapsibleSection
title="Crédits"
icon={<BankIcon />}
badge={<span className="badge-green">{formatCurrency(totalMonthly)}/mois</span>}
>
<div className="space-y-3">
{/* Loan list */}
{state.loans.map((loan) => {
const loanResult = results.loanResults.find((lr) => lr.loan.id === loan.id);
return (
<LoanCard
key={loan.id}
loan={loan}
effectiveAmount={loanResult?.effectiveAmount ?? 0}
/>
);
})}
{/* Add loan */}
<AddLoanMenu onAdd={addLoan} />
{/* Global summary */}
<div className="rounded-lg bg-emerald-50 dark:bg-emerald-950/30 p-3 space-y-1.5 text-sm">
<div className="flex justify-between text-emerald-700 dark:text-emerald-300">
<span>Capital emprunté</span>
<span className="font-medium tabular-nums">{formatCurrency(results.totalCredit)}</span>
</div>
<div className="flex justify-between text-emerald-700 dark:text-emerald-300">
<span>Total intérêts</span>
<span className="font-medium tabular-nums">{formatCurrency(results.totalInterest)}</span>
</div>
<div className="flex justify-between text-emerald-700 dark:text-emerald-300">
<span>Total assurance</span>
<span className="font-medium tabular-nums">{formatCurrency(results.totalInsurance)}</span>
</div>
<div className="border-t border-emerald-200 dark:border-emerald-800 my-1" />
<div className="flex justify-between font-bold text-emerald-900 dark:text-emerald-100">
<span>Coût total du crédit</span>
<span className="tabular-nums">{formatCurrency(results.grandTotal)}</span>
</div>
</div>
</div>
</CollapsibleSection>
);
}

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

241
src/components/ui.tsx Normal file
View File

@@ -0,0 +1,241 @@
'use client';
import React, { useState, type ReactNode } from 'react';
// ─── Collapsible Section ────────────────────────────────────────────────────
interface CollapsibleSectionProps {
title: string;
icon?: ReactNode;
defaultOpen?: boolean;
badge?: ReactNode;
children: ReactNode;
}
export function CollapsibleSection({
title,
icon,
defaultOpen = true,
badge,
children,
}: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="card">
<button
className="card-header w-full"
onClick={() => setOpen(!open)}
aria-expanded={open}
>
<div className="flex items-center gap-2">
{icon && <span className="text-slate-400">{icon}</span>}
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">{title}</h3>
{badge}
</div>
<svg
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${
open ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && <div className="card-body animate-fade-in">{children}</div>}
</div>
);
}
// ─── Number Input ───────────────────────────────────────────────────────────
interface NumberInputProps {
label: string;
value: number;
onChange: (v: number) => void;
suffix?: string;
prefix?: string;
min?: number;
max?: number;
step?: number;
hint?: string;
className?: string;
}
export function NumberInput({
label,
value,
onChange,
suffix,
prefix,
min,
max,
step = 1,
hint,
className,
}: NumberInputProps) {
return (
<div className={`form-field ${className ?? ''}`}>
<label className="form-label">{label}</label>
<div className="relative">
{prefix && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
{prefix}
</span>
)}
<input
type="number"
className={`form-input-sm ${prefix ? 'pl-7' : ''} ${suffix ? 'pr-10' : ''}`}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
min={min}
max={max}
step={step}
/>
{suffix && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
{suffix}
</span>
)}
</div>
{hint && <p className="text-xs text-slate-400 mt-0.5">{hint}</p>}
</div>
);
}
// ─── Rate Input (special for percentages) ───────────────────────────────────
interface RateInputProps {
label: string;
value: number; // stored as decimal (0.0325 for 3.25%)
onChange: (v: number) => void;
hint?: string;
className?: string;
}
export function RateInput({ label, value, onChange, hint, className }: RateInputProps) {
const displayValue = Math.round(value * 10000) / 100; // 0.0325 → 3.25
return (
<div className={`form-field ${className ?? ''}`}>
<label className="form-label">{label}</label>
<div className="relative">
<input
type="number"
className="form-input-sm pr-8"
value={displayValue}
onChange={(e) => onChange((parseFloat(e.target.value) || 0) / 100)}
step={0.01}
min={0}
max={100}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
%
</span>
</div>
{hint && <p className="text-xs text-slate-400 mt-0.5">{hint}</p>}
</div>
);
}
// ─── Toggle ─────────────────────────────────────────────────────────────────
interface ToggleProps {
options: { value: string; label: string }[];
value: string;
onChange: (v: string) => void;
className?: string;
}
export function Toggle({ options, value, onChange, className }: ToggleProps) {
return (
<div className={`inline-flex rounded-lg bg-slate-100 dark:bg-slate-800 p-0.5 ${className ?? ''}`}>
{options.map((opt) => (
<button
key={opt.value}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all ${
value === opt.value
? 'bg-white dark:bg-slate-700 text-blue-700 dark:text-blue-400 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
}
// ─── Tabs ───────────────────────────────────────────────────────────────────
interface TabsProps {
tabs: { id: string; label: string; icon?: ReactNode }[];
activeTab: string;
onTabChange: (id: string) => void;
}
export function Tabs({ tabs, activeTab, onTabChange }: TabsProps) {
return (
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-800/50 rounded-xl overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
className={`tab-btn whitespace-nowrap flex items-center gap-1.5 ${
activeTab === tab.id ? 'tab-btn-active' : ''
}`}
onClick={() => onTabChange(tab.id)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
);
}
// ─── Tooltip ────────────────────────────────────────────────────────────────
interface TooltipProps {
text: string;
children: ReactNode;
}
export function Tooltip({ text, children }: TooltipProps) {
return (
<span className="relative group">
{children}
<span
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5
bg-slate-800 text-white text-xs rounded-lg whitespace-nowrap
opacity-0 invisible group-hover:opacity-100 group-hover:visible
transition-all duration-200 pointer-events-none z-50
after:content-[''] after:absolute after:top-full after:left-1/2
after:-translate-x-1/2 after:border-4 after:border-transparent
after:border-t-slate-800"
>
{text}
</span>
</span>
);
}
// ─── Empty State ────────────────────────────────────────────────────────────
interface EmptyStateProps {
title: string;
description: string;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<div className="text-center py-12 px-4">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{title}</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import React, {
createContext,
useContext,
useReducer,
useMemo,
useEffect,
useCallback,
type ReactNode,
} from 'react';
import type { SimulationState, SimulationResults, LoanInput } from '@/lib/types';
import { computeSimulation } from '@/lib/calculations';
import { defaultStateNeuf, defaultStateAncien, createEmptyLoan, generateLoanId } from '@/lib/defaults';
import { decodeState } from '@/lib/sharing';
// ─── Actions ────────────────────────────────────────────────────────────────
type Action =
| { type: 'SET_FIELD'; field: keyof SimulationState; value: unknown }
| { type: 'SET_PROPERTY_TYPE'; value: 'neuf' | 'ancien' }
| { type: 'ADD_LOAN'; loan: LoanInput }
| { type: 'REMOVE_LOAN'; id: string }
| { type: 'UPDATE_LOAN'; id: string; field: keyof LoanInput; value: unknown }
| { type: 'TOGGLE_LOAN'; id: string }
| { type: 'LOAD_STATE'; state: SimulationState }
| { type: 'RESET' };
function reducer(state: SimulationState, action: Action): SimulationState {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'SET_PROPERTY_TYPE': {
if (action.value === state.propertyType) return state;
// Switch between neuf and ancien defaults
const base = action.value === 'neuf' ? defaultStateNeuf() : defaultStateAncien();
return {
...state,
propertyType: action.value,
notaryFeesRate: base.notaryFeesRate,
loans: action.value === 'ancien'
? state.loans.filter((l) => l.name !== 'Prêt Taux Zéro')
: state.loans,
};
}
case 'ADD_LOAN':
return { ...state, loans: [...state.loans, action.loan] };
case 'REMOVE_LOAN':
return { ...state, loans: state.loans.filter((l) => l.id !== action.id) };
case 'UPDATE_LOAN':
return {
...state,
loans: state.loans.map((l) =>
l.id === action.id ? { ...l, [action.field]: action.value } : l
),
};
case 'TOGGLE_LOAN':
return {
...state,
loans: state.loans.map((l) =>
l.id === action.id ? { ...l, enabled: !l.enabled } : l
),
};
case 'LOAD_STATE':
return action.state;
case 'RESET':
return defaultStateNeuf();
default:
return state;
}
}
// ─── Context ────────────────────────────────────────────────────────────────
interface SimulationContextValue {
state: SimulationState;
results: SimulationResults;
setField: (field: keyof SimulationState, value: unknown) => void;
setPropertyType: (type: 'neuf' | 'ancien') => void;
addLoan: (loan: LoanInput) => void;
removeLoan: (id: string) => void;
updateLoan: (id: string, field: keyof LoanInput, value: unknown) => void;
toggleLoan: (id: string) => void;
loadState: (state: SimulationState) => void;
reset: () => void;
}
const SimulationContext = createContext<SimulationContextValue | null>(null);
// ─── Provider ───────────────────────────────────────────────────────────────
export function SimulationProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, defaultStateNeuf());
// Load state from URL hash on mount
useEffect(() => {
const hash = window.location.hash.slice(1);
if (hash) {
decodeState(hash).then((loaded) => {
if (loaded) {
// Re-generate loan IDs to avoid conflicts
const withIds = {
...loaded,
loans: loaded.loans.map((l) => ({ ...l, id: generateLoanId() })),
};
dispatch({ type: 'LOAD_STATE', state: withIds });
}
});
}
}, []);
const results = useMemo(() => computeSimulation(state), [state]);
const setField = useCallback(
(field: keyof SimulationState, value: unknown) =>
dispatch({ type: 'SET_FIELD', field, value }),
[]
);
const setPropertyType = useCallback(
(type: 'neuf' | 'ancien') => dispatch({ type: 'SET_PROPERTY_TYPE', value: type }),
[]
);
const addLoan = useCallback(
(loan: LoanInput) => dispatch({ type: 'ADD_LOAN', loan }),
[]
);
const removeLoan = useCallback(
(id: string) => dispatch({ type: 'REMOVE_LOAN', id }),
[]
);
const updateLoan = useCallback(
(id: string, field: keyof LoanInput, value: unknown) =>
dispatch({ type: 'UPDATE_LOAN', id, field, value }),
[]
);
const toggleLoan = useCallback(
(id: string) => dispatch({ type: 'TOGGLE_LOAN', id }),
[]
);
const loadState = useCallback(
(s: SimulationState) => dispatch({ type: 'LOAD_STATE', state: s }),
[]
);
const reset = useCallback(() => dispatch({ type: 'RESET' }), []);
const value = useMemo(
() => ({
state,
results,
setField,
setPropertyType,
addLoan,
removeLoan,
updateLoan,
toggleLoan,
loadState,
reset,
}),
[state, results, setField, setPropertyType, addLoan, removeLoan, updateLoan, toggleLoan, loadState, reset]
);
return (
<SimulationContext.Provider value={value}>
{children}
</SimulationContext.Provider>
);
}
// ─── Hook ───────────────────────────────────────────────────────────────────
export function useSimulation(): SimulationContextValue {
const ctx = useContext(SimulationContext);
if (!ctx) {
throw new Error('useSimulation must be used within SimulationProvider');
}
return ctx;
}

57
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,57 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
type Theme = 'light' | 'dark' | 'system';
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme: Theme) {
const resolved = theme === 'system' ? getSystemTheme() : theme;
const root = document.documentElement;
if (resolved === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>('system');
// Read from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem('simo-theme') as Theme | null;
const initial = stored ?? 'system';
setThemeState(initial);
applyTheme(initial);
}, []);
// Listen for system theme changes when in 'system' mode
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
if (theme === 'system') applyTheme('system');
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
setThemeState(t);
localStorage.setItem('simo-theme', t);
applyTheme(t);
}, []);
const cycle = useCallback(() => {
const order: Theme[] = ['light', 'dark', 'system'];
const idx = order.indexOf(theme);
const next = order[(idx + 1) % order.length];
setTheme(next);
}, [theme, setTheme]);
return { theme, setTheme, cycle };
}

267
src/lib/calculations.ts Normal file
View File

@@ -0,0 +1,267 @@
import { pmt } from './financial';
import type {
SimulationState,
SimulationResults,
LoanResult,
LoanInput,
MonthlyRow,
YearlyRow,
YearlySummary,
} from './types';
const MAX_YEARS = 30;
// ─── Main computation ───────────────────────────────────────────────────────
export function computeSimulation(state: SimulationState): SimulationResults {
// 1. Financing
const notaryFees = state.propertyPrice * state.notaryFeesRate;
const guaranteeFees = state.propertyPrice * state.guaranteeRate;
const totalFinancing =
state.propertyPrice + notaryFees + guaranteeFees + state.bankFees + state.worksCost;
const totalCredit = Math.max(0, totalFinancing - state.downPayment);
// 2. Main loan amount
const otherLoansTotal = state.loans
.filter((l) => !l.isMainLoan && l.enabled)
.reduce((sum, l) => sum + l.amount, 0);
const mainLoanAmount = Math.max(0, totalCredit - otherLoansTotal);
// 3. Per-loan amortization
const loanResults: LoanResult[] = state.loans
.filter((l) => l.enabled)
.map((loan) => {
const amount = loan.isMainLoan ? mainLoanAmount : loan.amount;
return computeLoan(loan, amount);
});
// 4. Max duration
const maxDuration = loanResults.length > 0
? Math.max(...loanResults.map((lr) => lr.loan.duration))
: 0;
// 5. Yearly summaries
const projectedChargesMonthly =
state.projectedCharges +
state.propertyTax +
state.projectedUtilities +
state.projectedHomeInsurance;
const currentChargesMonthly =
state.currentUtilities + state.currentCharges + state.currentHomeInsurance;
const yearlySummaries: YearlySummary[] = [];
let resalePrice = state.propertyPrice * (1 + state.firstYearDepreciation);
for (let y = 1; y <= MAX_YEARS; y++) {
// Aggregate loan data for this year
let totalInterest = 0;
let totalPrincipal = 0;
let totalInsurance = 0;
let totalRemaining = 0;
let totalPayment = 0;
for (const lr of loanResults) {
const yearData = lr.yearly.find((yr) => yr.year === y);
if (yearData) {
totalInterest += yearData.interest;
totalPrincipal += yearData.principal;
totalInsurance += yearData.insurance;
totalRemaining += yearData.remaining;
totalPayment += yearData.payment;
} else if (y <= lr.loan.duration) {
// Year exists but no data (shouldn't happen with correct computation)
} else {
// After loan duration — find last remaining
const lastYear = lr.yearly[lr.yearly.length - 1];
if (lastYear) {
totalRemaining += lastYear.remaining;
}
}
}
// Monthly projected costs
const monthlyInterest = totalInterest / 12;
const monthlyPrincipal = totalPrincipal / 12;
const monthlyLoanInsurance = totalInsurance / 12;
const monthlyTotal = monthlyInterest + monthlyPrincipal + projectedChargesMonthly + monthlyLoanInsurance;
const capitalRatio =
monthlyInterest + monthlyPrincipal + projectedChargesMonthly + monthlyLoanInsurance > 0
? monthlyPrincipal / (monthlyInterest + monthlyPrincipal + projectedChargesMonthly + monthlyLoanInsurance)
: 0;
// Current costs (rent evolves)
const currentRent = state.currentRent * Math.pow(1 + state.rentEvolutionRate, y - 1);
const currentMonthlyTotal = currentRent + currentChargesMonthly;
// Difference
const difference = monthlyTotal - currentMonthlyTotal;
// Resale
if (y > 1) {
resalePrice = resalePrice * (1 + state.annualAppreciation);
}
const netResale = resalePrice * (1 - state.saleFees) - totalRemaining - state.downPayment;
yearlySummaries.push({
year: y,
totalInterest,
totalPrincipal,
totalInsurance,
totalRemaining,
totalPayment,
monthlyInterest,
monthlyPrincipal,
monthlyCharges: projectedChargesMonthly,
monthlyLoanInsurance,
monthlyTotal,
capitalRatio,
yearlyTotal: monthlyTotal * 12,
currentRent,
currentCharges: currentChargesMonthly,
currentMonthlyTotal,
currentYearlyTotal: currentMonthlyTotal * 12,
difference,
resalePrice,
netResale,
});
}
// 6. Global totals
const totalCapitalAll = loanResults.reduce((s, lr) => s + lr.effectiveAmount, 0);
const totalInterestAll = loanResults.reduce((s, lr) => s + lr.totalInterest, 0);
const totalInsuranceAll = loanResults.reduce((s, lr) => s + lr.totalInsurance, 0);
const grandTotal = totalCapitalAll + totalInterestAll + totalInsuranceAll;
// Year 1 snapshot
const year1 = yearlySummaries[0];
const monthlyPaymentYear1 = year1 ? year1.monthlyInterest + year1.monthlyPrincipal + year1.monthlyLoanInsurance : 0;
return {
notaryFees,
guaranteeFees,
totalFinancing,
totalCredit,
mainLoanAmount,
loanResults,
yearlySummaries,
totalCapital: totalCapitalAll,
totalInterest: totalInterestAll,
totalInsurance: totalInsuranceAll,
grandTotal,
monthlyPaymentYear1,
projectedMonthlyCharges: projectedChargesMonthly,
projectedMonthlyTotal: year1?.monthlyTotal ?? 0,
currentMonthlyTotal: year1?.currentMonthlyTotal ?? 0,
maxDuration,
};
}
// ─── Loan-level computation ─────────────────────────────────────────────────
function computeLoan(loan: LoanInput, amount: number): LoanResult {
if (amount <= 0 || loan.duration <= 0) {
return emptyLoanResult(loan, amount);
}
const monthlyRate = loan.rate / 12;
const deferralMonths = Math.round(loan.deferral * 12);
const totalMonths = Math.round(loan.duration * 12);
const repaymentMonths = totalMonths - deferralMonths;
const monthlyPmt = repaymentMonths > 0 ? pmt(monthlyRate, repaymentMonths, amount) : 0;
const monthlyInsurance = amount * loan.insuranceRate / 12;
let remaining = amount;
const monthly: MonthlyRow[] = [];
for (let m = 1; m <= totalMonths; m++) {
const interest = remaining * monthlyRate;
let principal = 0;
if (m > deferralMonths && repaymentMonths > 0) {
principal = monthlyPmt - interest;
// Protect against floating-point overshoot on the last payment
if (principal > remaining) {
principal = remaining;
}
remaining = Math.max(0, remaining - principal);
}
monthly.push({
month: m,
monthInYear: ((m - 1) % 12) + 1,
year: Math.ceil(m / 12),
interest,
principal,
insurance: monthlyInsurance,
remaining,
payment: interest + principal + monthlyInsurance,
});
}
// Aggregate to yearly
const yearly: YearlyRow[] = [];
const years = Math.ceil(totalMonths / 12);
for (let y = 1; y <= years; y++) {
const rows = monthly.filter((r) => r.year === y);
if (rows.length === 0) continue;
yearly.push({
year: y,
interest: rows.reduce((s, r) => s + r.interest, 0),
principal: rows.reduce((s, r) => s + r.principal, 0),
insurance: rows.reduce((s, r) => s + r.insurance, 0),
remaining: rows[rows.length - 1].remaining,
payment: rows.reduce((s, r) => s + r.payment, 0),
});
}
// Pad yearly to MAX_YEARS for easy aggregation
for (let y = years + 1; y <= MAX_YEARS; y++) {
yearly.push({
year: y,
interest: 0,
principal: 0,
insurance: 0,
remaining: 0,
payment: 0,
});
}
const totalInterest = monthly.reduce((s, r) => s + r.interest, 0);
const totalInsurance = monthly.reduce((s, r) => s + r.insurance, 0);
return {
loan,
effectiveAmount: amount,
monthlyPayment: monthlyPmt,
monthlyInsurance,
monthly,
yearly,
totalInterest,
totalInsurance,
totalCost: amount + totalInterest + totalInsurance,
};
}
function emptyLoanResult(loan: LoanInput, amount: number): LoanResult {
const yearly: YearlyRow[] = Array.from({ length: MAX_YEARS }, (_, i) => ({
year: i + 1,
interest: 0,
principal: 0,
insurance: 0,
remaining: 0,
payment: 0,
}));
return {
loan,
effectiveAmount: Math.max(0, amount),
monthlyPayment: 0,
monthlyInsurance: 0,
monthly: [],
yearly,
totalInterest: 0,
totalInsurance: 0,
totalCost: 0,
};
}

174
src/lib/defaults.ts Normal file
View File

@@ -0,0 +1,174 @@
import type { SimulationState, LoanInput } from './types';
let loanIdCounter = 0;
export function generateLoanId(): string {
return `loan_${++loanIdCounter}_${Date.now()}`;
}
// ─── Default loans ──────────────────────────────────────────────────────────
export function defaultPTZ(): LoanInput {
return {
id: generateLoanId(),
name: 'Prêt Taux Zéro',
amount: 90000,
rate: 0,
duration: 20,
deferral: 0,
insuranceRate: 0.003,
isMainLoan: false,
enabled: true,
};
}
export function defaultActionLogement(): LoanInput {
return {
id: generateLoanId(),
name: 'Action Logement',
amount: 30000,
rate: 0.01,
duration: 25,
deferral: 0,
insuranceRate: 0.003,
isMainLoan: false,
enabled: true,
};
}
export function defaultPrimoBoost(): LoanInput {
return {
id: generateLoanId(),
name: 'Primo Boost',
amount: 25000,
rate: 0.0199,
duration: 25,
deferral: 0,
insuranceRate: 0.003,
isMainLoan: false,
enabled: true,
};
}
export function defaultMainLoan(): LoanInput {
return {
id: generateLoanId(),
name: 'Prêt Immobilier Classique',
amount: 0, // Auto-calculated
rate: 0.0325,
duration: 25,
deferral: 0,
insuranceRate: 0.003,
isMainLoan: true,
enabled: true,
};
}
export function createEmptyLoan(): LoanInput {
return {
id: generateLoanId(),
name: 'Nouveau prêt',
amount: 0,
rate: 0,
duration: 25,
deferral: 0,
insuranceRate: 0.003,
isMainLoan: false,
enabled: true,
};
}
// ─── Default state for "Neuf" ───────────────────────────────────────────────
export function defaultStateNeuf(): SimulationState {
return {
propertyType: 'neuf',
propertyPrice: 396000,
notaryFeesRate: 0.025,
guaranteeRate: 0.01,
bankFees: 1280,
worksCost: 0,
downPayment: 15000,
currentRent: 1216,
rentEvolutionRate: 0.012,
currentUtilities: 160,
currentCharges: 100,
currentHomeInsurance: 32,
projectedCharges: 150,
propertyTax: 100,
projectedUtilities: 80,
projectedHomeInsurance: 35,
loans: [defaultPTZ(), defaultActionLogement(), defaultPrimoBoost(), defaultMainLoan()],
firstYearDepreciation: -0.05,
annualAppreciation: 0.01,
saleFees: 0.07,
};
}
// ─── Default state for "Ancien" ─────────────────────────────────────────────
export function defaultStateAncien(): SimulationState {
return {
propertyType: 'ancien',
propertyPrice: 396000,
notaryFeesRate: 0.08,
guaranteeRate: 0.01,
bankFees: 1280,
worksCost: 0,
downPayment: 15000,
currentRent: 1216,
rentEvolutionRate: 0.012,
currentUtilities: 160,
currentCharges: 100,
currentHomeInsurance: 32,
projectedCharges: 150,
propertyTax: 100,
projectedUtilities: 80,
projectedHomeInsurance: 35,
// No PTZ for ancien
loans: [defaultActionLogement(), defaultPrimoBoost(), defaultMainLoan()],
firstYearDepreciation: -0.05,
annualAppreciation: 0.01,
saleFees: 0.07,
};
}
// ─── Loan presets ───────────────────────────────────────────────────────────
export interface LoanPreset {
label: string;
description: string;
create: () => LoanInput;
neufOnly?: boolean;
}
export const LOAN_PRESETS: LoanPreset[] = [
{
label: 'Prêt Taux Zéro (PTZ)',
description: 'Prêt à 0% pour primo-accédants (neuf uniquement)',
create: defaultPTZ,
neufOnly: true,
},
{
label: 'Action Logement',
description: 'Prêt employeur à taux réduit',
create: defaultActionLogement,
},
{
label: 'Primo Boost',
description: 'Prêt aidé pour primo-accédants',
create: defaultPrimoBoost,
},
{
label: 'Prêt personnalisé',
description: 'Ajouter un prêt avec des paramètres libres',
create: createEmptyLoan,
},
];

54
src/lib/financial.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* Core financial functions replicating Excel's PMT, IPMT, PPMT.
* All functions return positive values (Excel returns negative for payments).
*/
/**
* PMT — Fixed periodic payment for a loan.
* Equivalent to Excel: -PMT(rate, nper, pv)
*/
export function pmt(rate: number, nper: number, pv: number): number {
if (pv <= 0 || nper <= 0) return 0;
if (rate === 0) return pv / nper;
const factor = Math.pow(1 + rate, nper);
return (pv * rate * factor) / (factor - 1);
}
/**
* IPMT — Interest portion of a specific payment period.
* Equivalent to Excel: -IPMT(rate, per, nper, pv)
* @param per 1-based period number
*/
export function ipmt(rate: number, per: number, nper: number, pv: number): number {
if (rate === 0 || pv <= 0 || nper <= 0) return 0;
const bal = remainingBalance(rate, per - 1, nper, pv);
return bal * rate;
}
/**
* PPMT — Principal portion of a specific payment period.
* Equivalent to Excel: -PPMT(rate, per, nper, pv)
* @param per 1-based period number
*/
export function ppmt(rate: number, per: number, nper: number, pv: number): number {
if (pv <= 0 || nper <= 0) return 0;
return pmt(rate, nper, pv) - ipmt(rate, per, nper, pv);
}
/**
* Remaining balance after `per` payments.
* @param per Number of payments already made (0 = initial balance)
*/
export function remainingBalance(rate: number, per: number, nper: number, pv: number): number {
if (pv <= 0 || nper <= 0) return 0;
if (per <= 0) return pv;
if (per >= nper) return 0;
if (rate === 0) {
return pv - pmt(rate, nper, pv) * per;
}
const payment = pmt(rate, nper, pv);
const factor = Math.pow(1 + rate, per);
return pv * factor - payment * (factor - 1) / rate;
}

49
src/lib/formatters.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* Format a number as Euro currency (French locale).
* 396000 → "396 000 €"
*/
export function formatCurrency(value: number, decimals = 0): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}
/**
* Format a number as a short currency (for charts).
* 396000 → "396k €"
*/
export function formatCurrencyShort(value: number): string {
if (Math.abs(value) >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M €`;
}
if (Math.abs(value) >= 1_000) {
return `${(value / 1_000).toFixed(0)}k €`;
}
return `${value.toFixed(0)}`;
}
/**
* Format a rate as percentage.
* 0.0325 → "3,25 %"
*/
export function formatPercent(value: number, decimals = 2): string {
return new Intl.NumberFormat('fr-FR', {
style: 'percent',
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}
/**
* Format a plain number with French locale.
* 1234.56 → "1 234,56"
*/
export function formatNumber(value: number, decimals = 0): string {
return new Intl.NumberFormat('fr-FR', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}

57
src/lib/sharing.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { SimulationState } from './types';
/**
* Encode simulation state into a shareable URL hash.
* Uses lz-string for compression.
*/
export async function encodeState(state: SimulationState): Promise<string> {
const { compressToEncodedURIComponent } = await import('lz-string');
const json = JSON.stringify(state);
return compressToEncodedURIComponent(json);
}
/**
* Decode simulation state from a URL hash.
*/
export async function decodeState(hash: string): Promise<SimulationState | null> {
try {
const { decompressFromEncodedURIComponent } = await import('lz-string');
const json = decompressFromEncodedURIComponent(hash);
if (!json) return null;
return JSON.parse(json) as SimulationState;
} catch {
return null;
}
}
/**
* Export yearly data as CSV.
*/
export function exportCSV(headers: string[], rows: (string | number)[][]): void {
const bom = '\uFEFF'; // UTF-8 BOM for Excel
const sep = ';';
const lines = [
headers.join(sep),
...rows.map((row) => row.map((v) => (typeof v === 'number' ? v.toFixed(2) : v)).join(sep)),
];
const csv = bom + lines.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `simulation-pret-immo-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/**
* Copy text to clipboard.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}

156
src/lib/types.ts Normal file
View File

@@ -0,0 +1,156 @@
// ─── Property Type ───────────────────────────────────────────────────────────
export type PropertyType = 'neuf' | 'ancien';
// ─── Loan Input ──────────────────────────────────────────────────────────────
export interface LoanInput {
id: string;
name: string;
amount: number;
rate: number; // Annual rate as decimal (3.25% → 0.0325)
duration: number; // Years
deferral: number; // Deferred period in years
insuranceRate: number; // Annual rate on initial capital (0.3% → 0.003)
isMainLoan: boolean; // If true, amount = totalCredit - sum(others)
enabled: boolean;
}
// ─── Simulation State (all inputs) ──────────────────────────────────────────
export interface SimulationState {
// Project
propertyType: PropertyType;
propertyPrice: number;
notaryFeesRate: number;
guaranteeRate: number;
bankFees: number;
worksCost: number;
downPayment: number;
// Current costs (tenant)
currentRent: number;
rentEvolutionRate: number; // Annual evolution (1.2% → 0.012)
currentUtilities: number; // EDF / fibre / eau
currentCharges: number;
currentHomeInsurance: number;
// Projected costs (owner)
projectedCharges: number; // Copropriété
propertyTax: number; // Taxe foncière (monthly)
projectedUtilities: number; // EDF / fibre / eau
projectedHomeInsurance: number;
// Loans
loans: LoanInput[];
// Resale parameters
firstYearDepreciation: number; // e.g., -0.05 (5% loss first year)
annualAppreciation: number; // e.g., 0.01 (1% per year)
saleFees: number; // e.g., 0.07 (7% agency + notary)
}
// ─── Monthly Amortization (per loan) ────────────────────────────────────────
export interface MonthlyRow {
month: number; // 1-based global month
monthInYear: number; // 1-12
year: number; // 1-based year
interest: number;
principal: number;
insurance: number;
remaining: number;
payment: number; // interest + principal + insurance
}
// ─── Yearly Amortization (per loan) ─────────────────────────────────────────
export interface YearlyRow {
year: number;
interest: number;
principal: number;
insurance: number;
remaining: number;
payment: number;
}
// ─── Loan Computation Result ────────────────────────────────────────────────
export interface LoanResult {
loan: LoanInput;
effectiveAmount: number; // Actual amount (auto-calculated for main)
monthlyPayment: number; // Monthly principal + interest (excl. insurance)
monthlyInsurance: number;
monthly: MonthlyRow[];
yearly: YearlyRow[];
totalInterest: number;
totalInsurance: number;
totalCost: number; // amount + totalInterest + totalInsurance
}
// ─── Yearly Summary (aggregated across all loans) ───────────────────────────
export interface YearlySummary {
year: number;
// Credits aggregated
totalInterest: number;
totalPrincipal: number;
totalInsurance: number;
totalRemaining: number;
totalPayment: number;
// Projected monthly costs (as owner)
monthlyInterest: number;
monthlyPrincipal: number;
monthlyCharges: number; // charges + tax + utilities + home insurance
monthlyLoanInsurance: number;
monthlyTotal: number;
capitalRatio: number; // Part of payment building equity
yearlyTotal: number;
// Current monthly costs (as tenant)
currentRent: number;
currentCharges: number; // utilities + charges + insurance
currentMonthlyTotal: number;
currentYearlyTotal: number;
// Comparison
difference: number; // projected - current (negative = savings)
// Resale projection
resalePrice: number;
netResale: number; // After fees - remaining debt - down payment
}
// ─── Final Simulation Results ───────────────────────────────────────────────
export interface SimulationResults {
// Financing
notaryFees: number;
guaranteeFees: number;
totalFinancing: number;
totalCredit: number;
mainLoanAmount: number;
// Per-loan results
loanResults: LoanResult[];
// Yearly summaries (up to 30 years)
yearlySummaries: YearlySummary[];
// Global totals
totalCapital: number;
totalInterest: number;
totalInsurance: number;
grandTotal: number;
// Year 1 monthly snapshot
monthlyPaymentYear1: number;
projectedMonthlyCharges: number;
projectedMonthlyTotal: number;
currentMonthlyTotal: number;
// Max duration across all loans
maxDuration: number;
}

49
tailwind.config.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: 'class',
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
colors: {
brand: {
50: '#eef5ff',
100: '#d9e8ff',
200: '#bbd5ff',
300: '#8cbbff',
400: '#5695ff',
500: '#2f6cff',
600: '#1849f5',
700: '#1038e1',
800: '#142eb6',
900: '#172c8f',
950: '#121d57',
},
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
},
keyframes: {
fadeIn: {
from: { opacity: '0' },
to: { opacity: '1' },
},
slideDown: {
from: { opacity: '0', transform: 'translateY(-8px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
},
},
},
plugins: [
require('@tailwindcss/forms'),
],
};
export default config;

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}