From f47529181d58a9a77b9a519f9a917e9f3450e9f5 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 10:52:43 +0200 Subject: [PATCH 01/13] rename get to fetch because it's a promise --- webapp/src/shared/api/client.ts | 2 +- webapp/src/widgets/dashboard/dashboard.tsx | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/webapp/src/shared/api/client.ts b/webapp/src/shared/api/client.ts index bfed61be..181b5c16 100644 --- a/webapp/src/shared/api/client.ts +++ b/webapp/src/shared/api/client.ts @@ -41,7 +41,7 @@ export const fetchJSONWithAuth = async ( export const createApiClient = (authToken: string | null) => ({ // Bases - getDashboardData: (layerId: string) => + fetchDashboardData: (layerId: string) => fetchJSONWithAuth(`/maps/dashboard/${layerId}`, {}, authToken), }); diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index 75cf6f6d..ffb6f200 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -7,41 +7,41 @@ import LoadedDashboard, { import { LAYERS } from "@shared/api/layers"; import { useApi } from "@shared/hooks/useApi"; -type GetDashboardData = (layer: string) => Promise; +type FetchDashboardData = (layer: string) => Promise; type Layer = (typeof LAYERS)[keyof typeof LAYERS]; // ✅ Cache Promises so the same one is reused across renders // required by 'use()', see https://react.dev/reference/react/use#caching-promises-for-client-components // Cache is scoped by API client (auth token) + layer to avoid leaking data across sessions. const cache = new WeakMap< - GetDashboardData, + FetchDashboardData, Map> >(); -function getPerApiCache(getDashboardData: GetDashboardData) { - const perApiCache = cache.get(getDashboardData); +function getPerApiCache(fetchDashboardData: FetchDashboardData) { + const perApiCache = cache.get(fetchDashboardData); if (perApiCache) { return perApiCache; } const newPerApiCache = new Map>(); - cache.set(getDashboardData, newPerApiCache); + cache.set(fetchDashboardData, newPerApiCache); return newPerApiCache; } export function fetchData({ - getDashboardData, + fetchDashboardData, layer, }: { - getDashboardData: GetDashboardData; + fetchDashboardData: FetchDashboardData; layer: Layer; }): Promise { - const cache = getPerApiCache(getDashboardData); + const cache = getPerApiCache(fetchDashboardData); const cachedPromise = cache.get(layer); if (cachedPromise) { return cachedPromise; } - const promise = getDashboardData(layer).catch((err) => { + const promise = fetchDashboardData(layer).catch((err) => { // Don't cache failures forever; allow retries (e.g. after navigation / remount). cache.delete(layer); throw err; @@ -52,11 +52,11 @@ export function fetchData({ } export default function Dashboard() { - const api = useApi(); + const { fetchDashboardData } = useApi(); const data = use( fetchData({ - getDashboardData: api.getDashboardData, + fetchDashboardData, layer: LAYERS.INVENTARY, }), ); From f740b53420913055eb67061698421e51e7edee16 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 11:01:15 +0200 Subject: [PATCH 02/13] move ApiContext from shared to features to later improve types ('metier') --- webapp/src/app/providers/ApiProvider.tsx | 5 +- webapp/src/features/api/client.ts | 47 +++++++++++++++++++ .../contexts/ApiContext.ts | 2 +- .../src/{shared => features}/hooks/useApi.ts | 2 +- webapp/src/shared/api/client.ts | 46 ------------------ webapp/src/widgets/dashboard/dashboard.tsx | 3 +- 6 files changed, 53 insertions(+), 52 deletions(-) create mode 100644 webapp/src/features/api/client.ts rename webapp/src/{shared => features}/contexts/ApiContext.ts (67%) rename webapp/src/{shared => features}/hooks/useApi.ts (79%) diff --git a/webapp/src/app/providers/ApiProvider.tsx b/webapp/src/app/providers/ApiProvider.tsx index bedf9447..aaf2888b 100644 --- a/webapp/src/app/providers/ApiProvider.tsx +++ b/webapp/src/app/providers/ApiProvider.tsx @@ -1,9 +1,8 @@ import { type ReactNode, useMemo } from "react"; +import { createApiClient } from "@features/api/client"; import { useAuth } from "@features/auth/useAuth"; - -import { createApiClient } from "@shared/api/client"; -import { ApiContext } from "@shared/contexts/ApiContext"; +import { ApiContext } from "@features/contexts/ApiContext"; interface ApiProviderProps { children: ReactNode; diff --git a/webapp/src/features/api/client.ts b/webapp/src/features/api/client.ts new file mode 100644 index 00000000..a4ce00d0 --- /dev/null +++ b/webapp/src/features/api/client.ts @@ -0,0 +1,47 @@ +import { API_URL } from "@shared/api/client"; + +const fetchWithAuth = async ( + endpoint: string, + options: RequestInit = {}, + authToken: string | null, +) => { + const headers = new Headers(options.headers); + headers.set("Authorization", authToken ? `Bearer ${authToken}` : ""); + + const res = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers, + }); + + if (!res.ok) { + console.error(`Erreur API: ${res.status} ${res.statusText}`); + const errorData = await res.json().catch(() => ({ + details: [res.statusText], + error: "Erreur de communication", + })); + console.error("Détails de l'erreur:", JSON.stringify(errorData, null, 2)); + + const error = new Error(`Erreur API: ${res.status}`); + + // @ts-expect-error Property 'response' does not exist on type 'Error'. + error.response = { data: errorData }; + + throw error; + } + + return res; +}; + +const fetchJSONWithAuth = async ( + endpoint: string, + options: RequestInit = {}, + authToken: string | null, +) => (await fetchWithAuth(endpoint, options, authToken)).json(); + +export const createApiClient = (authToken: string | null) => ({ + // Bases + fetchDashboardData: (layerId: string) => + fetchJSONWithAuth(`/maps/dashboard/${layerId}`, {}, authToken), +}); + +export type ApiClient = ReturnType; diff --git a/webapp/src/shared/contexts/ApiContext.ts b/webapp/src/features/contexts/ApiContext.ts similarity index 67% rename from webapp/src/shared/contexts/ApiContext.ts rename to webapp/src/features/contexts/ApiContext.ts index eaec3e46..08423f89 100644 --- a/webapp/src/shared/contexts/ApiContext.ts +++ b/webapp/src/features/contexts/ApiContext.ts @@ -1,5 +1,5 @@ import { createContext } from "react"; -import type { ApiClient } from "@shared/api/client"; +import type { ApiClient } from "@features/api/client"; export const ApiContext = createContext(undefined); diff --git a/webapp/src/shared/hooks/useApi.ts b/webapp/src/features/hooks/useApi.ts similarity index 79% rename from webapp/src/shared/hooks/useApi.ts rename to webapp/src/features/hooks/useApi.ts index 85b4a7c8..0df5bf46 100644 --- a/webapp/src/shared/hooks/useApi.ts +++ b/webapp/src/features/hooks/useApi.ts @@ -1,6 +1,6 @@ import { useContext } from "react"; -import { ApiContext } from "@shared/contexts/ApiContext"; +import { ApiContext } from "@features/contexts/ApiContext"; export function useApi() { const context = useContext(ApiContext); diff --git a/webapp/src/shared/api/client.ts b/webapp/src/shared/api/client.ts index 181b5c16..28e8c6ca 100644 --- a/webapp/src/shared/api/client.ts +++ b/webapp/src/shared/api/client.ts @@ -1,48 +1,2 @@ export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api"; - -export const fetchWithAuth = async ( - endpoint: string, - options: RequestInit = {}, - authToken: string | null, -) => { - const headers = new Headers(options.headers); - headers.set("Authorization", authToken ? `Bearer ${authToken}` : ""); - - const res = await fetch(`${API_URL}${endpoint}`, { - ...options, - headers, - }); - - if (!res.ok) { - console.error(`Erreur API: ${res.status} ${res.statusText}`); - const errorData = await res.json().catch(() => ({ - details: [res.statusText], - error: "Erreur de communication", - })); - console.error("Détails de l'erreur:", JSON.stringify(errorData, null, 2)); - - const error = new Error(`Erreur API: ${res.status}`); - - // @ts-expect-error Property 'response' does not exist on type 'Error'. - error.response = { data: errorData }; - - throw error; - } - - return res; -}; - -export const fetchJSONWithAuth = async ( - endpoint: string, - options: RequestInit = {}, - authToken: string | null, -) => (await fetchWithAuth(endpoint, options, authToken)).json(); - -export const createApiClient = (authToken: string | null) => ({ - // Bases - fetchDashboardData: (layerId: string) => - fetchJSONWithAuth(`/maps/dashboard/${layerId}`, {}, authToken), -}); - -export type ApiClient = ReturnType; diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index ffb6f200..4aace6a0 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -4,8 +4,9 @@ import LoadedDashboard, { type DashboardData, } from "@widgets/dashboard/loaded-dashboard"; +import { useApi } from "@features/hooks/useApi"; + import { LAYERS } from "@shared/api/layers"; -import { useApi } from "@shared/hooks/useApi"; type FetchDashboardData = (layer: string) => Promise; type Layer = (typeof LAYERS)[keyof typeof LAYERS]; From 34e7349122f33291c8f7894653fd2b6653b1e032 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 13:08:08 +0200 Subject: [PATCH 03/13] i18n dashboard header --- webapp/src/shared/i18n/translations/en/all4trees.json | 6 ++++++ webapp/src/shared/i18n/translations/fr/all4trees.json | 6 ++++++ webapp/src/widgets/dashboard/dashboard-header.tsx | 8 ++++---- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/webapp/src/shared/i18n/translations/en/all4trees.json b/webapp/src/shared/i18n/translations/en/all4trees.json index 881d9075..0a3bc297 100644 --- a/webapp/src/shared/i18n/translations/en/all4trees.json +++ b/webapp/src/shared/i18n/translations/en/all4trees.json @@ -4,6 +4,12 @@ "title": "Error while loading data", "unknownMessage": "An unknown error occurred. Please try again later." }, + "header": { + "catalog": "Charts catalog", + "greeting": "Hello {{username}}! 👋", + "temporaryBeneficiary": "⚠ Beneficiary", + "temporaryFilter": "⚠ Filter" + }, "select": { "year": "Year" } diff --git a/webapp/src/shared/i18n/translations/fr/all4trees.json b/webapp/src/shared/i18n/translations/fr/all4trees.json index d4f18f4b..4cabf3c7 100644 --- a/webapp/src/shared/i18n/translations/fr/all4trees.json +++ b/webapp/src/shared/i18n/translations/fr/all4trees.json @@ -4,6 +4,12 @@ "title": "Erreur lors du chargement des données", "unknownMessage": "Une erreur inconnue s'est produite. Veuillez réessayer plus tard." }, + "header": { + "catalog": "Catalogue des graphiques", + "greeting": "Bonjour {{username}} ! 👋", + "temporaryBeneficiary": "⚠ Bénéficiaire", + "temporaryFilter": "⚠ Filtre" + }, "select": { "year": "Année" } diff --git a/webapp/src/widgets/dashboard/dashboard-header.tsx b/webapp/src/widgets/dashboard/dashboard-header.tsx index 59a9a4fa..345c980d 100644 --- a/webapp/src/widgets/dashboard/dashboard-header.tsx +++ b/webapp/src/widgets/dashboard/dashboard-header.tsx @@ -27,14 +27,14 @@ export function DashboardHeader({

- Bonjour{` ${username}`} ! 👋 + {t("dashboard.header.greeting", { username })}

-

⚠ Filtre

+

{t("dashboard.header.temporaryFilter")}

-

Catalogue des graphiques

+

{t("dashboard.header.catalog")}

-

⚠ Bénéficiaire

+

{t("dashboard.header.temporaryBeneficiary")}

From eb16ce68ce4fecd3d8fdced74b860997cfa34313 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 14:55:19 +0200 Subject: [PATCH 04/13] remove type, as typescript infers it better --- .../features/popup/forest-inventory/popup-forest-inventory.tsx | 2 +- webapp/src/features/popup/socio-eco/popup-socio-eco.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx index 22cfad3d..317762c9 100644 --- a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx +++ b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx @@ -20,7 +20,7 @@ type ForestInventoryPopupContentProps = RenderPopupProps; type TabKind = "biodiversity" | "soil"; -const TABS: Record = { +const TABS = { BIODIVERSITY: "biodiversity", SOIL: "soil", } as const; diff --git a/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx b/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx index 8daa1729..455a0a1c 100644 --- a/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx +++ b/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx @@ -21,7 +21,7 @@ type SocioEcoIndicatorProps = RenderPopupProps; type TabKind = "resources" | "economy"; -const TABS: Record = { +const TABS = { ECONOMY: "economy", RESOURCES: "resources", } as const; From 68d2d7cda3f53613f5d69992d073d9aa27546f63 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 15:37:28 +0200 Subject: [PATCH 05/13] improve type --- webapp/src/features/charts/soil/lib/sunburst.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/webapp/src/features/charts/soil/lib/sunburst.ts b/webapp/src/features/charts/soil/lib/sunburst.ts index dcfa5844..2954ddd2 100644 --- a/webapp/src/features/charts/soil/lib/sunburst.ts +++ b/webapp/src/features/charts/soil/lib/sunburst.ts @@ -48,11 +48,18 @@ export function buildSunburstNodes( return nodes; } -export const getLevelPalettes = () => { +type ThreeColorsPalette = [string, string, string]; +type ThreePalettes = [ + ThreeColorsPalette, + ThreeColorsPalette, + ThreeColorsPalette, +]; + +export const getLevelPalettes = (): ThreePalettes => { const palette = getChartPalette(); return [ - palette.slice(0, 3), + [palette[0], palette[1], palette[2]], [palette[3], palette[4], palette[0]], [palette[1], palette[2], palette[3]], ]; From c6fb82d06e8bbcfabc52ec8356bc08e11940bcd5 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 15:39:49 +0200 Subject: [PATCH 06/13] fix types by throwing an error if a taxon is undefined (bug) --- webapp/src/features/charts/soil/lib/taxon.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/webapp/src/features/charts/soil/lib/taxon.ts b/webapp/src/features/charts/soil/lib/taxon.ts index 9b6f9972..8b6a0dc4 100644 --- a/webapp/src/features/charts/soil/lib/taxon.ts +++ b/webapp/src/features/charts/soil/lib/taxon.ts @@ -8,6 +8,11 @@ export function getTaxonLabels( dataType: "tsbf" | "barbA", ): [string, string, string] { const [taxon1, taxon2, taxon3] = element.split("-"); + if (taxon1 === undefined || taxon2 === undefined || taxon3 === undefined) { + throw new Error( + `Invalid taxon element: ${element}. It should have three parts separated by a dash '-'.`, + ); + } const taxon1Label = findCategoricalLabel(metadata, `tax1_${dataType}`, taxon1) || taxon1; const taxon2Label = From 00338ed86f79d9490311ef0358c659c3fde72d91 Mon Sep 17 00:00:00 2001 From: Sylvain Lesage Date: Thu, 25 Jun 2026 15:54:49 +0200 Subject: [PATCH 07/13] refactor to extract logic to entities and add type safety --- webapp/biome.json | 3 + webapp/package-lock.json | 12 ++- webapp/package.json | 3 +- webapp/src/entities/dashboard/epf.ts | 20 ++++ webapp/src/entities/dashboard/generic.ts | 30 ++++++ webapp/src/features/api/client.ts | 53 +++-------- .../biodiversity/chart-forest-potential.tsx | 20 +++- .../charts/components/radar-benef-control.tsx | 7 +- webapp/src/shared/api/client.ts | 39 ++++++++ webapp/src/shared/lib/palette.ts | 9 +- webapp/src/shared/lib/utils.ts | 2 + webapp/src/shared/ui/chart.tsx | 4 + webapp/src/widgets/dashboard/dashboard.tsx | 6 +- .../{dashboard-header.tsx => header.tsx} | 27 ++++-- .../widgets/dashboard/loaded-dashboard.tsx | 92 +++++++------------ .../src/widgets/dashboard/year-dashboard.tsx | 38 ++++++++ webapp/tsconfig.app.json | 3 +- 17 files changed, 252 insertions(+), 116 deletions(-) create mode 100644 webapp/src/entities/dashboard/epf.ts create mode 100644 webapp/src/entities/dashboard/generic.ts rename webapp/src/widgets/dashboard/{dashboard-header.tsx => header.tsx} (74%) create mode 100644 webapp/src/widgets/dashboard/year-dashboard.tsx diff --git a/webapp/biome.json b/webapp/biome.json index ea96cdd8..cee73349 100644 --- a/webapp/biome.json +++ b/webapp/biome.json @@ -16,6 +16,7 @@ "!@pages/**", "!@widgets/**", "!@features/**", + "!@entities/**", "!@shared/**", "!@lib/**", "!@i18n", @@ -31,6 +32,8 @@ ":BLANK_LINE:", "@features/**", ":BLANK_LINE:", + "@entities/**", + ":BLANK_LINE:", "@shared/**", "@lib/**", "@i18n", diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 99cd8711..47d1a0e3 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -38,7 +38,8 @@ "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.13.0", "react-spinners": "^0.17.0", - "recharts": "^2.15.4" + "recharts": "^2.15.4", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "^2.4.4", @@ -8107,6 +8108,15 @@ "version": "3.1.1", "dev": true, "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/webapp/package.json b/webapp/package.json index c4ded8d7..8dd1726f 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -55,7 +55,8 @@ "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.13.0", "react-spinners": "^0.17.0", - "recharts": "^2.15.4" + "recharts": "^2.15.4", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "^2.4.4", diff --git a/webapp/src/entities/dashboard/epf.ts b/webapp/src/entities/dashboard/epf.ts new file mode 100644 index 00000000..82a2b592 --- /dev/null +++ b/webapp/src/entities/dashboard/epf.ts @@ -0,0 +1,20 @@ +// TODO: rename epf to something more meaningful, once I understand what it means. + +import * as z from "zod"; + +import { ValueAndErrorSchema } from "@entities/dashboard/generic"; + +export const EpfDataSchema = z.object({ + epf_deadWood: ValueAndErrorSchema, + epf_diameter_distribution: ValueAndErrorSchema, + epf_dominant_height: ValueAndErrorSchema, + epf_microhabitats: ValueAndErrorSchema, + epf_necromass_pied: ValueAndErrorSchema, // needed? + epf_necromass_sol: ValueAndErrorSchema, // needed? + epf_spatial_distribution: ValueAndErrorSchema, + epf_tree_density: ValueAndErrorSchema, + epf_tree_diversity: ValueAndErrorSchema, + epf_vertical_distribution: ValueAndErrorSchema, +}); + +export type EpfData = z.infer; diff --git a/webapp/src/entities/dashboard/generic.ts b/webapp/src/entities/dashboard/generic.ts new file mode 100644 index 00000000..ddb6b88e --- /dev/null +++ b/webapp/src/entities/dashboard/generic.ts @@ -0,0 +1,30 @@ +import * as z from "zod"; + +export const ValueAndErrorSchema = z + .object({ + error: z.number().nullable(), + value: z.number().nullable(), + }) + .default(() => ({ + error: null, + value: null, + })); + +const DictionaryDataSchema = z.record(z.string(), ValueAndErrorSchema); + +export type DictionaryData = z.infer; + +const MIN_YEAR = 1900; +const MAX_YEAR = 2100; +const YearSchema = z.number().int().min(MIN_YEAR).max(MAX_YEAR); + +const YearDataSchema = z.object({ + beneficiary: DictionaryDataSchema, + control: DictionaryDataSchema, +}); + +export type YearData = z.infer; + +export const DashboardDataSchema = z.record(YearSchema, YearDataSchema); + +export type DashboardData = z.infer; diff --git a/webapp/src/features/api/client.ts b/webapp/src/features/api/client.ts index a4ce00d0..6dee3419 100644 --- a/webapp/src/features/api/client.ts +++ b/webapp/src/features/api/client.ts @@ -1,47 +1,20 @@ -import { API_URL } from "@shared/api/client"; +import { + type DashboardData, + DashboardDataSchema, +} from "@entities/dashboard/generic"; -const fetchWithAuth = async ( - endpoint: string, - options: RequestInit = {}, - authToken: string | null, -) => { - const headers = new Headers(options.headers); - headers.set("Authorization", authToken ? `Bearer ${authToken}` : ""); - - const res = await fetch(`${API_URL}${endpoint}`, { - ...options, - headers, - }); - - if (!res.ok) { - console.error(`Erreur API: ${res.status} ${res.statusText}`); - const errorData = await res.json().catch(() => ({ - details: [res.statusText], - error: "Erreur de communication", - })); - console.error("Détails de l'erreur:", JSON.stringify(errorData, null, 2)); - - const error = new Error(`Erreur API: ${res.status}`); - - // @ts-expect-error Property 'response' does not exist on type 'Error'. - error.response = { data: errorData }; - - throw error; - } - - return res; -}; - -const fetchJSONWithAuth = async ( - endpoint: string, - options: RequestInit = {}, - authToken: string | null, -) => (await fetchWithAuth(endpoint, options, authToken)).json(); +import { fetchJSONWithAuth } from "@shared/api/client"; export const createApiClient = (authToken: string | null) => ({ // Bases - fetchDashboardData: (layerId: string) => - fetchJSONWithAuth(`/maps/dashboard/${layerId}`, {}, authToken), + fetchDashboardData: async (layerId: string): Promise => { + const json = await fetchJSONWithAuth( + `/maps/dashboard/${layerId}`, + {}, + authToken, + ); + return DashboardDataSchema.parse(json); + }, }); export type ApiClient = ReturnType; diff --git a/webapp/src/features/charts/biodiversity/chart-forest-potential.tsx b/webapp/src/features/charts/biodiversity/chart-forest-potential.tsx index 0468cd28..14b4b377 100644 --- a/webapp/src/features/charts/biodiversity/chart-forest-potential.tsx +++ b/webapp/src/features/charts/biodiversity/chart-forest-potential.tsx @@ -1,7 +1,9 @@ -import { useTranslation } from "@i18n"; +import type { ChartComponentType } from "@features/charts/components/chart-component"; +import { ChartRadarWithBenefAndControl } from "@features/charts/components/radar-benef-control"; + +import type { EpfData } from "@entities/dashboard/epf"; -import type { ChartComponentType } from "../components/chart-component"; -import { ChartRadarWithBenefAndControl } from "../components/radar-benef-control"; +import { useTranslation } from "@i18n"; export type ChartForestPotentialData = { density: number; @@ -19,6 +21,18 @@ type ChartForestPotentialProps = { temoin?: ChartForestPotentialData; }; +export function fromEpfData(data: EpfData): ChartForestPotentialData { + return { + deadWood: data.epf_deadWood.value ?? 0, + density: data.epf_tree_density.value ?? 0, + diameterDistribution: data.epf_diameter_distribution.value ?? 0, + diversity: data.epf_tree_diversity.value ?? 0, + dominantHeight: data.epf_dominant_height.value ?? 0, + microHabitat: data.epf_microhabitats.value ?? 0, + spatialDistribution: data.epf_spatial_distribution.value ?? 0, + verticalDistribution: data.epf_vertical_distribution.value ?? 0, + }; +} export const ChartForestPotential: ChartComponentType< ChartForestPotentialProps > = ({ benef, temoin }) => { diff --git a/webapp/src/features/charts/components/radar-benef-control.tsx b/webapp/src/features/charts/components/radar-benef-control.tsx index 3fc0e7d3..f06ea8e4 100644 --- a/webapp/src/features/charts/components/radar-benef-control.tsx +++ b/webapp/src/features/charts/components/radar-benef-control.tsx @@ -57,7 +57,12 @@ export const ChartRadarWithBenefAndControl: ChartComponentType< outerRadius="68%" > } + content={ + `AAA ${value} BBB`} + /> + } cursor={true} /> { + const headers = new Headers(options.headers); + headers.set("Authorization", authToken ? `Bearer ${authToken}` : ""); + + const res = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers, + }); + + if (!res.ok) { + console.error(`Erreur API: ${res.status} ${res.statusText}`); + const errorData = await res.json().catch(() => ({ + details: [res.statusText], + error: "Erreur de communication", + })); + console.error("Détails de l'erreur:", JSON.stringify(errorData, null, 2)); + + const error = new Error(`Erreur API: ${res.status}`); + + // @ts-expect-error Property 'response' does not exist on type 'Error'. + error.response = { data: errorData }; + + throw error; + } + + return res; +}; + +export const fetchJSONWithAuth = async ( + endpoint: string, + options: RequestInit = {}, + authToken: string | null, +): Promise => + (await fetchWithAuth(endpoint, options, authToken)).json(); diff --git a/webapp/src/shared/lib/palette.ts b/webapp/src/shared/lib/palette.ts index ff7b4bc6..f51e7a62 100644 --- a/webapp/src/shared/lib/palette.ts +++ b/webapp/src/shared/lib/palette.ts @@ -6,7 +6,14 @@ const getCssVarColor = (name: string, fallback: string) => { ); }; -export const getChartPalette = () => [ +export const getChartPalette = (): [ + string, + string, + string, + string, + string, + string, +] => [ getCssVarColor("--chart-1", "#97cf17"), getCssVarColor("--chart-2", "#f98038"), getCssVarColor("--chart-3", "#2d6db4"), diff --git a/webapp/src/shared/lib/utils.ts b/webapp/src/shared/lib/utils.ts index 06bc3f27..d3e67ba4 100644 --- a/webapp/src/shared/lib/utils.ts +++ b/webapp/src/shared/lib/utils.ts @@ -10,6 +10,8 @@ export function cn(...inputs: ClassValue[]) { export function precise(value?: number | null) { if (!value || Number.isNaN(value)) { + // TODO: missing or erroneous values should not be considered as 0, but rather as null or undefined. + // when displayed, they should be shown as "N/A" or "No data", or the point could be omitted from the chart. return "0"; } if (value > 999) { diff --git a/webapp/src/shared/ui/chart.tsx b/webapp/src/shared/ui/chart.tsx index 7b319fd6..b001ab42 100644 --- a/webapp/src/shared/ui/chart.tsx +++ b/webapp/src/shared/ui/chart.tsx @@ -244,6 +244,10 @@ const ChartTooltipContent = React.forwardRef< { /* Force a space between item label and value*/ "\xa0" + item.value.toLocaleString() + // ^ TODO: why not using CSS to add a space between the label and the value? + // ^ TODO: pass a prop to format the item value (e.g.: no more than 2 decimals, or other formatting rules) + // ^ TODO: use the current locale to format the item value (e.g.: 1,000.00 in en-US, 1 000,00 in fr-FR, etc.) + // ^ TODO: add the unit? } )} diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index 4aace6a0..3e352c9f 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -1,11 +1,11 @@ import { use } from "react"; -import LoadedDashboard, { - type DashboardData, -} from "@widgets/dashboard/loaded-dashboard"; +import LoadedDashboard from "@widgets/dashboard/loaded-dashboard"; import { useApi } from "@features/hooks/useApi"; +import type { DashboardData } from "@entities/dashboard/generic"; + import { LAYERS } from "@shared/api/layers"; type FetchDashboardData = (layer: string) => Promise; diff --git a/webapp/src/widgets/dashboard/dashboard-header.tsx b/webapp/src/widgets/dashboard/header.tsx similarity index 74% rename from webapp/src/widgets/dashboard/dashboard-header.tsx rename to webapp/src/widgets/dashboard/header.tsx index 345c980d..0ca63f29 100644 --- a/webapp/src/widgets/dashboard/dashboard-header.tsx +++ b/webapp/src/widgets/dashboard/header.tsx @@ -1,3 +1,5 @@ +import { useCallback } from "react"; + import { useTranslation } from "@shared/i18n"; import { @@ -9,19 +11,32 @@ import { SelectValue, } from "@ui/select"; -export type DashboardHeaderProps = { +export type HeaderProps = { years: number[]; selectedYear: number; - onValueChange: (year: string) => void; + onYearChange: (year: number) => void; }; -export function DashboardHeader({ +export default function Header({ years, selectedYear, - onValueChange: onvalueChange, -}: DashboardHeaderProps) { + onYearChange, +}: HeaderProps) { const { t } = useTranslation("all4trees"); const username = localStorage.getItem("username") || ""; + + const onStringYearChange = useCallback( + (year: string) => { + const numericYear = Number(year); + if (!Number.isNaN(numericYear)) { + onYearChange(numericYear); + } else { + console.warn("Année sélectionnée invalide:", year); + } + }, + [onYearChange], + ); + return (
@@ -37,7 +52,7 @@ export function DashboardHeader({

{t("dashboard.header.catalog")}