454 lines
12 KiB
TypeScript
454 lines
12 KiB
TypeScript
'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[];
|
|
}
|