diff --git a/pwa/app/components/organisms/Dashboard.tsx b/pwa/app/components/organisms/Dashboard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..84656879cc18abbe2fff507b05472bd26f6f75eb --- /dev/null +++ b/pwa/app/components/organisms/Dashboard.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useState } from 'react' +import Sidebar, { type TabKey } from '../molecules/Sidebar' +import Header from '../molecules/Header' +import Temporal from '../tabs/temporal' +import Points from '../tabs/points' +import Diagram from '../tabs/diagram' + +interface DashboardProps { + regions: string[] +} + +const tabKeys = ['temporal', 'points', 'diagram'] as const + +export default function Dashboard({ regions }: DashboardProps) { + const [activeTab, setActiveTab] = useState('temporal') + const [sidebarOpen, setSidebarOpen] = useState(false) + + return ( +
+ + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + +
+
setSidebarOpen(prev => !prev)} /> +
+ {activeTab === 'temporal' && } + {activeTab === 'points' && } + {activeTab === 'diagram' && } +
+
+
+ ) +} diff --git a/pwa/app/components/tabs/points.tsx b/pwa/app/components/tabs/points.tsx index 29acdb31ade0cb7adcb3e9093c778a3f009a4607..62b9146616448a8fdab2a261f370faf1eff390b7 100644 --- a/pwa/app/components/tabs/points.tsx +++ b/pwa/app/components/tabs/points.tsx @@ -10,29 +10,65 @@ import { TAX_TYPES, YEARS } from '../../constants' import ErrorDiv from '../molecules/ErrorDiv' export default function Points() { + // Department search autocomplete results from API const [results, setResults] = useState([]) + // Selected department code for the correlation query const [departmentCode, setDepartmentCode] = useState('') + // Department search input value const [search, setSearch] = useState('') + // Controls department dropdown visibility const [showDropdown, setShowDropdown] = useState(false) + // Selected tax type filter (th, tfpb, tfpnb, cfe) const [taxType, setTaxType] = useState('th') + // Selected year filter const [year, setYear] = useState(2019) + // Correlation data points returned by the API const [data, setData] = useState([]) + // API request loading state const [loading, setLoading] = useState(false) + // API error message const [error, setError] = useState(null) + // Ref for click-outside detection on department dropdown const dropdownRef = useRef(null) + // Debounce timer for department search input const debounceRef = useRef>(null) + // Currently selected department (for display) const [selectedDept, setSelectedDept] = useState(null) + // Commune search input value (post-chart highlight) + const [communeSearch, setCommuneSearch] = useState('') + // Commune name to highlight on the scatter plot + const [highlightedCommune, setHighlightedCommune] = useState(null) + // Controls commune dropdown visibility + const [showCommuneDropdown, setShowCommuneDropdown] = useState(false) + // Ref for click-outside detection on commune dropdown + const communeDropdownRef = useRef(null) + + // Filtered commune suggestions based on search input (starts with match, max 10) + const communeSuggestions = communeSearch.length >= 2 + ? data.filter(p => p.commune_name.toLowerCase().startsWith(communeSearch.toLowerCase())).slice(0, 10) + : [] + + // Register zoom plugin client-side only (hammerjs requires window) + useEffect(() => { + import('chartjs-plugin-zoom').then(mod => { + ChartJS.register(mod.default) + }) + }, []) useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setShowDropdown(false) } + if (communeDropdownRef.current && !communeDropdownRef.current.contains(e.target as Node)) { + setShowCommuneDropdown(false) + } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, []) + // Debounced department search handler (calls API after 300ms) const handleSearchChange = (value: string) => { setSearch(value) setDepartmentCode('') @@ -47,6 +83,7 @@ export default function Points() { }, 300) } + // Sets selected department and fills search input const selectDepartment = (dept: Department) => { setDepartmentCode(dept.departmentCode) setSelectedDept(dept) @@ -54,6 +91,7 @@ export default function Points() { setShowDropdown(false) } + // Fetches correlation data from API for the selected department, tax type and year const handleSubmit = async () => { if (!departmentCode) return setLoading(true) @@ -69,20 +107,22 @@ export default function Points() { } } + // Chart.js dataset config — per-point styling highlights the selected commune const chartConfig = { datasets: [ { label: 'Communes', data: data.map(p => ({ x: p.rate, y: p.amount / 1_000_000, commune: p.commune_name })), - borderColor: 'rgba(136, 132, 216, 0.8)', - backgroundColor: 'rgba(136, 132, 216, 0.8)', - pointStyle: 'cross' as const, - pointRadius: 4, + borderColor: data.map(p => p.commune_name === highlightedCommune ? '#ff4444' : 'rgba(136, 132, 216, 0.8)'), + backgroundColor: data.map(p => p.commune_name === highlightedCommune ? '#ff4444' : 'rgba(136, 132, 216, 0.8)'), + pointStyle: data.map(p => p.commune_name === highlightedCommune ? 'circle' as const : 'cross' as const), + pointRadius: data.map(p => p.commune_name === highlightedCommune ? 8 : 4), borderWidth: 2, }, ], } + // Chart.js options: axes labels, zoom/pan plugin, tooltip formatting const chartOptions = { responsive: true, maintainAspectRatio: false, @@ -101,6 +141,17 @@ export default function Points() { plugins: { legend: { display: false }, datalabels: { display: false }, + zoom: { + zoom: { + wheel: { enabled: true }, + pinch: { enabled: true }, + mode: 'xy' as const, + }, + pan: { + enabled: true, + mode: 'xy' as const, + }, + }, tooltip: { backgroundColor: '#212529', borderColor: '#3a3f44', @@ -188,9 +239,41 @@ export default function Points() { {!error && data.length > 0 && ( <> -

- {selectedDept?.departmentName} — {data.length} communes -

+
+

+ {selectedDept?.departmentName} — {data.length} communes +

+
+ { + setCommuneSearch(e.target.value) + setShowCommuneDropdown(true) + if (!e.target.value) setHighlightedCommune(null) + }} + onFocus={() => communeSearch.length >= 2 && setShowCommuneDropdown(true)} + placeholder="Rechercher une commune..." + className="bg-[#212529] border border-[#3a3f44] rounded px-3 py-1.5 text-white text-sm w-64" + /> + {showCommuneDropdown && communeSuggestions.length > 0 && ( +
+ {communeSuggestions.map(c => ( + + ))} +
+ )} +
+
diff --git a/pwa/app/components/tabs/temporal.tsx b/pwa/app/components/tabs/temporal.tsx index ab904c18cdf3c0d637957c6a84b5b5e1ace62b99..71650635af59327cd3aaeb721e60e2f68c5da282 100644 --- a/pwa/app/components/tabs/temporal.tsx +++ b/pwa/app/components/tabs/temporal.tsx @@ -1,32 +1,37 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, CartesianGrid } from 'recharts' -import { getRegions, getTimeSeries, type TimeSeriesData } from '../../services/temporal.services' +import { getTimeSeries, type TimeSeriesData } from '../../services/temporal.services' import { TAX_TYPES, YEARS, COLORS } from '../../constants' import ErrorDiv from '../molecules/ErrorDiv' -export default function Temporal() { +interface TemporalProps { + regions: string[] +} + +export default function Temporal({ regions }: TemporalProps) { + // Selected tax type filter (tfpb, tfpnb, th, cfe) const [taxType, setTaxType] = useState('tfpb') + // Start year for the time range const [startYear, setStartYear] = useState(2019) + // End year for the time range const [endYear, setEndYear] = useState(2022) + // Time series data grouped by region { region: [{year, avg_rate}] } const [data, setData] = useState(null) + // API request loading state const [loading, setLoading] = useState(false) + // API error message const [error, setError] = useState(null) - const [regions, setRegions] = useState([]) - const [selectedRegions, setSelectedRegions] = useState([]) - - useEffect(() => { - getRegions().then(r => { - setRegions(r) - setSelectedRegions(r) - }) - }, []) + // Regions currently selected for display (initialized from SSR props) + const [selectedRegions, setSelectedRegions] = useState(regions) + // Toggles a region on/off in the selection const toggleRegion = (region: string) => { setSelectedRegions(prev => (prev.includes(region) ? prev.filter(r => r !== region) : [...prev, region])) } + // Fetches time series data from API for all regions with selected tax type and year range const handleSubmit = async () => { setLoading(true) setError(null) @@ -41,8 +46,10 @@ export default function Temporal() { } } + // Regions present in API response that are also selected by the user const visibleRegions = data ? Object.keys(data).filter(r => selectedRegions.includes(r)) : [] + // Pivoted data for Recharts: [{year, Bretagne: 1.2, Normandie: 1.5, ...}] const chartData = data ? YEARS.filter(y => y >= startYear && y <= endYear).map(year => { const point: Record = { year } diff --git a/pwa/app/page.tsx b/pwa/app/page.tsx index 183998e7152c7477df755d4bc317f8b43194dc54..8e0945508e4479173098e0e94c40398e1ea1eab0 100644 --- a/pwa/app/page.tsx +++ b/pwa/app/page.tsx @@ -1,39 +1,19 @@ -'use client' +import Dashboard from './components/organisms/Dashboard' -import { useState } from 'react' -import Sidebar, { type TabKey } from './components/molecules/Sidebar' -import Header from './components/molecules/Header' -import Temporal from './components/tabs/temporal' -import Points from './components/tabs/points' -import Diagram from './components/tabs/diagram' +const API_URL = process.env.NEXT_PUBLIC_ENTRYPOINT || 'http://php' -const tabComponents: Record = { - temporal: Temporal, - points: Points, - diagram: Diagram, +async function getRegions(): Promise { + try { + const res = await fetch(`${API_URL}/api/regions`) + if (!res.ok) return [] + const data = await res.json() + return data.member.map((r: { regionName: string }) => r.regionName) + } catch { + return [] + } } -export default function Home() { - const [activeTab, setActiveTab] = useState('temporal') - const [sidebarOpen, setSidebarOpen] = useState(false) - const ActiveComponent = tabComponents[activeTab] - - return ( -
- - {sidebarOpen && ( -
setSidebarOpen(false)} - /> - )} - -
-
setSidebarOpen(prev => !prev)} /> -
- -
-
-
- ) +export default async function Home() { + const regions = await getRegions() + return } diff --git a/pwa/package-lock.json b/pwa/package-lock.json index a92e3395b0aa83ed5f8d068c8f554785f81c8ac8..1043076acca07a5b45feb82fb02308e3f0bf12a1 100644 --- a/pwa/package-lock.json +++ b/pwa/package-lock.json @@ -11,6 +11,7 @@ "@tailwindcss/postcss": "^4.1.18", "chart.js": "^4.5.1", "chartjs-plugin-datalabels": "^2.2.0", + "chartjs-plugin-zoom": "^2.2.0", "lucide-react": "^0.563.0", "next": "^15", "postcss": "^8.5.6", @@ -1345,6 +1346,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1973,6 +1980,19 @@ "chart.js": ">=3.0.0" } }, + "node_modules/chartjs-plugin-zoom": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", + "integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.45", + "hammerjs": "^2.0.8" + }, + "peerDependencies": { + "chart.js": ">=3.2.0" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -3159,6 +3179,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", diff --git a/pwa/package.json b/pwa/package.json index 3c5c94cb4d1ce3a38c6431d0eeecf7b5460cbceb..62f8ad06ce32be8a809c8d9a40d0a963aa0bf386 100644 --- a/pwa/package.json +++ b/pwa/package.json @@ -15,6 +15,7 @@ "@tailwindcss/postcss": "^4.1.18", "chart.js": "^4.5.1", "chartjs-plugin-datalabels": "^2.2.0", + "chartjs-plugin-zoom": "^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 5fdf5214792a2fe9eb58cb201e414c8672c752eb..5037e7765164f49cb712fc699154dd8911a28575 100644 --- a/pwa/pnpm-lock.yaml +++ b/pwa/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: chartjs-plugin-datalabels: specifier: ^2.2.0 version: 2.2.0(chart.js@4.5.1) + chartjs-plugin-zoom: + 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) @@ -515,6 +518,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -683,6 +689,11 @@ packages: peerDependencies: chart.js: '>=3.0.0' + chartjs-plugin-zoom@2.2.0: + resolution: {integrity: sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==} + peerDependencies: + chart.js: '>=3.2.0' + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -1003,6 +1014,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hammerjs@2.0.8: + resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==} + engines: {node: '>=0.8.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -2038,6 +2053,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/hammerjs@2.0.46': {} + '@types/json-schema@7.0.15': {} '@types/node@22.19.10': @@ -2268,6 +2285,12 @@ snapshots: dependencies: chart.js: 4.5.1 + chartjs-plugin-zoom@2.2.0(chart.js@4.5.1): + dependencies: + '@types/hammerjs': 2.0.46 + chart.js: 4.5.1 + hammerjs: 2.0.8 + client-only@0.0.1: {} clsx@2.1.1: {} @@ -2681,6 +2704,8 @@ snapshots: graceful-fs@4.2.11: {} + hammerjs@2.0.8: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {}