feat: results views (summary, amortization, comparison, charts) and main page
This commit is contained in:
453
src/components/Charts.tsx
Normal file
453
src/components/Charts.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
'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[];
|
||||
}
|
||||
Reference in New Issue
Block a user