feat: input forms (project, loans, costs) and header with actions
This commit is contained in:
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user