SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
2 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
6.6 KB · 64 lines tsx
Raw Blame History
1"use client";2import { useMemo, useState } from "react";3import { computeMortgage, minDownPayment, transferDuty } from "@/lib/mortgage";4import { fmtPrice, fmtPct } from "@/lib/format";5import { Notice, Stat } from "@/components/ui";67const DEFAULT_RATE = 4.89; // hypothèse éditable — aucun taux « réel » n'est importé ; l'utilisateur saisit le taux de son prêteur89function F({ label, value, set, step = 1, suffix = "" }: { label: string; value: number; set: (v: number) => void; step?: number; suffix?: string }): React.ReactElement {10  return (    <label className="block text-sm"><span className="tv-label">{label}</span><div className="relative mt-1"><input type="number" inputMode="decimal" step={step} value={value} onChange={(e) => set(Number(e.target.value))} className="tv-chip w-full !pl-3 !pr-8 tabular-nums" />{suffix && <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-ink-3">{suffix}</span>}</div></label>11  );12}1314export function MortgageWidget({ price: p0 = 500000, city, compact = false, taxes: t0, schoolTaxes: s0, condoFees: c0 }: { price?: number; city?: string | null; compact?: boolean; taxes?: number; schoolTaxes?: number; condoFees?: number }) {15  const [price, setPrice] = useState(p0);16  const [dpPct, setDpPct] = useState(20);17  const [rate, setRate] = useState(DEFAULT_RATE);18  const [amort, setAmort] = useState(25);19  const [freq, setFreq] = useState<12 | 26 | 52>(12);20  const [taxes, setTaxes] = useState(t0 ?? Math.round(p0 * 0.009));21  const [school, setSchool] = useState(s0 ?? Math.round(p0 * 0.0007));22  const [ins, setIns] = useState(90);23  const [condo, setCondo] = useState(c0 ?? 0);24  const [heat, setHeat] = useState(150);25  const dp = Math.round(price * dpPct / 100);26  const r = useMemo(() => computeMortgage({ price, downPayment: dp, rateAnnualPct: rate, amortYears: amort, paymentsPerYear: freq, municipalTaxAnnual: taxes, schoolTaxAnnual: school, insuranceMonthly: ins, condoFeesMonthly: condo, heatingMonthly: heat }), [price, dp, rate, amort, freq, taxes, school, ins, condo, heat]);27  const isMtl = /montr[ée]al/i.test(city ?? "");28  const duty = transferDuty(price, isMtl);29  const closing = duty + 1500 + 1000; // notaire + inspection (ordres de grandeur éditables mentalement)30  return (31    <div className="grid lg:grid-cols-[1fr_1.1fr] gap-5">32      <div className="tv-card p-4 grid grid-cols-2 gap-3">33        <F label="Prix" value={price} set={setPrice} step={1000} suffix="$" />34        <label className="block text-sm"><span className="tv-label">Mise de fonds</span><div className="mt-1 flex items-center gap-2"><input type="range" min={5} max={50} step={1} value={dpPct} onChange={(e) => setDpPct(Number(e.target.value))} className="flex-1 accent-[#e0263c]" aria-label="Mise de fonds en pourcentage" /><span className="text-xs tabular-nums w-24 text-right">{dpPct} % · {fmtPrice(dp, { compact: true })}</span></div></label>35        <F label="Taux d'intérêt (hypothèse)" value={rate} set={setRate} step={0.05} suffix="%" />36        <label className="block text-sm"><span className="tv-label">Amortissement</span><select value={amort} onChange={(e) => setAmort(Number(e.target.value))} className="tv-chip w-full mt-1 appearance-none">{[15, 20, 25, 30].map((y) => <option key={y} value={y}>{y} ans</option>)}</select></label>37        <label className="block text-sm"><span className="tv-label">Fréquence</span><select value={freq} onChange={(e) => setFreq(Number(e.target.value) as 12 | 26 | 52)} className="tv-chip w-full mt-1 appearance-none"><option value={12}>Mensuelle</option><option value={26}>Aux deux semaines</option><option value={52}>Hebdomadaire</option></select></label>38        {!compact && <F label="Taxes municipales (an)" value={taxes} set={setTaxes} step={50} suffix="$" />}39        {!compact && <F label="Taxes scolaires (an)" value={school} set={setSchool} step={10} suffix="$" />}40        <F label="Assurance habitation (mois)" value={ins} set={setIns} step={5} suffix="$" />41        <F label="Frais de condo (mois)" value={condo} set={setCondo} step={10} suffix="$" />42        {!compact && <F label="Chauffage (mois)" value={heat} set={setHeat} step={10} suffix="$" />}43      </div>44      <div className="space-y-3">45        <div className="grid grid-cols-2 gap-3">46          <Stat label={`Versement ${freq === 12 ? "mensuel" : freq === 26 ? "aux deux semaines" : "hebdomadaire"}`} value={fmtPrice(r.payment)} sub={freq !== 12 ? `≈ ${fmtPrice(r.paymentMonthlyEq)} / mois` : `taux ${rate.toLocaleString("fr-CA")} %, ${amort} ans`} />47          <Stat label="Coût mensuel total estimé" value={fmtPrice(r.monthlyAllIn)} sub="hypothèque + taxes + assurance + condo + chauffage" tone="info" />48          <Stat label="Prêt total" value={fmtPrice(r.loan)} sub={r.cmhcPremium > 0 ? `dont prime d'assurance ${fmtPrice(r.cmhcPremium)} (${r.cmhcRatePct} %)` : "sans assurance prêt (mise de fonds ≥ 20 %)"} />49          <Stat label="Intérêts sur la durée" value={fmtPrice(r.totalInterest)} sub={`total remboursé ${fmtPrice(r.totalPaid)}`} />50          <Stat label="Test de résistance" value={fmtPrice(r.stressTest.payment)} sub={`versement mensuel à ${r.stressTest.qualifyingRatePct.toLocaleString("fr-CA")} % (taux + 2 pts, min 5,25 %)`} />51          <Stat label="Frais de clôture (ordre de grandeur)" value={fmtPrice(closing)} sub={`droits de mutation ${fmtPrice(duty)}${isMtl ? " (barème Montréal)" : ""} + notaire ~1 500 $ + inspection ~1 000 $`} />52        </div>53        {r.warnings.map((w) => <Notice key={w} tone="warn">{w}</Notice>)}54        <Notice>Calcul illustratif à partir de vos hypothèses (taux composé semestriellement, barème d'assurance prêt standard, mise de fonds minimale {fmtPct(minDownPayment(price) / price, 1, false)}). Ce n'est ni une offre ni un conseil financier : vérifiez les taux et conditions auprès d'un prêteur.</Notice>55        {!compact && (56          <details className="tv-card p-3 text-sm"><summary className="cursor-pointer font-semibold">Tableau d'amortissement annuel</summary>57            <table className="w-full mt-2 text-xs"><thead><tr className="text-ink-3 text-left"><th className="py-1 font-medium">Année</th><th className="py-1 font-medium text-right">Intérêts</th><th className="py-1 font-medium text-right">Capital</th><th className="py-1 font-medium text-right">Solde</th></tr></thead><tbody>{r.schedule.map((s) => <tr key={s.year} className="border-t border-line"><td className="py-1">{s.year}</td><td className="py-1 text-right tabular-nums">{fmtPrice(s.interest)}</td><td className="py-1 text-right tabular-nums">{fmtPrice(s.principal)}</td><td className="py-1 text-right tabular-nums">{fmtPrice(s.balance)}</td></tr>)}</tbody></table>58          </details>59        )}60      </div>61    </div>62  );63}64