Compare commits
7 Commits
4a3b69babc
...
a4c4ac9e51
| Author | SHA1 | Date | |
|---|---|---|---|
| a4c4ac9e51 | |||
| 74cdb7b064 | |||
| 99ab1fc6fb | |||
| bfe88b7ea8 | |||
| 7defeb87b3 | |||
| 87370d8fad | |||
| 8f9674730d |
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal 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
10
next.config.mjs
Normal 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
1715
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal 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
9
postcss.config.js
Normal 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
226
src/app/globals.css
Normal 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
43
src/app/layout.tsx
Normal 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
124
src/app/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { SimulationProvider } from '@/contexts/SimulationContext';
|
||||||
|
import Header from '@/components/Header';
|
||||||
|
import { ProjectForm, CurrentCostsForm, ProjectedCostsForm, ResaleForm } from '@/components/InputForms';
|
||||||
|
import LoansForm from '@/components/LoansForm';
|
||||||
|
import ResultsSummary from '@/components/ResultsSummary';
|
||||||
|
import AmortizationTable from '@/components/AmortizationTable';
|
||||||
|
import ComparisonTable from '@/components/ComparisonTable';
|
||||||
|
import Charts from '@/components/Charts';
|
||||||
|
import { Tabs } from '@/components/ui';
|
||||||
|
|
||||||
|
// ─── Tab icons ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SummaryIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompareIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tabs definition ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RESULT_TABS = [
|
||||||
|
{ id: 'summary', label: 'Résumé', icon: <SummaryIcon /> },
|
||||||
|
{ id: 'amortization', label: 'Amortissement', icon: <TableIcon /> },
|
||||||
|
{ id: 'comparison', label: 'Comparaison', icon: <CompareIcon /> },
|
||||||
|
{ id: 'charts', label: 'Graphiques', icon: <ChartIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Page Content ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SimulationPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState('summary');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="max-w-screen-2xl mx-auto px-4 sm:px-6 py-6">
|
||||||
|
<div className="lg:flex lg:gap-6">
|
||||||
|
{/* ─── Left panel: Inputs ─── */}
|
||||||
|
<aside className="lg:w-[400px] xl:w-[420px] lg:shrink-0 space-y-3 no-print">
|
||||||
|
<ProjectForm />
|
||||||
|
<LoansForm />
|
||||||
|
<CurrentCostsForm />
|
||||||
|
<ProjectedCostsForm />
|
||||||
|
<ResaleForm />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ─── Right panel: Results ─── */}
|
||||||
|
<section className="flex-1 mt-6 lg:mt-0 min-w-0">
|
||||||
|
<div className="sticky top-[57px] z-40 bg-slate-50 dark:bg-slate-950 pb-3 no-print">
|
||||||
|
<Tabs
|
||||||
|
tabs={RESULT_TABS}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1">
|
||||||
|
{activeTab === 'summary' && <ResultsSummary />}
|
||||||
|
{activeTab === 'amortization' && <AmortizationTable />}
|
||||||
|
{activeTab === 'comparison' && <ComparisonTable />}
|
||||||
|
{activeTab === 'charts' && <Charts />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 mt-12 no-print">
|
||||||
|
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 py-6">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-slate-400">
|
||||||
|
<p>
|
||||||
|
SimO — Simulateur de prêt immobilier gratuit et open source.
|
||||||
|
Les résultats sont donnés à titre indicatif.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Calculs basés sur des formules d'amortissement standard.
|
||||||
|
Consultez un professionnel pour votre projet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Root page with Provider ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<SimulationProvider>
|
||||||
|
<SimulationPage />
|
||||||
|
</SimulationProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
383
src/components/AmortizationTable.tsx
Normal file
383
src/components/AmortizationTable.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useSimulation } from '@/contexts/SimulationContext';
|
||||||
|
import { Toggle } from './ui';
|
||||||
|
import { formatCurrency, formatPercent } from '@/lib/formatters';
|
||||||
|
|
||||||
|
type ViewMode = 'yearly' | 'monthly';
|
||||||
|
|
||||||
|
export default function AmortizationTable() {
|
||||||
|
const { results } = useSimulation();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('yearly');
|
||||||
|
const [expandedYear, setExpandedYear] = useState<number | null>(null);
|
||||||
|
const [selectedLoan, setSelectedLoan] = useState<string>('all');
|
||||||
|
|
||||||
|
const activeDuration = results.maxDuration;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-fade-in">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Toggle
|
||||||
|
options={[
|
||||||
|
{ value: 'yearly', label: 'Annuel' },
|
||||||
|
{ value: 'monthly', label: 'Mensuel' },
|
||||||
|
]}
|
||||||
|
value={viewMode}
|
||||||
|
onChange={(v) => setViewMode(v as ViewMode)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loan filter */}
|
||||||
|
<select
|
||||||
|
className="form-input-sm w-auto"
|
||||||
|
value={selectedLoan}
|
||||||
|
onChange={(e) => setSelectedLoan(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">Tous les prêts</option>
|
||||||
|
{results.loanResults.map((lr) => (
|
||||||
|
<option key={lr.loan.id} value={lr.loan.id}>
|
||||||
|
{lr.loan.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
{viewMode === 'yearly' ? (
|
||||||
|
<YearlyTable
|
||||||
|
selectedLoan={selectedLoan}
|
||||||
|
expandedYear={expandedYear}
|
||||||
|
onToggleYear={(y) => setExpandedYear(expandedYear === y ? null : y)}
|
||||||
|
activeDuration={activeDuration}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MonthlyTable
|
||||||
|
selectedLoan={selectedLoan}
|
||||||
|
activeDuration={activeDuration}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Yearly Table ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function YearlyTable({
|
||||||
|
selectedLoan,
|
||||||
|
expandedYear,
|
||||||
|
onToggleYear,
|
||||||
|
activeDuration,
|
||||||
|
}: {
|
||||||
|
selectedLoan: string;
|
||||||
|
expandedYear: number | null;
|
||||||
|
onToggleYear: (y: number) => void;
|
||||||
|
activeDuration: number;
|
||||||
|
}) {
|
||||||
|
const { results } = useSimulation();
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
if (selectedLoan === 'all') {
|
||||||
|
return results.yearlySummaries
|
||||||
|
.filter((y) => y.year <= activeDuration)
|
||||||
|
.map((y) => ({
|
||||||
|
year: y.year,
|
||||||
|
interest: y.totalInterest,
|
||||||
|
principal: y.totalPrincipal,
|
||||||
|
insurance: y.totalInsurance,
|
||||||
|
remaining: y.totalRemaining,
|
||||||
|
payment: y.totalPayment,
|
||||||
|
monthlyTotal: y.monthlyTotal,
|
||||||
|
capitalRatio: y.capitalRatio,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const lr = results.loanResults.find((l) => l.loan.id === selectedLoan);
|
||||||
|
if (!lr) return [];
|
||||||
|
|
||||||
|
return lr.yearly
|
||||||
|
.filter((y) => y.year <= lr.loan.duration)
|
||||||
|
.map((y) => ({
|
||||||
|
year: y.year,
|
||||||
|
interest: y.interest,
|
||||||
|
principal: y.principal,
|
||||||
|
insurance: y.insurance,
|
||||||
|
remaining: y.remaining,
|
||||||
|
payment: y.payment,
|
||||||
|
monthlyTotal: y.payment / 12,
|
||||||
|
capitalRatio: y.payment > 0 ? y.principal / y.payment : 0,
|
||||||
|
}));
|
||||||
|
}, [results, selectedLoan, activeDuration]);
|
||||||
|
|
||||||
|
// Get monthly details for expanded year
|
||||||
|
const expandedMonths = useMemo(() => {
|
||||||
|
if (expandedYear === null) return [];
|
||||||
|
|
||||||
|
if (selectedLoan === 'all') {
|
||||||
|
// Aggregate monthly data across all loans for the given year
|
||||||
|
const months: { month: number; interest: number; principal: number; insurance: number; remaining: number; payment: number }[] = [];
|
||||||
|
for (let m = 1; m <= 12; m++) {
|
||||||
|
let interest = 0, principal = 0, insurance = 0, remaining = 0, payment = 0;
|
||||||
|
for (const lr of results.loanResults) {
|
||||||
|
const row = lr.monthly.find((r) => r.year === expandedYear && r.monthInYear === m);
|
||||||
|
if (row) {
|
||||||
|
interest += row.interest;
|
||||||
|
principal += row.principal;
|
||||||
|
insurance += row.insurance;
|
||||||
|
remaining += row.remaining;
|
||||||
|
payment += row.payment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (interest > 0 || principal > 0 || insurance > 0) {
|
||||||
|
months.push({ month: m, interest, principal, insurance, remaining, payment });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return months;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lr = results.loanResults.find((l) => l.loan.id === selectedLoan);
|
||||||
|
if (!lr) return [];
|
||||||
|
return lr.monthly
|
||||||
|
.filter((r) => r.year === expandedYear)
|
||||||
|
.map((r) => ({
|
||||||
|
month: r.monthInYear,
|
||||||
|
interest: r.interest,
|
||||||
|
principal: r.principal,
|
||||||
|
insurance: r.insurance,
|
||||||
|
remaining: r.remaining,
|
||||||
|
payment: r.payment,
|
||||||
|
}));
|
||||||
|
}, [results, selectedLoan, expandedYear]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="w-16">Année</th>
|
||||||
|
<th className="text-right">Intérêts</th>
|
||||||
|
<th className="text-right">Capital</th>
|
||||||
|
<th className="text-right">Assurance</th>
|
||||||
|
<th className="text-right">Restant dû</th>
|
||||||
|
<th className="text-right">Mensualité</th>
|
||||||
|
<th className="text-right">Ratio capital</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row) => (
|
||||||
|
<React.Fragment key={row.year}>
|
||||||
|
<tr
|
||||||
|
className="cursor-pointer hover:bg-blue-50/60 dark:hover:bg-blue-950/30"
|
||||||
|
onClick={() => onToggleYear(row.year)}
|
||||||
|
>
|
||||||
|
<td className="font-medium">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 text-slate-400 transition-transform ${
|
||||||
|
expandedYear === row.year ? 'rotate-90' : ''
|
||||||
|
}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
{row.year}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-right text-amber-600 tabular-nums">{formatCurrency(row.interest)}</td>
|
||||||
|
<td className="text-right text-blue-600 tabular-nums">{formatCurrency(row.principal)}</td>
|
||||||
|
<td className="text-right text-purple-500 tabular-nums">{formatCurrency(row.insurance)}</td>
|
||||||
|
<td className="text-right tabular-nums font-medium">{formatCurrency(row.remaining)}</td>
|
||||||
|
<td className="text-right tabular-nums">{formatCurrency(row.monthlyTotal)}</td>
|
||||||
|
<td className="text-right">
|
||||||
|
<RatioBar value={row.capitalRatio} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded monthly detail */}
|
||||||
|
{expandedYear === row.year && expandedMonths.length > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="p-0 bg-slate-50 dark:bg-slate-800/50">
|
||||||
|
<div className="p-3 animate-fade-in">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-400 dark:text-slate-500">
|
||||||
|
<th className="text-left py-1 px-2 font-medium">Mois</th>
|
||||||
|
<th className="text-right py-1 px-2 font-medium">Intérêts</th>
|
||||||
|
<th className="text-right py-1 px-2 font-medium">Capital</th>
|
||||||
|
<th className="text-right py-1 px-2 font-medium">Assurance</th>
|
||||||
|
<th className="text-right py-1 px-2 font-medium">Restant dû</th>
|
||||||
|
<th className="text-right py-1 px-2 font-medium">Mensualité</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{expandedMonths.map((m) => (
|
||||||
|
<tr key={m.month} className="border-t border-slate-200/50 dark:border-slate-700/50">
|
||||||
|
<td className="py-1 px-2 font-medium text-slate-600 dark:text-slate-400">
|
||||||
|
{MONTH_NAMES[m.month - 1]}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 px-2 text-amber-600 tabular-nums">
|
||||||
|
{formatCurrency(m.interest, 2)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 px-2 text-blue-600 tabular-nums">
|
||||||
|
{formatCurrency(m.principal, 2)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 px-2 text-purple-500 tabular-nums">
|
||||||
|
{formatCurrency(m.insurance, 2)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 px-2 tabular-nums font-medium">
|
||||||
|
{formatCurrency(m.remaining, 2)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 px-2 tabular-nums">
|
||||||
|
{formatCurrency(m.payment, 2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Monthly Table (flat view) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MonthlyTable({
|
||||||
|
selectedLoan,
|
||||||
|
activeDuration,
|
||||||
|
}: {
|
||||||
|
selectedLoan: string;
|
||||||
|
activeDuration: number;
|
||||||
|
}) {
|
||||||
|
const { results } = useSimulation();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const pageSize = 24; // 2 years per page
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
if (selectedLoan === 'all') {
|
||||||
|
const totalMonths = activeDuration * 12;
|
||||||
|
const months: { month: number; year: number; interest: number; principal: number; insurance: number; remaining: number; payment: number }[] = [];
|
||||||
|
|
||||||
|
for (let m = 1; m <= totalMonths; m++) {
|
||||||
|
let interest = 0, principal = 0, insurance = 0, remaining = 0, payment = 0;
|
||||||
|
for (const lr of results.loanResults) {
|
||||||
|
const row = lr.monthly.find((r) => r.month === m);
|
||||||
|
if (row) {
|
||||||
|
interest += row.interest;
|
||||||
|
principal += row.principal;
|
||||||
|
insurance += row.insurance;
|
||||||
|
remaining += row.remaining;
|
||||||
|
payment += row.payment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
months.push({
|
||||||
|
month: m,
|
||||||
|
year: Math.ceil(m / 12),
|
||||||
|
interest, principal, insurance, remaining, payment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return months;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lr = results.loanResults.find((l) => l.loan.id === selectedLoan);
|
||||||
|
if (!lr) return [];
|
||||||
|
return lr.monthly.map((r) => ({
|
||||||
|
month: r.month,
|
||||||
|
year: r.year,
|
||||||
|
interest: r.interest,
|
||||||
|
principal: r.principal,
|
||||||
|
insurance: r.insurance,
|
||||||
|
remaining: r.remaining,
|
||||||
|
payment: r.payment,
|
||||||
|
}));
|
||||||
|
}, [results, selectedLoan, activeDuration]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(data.length / pageSize);
|
||||||
|
const pageData = data.slice(page * pageSize, (page + 1) * pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Mois</th>
|
||||||
|
<th>Année</th>
|
||||||
|
<th className="text-right">Intérêts</th>
|
||||||
|
<th className="text-right">Capital</th>
|
||||||
|
<th className="text-right">Assurance</th>
|
||||||
|
<th className="text-right">Restant dû</th>
|
||||||
|
<th className="text-right">Mensualité</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pageData.map((row) => (
|
||||||
|
<tr key={row.month}>
|
||||||
|
<td className="font-medium text-slate-600 dark:text-slate-400">{row.month}</td>
|
||||||
|
<td className="text-slate-400 dark:text-slate-500">{row.year}</td>
|
||||||
|
<td className="text-right text-amber-600 tabular-nums">{formatCurrency(row.interest, 2)}</td>
|
||||||
|
<td className="text-right text-blue-600 tabular-nums">{formatCurrency(row.principal, 2)}</td>
|
||||||
|
<td className="text-right text-purple-500 tabular-nums">{formatCurrency(row.insurance, 2)}</td>
|
||||||
|
<td className="text-right tabular-nums font-medium">{formatCurrency(row.remaining)}</td>
|
||||||
|
<td className="text-right tabular-nums">{formatCurrency(row.payment, 2)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 disabled:text-slate-300 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Précédent
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-slate-500">
|
||||||
|
Page {page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 disabled:text-slate-300 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ratio Bar ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RatioBar({ value }: { value: number }) {
|
||||||
|
const pct = Math.round(value * 100);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<div className="w-16 h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs tabular-nums text-slate-500 w-8 text-right">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||||
|
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre',
|
||||||
|
];
|
||||||
453
src/components/Charts.tsx
Normal file
453
src/components/Charts.tsx
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
ArcElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
type ChartOptions,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Line, Bar, Doughnut } from 'react-chartjs-2';
|
||||||
|
import { useSimulation } from '@/contexts/SimulationContext';
|
||||||
|
import { formatCurrencyShort, formatCurrency } from '@/lib/formatters';
|
||||||
|
|
||||||
|
// Register ChartJS components
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
ArcElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Color palette ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
capital: { bg: 'rgba(37, 99, 235, 0.15)', border: 'rgb(37, 99, 235)' },
|
||||||
|
interest: { bg: 'rgba(245, 158, 11, 0.15)', border: 'rgb(245, 158, 11)' },
|
||||||
|
insurance: { bg: 'rgba(139, 92, 246, 0.15)', border: 'rgb(139, 92, 246)' },
|
||||||
|
charges: { bg: 'rgba(107, 114, 128, 0.15)', border: 'rgb(107, 114, 128)' },
|
||||||
|
rent: { bg: 'rgba(239, 68, 68, 0.15)', border: 'rgb(239, 68, 68)' },
|
||||||
|
projected: { bg: 'rgba(37, 99, 235, 0.15)', border: 'rgb(37, 99, 235)' },
|
||||||
|
resale: { bg: 'rgba(5, 150, 105, 0.15)', border: 'rgb(5, 150, 105)' },
|
||||||
|
negative: { bg: 'rgba(220, 38, 38, 0.15)', border: 'rgb(220, 38, 38)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultTooltipOptions = {
|
||||||
|
mode: 'index' as const,
|
||||||
|
intersect: false,
|
||||||
|
backgroundColor: 'rgba(15, 23, 42, 0.9)',
|
||||||
|
titleColor: '#fff',
|
||||||
|
bodyColor: '#cbd5e1',
|
||||||
|
borderColor: 'rgba(148, 163, 184, 0.3)',
|
||||||
|
borderWidth: 1,
|
||||||
|
cornerRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
bodySpacing: 6,
|
||||||
|
titleSpacing: 4,
|
||||||
|
usePointStyle: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultLegendOptions = {
|
||||||
|
position: 'bottom' as const,
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle',
|
||||||
|
padding: 16,
|
||||||
|
font: { size: 11 },
|
||||||
|
color: '#64748b',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Main Charts Component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Charts() {
|
||||||
|
const { results } = useSimulation();
|
||||||
|
const data = results.yearlySummaries.filter((y) => y.year <= results.maxDuration);
|
||||||
|
const labels = data.map((y) => `An ${y.year}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
|
{/* 1. Remaining balance over time */}
|
||||||
|
<ChartCard title="Évolution du capital restant dû">
|
||||||
|
<RemainingBalanceChart data={data} labels={labels} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 2. Capital vs Interest vs Insurance breakdown */}
|
||||||
|
<ChartCard title="Répartition annuelle des paiements">
|
||||||
|
<PaymentBreakdownChart data={data} labels={labels} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 3. Monthly comparison: rent vs projected */}
|
||||||
|
<ChartCard title="Comparaison mensuelle : propriétaire vs locataire">
|
||||||
|
<ComparisonChart data={data} labels={labels} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
{/* 4. Resale projection */}
|
||||||
|
<ChartCard title="Projection de revente (plus/moins-value nette)">
|
||||||
|
<ResaleChart data={data} labels={labels} />
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost breakdown donut */}
|
||||||
|
<ChartCard title="Répartition du coût total du crédit">
|
||||||
|
<div className="max-w-sm mx-auto">
|
||||||
|
<CostDonut />
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chart Components ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RemainingBalanceChart({ data, labels }: ChartProps) {
|
||||||
|
const { results } = useSimulation();
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
// Stack per-loan remaining balances
|
||||||
|
const datasets = results.loanResults.map((lr, idx) => {
|
||||||
|
const colors = [
|
||||||
|
COLORS.capital,
|
||||||
|
COLORS.interest,
|
||||||
|
COLORS.insurance,
|
||||||
|
COLORS.charges,
|
||||||
|
COLORS.rent,
|
||||||
|
];
|
||||||
|
const color = colors[idx % colors.length];
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: lr.loan.name,
|
||||||
|
data: data.map((y) => {
|
||||||
|
const yearData = lr.yearly.find((yr) => yr.year === y.year);
|
||||||
|
return yearData?.remaining ?? 0;
|
||||||
|
}),
|
||||||
|
borderColor: color.border,
|
||||||
|
backgroundColor: color.bg,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHitRadius: 10,
|
||||||
|
borderWidth: 2,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { labels, datasets };
|
||||||
|
}, [data, labels, results.loanResults]);
|
||||||
|
|
||||||
|
const options: ChartOptions<'line'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: defaultTooltipOptions,
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
...defaultTooltipOptions,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y ?? 0)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: defaultLegendOptions,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: '#94a3b8', font: { size: 10 } },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
grid: { color: 'rgba(148, 163, 184, 0.1)' },
|
||||||
|
ticks: {
|
||||||
|
color: '#94a3b8',
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: (v) => formatCurrencyShort(v as number),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-72">
|
||||||
|
<Line data={chartData} options={options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentBreakdownChart({ data, labels }: ChartProps) {
|
||||||
|
const chartData = useMemo(
|
||||||
|
() => ({
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Capital',
|
||||||
|
data: data.map((y) => y.totalPrincipal),
|
||||||
|
backgroundColor: COLORS.capital.border,
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Intérêts',
|
||||||
|
data: data.map((y) => y.totalInterest),
|
||||||
|
backgroundColor: COLORS.interest.border,
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Assurance',
|
||||||
|
data: data.map((y) => y.totalInsurance),
|
||||||
|
backgroundColor: COLORS.insurance.border,
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[data, labels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const options: ChartOptions<'bar'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
...defaultTooltipOptions,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y ?? 0)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: defaultLegendOptions,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: '#94a3b8', font: { size: 10 } },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
grid: { color: 'rgba(148, 163, 184, 0.1)' },
|
||||||
|
ticks: {
|
||||||
|
color: '#94a3b8',
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: (v) => formatCurrencyShort(v as number),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-72">
|
||||||
|
<Bar data={chartData} options={options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComparisonChart({ data, labels }: ChartProps) {
|
||||||
|
const chartData = useMemo(
|
||||||
|
() => ({
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Mensualité propriétaire',
|
||||||
|
data: data.map((y) => y.monthlyTotal),
|
||||||
|
borderColor: COLORS.projected.border,
|
||||||
|
backgroundColor: COLORS.projected.bg,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHitRadius: 10,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Coût locataire',
|
||||||
|
data: data.map((y) => y.currentMonthlyTotal),
|
||||||
|
borderColor: COLORS.rent.border,
|
||||||
|
backgroundColor: COLORS.rent.bg,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHitRadius: 10,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 3],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[data, labels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const options: ChartOptions<'line'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
...defaultTooltipOptions,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y ?? 0)}/mois`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: defaultLegendOptions,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: '#94a3b8', font: { size: 10 } },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: 'rgba(148, 163, 184, 0.1)' },
|
||||||
|
ticks: {
|
||||||
|
color: '#94a3b8',
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: (v) => formatCurrencyShort(v as number),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-72">
|
||||||
|
<Line data={chartData} options={options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResaleChart({ data, labels }: ChartProps) {
|
||||||
|
const chartData = useMemo(
|
||||||
|
() => ({
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Plus/moins-value nette',
|
||||||
|
data: data.map((y) => y.netResale),
|
||||||
|
borderColor: data.map((y) =>
|
||||||
|
y.netResale >= 0 ? COLORS.resale.border : COLORS.negative.border
|
||||||
|
),
|
||||||
|
backgroundColor: data.map((y) =>
|
||||||
|
y.netResale >= 0 ? COLORS.resale.bg : COLORS.negative.bg
|
||||||
|
),
|
||||||
|
borderWidth: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[data, labels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const options: ChartOptions<'bar'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
...defaultTooltipOptions,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `Plus/moins-value: ${formatCurrency(ctx.parsed.y ?? 0)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: { display: false },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: '#94a3b8', font: { size: 10 } },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: 'rgba(148, 163, 184, 0.1)' },
|
||||||
|
ticks: {
|
||||||
|
color: '#94a3b8',
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: (v) => formatCurrencyShort(v as number),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-72">
|
||||||
|
<Bar data={chartData} options={options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CostDonut() {
|
||||||
|
const { results } = useSimulation();
|
||||||
|
|
||||||
|
const chartData = useMemo(
|
||||||
|
() => ({
|
||||||
|
labels: ['Capital', 'Intérêts', 'Assurance'],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [results.totalCapital, results.totalInterest, results.totalInsurance],
|
||||||
|
backgroundColor: [
|
||||||
|
COLORS.capital.border,
|
||||||
|
COLORS.interest.border,
|
||||||
|
COLORS.insurance.border,
|
||||||
|
],
|
||||||
|
borderWidth: 0,
|
||||||
|
hoverOffset: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[results]
|
||||||
|
);
|
||||||
|
|
||||||
|
const options: ChartOptions<'doughnut'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
cutout: '65%',
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
...defaultTooltipOptions,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const total = results.grandTotal;
|
||||||
|
const pct = total > 0 ? ((ctx.parsed / total) * 100).toFixed(1) : 0;
|
||||||
|
return `${ctx.label}: ${formatCurrency(ctx.parsed)} (${pct}%)`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: defaultLegendOptions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Doughnut data={chartData} options={options} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-bold text-slate-900 dark:text-slate-100 tabular-nums">
|
||||||
|
{formatCurrency(results.grandTotal)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 dark:text-slate-500">Coût total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chart Card Wrapper ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ChartProps {
|
||||||
|
data: ReturnType<typeof useSimulation>['results']['yearlySummaries'];
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
196
src/components/ComparisonTable.tsx
Normal file
196
src/components/ComparisonTable.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useSimulation } from '@/contexts/SimulationContext';
|
||||||
|
import { formatCurrency } from '@/lib/formatters';
|
||||||
|
|
||||||
|
export default function ComparisonTable() {
|
||||||
|
const { results } = useSimulation();
|
||||||
|
const data = results.yearlySummaries.filter((y) => y.year <= Math.max(results.maxDuration, 5));
|
||||||
|
|
||||||
|
// Find break-even year (when projected <= current)
|
||||||
|
const breakEvenYear = data.find((y) => y.difference <= 0)?.year;
|
||||||
|
// Find resale profit year
|
||||||
|
const profitYear = data.find((y) => y.netResale > 0)?.year;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Key insights */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<InsightCard
|
||||||
|
label="Mensualité projetée (an 1)"
|
||||||
|
value={formatCurrency(data[0]?.monthlyTotal ?? 0)}
|
||||||
|
detail="Crédit + charges propriétaire"
|
||||||
|
variant="blue"
|
||||||
|
/>
|
||||||
|
<InsightCard
|
||||||
|
label="Loyer actuel (an 1)"
|
||||||
|
value={formatCurrency(data[0]?.currentMonthlyTotal ?? 0)}
|
||||||
|
detail="Loyer + charges locataire"
|
||||||
|
variant="amber"
|
||||||
|
/>
|
||||||
|
<InsightCard
|
||||||
|
label={profitYear ? `Revente rentable dès l'année ${profitYear}` : 'Revente non rentable'}
|
||||||
|
value={
|
||||||
|
profitYear
|
||||||
|
? formatCurrency(data.find((y) => y.year === profitYear)?.netResale ?? 0)
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
detail={profitYear ? 'Plus-value nette après frais' : 'Sur la période simulée'}
|
||||||
|
variant={profitYear ? 'green' : 'red'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main comparison table */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||||
|
Comparaison annuelle : propriétaire vs locataire
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th rowSpan={2} className="border-r border-slate-200 dark:border-slate-700">Année</th>
|
||||||
|
<th colSpan={4} className="text-center border-r border-slate-200 dark:border-slate-700 bg-blue-50/50 dark:bg-blue-950/20">
|
||||||
|
Propriétaire (mensuel)
|
||||||
|
</th>
|
||||||
|
<th colSpan={2} className="text-center border-r border-slate-200 dark:border-slate-700 bg-amber-50/50 dark:bg-amber-950/20">
|
||||||
|
Locataire (mensuel)
|
||||||
|
</th>
|
||||||
|
<th colSpan={2} className="text-center border-r border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||||||
|
Comparaison
|
||||||
|
</th>
|
||||||
|
<th colSpan={2} className="text-center bg-emerald-50/50 dark:bg-emerald-950/20">
|
||||||
|
Revente
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{/* Propriétaire */}
|
||||||
|
<th className="text-right text-blue-600">Capital</th>
|
||||||
|
<th className="text-right text-amber-500">Intérêts</th>
|
||||||
|
<th className="text-right text-slate-400">Charges</th>
|
||||||
|
<th className="text-right border-r border-slate-200 dark:border-slate-700 font-bold">Total</th>
|
||||||
|
{/* Locataire */}
|
||||||
|
<th className="text-right">Loyer</th>
|
||||||
|
<th className="text-right border-r border-slate-200 dark:border-slate-700 font-bold">Total</th>
|
||||||
|
{/* Comparaison */}
|
||||||
|
<th className="text-right">Diff/mois</th>
|
||||||
|
<th className="text-right border-r border-slate-200 dark:border-slate-700">Ratio cap.</th>
|
||||||
|
{/* Revente */}
|
||||||
|
<th className="text-right">Prix</th>
|
||||||
|
<th className="text-right">+/- value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row) => {
|
||||||
|
const isBreakEven = row.year === breakEvenYear;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.year}
|
||||||
|
className={isBreakEven ? 'bg-emerald-50/50 dark:bg-emerald-950/20' : ''}
|
||||||
|
>
|
||||||
|
<td className="font-medium border-r border-slate-100 dark:border-slate-800">{row.year}</td>
|
||||||
|
|
||||||
|
{/* Propriétaire */}
|
||||||
|
<td className="text-right text-blue-600 tabular-nums">
|
||||||
|
{formatCurrency(row.monthlyPrincipal)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right text-amber-500 tabular-nums">
|
||||||
|
{formatCurrency(row.monthlyInterest)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right text-slate-400 tabular-nums">
|
||||||
|
{formatCurrency(row.monthlyCharges + row.monthlyLoanInsurance)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right font-semibold border-r border-slate-100 dark:border-slate-800 tabular-nums">
|
||||||
|
{formatCurrency(row.monthlyTotal)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Locataire */}
|
||||||
|
<td className="text-right tabular-nums">{formatCurrency(row.currentRent)}</td>
|
||||||
|
<td className="text-right font-semibold border-r border-slate-100 dark:border-slate-800 tabular-nums">
|
||||||
|
{formatCurrency(row.currentMonthlyTotal)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Comparaison */}
|
||||||
|
<td className={`text-right font-medium tabular-nums ${
|
||||||
|
row.difference > 0 ? 'text-red-500' : 'text-emerald-600'
|
||||||
|
}`}>
|
||||||
|
{row.difference > 0 ? '+' : ''}{formatCurrency(row.difference)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right border-r border-slate-100 dark:border-slate-800">
|
||||||
|
<RatioBar value={row.capitalRatio} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Revente */}
|
||||||
|
<td className="text-right tabular-nums">{formatCurrency(row.resalePrice)}</td>
|
||||||
|
<td className={`text-right font-medium tabular-nums ${
|
||||||
|
row.netResale >= 0 ? 'text-emerald-600' : 'text-red-500'
|
||||||
|
}`}>
|
||||||
|
{row.netResale >= 0 ? '+' : ''}{formatCurrency(row.netResale)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="text-xs text-slate-400 space-y-1 px-1">
|
||||||
|
<p>
|
||||||
|
<strong>Ratio capital :</strong> Part de votre mensualité qui constitue du patrimoine (remboursement du capital emprunté).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Plus/Moins-value :</strong> Gain net si vous revendez, après déduction des frais de vente, du capital restant dû et de l'apport.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function InsightCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
variant: 'blue' | 'amber' | 'green' | 'red';
|
||||||
|
}) {
|
||||||
|
const styles = {
|
||||||
|
blue: 'border-blue-200 bg-blue-50/50 dark:border-blue-900 dark:bg-blue-950/30',
|
||||||
|
amber: 'border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/30',
|
||||||
|
green: 'border-emerald-200 bg-emerald-50/50 dark:border-emerald-900 dark:bg-emerald-950/30',
|
||||||
|
red: 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-4 ${styles[variant]}`}>
|
||||||
|
<div className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</div>
|
||||||
|
<div className="text-xl font-bold text-slate-900 dark:text-slate-100 mt-1 tabular-nums">{value}</div>
|
||||||
|
<div className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{detail}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatioBar({ value }: { value: number }) {
|
||||||
|
const pct = Math.round(value * 100);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 justify-end">
|
||||||
|
<div className="w-12 h-1.5 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full"
|
||||||
|
style={{ width: `${Math.min(100, pct)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs tabular-nums text-slate-400 w-7 text-right">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
src/components/Header.tsx
Normal file
225
src/components/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
311
src/components/InputForms.tsx
Normal file
311
src/components/InputForms.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
src/components/LoansForm.tsx
Normal file
336
src/components/LoansForm.tsx
Normal 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 · {loan.duration} ans · {(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
269
src/components/ResultsSummary.tsx
Normal file
269
src/components/ResultsSummary.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useSimulation } from '@/contexts/SimulationContext';
|
||||||
|
import { formatCurrency, formatPercent } from '@/lib/formatters';
|
||||||
|
|
||||||
|
export default function ResultsSummary() {
|
||||||
|
const { results, state } = useSimulation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Key metrics */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<MetricCard
|
||||||
|
label="Mensualité totale"
|
||||||
|
value={formatCurrency(results.projectedMonthlyTotal)}
|
||||||
|
sub="Crédit + charges"
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Coût total du crédit"
|
||||||
|
value={formatCurrency(results.grandTotal)}
|
||||||
|
sub={`dont ${formatCurrency(results.totalInterest)} d'intérêts`}
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Loyer actuel"
|
||||||
|
value={formatCurrency(results.currentMonthlyTotal)}
|
||||||
|
sub="Loyer + charges"
|
||||||
|
color="slate"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Différence mensuelle"
|
||||||
|
value={formatCurrency(results.projectedMonthlyTotal - results.currentMonthlyTotal)}
|
||||||
|
sub={
|
||||||
|
results.projectedMonthlyTotal > results.currentMonthlyTotal
|
||||||
|
? 'Surcoût vs loyer'
|
||||||
|
: 'Économie vs loyer'
|
||||||
|
}
|
||||||
|
color={results.projectedMonthlyTotal > results.currentMonthlyTotal ? 'red' : 'green'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Financing breakdown */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Récapitulatif du financement</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<Row label="Prix du bien" value={formatCurrency(state.propertyPrice)} />
|
||||||
|
<Row label="Frais de notaire" value={formatCurrency(results.notaryFees)} muted />
|
||||||
|
<Row label="Frais de garantie" value={formatCurrency(results.guaranteeFees)} muted />
|
||||||
|
<Row label="Frais de dossier" value={formatCurrency(state.bankFees)} muted />
|
||||||
|
<Row label="Travaux" value={formatCurrency(state.worksCost)} muted />
|
||||||
|
<div className="border-t border-slate-200 dark:border-slate-700 pt-2">
|
||||||
|
<Row label="Financement total" value={formatCurrency(results.totalFinancing)} bold />
|
||||||
|
</div>
|
||||||
|
<Row label="Apport" value={`- ${formatCurrency(state.downPayment)}`} className="text-emerald-600" />
|
||||||
|
<div className="border-t border-slate-200 dark:border-slate-700 pt-2">
|
||||||
|
<Row label="Crédit total" value={formatCurrency(results.totalCredit)} bold />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<Row label="Capital emprunté" value={formatCurrency(results.totalCapital)} />
|
||||||
|
<Row label="Total intérêts" value={formatCurrency(results.totalInterest)} className="text-amber-600" />
|
||||||
|
<Row label="Total assurance" value={formatCurrency(results.totalInsurance)} className="text-purple-600" />
|
||||||
|
<div className="border-t border-slate-200 dark:border-slate-700 pt-2">
|
||||||
|
<Row label="Coût total" value={formatCurrency(results.grandTotal)} bold />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 rounded-lg bg-slate-50 dark:bg-slate-800 p-3">
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400 mb-1">Surcoût du crédit</div>
|
||||||
|
<div className="text-lg font-bold text-slate-900 dark:text-slate-100 tabular-nums">
|
||||||
|
{formatCurrency(results.totalInterest + results.totalInsurance)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
soit {formatPercent((results.totalInterest + results.totalInsurance) / results.totalCredit)} du capital
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-loan breakdown */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Détail par prêt</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prêt</th>
|
||||||
|
<th className="text-right">Capital</th>
|
||||||
|
<th className="text-right">Taux</th>
|
||||||
|
<th className="text-right">Durée</th>
|
||||||
|
<th className="text-right">Mensualité</th>
|
||||||
|
<th className="text-right">Intérêts</th>
|
||||||
|
<th className="text-right">Assurance</th>
|
||||||
|
<th className="text-right">Coût total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.loanResults.map((lr) => (
|
||||||
|
<tr key={lr.loan.id}>
|
||||||
|
<td className="font-medium text-slate-800 dark:text-slate-200">
|
||||||
|
{lr.loan.name}
|
||||||
|
{lr.loan.isMainLoan && (
|
||||||
|
<span className="ml-1.5 text-[10px] text-blue-500 bg-blue-50 px-1 py-0.5 rounded">
|
||||||
|
AUTO
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right tabular-nums">{formatCurrency(lr.effectiveAmount)}</td>
|
||||||
|
<td className="text-right tabular-nums">{formatPercent(lr.loan.rate)}</td>
|
||||||
|
<td className="text-right tabular-nums">{lr.loan.duration} ans</td>
|
||||||
|
<td className="text-right tabular-nums font-medium">
|
||||||
|
{formatCurrency(lr.monthlyPayment + lr.monthlyInsurance)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right tabular-nums text-amber-600">
|
||||||
|
{formatCurrency(lr.totalInterest)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right tabular-nums text-purple-600">
|
||||||
|
{formatCurrency(lr.totalInsurance)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right tabular-nums font-semibold">
|
||||||
|
{formatCurrency(lr.totalCost)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="bg-slate-50 dark:bg-slate-800/50 font-semibold">
|
||||||
|
<td>Total</td>
|
||||||
|
<td className="text-right tabular-nums">{formatCurrency(results.totalCapital)}</td>
|
||||||
|
<td></td>
|
||||||
|
<td className="text-right tabular-nums">{results.maxDuration} ans</td>
|
||||||
|
<td className="text-right tabular-nums">{formatCurrency(results.monthlyPaymentYear1)}</td>
|
||||||
|
<td className="text-right tabular-nums text-amber-600">{formatCurrency(results.totalInterest)}</td>
|
||||||
|
<td className="text-right tabular-nums text-purple-600">{formatCurrency(results.totalInsurance)}</td>
|
||||||
|
<td className="text-right tabular-nums">{formatCurrency(results.grandTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly cost breakdown for year 1 */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Décomposition mensuelle (année 1)</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{results.yearlySummaries[0] && (
|
||||||
|
<CostBreakdownBar summary={results.yearlySummaries[0]} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MetricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub: string;
|
||||||
|
color: 'blue' | 'amber' | 'green' | 'red' | 'slate';
|
||||||
|
}) {
|
||||||
|
const colors = {
|
||||||
|
blue: 'border-blue-200 bg-blue-50/50 dark:border-blue-900 dark:bg-blue-950/30',
|
||||||
|
amber: 'border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/30',
|
||||||
|
green: 'border-emerald-200 bg-emerald-50/50 dark:border-emerald-900 dark:bg-emerald-950/30',
|
||||||
|
red: 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/30',
|
||||||
|
slate: 'border-slate-200 bg-slate-50/50 dark:border-slate-700 dark:bg-slate-800/50',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-4 ${colors[color]}`}>
|
||||||
|
<div className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</div>
|
||||||
|
<div className="text-xl font-bold text-slate-900 dark:text-slate-100 mt-1 tabular-nums">{value}</div>
|
||||||
|
<div className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{sub}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
bold,
|
||||||
|
muted,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
bold?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`flex justify-between ${className ?? ''}`}>
|
||||||
|
<span className={`${muted ? 'text-slate-400 dark:text-slate-500' : 'text-slate-600 dark:text-slate-400'} ${bold ? 'font-semibold text-slate-900 dark:text-slate-100' : ''}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className={`tabular-nums ${bold ? 'font-semibold text-slate-900 dark:text-slate-100' : ''} ${muted ? 'text-slate-400 dark:text-slate-500' : ''}`}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CostBreakdownBar({ summary }: { summary: ReturnType<typeof useSimulation>['results']['yearlySummaries'][0] }) {
|
||||||
|
const items = [
|
||||||
|
{ label: 'Capital', value: summary.monthlyPrincipal, color: 'bg-blue-500' },
|
||||||
|
{ label: 'Intérêts', value: summary.monthlyInterest, color: 'bg-amber-400' },
|
||||||
|
{ label: 'Assurance prêt', value: summary.monthlyLoanInsurance, color: 'bg-purple-400' },
|
||||||
|
{ label: 'Charges', value: summary.monthlyCharges, color: 'bg-slate-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const total = items.reduce((s, i) => s + i.value, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Bar */}
|
||||||
|
<div className="flex rounded-lg overflow-hidden h-8">
|
||||||
|
{items.map((item) => {
|
||||||
|
const pct = total > 0 ? (item.value / total) * 100 : 0;
|
||||||
|
if (pct < 0.5) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className={`${item.color} flex items-center justify-center transition-all`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
title={`${item.label}: ${formatCurrency(item.value)}`}
|
||||||
|
>
|
||||||
|
{pct > 10 && (
|
||||||
|
<span className="text-xs text-white font-medium">{pct.toFixed(0)}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-x-5 gap-y-2 mt-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.label} className="flex items-center gap-2 text-sm">
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-sm ${item.color}`} />
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">{item.label}</span>
|
||||||
|
<span className="font-medium text-slate-700 dark:text-slate-300 tabular-nums">{formatCurrency(item.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-slate-700 flex justify-between text-sm font-semibold">
|
||||||
|
<span>Total mensuel</span>
|
||||||
|
<span className="tabular-nums">{formatCurrency(total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
src/components/ui.tsx
Normal file
241
src/components/ui.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
src/contexts/SimulationContext.tsx
Normal file
192
src/contexts/SimulationContext.tsx
Normal 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
57
src/hooks/useTheme.ts
Normal 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
267
src/lib/calculations.ts
Normal 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
174
src/lib/defaults.ts
Normal 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
54
src/lib/financial.ts
Normal 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
49
src/lib/formatters.ts
Normal 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
57
src/lib/sharing.ts
Normal 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
156
src/lib/types.ts
Normal 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
49
tailwind.config.ts
Normal 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
23
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user