feat: input forms (project, loans, costs) and header with actions

This commit is contained in:
2026-02-22 20:00:43 +01:00
parent bfe88b7ea8
commit 99ab1fc6fb
3 changed files with 872 additions and 0 deletions

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

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

View File

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

View File

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