diff --git a/pwa/app/components/tabs/diagram.jsx b/pwa/app/components/tabs/diagram.jsx deleted file mode 100644 index 9159d2418a70c0f1a3fca0d517849b1bad851938..0000000000000000000000000000000000000000 --- a/pwa/app/components/tabs/diagram.jsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Diagram() { - return
Diagram
-} diff --git a/pwa/app/components/tabs/diagram.tsx b/pwa/app/components/tabs/diagram.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c9df2e01c20bdb5663ef46c64bdf11df03aae6e7 --- /dev/null +++ b/pwa/app/components/tabs/diagram.tsx @@ -0,0 +1,190 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Doughnut } from 'react-chartjs-2' +import { Chart as ChartJS, ArcElement, Tooltip, Legend, TooltipItem } from 'chart.js' +import ChartDataLabels from 'chartjs-plugin-datalabels' +import { TAX_TYPES, YEARS, COLORS } from '../../constants' +import { getRegionDistribution, prepareChartData, RegionDistribution } from '../../services/diagram.services' +import ErrorDiv from '../molecules/ErrorDiv' + +ChartJS.register(ArcElement, Tooltip, Legend, ChartDataLabels) + +export default function Diagram() { + const [taxType, setTaxType] = useState('th') + const [year, setYear] = useState(2019) + const [topCount, setTopCount] = useState(9) + const [data, setData] = useState([]) + const [maxRegions, setMaxRegions] = useState(20) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Load number of regions on mount + useEffect(() => { + const loadMaxRegions = async () => { + try { + const result = await getRegionDistribution(taxType, year) + setMaxRegions(result.length) + } catch { + // Keep default value on error + } + } + loadMaxRegions() + }, [taxType, year]) + + const handleSubmit = async () => { + setLoading(true) + setError(null) + try { + const result = await getRegionDistribution(taxType, year) + setMaxRegions(result.length) + const chartData = prepareChartData(result, topCount) + setData(chartData) + } catch (e) { + setData([]) + setError(e instanceof Error ? e.message : 'Une erreur est survenue') + } finally { + setLoading(false) + } + } + + const chartConfig = { + labels: data.map(d => d.region), + datasets: [ + { + data: data.map(d => d.total_amount), + backgroundColor: COLORS, + borderColor: '#1a1d21', + borderWidth: 2, + }, + ], + } + + const chartOptions = { + responsive: true, + maintainAspectRatio: true, + aspectRatio: 1.5, + plugins: { + legend: { + position: 'right' as const, + labels: { + color: '#b0afaf', + padding: 25, + font: { size: 18 }, + boxWidth: 30, + boxHeight: 30, + }, + }, + tooltip: { + backgroundColor: '#212529', + borderColor: '#3a3f44', + borderWidth: 1, + titleFont: { size: 18 }, + bodyFont: { size: 16 }, + padding: 12, + callbacks: { + label: (ctx: TooltipItem<'doughnut'>) => { + const region = data[ctx.dataIndex] + if (!region) return '' + const amount = region.total_amount ? (region.total_amount / 1_000_000).toFixed(2) : '0.00' + const percentage = region.percentage ? region.percentage.toFixed(2) : '0.00' + + const lines = [`${region.region}`, `Volume: ${amount} M€`, `Part: ${percentage}%`] + + // If it's "Others", add the list of regions + if (region.region === 'Autres' && region.otherRegions) { + lines.push('', 'Régions incluses:') + region.otherRegions.forEach((r: string) => lines.push(` • ${r}`)) + } + + return lines + }, + }, + }, + datalabels: { + color: '#fff', + font: { + weight: 'bold' as const, + size: 24, + }, + formatter: (_value: unknown, ctx: { dataIndex: number }) => { + const region = data[ctx.dataIndex] + if (!region || !region.percentage) return '' + // Only display percentage if greater than 3% + return region.percentage >= 3 ? `${region.percentage.toFixed(1)}%` : '' + }, + }, + }, + } + + return ( +
+

Répartition des Volumes Collectés par Région

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + {error && } + + {!error && data.length > 0 && ( +
+
+ +
+
+ )} +
+ ) +} diff --git a/pwa/app/components/tabs/temporal.tsx b/pwa/app/components/tabs/temporal.tsx index 7cdcb972526553fd62dab09a8ae78a5372063187..ab904c18cdf3c0d637957c6a84b5b5e1ace62b99 100644 --- a/pwa/app/components/tabs/temporal.tsx +++ b/pwa/app/components/tabs/temporal.tsx @@ -3,30 +3,8 @@ import { useEffect, useState } from 'react' import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, CartesianGrid } from 'recharts' import { getRegions, getTimeSeries, type TimeSeriesData } from '../../services/temporal.services' -import { TAX_TYPES, YEARS } from '../../constants' +import { TAX_TYPES, YEARS, COLORS } from '../../constants' import ErrorDiv from '../molecules/ErrorDiv' -const COLORS = [ - '#8884d8', - '#82ca9d', - '#ffc658', - '#ff7300', - '#00C49F', - '#0088FE', - '#FF8042', - '#A4DE6C', - '#D0ED57', - '#FFBB28', - '#FF4444', - '#44AAFF', - '#AA44FF', - '#E91E63', - '#4CAF50', - '#9C27B0', - '#00BCD4', - '#FF5722', - '#607D8B', - '#795548', -] export default function Temporal() { const [taxType, setTaxType] = useState('tfpb') diff --git a/pwa/app/constants.ts b/pwa/app/constants.ts index f1f007c6981b85a33f028e70c767299739c42cca..95a8f58bcd4876cc4c7758c20d4f90a524221c70 100644 --- a/pwa/app/constants.ts +++ b/pwa/app/constants.ts @@ -1,2 +1,24 @@ export const TAX_TYPES = ['tfpnb', 'tfpb', 'th', 'cfe'] as const export const YEARS = [2019, 2020, 2021, 2022] as const +export const COLORS = [ + '#8884d8', + '#82ca9d', + '#ffc658', + '#ff7300', + '#00C49F', + '#0088FE', + '#FF8042', + '#A4DE6C', + '#D0ED57', + '#FFBB28', + '#FF4444', + '#44AAFF', + '#AA44FF', + '#E91E63', + '#4CAF50', + '#9C27B0', + '#00BCD4', + '#FF5722', + '#607D8B', + '#795548', +] as const diff --git a/pwa/app/services/diagram.services.tsx b/pwa/app/services/diagram.services.tsx index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..841506e679c71a5bc421c1291f5fb292183dfe65 100644 --- a/pwa/app/services/diagram.services.tsx +++ b/pwa/app/services/diagram.services.tsx @@ -0,0 +1,56 @@ +export interface RegionDistribution { + region: string + total_amount: number + percentage: number + otherRegions?: string[] +} + +export async function getRegionDistribution(taxType: string, year: number): Promise { + const params = new URLSearchParams({ + tax_type: taxType, + year: String(year), + }) + const res = await fetch(`/api/regions/distribution?${params}`) + if (!res.ok) throw new Error('Erreur lors du chargement de la distribution régionale') + const data = await res.json() + // Ensure amounts are numbers + const normalized = data.data.map((item: { region: string; total_amount: string | number; percentage?: string | number }) => ({ + region: item.region, + total_amount: parseFloat(item.total_amount?.toString() || '0') || 0, + percentage: item.percentage ? parseFloat(item.percentage.toString()) : 0, + })) + return normalized +} + +export function prepareChartData(data: RegionDistribution[], topCount: number = 9): RegionDistribution[] { + // Sort by descending amount + const sorted = [...data].sort((a, b) => b.total_amount - a.total_amount) + + // Calculate total for percentages + const totalAmount = sorted.reduce((sum, item) => sum + item.total_amount, 0) + + // Keep top N regions and recalculate percentages + const topN: RegionDistribution[] = sorted.slice(0, topCount).map(item => ({ + region: item.region, + total_amount: item.total_amount, + percentage: totalAmount > 0 ? (item.total_amount / totalAmount) * 100 : 0, + })) + + // Group the others + const others = sorted.slice(topCount) + const othersTotal = others.reduce((sum, item) => sum + item.total_amount, 0) + const othersPercentage = totalAmount > 0 ? (othersTotal / totalAmount) * 100 : 0 + + // Create final array with "Others" if needed + const result: RegionDistribution[] = [...topN] + if (others.length > 0) { + result.push({ + region: 'Autres', + total_amount: othersTotal, + percentage: othersPercentage, + otherRegions: others.map(item => item.region), + }) + } + + return result +} diff --git a/pwa/package-lock.json b/pwa/package-lock.json index e5a388cc6239442069f370d8bd45d19e48c1ad5f..a92e3395b0aa83ed5f8d068c8f554785f81c8ac8 100644 --- a/pwa/package-lock.json +++ b/pwa/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tailwindcss/postcss": "^4.1.18", "chart.js": "^4.5.1", + "chartjs-plugin-datalabels": "^2.2.0", "lucide-react": "^0.563.0", "next": "^15", "postcss": "^8.5.6", @@ -1367,7 +1368,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1413,7 +1413,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -1618,7 +1617,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1959,7 +1957,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -1967,6 +1964,15 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-plugin-datalabels": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2529,7 +2535,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2590,7 +2595,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4493,7 +4497,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4555,7 +4558,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4606,7 +4608,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4626,7 +4627,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4638,15 +4638,13 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4699,8 +4697,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5413,7 +5410,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/pwa/package.json b/pwa/package.json index 329c5bcc97750afda420095f7b520f353b34c321..3c5c94cb4d1ce3a38c6431d0eeecf7b5460cbceb 100644 --- a/pwa/package.json +++ b/pwa/package.json @@ -14,6 +14,7 @@ "dependencies": { "@tailwindcss/postcss": "^4.1.18", "chart.js": "^4.5.1", + "chartjs-plugin-datalabels": "^2.2.0", "lucide-react": "^0.563.0", "next": "^15", "postcss": "^8.5.6", diff --git a/pwa/pnpm-lock.yaml b/pwa/pnpm-lock.yaml index 4b41be201c99914ca8f48933bf152b6ac22c86bd..5fdf5214792a2fe9eb58cb201e414c8672c752eb 100644 --- a/pwa/pnpm-lock.yaml +++ b/pwa/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: chart.js: specifier: ^4.5.1 version: 4.5.1 + chartjs-plugin-datalabels: + specifier: ^2.2.0 + version: 2.2.0(chart.js@4.5.1) lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.4) @@ -675,6 +678,11 @@ packages: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} + chartjs-plugin-datalabels@2.2.0: + resolution: {integrity: sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==} + peerDependencies: + chart.js: '>=3.0.0' + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -2256,6 +2264,10 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 + chartjs-plugin-datalabels@2.2.0(chart.js@4.5.1): + dependencies: + chart.js: 4.5.1 + client-only@0.0.1: {} clsx@2.1.1: {}