diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..11a8ee9 --- /dev/null +++ b/src/components/Header.tsx @@ -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 ( + + + + ); +} + +function DownloadIcon() { + return ( + + + + ); +} + +function PrintIcon() { + return ( + + + + ); +} + +function SunIcon() { + return ( + + + + ); +} + +function MoonIcon() { + return ( + + + + ); +} + +function MonitorIcon() { + return ( + + + + ); +} + +// ─── 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 ( +
+
+
+ {/* Logo */} +
+
+
+ S +
+
+

SimO

+

+ Simulateur de prêt immobilier +

+
+
+
+ + {/* Monthly payment quick view */} +
+
+
+ {formatCurrency(results.projectedMonthlyTotal)} +
+
Mensualité
+
+
+
+
+ {formatCurrency(results.grandTotal)} +
+
Coût total
+
+
+ + {/* Actions */} +
+ + + + + + +
+ + {/* Theme toggle */} + + + +
+
+
+
+ ); +} diff --git a/src/components/InputForms.tsx b/src/components/InputForms.tsx new file mode 100644 index 0000000..2e1cf71 --- /dev/null +++ b/src/components/InputForms.tsx @@ -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 ( + + + + ); +} + +function WalletIcon() { + return ( + + + + ); +} + +function ArrowTrendIcon() { + return ( + + + + ); +} + +function CogIcon() { + return ( + + + + + ); +} + +// ─── Project Form ─────────────────────────────────────────────────────────── + +export function ProjectForm() { + const { state, results, setField, setPropertyType } = useSimulation(); + + return ( + } + badge={ + {state.propertyType === 'neuf' ? 'Neuf' : 'Ancien'} + } + > +
+ {/* Neuf / Ancien toggle */} +
+ setPropertyType(v as 'neuf' | 'ancien')} + /> +
+ + {/* Price */} + setField('propertyPrice', v)} + suffix="€" + step={1000} + min={0} + /> + + {/* Fees grid */} +
+ setField('notaryFeesRate', v)} + hint={formatCurrency(results.notaryFees)} + /> + setField('guaranteeRate', v)} + hint={formatCurrency(results.guaranteeFees)} + /> + setField('bankFees', v)} + suffix="€" + step={100} + /> + setField('worksCost', v)} + suffix="€" + step={500} + /> +
+ + {/* Down payment */} + setField('downPayment', v)} + suffix="€" + step={1000} + min={0} + /> + + {/* Summary */} +
+
+ Financement total + + {formatCurrency(results.totalFinancing)} + +
+
+ Crédit nécessaire + {formatCurrency(results.totalCredit)} +
+
+
+
+ ); +} + +// ─── Current Costs Form ───────────────────────────────────────────────────── + +export function CurrentCostsForm() { + const { state, setField } = useSimulation(); + + const total = + state.currentRent + + state.currentUtilities + + state.currentCharges + + state.currentHomeInsurance; + + return ( + } + badge={{formatCurrency(total)}/mois} + defaultOpen={false} + > +
+ setField('currentRent', v)} + suffix="€/mois" + step={10} + /> + setField('rentEvolutionRate', v)} + /> +
+ setField('currentUtilities', v)} + suffix="€" + step={5} + /> + setField('currentCharges', v)} + suffix="€" + step={5} + /> +
+ setField('currentHomeInsurance', v)} + suffix="€/mois" + step={1} + /> + +
+
+ Total mensuel actuel + {formatCurrency(total)} +
+
+
+
+ ); +} + +// ─── Projected Costs Form ─────────────────────────────────────────────────── + +export function ProjectedCostsForm() { + const { state, results, setField } = useSimulation(); + + const chargesTotal = + state.projectedCharges + + state.propertyTax + + state.projectedUtilities + + state.projectedHomeInsurance; + + return ( + } + badge={{formatCurrency(chargesTotal)}/mois} + defaultOpen={false} + > +
+
+ setField('projectedCharges', v)} + suffix="€" + step={5} + /> + setField('propertyTax', v)} + suffix="€/mois" + step={5} + hint="Montant mensuel" + /> + setField('projectedUtilities', v)} + suffix="€" + step={5} + /> + setField('projectedHomeInsurance', v)} + suffix="€" + step={1} + /> +
+ +
+
+ Charges mensuelles + {formatCurrency(chargesTotal)} +
+
+ Assurance prêt + + {formatCurrency( + results.loanResults.reduce((s, lr) => s + lr.monthlyInsurance, 0) + )} + +
+
+
+ Total mensuel projeté + {formatCurrency(results.projectedMonthlyTotal)} +
+
+
+ + ); +} + +// ─── Resale Form ──────────────────────────────────────────────────────────── + +export function ResaleForm() { + const { state, setField } = useSimulation(); + + return ( + } + defaultOpen={false} + > +
+ setField('firstYearDepreciation', -Math.abs(v))} + hint="Perte de valeur la première année" + /> + setField('annualAppreciation', v)} + hint="Croissance moyenne du prix/an" + /> + setField('saleFees', v)} + hint="Agence + notaire vendeur" + /> +
+
+ ); +} diff --git a/src/components/LoansForm.tsx b/src/components/LoansForm.tsx new file mode 100644 index 0000000..285eb37 --- /dev/null +++ b/src/components/LoansForm.tsx @@ -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 ( + + + + ); +} + +function PlusIcon() { + return ( + + + + ); +} + +function TrashIcon() { + return ( + + + + ); +} + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + + + ); +} + +// ─── 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 ( +
+ + + {open && ( + <> +
setOpen(false)} /> +
+ {availablePresets.map((preset) => ( + + ))} +
+ + )} +
+ ); +} + +// ─── 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 ( +
+ {/* Header */} +
+ {/* Enable/Disable checkbox */} + toggleLoan(loan.id)} + className="rounded border-slate-300 text-blue-600 focus:ring-blue-500 + h-4 w-4 cursor-pointer" + /> + + {/* Name */} + +
+ + {/* Body */} + {expanded && loan.enabled && ( +
+
+ {/* Name input */} +
+ + updateLoan(loan.id, 'name', e.target.value)} + /> +
+ + {/* Amount (only for non-main loans) */} + {!loan.isMainLoan && ( + updateLoan(loan.id, 'amount', v)} + suffix="€" + step={1000} + min={0} + /> + )} + + {loan.isMainLoan && ( +
+ Montant calculé automatiquement : {formatCurrency(effectiveAmount)} +
+ = Crédit total − Somme des autres prêts +
+ )} + + {/* Rate + Duration */} +
+ updateLoan(loan.id, 'rate', v)} + /> + updateLoan(loan.id, 'duration', v)} + suffix="ans" + step={1} + min={1} + max={30} + /> +
+ + {/* Deferral + Insurance */} +
+ updateLoan(loan.id, 'deferral', v)} + suffix="ans" + step={1} + min={0} + max={loan.duration} + hint="Période sans remboursement" + /> + updateLoan(loan.id, 'insuranceRate', v)} + hint={`${formatCurrency(monthlyInsurance)}/mois`} + /> +
+ + {/* Monthly summary */} +
+
+ Mensualité hors assurance + {formatCurrency(monthlyPayment, 2)} +
+
+ Assurance + {formatCurrency(monthlyInsurance, 2)} +
+
+
+ Total mensuel + {formatCurrency(totalMonthly, 2)} +
+
+ + {/* Delete button */} + {!loan.isMainLoan && ( + + )} +
+
+ )} +
+ ); +} + +// ─── 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 ( + } + badge={{formatCurrency(totalMonthly)}/mois} + > +
+ {/* Loan list */} + {state.loans.map((loan) => { + const loanResult = results.loanResults.find((lr) => lr.loan.id === loan.id); + return ( + + ); + })} + + {/* Add loan */} + + + {/* Global summary */} +
+
+ Capital emprunté + {formatCurrency(results.totalCredit)} +
+
+ Total intérêts + {formatCurrency(results.totalInterest)} +
+
+ Total assurance + {formatCurrency(results.totalInsurance)} +
+
+
+ Coût total du crédit + {formatCurrency(results.grandTotal)} +
+
+
+ + ); +}