diff --git a/.vscode/settings.json b/.vscode/settings.json index d8203bb9e..3d1a7931a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -54,5 +54,8 @@ "compareFolders.ignoreWhiteSpaces": true, "compareFolders.respectGitIgnore": true, "eslint.useFlatConfig": false, - "eslint.options": { "overrideConfigFile": ".eslintrc.json" } + "eslint.options": { "overrideConfigFile": ".eslintrc.json" }, + "[sql]": { + "editor.defaultFormatter": "ReneSaarsoo.sql-formatter-vsc" + } } diff --git a/server/src/modules/data/usecases/getPilotageReformeStats/dependencies.ts b/server/src/modules/data/usecases/getPilotageReformeStats/dependencies.ts deleted file mode 100644 index 47b894e62..000000000 --- a/server/src/modules/data/usecases/getPilotageReformeStats/dependencies.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { sql } from "kysely"; - -import { getKbdClient } from "@/db/db"; -import { getMillesimeFromRentreeScolaire } from "@/modules/data/services/getMillesime"; -import { getRentreeScolaire } from "@/modules/data/services/getRentreeScolaire"; -import { effectifAnnee } from "@/modules/data/utils/effectifAnnee"; -import { isScolaireIndicateurRegionSortie } from "@/modules/data/utils/isScolaire"; -import { notAnneeCommune, notAnneeCommuneIndicateurRegionSortie } from "@/modules/data/utils/notAnneeCommune"; -import { - notHistorique, - notHistoriqueFormation, - notHistoriqueIndicateurRegionSortie, -} from "@/modules/data/utils/notHistorique"; -import { genericOnConstatRentree } from "@/modules/data/utils/onConstatDeRentree"; -import { genericOnDemandes } from "@/modules/data/utils/onDemande"; -import { selectTauxInsertion6moisAgg } from "@/modules/data/utils/tauxInsertion6mois"; -import { selectTauxPoursuiteAgg } from "@/modules/data/utils/tauxPoursuite"; -import { cleanNull } from "@/utils/noNull"; - -import type { Filters } from "./getPilotageReformeStats.usecase"; - -const getRentreesScolaires = async () => { - return await getKbdClient() - .selectFrom("indicateurEntree") - .select("rentreeScolaire") - .distinct() - .orderBy("rentreeScolaire", "desc") - .execute() - .then((rentreesScolaireArray) => rentreesScolaireArray.map((rentreeScolaire) => rentreeScolaire.rentreeScolaire)); -}; - -const getMillesimesSortie = async () => { - return await getKbdClient() - .selectFrom("indicateurRegionSortie") - .select("millesimeSortie") - .distinct() - .orderBy("millesimeSortie", "desc") - .execute() - .then((millesimesSortieArray) => millesimesSortieArray.map((millesimeSortie) => millesimeSortie.millesimeSortie)); -}; - -export const getStats = async ({ - codeRegion, - codeNiveauDiplome, -}: { - codeRegion?: string; - codeNiveauDiplome?: string[]; -}) => { - const rentreesScolaires = await getRentreesScolaires(); - const rentreeScolaire = rentreesScolaires[0]; - const millesimesSortie = await getMillesimesSortie(); - - const selectStatsEffectif = ({ isScoped = false, annee = 0 }: { isScoped: boolean; annee: number }) => { - return getKbdClient() - .selectFrom("formationEtablissement") - .leftJoin("formationScolaireView as formationView", "formationView.cfd", "formationEtablissement.cfd") - .innerJoin("indicateurEntree", (join) => - join - .onRef("formationEtablissement.id", "=", "indicateurEntree.formationEtablissementId") - .on("indicateurEntree.rentreeScolaire", "=", getRentreeScolaire({ rentreeScolaire, offset: annee })) - ) - .leftJoin("etablissement", "etablissement.uai", "formationEtablissement.uai") - .$call((q) => { - if (!isScoped || !codeRegion) return q; - return q.where("etablissement.codeRegion", "=", codeRegion); - }) - .$call((q) => { - if (!codeNiveauDiplome?.length) return q; - return q.where("formationView.codeNiveauDiplome", "in", codeNiveauDiplome); - }) - .where(notHistorique) - .where(notAnneeCommune) - .select([ - sql`COUNT(distinct CONCAT("formationEtablissement"."cfd", "formationEtablissement"."codeDispositif"))`.as( - "nbFormations" - ), - sql`COUNT(distinct "formationEtablissement"."uai")`.as("nbEtablissements"), - sql`COALESCE(SUM(${effectifAnnee({ - alias: "indicateurEntree", - })}),0)`.as("effectif"), - ]) - .executeTakeFirstOrThrow(); - }; - - const selectStatsSortie = ({ isScoped = false, annee = 0 }: { isScoped: boolean; annee: number }) => - getKbdClient() - .selectFrom("indicateurRegionSortie") - .leftJoin("formationScolaireView as formationView", "formationView.cfd", "indicateurRegionSortie.cfd") - .$call((q) => { - if (!isScoped || !codeRegion) return q; - return q.where("indicateurRegionSortie.codeRegion", "=", codeRegion); - }) - .$call((q) => { - if (!codeNiveauDiplome?.length) return q; - return q.where("formationView.codeNiveauDiplome", "in", codeNiveauDiplome); - }) - .$call((q) => - q.where( - "indicateurRegionSortie.millesimeSortie", - "=", - getMillesimeFromRentreeScolaire({ rentreeScolaire, offset: annee }) - ) - ) - .where("indicateurRegionSortie.cfdContinuum", "is", null) - .where(isScolaireIndicateurRegionSortie) - .where(notAnneeCommuneIndicateurRegionSortie) - .where(notHistoriqueIndicateurRegionSortie) - .select([ - selectTauxInsertion6moisAgg("indicateurRegionSortie").as("tauxInsertion"), - selectTauxPoursuiteAgg("indicateurRegionSortie").as("tauxPoursuite"), - ]) - .executeTakeFirstOrThrow(); - - const getStatsAnnee = async (millesimeSortie: string) => { - // millesimeSortie est au format 2000_2001 - const finDanneeScolaireMillesime = parseInt(millesimeSortie.split("_")[1]); - const rentree = parseInt(rentreeScolaire); - const offset = finDanneeScolaireMillesime - rentree; - - return { - annee: finDanneeScolaireMillesime, - millesime: millesimeSortie.split("_"), - scoped: { - ...(await selectStatsEffectif({ isScoped: true, annee: offset })), - ...(await selectStatsSortie({ isScoped: true, annee: offset + 1 })), - }, - nationale: { - ...(await selectStatsEffectif({ isScoped: false, annee: offset })), - ...(await selectStatsSortie({ isScoped: false, annee: offset + 1 })), - }, - }; - }; - - const annees = await Promise.all(millesimesSortie.map(async (millesimeSortie) => getStatsAnnee(millesimeSortie))); - - return { - annees, - }; -}; - -const findFiltersInDb = async () => { - const filtersBase = getKbdClient() - .selectFrom("formationScolaireView as formationView") - .leftJoin("formationEtablissement", "formationEtablissement.cfd", "formationView.cfd") - .leftJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formationView.codeNiveauDiplome") - .leftJoin("etablissement", "etablissement.uai", "formationEtablissement.uai") - .leftJoin("region", "region.codeRegion", "etablissement.codeRegion") - .where(notHistoriqueFormation) - .distinct() - .$castTo<{ label: string; value: string }>() - .orderBy("label", "asc"); - - const regions = filtersBase - .select(["region.libelleRegion as label", "region.codeRegion as value"]) - .where("region.codeRegion", "is not", null) - .execute(); - - const diplomes = filtersBase - .select(["niveauDiplome.libelleNiveauDiplome as label", "niveauDiplome.codeNiveauDiplome as value"]) - .where("niveauDiplome.codeNiveauDiplome", "is not", null) - .where("niveauDiplome.codeNiveauDiplome", "in", ["500", "320", "400"]) - .execute(); - - return { - regions: (await regions).map(cleanNull), - diplomes: (await diplomes).map(cleanNull), - }; -}; - -const getTauxTransformationData = async (filters: Filters) => { - return getKbdClient() - .selectFrom(genericOnDemandes({ ...filters, rentreeScolaire: ["2025"] }).as("demande")) - .leftJoin( - genericOnConstatRentree(filters) - .select((eb) => [sql`SUM(${eb.ref("constatRentree.effectif")})`.as("effectif")]) - .as("effectifs"), - (join) => join.onTrue() - ) - .select((eb) => [ - eb.fn.coalesce("effectifs.effectif", eb.val(0)).as("effectif"), - eb.fn.coalesce("placesTransformees", eb.val(0)).as("placesTransformees"), - ]) - .execute() - .then(cleanNull); -}; - -export const dependencies = { - getStats, - findFiltersInDb, - getTauxTransformationData, -}; diff --git a/server/src/modules/data/usecases/getPilotageReformeStats/deps/getFilters.dep.ts b/server/src/modules/data/usecases/getPilotageReformeStats/deps/getFilters.dep.ts new file mode 100644 index 000000000..6a3301b46 --- /dev/null +++ b/server/src/modules/data/usecases/getPilotageReformeStats/deps/getFilters.dep.ts @@ -0,0 +1,27 @@ +import { getKbdClient } from "@/db/db"; +import { isInPerimetreIJRegion } from "@/modules/data/utils/isInPerimetreIJ"; +import { cleanNull } from "@/utils/noNull"; + +export const getFilters = async () => { + const regions = getKbdClient() + .selectFrom("region") + .where(isInPerimetreIJRegion) + .distinct() + .$castTo<{ label: string; value: string }>() + .select(["region.libelleRegion as label", "region.codeRegion as value"]) + .orderBy("label", "asc") + .execute(); + + const diplomes = getKbdClient().selectFrom("niveauDiplome") + .select(["libelleNiveauDiplome as label", "codeNiveauDiplome as value"]) + .where("codeNiveauDiplome", "is not", null) + .where("codeNiveauDiplome", "in", ["500", "320", "400", "461", "561", '010', '241','401', '381', '481', '581']) + .$castTo<{ label: string; value: string }>() + .orderBy("label", "asc") + .execute(); + + return { + regions: (await regions).map(cleanNull), + diplomes: (await diplomes).map(cleanNull), + }; +}; diff --git a/server/src/modules/data/usecases/getPilotageReformeStats/deps/getRentreesScolaire.dep.ts b/server/src/modules/data/usecases/getPilotageReformeStats/deps/getRentreesScolaire.dep.ts new file mode 100644 index 000000000..f48a382ac --- /dev/null +++ b/server/src/modules/data/usecases/getPilotageReformeStats/deps/getRentreesScolaire.dep.ts @@ -0,0 +1,14 @@ +import { sql } from "kysely"; + +import { getKbdClient } from "@/db/db"; + +export const getRentreesScolaire = async () => { + return (await getKbdClient() + .selectFrom("campagne") + .select(eb => [ + sql`CAST(${eb.ref("annee")} AS INTEGER) + 1`.as("rentreeScolaire") + ]) + .distinct() + .execute()) + .map(({rentreeScolaire}) => rentreeScolaire.toString()); +}; diff --git a/server/src/modules/data/usecases/getPilotageReformeStats/deps/getStats.dep.ts b/server/src/modules/data/usecases/getPilotageReformeStats/deps/getStats.dep.ts new file mode 100644 index 000000000..5eacbfd3a --- /dev/null +++ b/server/src/modules/data/usecases/getPilotageReformeStats/deps/getStats.dep.ts @@ -0,0 +1,135 @@ +import { sql } from "kysely"; +import { getMillesimeFromRentreeScolaire } from "shared/utils/getMillesime"; +import { getRentreeScolaire } from "shared/utils/getRentreeScolaire"; + +import { getKbdClient } from "@/db/db"; +import { effectifAnnee } from "@/modules/data/utils/effectifAnnee"; +import { isScolaireIndicateurRegionSortie } from "@/modules/data/utils/isScolaire"; +import { notAnneeCommune, notAnneeCommuneIndicateurRegionSortie } from "@/modules/data/utils/notAnneeCommune"; +import { notHistorique } from "@/modules/data/utils/notHistorique"; +import { selectTauxInsertion6moisAgg } from "@/modules/data/utils/tauxInsertion6mois"; +import { selectTauxPoursuiteAgg } from "@/modules/data/utils/tauxPoursuite"; +import { cleanNull } from "@/utils/noNull"; + +const getRentreesScolaires = async () => { + return await getKbdClient() + .selectFrom("indicateurEntree") + .select("rentreeScolaire") + .distinct() + .orderBy("rentreeScolaire", "desc") + .execute() + .then((rentreesScolaireArray) => rentreesScolaireArray.map((rentreeScolaire) => rentreeScolaire.rentreeScolaire)); +}; + +const getMillesimesSortie = async () => { + return await getKbdClient() + .selectFrom("indicateurRegionSortie") + .select("millesimeSortie") + .distinct() + .orderBy("millesimeSortie", "desc") + .execute() + .then((millesimesSortieArray) => millesimesSortieArray.map((millesimeSortie) => millesimeSortie.millesimeSortie)); +}; + +const selectStatsEffectif = async ( + { isScoped = false, annee = 0, rentreeScolaire, codeRegion, codeNiveauDiplome }: + { isScoped: boolean; annee: number, rentreeScolaire: string, codeRegion?: string, codeNiveauDiplome?: string }) => getKbdClient() + .selectFrom("formationEtablissement") + .leftJoin("formationScolaireView as formationView", "formationView.cfd", "formationEtablissement.cfd") + .innerJoin("indicateurEntree", (join) => + join + .onRef("formationEtablissement.id", "=", "indicateurEntree.formationEtablissementId") + .on("indicateurEntree.rentreeScolaire", "=", getRentreeScolaire({ rentreeScolaire, offset: annee })) + ) + .leftJoin("etablissement", "etablissement.uai", "formationEtablissement.uai") + .$call((q) => { + if (!isScoped || !codeRegion) return q; + return q.where("etablissement.codeRegion", "=", codeRegion); + }) + .$call((q) => { + if (!codeNiveauDiplome) return q; + return q.where("formationView.codeNiveauDiplome", "=", codeNiveauDiplome); + }) + .where(notHistorique) + .where(notAnneeCommune) + .select([ + sql`COUNT(distinct CONCAT("formationEtablissement"."cfd", "formationEtablissement"."codeDispositif"))`.as( + "nbFormations" + ), + sql`COUNT(distinct "formationEtablissement"."uai")`.as("nbEtablissements"), + sql`COALESCE(SUM(${effectifAnnee({ + alias: "indicateurEntree", + })}),0)`.as("effectif"), + ]) + .executeTakeFirstOrThrow() + .then(cleanNull); + +const selectStatsSortie = async ({ isScoped = false, annee = 0, rentreeScolaire, codeRegion, codeNiveauDiplome }: + { isScoped: boolean; annee: number, rentreeScolaire: string, codeRegion?: string, codeNiveauDiplome?: string }) => + getKbdClient() + .selectFrom("indicateurRegionSortie") + .leftJoin("formationScolaireView as formationView", "formationView.cfd", "indicateurRegionSortie.cfd") + .$call((q) => { + if (!isScoped || !codeRegion) return q; + return q.where("indicateurRegionSortie.codeRegion", "=", codeRegion); + }) + .$call((q) => { + if (!codeNiveauDiplome) return q; + return q.where("formationView.codeNiveauDiplome", "=", codeNiveauDiplome); + }) + .$call((q) => + q.where( + "indicateurRegionSortie.millesimeSortie", + "=", + getMillesimeFromRentreeScolaire({ rentreeScolaire, offset: annee }) + ) + ) + .where("indicateurRegionSortie.cfdContinuum", "is", null) + .where(isScolaireIndicateurRegionSortie) + .where(notAnneeCommuneIndicateurRegionSortie) + .select([ + selectTauxInsertion6moisAgg("indicateurRegionSortie").as("tauxInsertion"), + selectTauxPoursuiteAgg("indicateurRegionSortie").as("tauxPoursuite"), + ]) + .executeTakeFirstOrThrow() + .then(cleanNull); + +export const getStats = async ({ + codeRegion, + codeNiveauDiplome, +}: { + codeRegion?: string; + codeNiveauDiplome?: string; +}) => { + const rentreesScolaires = await getRentreesScolaires(); + const rentreeScolaire = rentreesScolaires[0]; + const millesimesSortie = await getMillesimesSortie(); + + + + const getStatsAnnee = async (millesimeSortie: string) => { + // millesimeSortie est au format 2000_2001 + const finDanneeScolaireMillesime = parseInt(millesimeSortie.split("_")[1]); + const rentree = parseInt(rentreeScolaire); + const offset = finDanneeScolaireMillesime - rentree; + + return { + annee: finDanneeScolaireMillesime, + millesime: millesimeSortie.split("_"), + scoped: { + ...(await selectStatsEffectif({ isScoped: true, annee: offset, rentreeScolaire, codeRegion, codeNiveauDiplome })), + ...(await selectStatsSortie({ isScoped: true, annee: offset + 1, rentreeScolaire, codeRegion, codeNiveauDiplome })), + }, + nationale: { + ...(await selectStatsEffectif({ isScoped: false, annee: offset, rentreeScolaire, codeRegion, codeNiveauDiplome })), + ...(await selectStatsSortie({ isScoped: false, annee: offset + 1, rentreeScolaire, codeRegion, codeNiveauDiplome })), + }, + }; + }; + + const annees = await Promise.all(millesimesSortie.map(async (millesimeSortie) => getStatsAnnee(millesimeSortie))); + + return { + annees, + }; +}; diff --git a/server/src/modules/data/usecases/getPilotageReformeStats/deps/getTauxTransformationCumule.dep.ts b/server/src/modules/data/usecases/getPilotageReformeStats/deps/getTauxTransformationCumule.dep.ts new file mode 100644 index 000000000..ae03498af --- /dev/null +++ b/server/src/modules/data/usecases/getPilotageReformeStats/deps/getTauxTransformationCumule.dep.ts @@ -0,0 +1,54 @@ +import { sql } from "kysely"; +import { DemandeStatutEnum } from "shared/enum/demandeStatutEnum"; + +import { getKbdClient } from "@/db/db"; +import { effectifTauxTransformationCumule } from "@/modules/data/utils/effectifTauxTransformationCumule"; +import { formatTauxTransformation } from "@/modules/data/utils/formatTauxTransformation"; +import { genericOnDemandes } from "@/modules/data/utils/onDemande"; +import { cleanNull } from "@/utils/noNull"; + + +export const getTauxTransformationCumule = async ({ + rentreesScolaire, + codeRegion, + codeNiveauDiplome, +}: { + codeRegion?: string; + codeNiveauDiplome?: string; + rentreesScolaire: string[]; +}) => { + const tauxTransformationCumuleNational = await getKbdClient() + .selectFrom( + genericOnDemandes({ + codeRegion, + rentreeScolaire: rentreesScolaire, + codeNiveauDiplome: codeNiveauDiplome ? [codeNiveauDiplome] : undefined, + statut: [DemandeStatutEnum["demande validée"]] + }) + .select((eb) => [eb.ref("demande.codeRegion").as("codeRegion")]) + .groupBy(["demande.codeRegion"]) + .as("demandes") + ) + .leftJoin( + effectifTauxTransformationCumule({ codeRegion, codeNiveauDiplome }).as("effectifs"), + (join) => join.onRef("demandes.codeRegion", "=", "effectifs.codeRegion") + ) + .select((eb) => [ + eb.fn.sum("effectifs.effectif").as("effectifs"), + eb.fn.sum("demandes.placesTransformees").as("placesTransformees"), + ]) + .$castTo<{effectifs: number | null; placesTransformees: number | null;}>() + .modifyEnd(sql.raw(`\n-- Taux de transformation cumulé national`)) + .executeTakeFirst() + .then(cleanNull); + + return { + placesTransformees: tauxTransformationCumuleNational?.placesTransformees, + effectifs: tauxTransformationCumuleNational?.effectifs, + taux: formatTauxTransformation( + tauxTransformationCumuleNational?.placesTransformees, + tauxTransformationCumuleNational?.effectifs + ), + }; +}; + diff --git a/server/src/modules/data/usecases/getPilotageReformeStats/deps/getTauxTransformationCumulePrevisionnel.dep.ts b/server/src/modules/data/usecases/getPilotageReformeStats/deps/getTauxTransformationCumulePrevisionnel.dep.ts new file mode 100644 index 000000000..ad4fd0cd8 --- /dev/null +++ b/server/src/modules/data/usecases/getPilotageReformeStats/deps/getTauxTransformationCumulePrevisionnel.dep.ts @@ -0,0 +1,54 @@ + +import { sql } from "kysely"; + +import { getKbdClient } from "@/db/db"; +import { effectifTauxTransformationCumule } from "@/modules/data/utils/effectifTauxTransformationCumule"; +import { formatTauxTransformation } from "@/modules/data/utils/formatTauxTransformation"; +import { genericOnDemandes } from "@/modules/data/utils/onDemande"; +import logger from "@/services/logger"; +import { cleanNull } from "@/utils/noNull"; + +export const getTauxTransformationCumulePrevisionnel = async ({ + rentreesScolaire, + codeRegion, + codeNiveauDiplome, +}: { + codeRegion?: string; + codeNiveauDiplome?: string; + rentreesScolaire: string[]; +}) => { + const tauxTransfoCumulePrevisionnelNational = await getKbdClient() + .selectFrom( + genericOnDemandes({ + codeRegion, + rentreeScolaire: rentreesScolaire, + codeNiveauDiplome: codeNiveauDiplome ? [codeNiveauDiplome] : undefined + }) + .select((eb) => [eb.ref("demande.codeRegion").as("codeRegion")]) + .groupBy(["demande.codeRegion"]) + .as("demandes") + ) + .leftJoin( + effectifTauxTransformationCumule({ codeRegion, codeNiveauDiplome }).as("effectifs"), + (join) => join.onRef("demandes.codeRegion", "=", "effectifs.codeRegion") + ) + .select((eb) => [ + eb.fn.sum("effectifs.effectif").as("effectifs"), + eb.fn.sum("demandes.placesTransformees").as("placesTransformees"), + ]) + .$castTo<{effectifs: number | null; placesTransformees: number | null;}>() + .modifyEnd(sql.raw('\n-- Taux de transformation prévisionnel national')) + .executeTakeFirst() + .then(cleanNull); + + logger.info({ tauxTransfoCumulePrevisionnelNational }); + + return { + placesTransformees: tauxTransfoCumulePrevisionnelNational?.placesTransformees, + effectifs: tauxTransfoCumulePrevisionnelNational?.effectifs, + taux: formatTauxTransformation( + tauxTransfoCumulePrevisionnelNational?.placesTransformees, + tauxTransfoCumulePrevisionnelNational?.effectifs + ), + }; +}; diff --git a/server/src/modules/data/usecases/getPilotageReformeStats/deps/index.ts b/server/src/modules/data/usecases/getPilotageReformeStats/deps/index.ts new file mode 100644 index 000000000..63e2c9a02 --- /dev/null +++ b/server/src/modules/data/usecases/getPilotageReformeStats/deps/index.ts @@ -0,0 +1,6 @@ +export { getFilters } from "./getFilters.dep"; +export { getRentreesScolaire } from "./getRentreesScolaire.dep"; +export { getStats } from "./getStats.dep"; +export { getTauxTransformationCumule } from "./getTauxTransformationCumule.dep"; +export { getTauxTransformationCumulePrevisionnel } from "./getTauxTransformationCumulePrevisionnel.dep"; + diff --git a/server/src/modules/data/usecases/getPilotageReformeStats/getPilotageReformeStats.usecase.ts b/server/src/modules/data/usecases/getPilotageReformeStats/getPilotageReformeStats.usecase.ts index 3ec13e83d..f5c64f8c2 100644 --- a/server/src/modules/data/usecases/getPilotageReformeStats/getPilotageReformeStats.usecase.ts +++ b/server/src/modules/data/usecases/getPilotageReformeStats/getPilotageReformeStats.usecase.ts @@ -1,10 +1,7 @@ import type { getPilotageReformeStatsSchema } from "shared/routes/schemas/get.pilotage-reforme.stats.schema"; import type { z } from "zod"; -import { getCurrentCampagneQuery } from "@/modules/data/queries/getCurrentCampagne/getCurrentCampagne.query"; -import { formatTauxTransformation } from "@/modules/data/utils/formatTauxTransformation"; - -import { dependencies } from "./dependencies"; +import * as dependencies from "./deps"; export interface Filters extends z.infer { campagne: string; @@ -12,28 +9,31 @@ export interface Filters extends z.infer - async (activeFilters: { codeNiveauDiplome?: string[]; orderBy?: { order: "asc" | "desc"; column: string } }) => { - const currentCampagne = await deps.getCurrentCampagneQuery(); - const anneeCampagne = currentCampagne.annee; - const [stats, filters] = await Promise.all([deps.getStats(activeFilters), deps.findFiltersInDb()]); + async (activeFilters: { codeNiveauDiplome?: string; codeRegion?: string }) => { + + const rentreesScolaire = await deps.getRentreesScolaire(); - const [{ placesTransformees, effectif }] = await deps.getTauxTransformationData({ - ...activeFilters, - campagne: anneeCampagne, - }); + const [filters,stats, tauxTransformationCumule, tauxTransformationCumulePrevisionnel] = await Promise.all([ + deps.getFilters(), + deps.getStats(activeFilters), + deps.getTauxTransformationCumule({ rentreesScolaire, ...activeFilters }), + deps.getTauxTransformationCumulePrevisionnel({ rentreesScolaire, ...activeFilters }) + ]); - const tauxTransformation = formatTauxTransformation(placesTransformees, effectif); return { ...stats, - tauxTransformation: tauxTransformation ?? 0, filters, + rentreesScolaire, + tauxTransformationCumule, + tauxTransformationCumulePrevisionnel }; }; diff --git a/server/src/modules/data/usecases/getPilotageReformeStatsRegions/dependencies.ts b/server/src/modules/data/usecases/getPilotageReformeStatsRegions/dependencies.ts deleted file mode 100644 index 0080f4c57..000000000 --- a/server/src/modules/data/usecases/getPilotageReformeStatsRegions/dependencies.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { ExpressionBuilder } from "kysely"; -import { sql } from "kysely"; -import { CURRENT_RENTREE } from "shared"; - -import type { DB } from "@/db/db"; -import { getKbdClient } from "@/db/db"; -import { getMillesimeFromRentreeScolaire } from "@/modules/data/services/getMillesime"; -import { isScolaireIndicateurRegionSortie } from "@/modules/data/utils/isScolaire"; -import { notAnneeCommuneIndicateurRegionSortie } from "@/modules/data/utils/notAnneeCommune"; -import { notHistoriqueFormation, notHistoriqueIndicateurRegionSortie } from "@/modules/data/utils/notHistorique"; -import { selectTauxInsertion6moisAgg } from "@/modules/data/utils/tauxInsertion6mois"; -import { selectTauxPoursuiteAgg } from "@/modules/data/utils/tauxPoursuite"; -import { cleanNull } from "@/utils/noNull"; - -/** - * On prend le taux de chomage du dernier trimestre de l'année - * définit le taux de chomage annuel. Or, à cette date (13/02/2024) - * le taux de chomage du T4 n'est pas encore disponible, nous - * prenons donc celui de 2022, et inscrivons la valeur en "dur". - */ -const dernierTauxDeChomage = (eb: ExpressionBuilder) => { - return eb.or([ - eb("indicateurRegion.rentreeScolaire", "=", "2022"), - eb("indicateurRegion.rentreeScolaire", "is", null), - ]); -}; - -const getStatsRegions = async ({ - codeNiveauDiplome, - orderBy = { order: "asc", column: "libelleRegion" }, -}: { - codeNiveauDiplome?: string[]; - orderBy?: { order: "asc" | "desc"; column: string }; -}) => { - const rentreeScolaire = CURRENT_RENTREE; - - const statsRegions = await getKbdClient() - .selectFrom("indicateurRegionSortie") - .leftJoin("formationScolaireView as formationView", "formationView.cfd", "indicateurRegionSortie.cfd") - .leftJoin("indicateurRegion", "indicateurRegion.codeRegion", "indicateurRegionSortie.codeRegion") - .leftJoin("region", "region.codeRegion", "indicateurRegionSortie.codeRegion") - .$call((q) => { - if (!codeNiveauDiplome?.length) return q; - return q.where("formationView.codeNiveauDiplome", "in", codeNiveauDiplome); - }) - .$call((q) => { - if (!rentreeScolaire?.length) return q; - return q.where( - "indicateurRegionSortie.millesimeSortie", - "=", - getMillesimeFromRentreeScolaire({ rentreeScolaire, offset: 0 }) - ); - }) - .where("indicateurRegionSortie.cfdContinuum", "is", null) - .where(notAnneeCommuneIndicateurRegionSortie) - .where(notHistoriqueIndicateurRegionSortie) - .where(isScolaireIndicateurRegionSortie) - .where(dernierTauxDeChomage) - .select([ - "indicateurRegionSortie.codeRegion", - "region.libelleRegion", - "indicateurRegion.tauxChomage", - selectTauxInsertion6moisAgg("indicateurRegionSortie").as("tauxInsertion"), - selectTauxPoursuiteAgg("indicateurRegionSortie").as("tauxPoursuite"), - ]) - .groupBy(["indicateurRegionSortie.codeRegion", "region.libelleRegion", "indicateurRegion.tauxChomage"]) - .$call((q) => { - if (!orderBy) return q; - return q.orderBy(sql.ref(orderBy.column), sql`${sql.raw(orderBy.order)} NULLS LAST`); - }) - .execute(); - - return statsRegions.map(cleanNull); -}; - -const findFiltersInDb = async () => { - const filtersBase = getKbdClient() - .selectFrom("formationScolaireView as formationView") - .leftJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formationView.codeNiveauDiplome") - .where(notHistoriqueFormation) - .distinct() - .$castTo<{ label: string; value: string }>(); - - const diplomes = filtersBase - .select(["niveauDiplome.libelleNiveauDiplome as label", "niveauDiplome.codeNiveauDiplome as value"]) - .where("niveauDiplome.codeNiveauDiplome", "is not", null) - .where("niveauDiplome.codeNiveauDiplome", "in", ["500", "320", "400"]) - .execute(); - - return { - diplomes: (await diplomes).map(cleanNull), - }; -}; - -export const dependencies = { - getStatsRegions, - findFiltersInDb, -}; diff --git a/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/getFilters.dep.ts b/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/getFilters.dep.ts new file mode 100644 index 000000000..84d739829 --- /dev/null +++ b/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/getFilters.dep.ts @@ -0,0 +1,22 @@ +import { getKbdClient } from "@/db/db"; +import { notHistoriqueFormation } from "@/modules/data/utils/notHistorique"; +import { cleanNull } from "@/utils/noNull"; + +export const getFilters = async () => { + const filtersBase = getKbdClient() + .selectFrom("formationScolaireView as formationView") + .leftJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formationView.codeNiveauDiplome") + .where(notHistoriqueFormation) + .distinct() + .$castTo<{ label: string; value: string }>(); + + const diplomes = filtersBase + .select(["niveauDiplome.libelleNiveauDiplome as label", "niveauDiplome.codeNiveauDiplome as value"]) + .where("niveauDiplome.codeNiveauDiplome", "is not", null) + .where("niveauDiplome.codeNiveauDiplome", "in", ["500", "320", "400"]) + .execute(); + + return { + diplomes: (await diplomes).map(cleanNull), + }; +}; diff --git a/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/getRentreesScolaire.dep.ts b/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/getRentreesScolaire.dep.ts new file mode 100644 index 000000000..f48a382ac --- /dev/null +++ b/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/getRentreesScolaire.dep.ts @@ -0,0 +1,14 @@ +import { sql } from "kysely"; + +import { getKbdClient } from "@/db/db"; + +export const getRentreesScolaire = async () => { + return (await getKbdClient() + .selectFrom("campagne") + .select(eb => [ + sql`CAST(${eb.ref("annee")} AS INTEGER) + 1`.as("rentreeScolaire") + ]) + .distinct() + .execute()) + .map(({rentreeScolaire}) => rentreeScolaire.toString()); +}; diff --git a/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/getStats.dep.ts b/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/getStats.dep.ts new file mode 100644 index 000000000..78852dc80 --- /dev/null +++ b/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/getStats.dep.ts @@ -0,0 +1,165 @@ +import type { ExpressionBuilder } from "kysely"; +import { sql } from "kysely"; +import { jsonBuildObject } from "kysely/helpers/postgres"; +import { CURRENT_RENTREE } from "shared"; +import { DemandeStatutEnum } from "shared/enum/demandeStatutEnum"; +import { getMillesimeFromRentreeScolaire } from "shared/utils/getMillesime"; + +import { getKbdClient } from "@/db/db"; +import type { DB } from "@/db/schema"; +import { effectifTauxTransformationCumule } from "@/modules/data/utils/effectifTauxTransformationCumule"; +import { isInPerimetreIJRegion } from "@/modules/data/utils/isInPerimetreIJ"; +import { isScolaireIndicateurRegionSortie } from "@/modules/data/utils/isScolaire"; +import { notAnneeCommuneIndicateurRegionSortie } from "@/modules/data/utils/notAnneeCommune"; +import { genericOnDemandes } from "@/modules/data/utils/onDemande"; +import { selectTauxInsertion6moisAgg } from "@/modules/data/utils/tauxInsertion6mois"; +import { selectTauxPoursuiteAgg } from "@/modules/data/utils/tauxPoursuite"; +import { cleanNull } from "@/utils/noNull"; + +/** + * On prend le taux de chomage du dernier trimestre de l'année + * définit le taux de chomage annuel. Or, à cette date (13/02/2024) + * le taux de chomage du T4 n'est pas encore disponible, nous + * prenons donc celui de 2022, et inscrivons la valeur en "dur". + */ +const dernierTauxDeChomage = (eb: ExpressionBuilder) => { + return eb.or([ + eb("indicateurRegion.rentreeScolaire", "=", "2022"), + eb("indicateurRegion.rentreeScolaire", "is", null), + ]); +}; + +export const getStatsRegions = async ({ + codeNiveauDiplome, + orderBy = { order: "asc", column: "libelleRegion" }, + rentreesScolaire, +}: { + codeNiveauDiplome?: string; + orderBy?: { order: "asc" | "desc"; column: string }; + rentreesScolaire: string[]; +}) => { + const rentreeScolaire = CURRENT_RENTREE; + + const stats = await getKbdClient() + .with("chomage", (qb) => qb.selectFrom("indicateurRegion").where(dernierTauxDeChomage).select(["tauxChomage", "codeRegion"])) + .with("indicateursIJ", (qb) => qb.selectFrom("indicateurRegionSortie") + .leftJoin("formationScolaireView as formationView", "formationView.cfd", "indicateurRegionSortie.cfd") + .leftJoin("indicateurRegion", "indicateurRegion.codeRegion", "indicateurRegionSortie.codeRegion") + .leftJoin("region", "region.codeRegion", "indicateurRegionSortie.codeRegion") + .$call((q) => { + if (!codeNiveauDiplome) return q; + return q.where("formationView.codeNiveauDiplome", "in", [codeNiveauDiplome]); + }) + .$call((q) => { + if (!rentreeScolaire?.length) return q; + return q.where( + "indicateurRegionSortie.millesimeSortie", + "=", + getMillesimeFromRentreeScolaire({ rentreeScolaire, offset: 0 }) + ); + }) + .where("indicateurRegionSortie.cfdContinuum", "is", null) + .where(notAnneeCommuneIndicateurRegionSortie) + .where(isScolaireIndicateurRegionSortie) + .select([ + "region.codeRegion", + "region.libelleRegion", + selectTauxInsertion6moisAgg("indicateurRegionSortie").as("tauxInsertion"), + selectTauxPoursuiteAgg("indicateurRegionSortie").as("tauxPoursuite"), + ]) + .groupBy(["region.codeRegion", "region.libelleRegion",])) + .with("tauxTransformationCumule", (qb) => qb.selectFrom( + genericOnDemandes({ + rentreeScolaire: rentreesScolaire, + codeNiveauDiplome: codeNiveauDiplome ? [codeNiveauDiplome] : undefined, + statut: [DemandeStatutEnum["demande validée"]], + }) + .select((eb) => [eb.ref("demande.codeRegion").as("codeRegion")]) + .groupBy(["demande.codeRegion"]) + .as("demandes") + ) + .leftJoin( + effectifTauxTransformationCumule({codeNiveauDiplome}).as("effectifs"), + (join) => join.onRef("demandes.codeRegion", "=", "effectifs.codeRegion") + ) + .select((eb) => [ + eb.ref("demandes.codeRegion").as("codeRegion"), + eb.fn.coalesce("effectifs.effectif", eb.val(0)).as("effectifs"), + eb.fn.coalesce("demandes.placesTransformees", eb.val(0)).as("placesTransformees"), + sql`CASE + WHEN ${eb.ref("effectifs.effectif")} IS NULL THEN NULL + ELSE ROUND( CAST( + (${eb.fn.coalesce("demandes.placesTransformees", eb.val(0))}::float / + ${eb.fn.coalesce("effectifs.effectif", eb.val(0))}::float) AS numeric), + 5 + ) + END`.as("tauxTransformationCumule") + ]) + .$castTo<{codeRegion: string; effectifs: number; placesTransformees: number; tauxTransformationCumule: number;}>()) + .with("tauxTransformationCumulePrevisionnel", (qb) => qb.selectFrom( + genericOnDemandes({ + rentreeScolaire: rentreesScolaire, + codeNiveauDiplome: codeNiveauDiplome ? [codeNiveauDiplome] : undefined, + }) + .select((eb) => [eb.ref("demande.codeRegion").as("codeRegion")]) + .groupBy(["demande.codeRegion"]) + .as("demandes") + ) + .leftJoin( + effectifTauxTransformationCumule({codeNiveauDiplome}).as("effectifs"), + (join) => join.onRef("demandes.codeRegion", "=", "effectifs.codeRegion") + ) + .select((eb) => [ + eb.ref("demandes.codeRegion").as("codeRegion"), + eb.fn.coalesce("effectifs.effectif", eb.val(0)).as("effectifs"), + eb.fn.coalesce("demandes.placesTransformees", eb.val(0)).as("placesTransformees"), + sql`CASE + WHEN ${eb.ref("effectifs.effectif")} IS NULL THEN NULL + ELSE ROUND( CAST( + (${eb.fn.coalesce("demandes.placesTransformees", eb.val(0))}::float / + ${eb.fn.coalesce("effectifs.effectif", eb.val(0))}::float) AS numeric), + 5 + ) + END`.as("tauxTransformationCumulePrevisionnel") + ]) + .$castTo<{ + codeRegion: string; + effectifs: number; + placesTransformees: number; + tauxTransformationCumulePrevisionnel: number; + }>()) + .selectFrom("region") + .leftJoin("indicateursIJ", "indicateursIJ.codeRegion", "region.codeRegion") + .leftJoin("tauxTransformationCumule", "tauxTransformationCumule.codeRegion", "region.codeRegion") + .leftJoin("tauxTransformationCumulePrevisionnel", "tauxTransformationCumulePrevisionnel.codeRegion", "region.codeRegion") + .leftJoin("chomage", "chomage.codeRegion", "region.codeRegion") + .where(isInPerimetreIJRegion) + .select((eb) => [ + eb.ref("region.codeRegion").as("codeRegion"), + eb.ref("region.libelleRegion").as("libelleRegion"), + sql`ROUND(CAST((${eb.ref("chomage.tauxChomage")}::float / ${eb.val(100)}::float) AS numeric), 5)`.as("tauxChomage"), + eb.ref("indicateursIJ.tauxInsertion").as("tauxInsertion"), + eb.ref("indicateursIJ.tauxPoursuite").as("tauxPoursuite"), + jsonBuildObject({ + placesTransformees: eb.ref("tauxTransformationCumule.placesTransformees"), + effectifs: eb.ref("tauxTransformationCumule.effectifs"), + taux: eb.ref("tauxTransformationCumule.tauxTransformationCumule"), + }).as("tauxTransformationCumule"), + jsonBuildObject({ + placesTransformees: eb.ref("tauxTransformationCumulePrevisionnel.placesTransformees"), + effectifs: eb.ref("tauxTransformationCumulePrevisionnel.effectifs"), + taux: eb.ref("tauxTransformationCumulePrevisionnel.tauxTransformationCumulePrevisionnel"), + }).as("tauxTransformationCumulePrevisionnel"), + ]) + .$call((q) => { + if (!orderBy) return q; + if (orderBy.column === "tauxTransformationCumule" || orderBy.column === "tauxTransformationCumulePrevisionnel") { + return q.orderBy(sql.ref(`${orderBy.column}.${orderBy.column}`), sql`${sql.raw(orderBy.order)} NULLS LAST`); + } + return q.orderBy(sql.ref(orderBy.column), sql`${sql.raw(orderBy.order)} NULLS LAST`); + }) + .modifyEnd(sql.raw('\n-- Taux de transformation régional')) + .execute(); + + return stats.map(cleanNull); +}; diff --git a/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/index.ts b/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/index.ts new file mode 100644 index 000000000..aaaca41cf --- /dev/null +++ b/server/src/modules/data/usecases/getPilotageReformeStatsRegions/deps/index.ts @@ -0,0 +1,4 @@ +export { getFilters } from "./getFilters.dep"; +export { getRentreesScolaire } from "./getRentreesScolaire.dep"; +export { getStatsRegions } from "./getStats.dep"; + diff --git a/server/src/modules/data/usecases/getPilotageReformeStatsRegions/getPilotageReformeStatsRegions.usecase.ts b/server/src/modules/data/usecases/getPilotageReformeStatsRegions/getPilotageReformeStatsRegions.usecase.ts index b5a767cb0..a38901707 100644 --- a/server/src/modules/data/usecases/getPilotageReformeStatsRegions/getPilotageReformeStatsRegions.usecase.ts +++ b/server/src/modules/data/usecases/getPilotageReformeStatsRegions/getPilotageReformeStatsRegions.usecase.ts @@ -1,18 +1,25 @@ -import { dependencies } from "./dependencies"; +import * as dependencies from "./deps"; const getPilotageReformeStatsRegionsFactory = ( deps = { getStatsRegions: dependencies.getStatsRegions, - findFiltersInDb: dependencies.findFiltersInDb, + getFilters: dependencies.getFilters, + getRentreesScolaire: dependencies.getRentreesScolaire, } ) => - async (activeFilters: { codeNiveauDiplome?: string[]; orderBy?: { order: "asc" | "desc"; column: string } }) => { - const [statsRegions, filters] = await Promise.all([deps.getStatsRegions(activeFilters), deps.findFiltersInDb()]); + async (activeFilters: { codeNiveauDiplome?: string; orderBy?: { order: "asc" | "desc"; column: string } }) => { + const rentreesScolaire = await deps.getRentreesScolaire(); + + const [statsRegions, filters] = await Promise.all([ + deps.getStatsRegions({ ...activeFilters, rentreesScolaire }), + deps.getFilters(), + ]); return { statsRegions, filters, + rentreesScolaire }; }; diff --git a/server/src/modules/data/utils/effectifTauxTransformationCumule.ts b/server/src/modules/data/utils/effectifTauxTransformationCumule.ts new file mode 100644 index 000000000..0fed98d22 --- /dev/null +++ b/server/src/modules/data/utils/effectifTauxTransformationCumule.ts @@ -0,0 +1,36 @@ +import { expressionBuilder, sql } from "kysely"; +import { FIRST_ANNEE_CAMPAGNE } from "shared"; + +import type { DB } from "@/db/db"; + +import { isInPerimetreIJDataEtablissement } from "./isInPerimetreIJ"; + +export const effectifTauxTransformationCumule = ({ + codeRegion, + codeNiveauDiplome, +}: { + codeRegion?: string; + codeNiveauDiplome?: string; +}) => { + return expressionBuilder() + .selectFrom("constatRentree") + .leftJoin("dataEtablissement", "dataEtablissement.uai", "constatRentree.uai") + .leftJoin("dataFormation", "dataFormation.cfd", "constatRentree.cfd") + .where(wb => wb + .case() + .when("dataFormation.typeFamille", "in", ["specialite", "option"]) + .then(wb("constatRentree.anneeDispositif", "=", 2)) + .when("dataFormation.typeFamille", "in", ["2nde_commune", "1ere_commune"]) + .then(false) + .else(wb("constatRentree.anneeDispositif", "=", 1)) + .end()) + .where(isInPerimetreIJDataEtablissement) + .where("constatRentree.rentreeScolaire", "=", FIRST_ANNEE_CAMPAGNE) + .$if(!!codeRegion, (qb) => qb.where("dataEtablissement.codeRegion", "=", codeRegion!)) + .$if(!!codeNiveauDiplome, (qb) => qb.where(wb => sql`LEFT(${wb.ref("constatRentree.cfd")}, 3)`, "=", codeNiveauDiplome!)) + .select(sb => [ + sb.ref("dataEtablissement.codeRegion").as("codeRegion"), + sb.fn.sum("constatRentree.effectif").as("effectif") + ]) + .groupBy(["dataEtablissement.codeRegion"]); +}; diff --git a/server/src/modules/data/utils/formatTauxTransformation.ts b/server/src/modules/data/utils/formatTauxTransformation.ts index a401a7ee4..fa330e0da 100644 --- a/server/src/modules/data/utils/formatTauxTransformation.ts +++ b/server/src/modules/data/utils/formatTauxTransformation.ts @@ -1,9 +1,12 @@ -export const formatTauxTransformation = (transformes: number, effectif: number | undefined) => { - if (typeof effectif === "undefined") { +export const formatTauxTransformation = (transformes: number | undefined, effectif: number | undefined) => { + + if(typeof effectif === "undefined"){ return undefined; } - if (effectif === 0) return 0; + if(typeof transformes === "undefined"){ + return 0; + } - return Math.round((transformes / effectif) * 10000) / 100; + return Number.parseFloat((transformes / effectif).toFixed(4)); }; diff --git a/server/src/modules/data/utils/onDemande.ts b/server/src/modules/data/utils/onDemande.ts index f0655a450..2a86e2e76 100644 --- a/server/src/modules/data/utils/onDemande.ts +++ b/server/src/modules/data/utils/onDemande.ts @@ -31,13 +31,12 @@ import { countPlacesOuvertesTransitionEcologique, countPlacesTransformeesParCampagne, } from "@/modules/utils/countCapacite"; -import { isDemandeProjetOrValidee } from "@/modules/utils/isDemandeProjetOrValidee"; import { isDemandeNotAjustementRentree } from "@/modules/utils/isDemandeSelectable"; import { isInPerimetreIJDataEtablissement } from "./isInPerimetreIJ"; export const genericOnDemandes = ({ - statut, + statut = [DemandeStatutEnum["projet de demande"], DemandeStatutEnum["demande validée"], DemandeStatutEnum["prêt pour le vote"]], rentreeScolaire, codeNiveauDiplome, CPC, @@ -51,7 +50,7 @@ export const genericOnDemandes = ({ formationSpecifique, withAjustementRentree = true, }: { - statut?: Array; + statut?: DemandeStatutType[]; rentreeScolaire?: string[]; codeNiveauDiplome?: string[]; CPC?: string[]; @@ -123,7 +122,7 @@ export const genericOnDemandes = ({ ]) .where(isInPerimetreIJDataEtablissement) .$if(withAjustementRentree, (eb) => eb.where(isDemandeNotAjustementRentree)) - .where("demande.statut", "in", [DemandeStatutEnum["projet de demande"], DemandeStatutEnum["demande validée"]]) + .where("demande.statut", "in", statut) .$call((eb) => { if (campagne) return eb.where("campagne.annee", "=", campagne); return eb; @@ -165,12 +164,6 @@ export const genericOnDemandes = ({ if (!secteur || secteur.length === 0) return q; return q.where("dataEtablissement.secteur", "in", secteur); }) - .$call((q) => { - if (!statut || statut.length === 0) { - return q.where(isDemandeProjetOrValidee); - } - return q.where("demande.statut", "in", statut); - }) .$call((q) => { if (withColoration === undefined) return q; if (withColoration === "false") diff --git a/shared/objectives/TAUX_TRANSFO.ts b/shared/objectives/TAUX_TRANSFO.ts index fd19142e6..59e0c9391 100644 --- a/shared/objectives/TAUX_TRANSFO.ts +++ b/shared/objectives/TAUX_TRANSFO.ts @@ -1,2 +1,3 @@ export const OBJECTIF_TAUX_TRANSFO_PERCENTAGE = 0.06; export const OBJECTIF_TAUX_TRANSFO = OBJECTIF_TAUX_TRANSFO_PERCENTAGE * 100; +export const OBJECTIF_TAUX_TRANSFO_REFORME = 0.25; diff --git a/shared/routes/schemas/get.pilotage-reforme.stats.regions.schema.ts b/shared/routes/schemas/get.pilotage-reforme.stats.regions.schema.ts index fec04a5e4..93f5daeed 100644 --- a/shared/routes/schemas/get.pilotage-reforme.stats.regions.schema.ts +++ b/shared/routes/schemas/get.pilotage-reforme.stats.regions.schema.ts @@ -2,16 +2,24 @@ import { z } from "zod"; import { OptionSchema } from "../../schema/optionSchema"; +const TauxTransformationSchema = z.object({ + placesTransformees: z.number().optional(), + effectifs: z.number().optional(), + taux: z.number().optional(), +}); + const StatsRegionLineSchema = z.object({ codeRegion: z.string(), libelleRegion: z.string().optional(), tauxChomage: z.coerce.number().optional(), tauxPoursuite: z.coerce.number().optional(), tauxInsertion: z.coerce.number().optional(), + tauxTransformationCumule: TauxTransformationSchema.optional(), + tauxTransformationCumulePrevisionnel: TauxTransformationSchema.optional(), }); const FiltersRegionsSchema = z.object({ - codeNiveauDiplome: z.array(z.string()).optional(), + codeNiveauDiplome: z.string().optional(), order: z.enum(["asc", "desc"]).optional(), orderBy: StatsRegionLineSchema.keyof().optional(), }); @@ -24,6 +32,7 @@ export const getPilotageReformeStatsRegionsSchema = { diplomes: z.array(OptionSchema), }), statsRegions: z.array(StatsRegionLineSchema), + rentreesScolaire: z.array(z.string()), }), }, }; diff --git a/shared/routes/schemas/get.pilotage-reforme.stats.schema.ts b/shared/routes/schemas/get.pilotage-reforme.stats.schema.ts index db0457df9..7691482e5 100644 --- a/shared/routes/schemas/get.pilotage-reforme.stats.schema.ts +++ b/shared/routes/schemas/get.pilotage-reforme.stats.schema.ts @@ -3,11 +3,11 @@ import { z } from "zod"; import { OptionSchema } from "../../schema/optionSchema"; const StatsSchema = z.object({ - effectif: z.coerce.number().optional(), - nbFormations: z.coerce.number().optional(), - nbEtablissements: z.coerce.number().optional(), - tauxPoursuite: z.coerce.number().optional(), - tauxInsertion: z.coerce.number().optional(), + effectif: z.number().optional(), + nbFormations: z.number().optional(), + nbEtablissements: z.number().optional(), + tauxPoursuite: z.number().optional(), + tauxInsertion: z.number().optional(), }); const StatsAnneeSchema = z.object({ @@ -18,10 +18,16 @@ const StatsAnneeSchema = z.object({ }); const FiltersSchema = z.object({ - codeNiveauDiplome: z.array(z.string()).optional(), + codeNiveauDiplome: z.string().optional(), codeRegion: z.string().optional(), }); +const TauxTransformationSchema = z.object({ + placesTransformees: z.number().optional(), + effectifs: z.number().optional(), + taux: z.number().optional(), +}); + export const getPilotageReformeStatsSchema = { querystring: FiltersSchema, response: { @@ -30,8 +36,10 @@ export const getPilotageReformeStatsSchema = { regions: z.array(OptionSchema), diplomes: z.array(OptionSchema), }), - tauxTransformation: z.number(), annees: z.array(StatsAnneeSchema), + tauxTransformationCumule: TauxTransformationSchema, + tauxTransformationCumulePrevisionnel: TauxTransformationSchema, + rentreesScolaire: z.array(z.string()), }), }, }; diff --git a/ui/app/(wrapped)/pilotage-reforme/components/CartoSection.tsx b/ui/app/(wrapped)/pilotage-reforme/components/CartoSection.tsx index 85ee43b24..c8705dc07 100644 --- a/ui/app/(wrapped)/pilotage-reforme/components/CartoSection.tsx +++ b/ui/app/(wrapped)/pilotage-reforme/components/CartoSection.tsx @@ -1,38 +1,69 @@ import { Box, Flex, Heading, Select, Skeleton, VisuallyHidden } from "@chakra-ui/react"; +import { useMemo, useState } from "react"; -import type { Filters, IndicateurType, PilotageReformeStatsRegion } from "@/app/(wrapped)/pilotage-reforme/types"; +import type { Filters, IndicateurOption, IndicateurType, PilotageReformeStatsRegion } from "@/app/(wrapped)/pilotage-reforme/types"; import { CartoGraph } from "@/components/CartoGraph"; import { formatNumber } from "@/utils/formatUtils"; interface CartoSelectionProps { data?: PilotageReformeStatsRegion; isLoading: boolean; - indicateur: IndicateurType; - handleIndicateurChange: (indicateur: string) => void; - indicateurOptions: { - label: string; - value: string; - isDefault: boolean; - }[]; activeFilters: Filters; handleFilters: (type: keyof Filters, value: Filters[keyof Filters]) => void; } +const getCustomPalette = (indicateur: IndicateurType) : number[][] | undefined => { + switch (indicateur) { + case "tauxTransformationCumule": + return [ + [0, 10], + [10, 15], + [15, 20], + [20, 10000], + ]; + default: + return undefined; + } +}; + export const CartoSection = ({ data, isLoading, - indicateur, - handleIndicateurChange, - indicateurOptions, activeFilters, handleFilters, }: CartoSelectionProps) => { - const graphData = data?.statsRegions.map((region) => { + + const [indicateur, setIndicateur] = useState("tauxInsertion"); + const indicateurOptions: IndicateurOption[] = [ + { + label: "Taux d'emploi à 6 mois", + value: "tauxInsertion", + isDefault: true, + }, + { + label: "Taux de poursuite d'études", + value: "tauxPoursuite", + isDefault: false, + }, + { + label: "Taux de transformation cumulé", + value: "tauxTransformationCumule", + isDefault: false, + }, + { + label: "Taux de chômage", + value: "tauxChomage", + isDefault: false, + }, + ]; + + const graphData = useMemo(() => data?.statsRegions.map((region) => { return { name: region.libelleRegion, - value: formatNumber((region[indicateur] ?? 0) * 100), + code: region.codeRegion, + value: indicateur === "tauxTransformationCumule" || indicateur === "tauxTransformationCumulePrevisionnel" ? formatNumber((region[indicateur]?.taux ?? 0) * 100, 1) : formatNumber((region[indicateur] ?? 0) * 100, 1), }; - }); + }), [data, indicateur]) ; const handleClickOnRegion = (codeRegion: string | undefined) => { if (activeFilters.codeRegion && activeFilters.codeRegion === codeRegion) handleFilters("codeRegion", undefined); @@ -58,11 +89,11 @@ export const CartoSection = ({ Indicateur Régions diff --git a/ui/app/(wrapped)/pilotage-reforme/components/IndicateursClesSection.tsx b/ui/app/(wrapped)/pilotage-reforme/components/IndicateursClesSection.tsx index 123047949..fc62a36ff 100644 --- a/ui/app/(wrapped)/pilotage-reforme/components/IndicateursClesSection.tsx +++ b/ui/app/(wrapped)/pilotage-reforme/components/IndicateursClesSection.tsx @@ -10,18 +10,19 @@ import { SimpleGrid, Skeleton, Text, - useDisclosure, + useToken, VStack, } from "@chakra-ui/react"; +import { OBJECTIF_TAUX_TRANSFO_REFORME } from "shared/objectives/TAUX_TRANSFO"; import { NEXT_RENTREE } from "shared/time/NEXT_RENTREE"; -import { DefinitionTauxTransfoModal } from "@/app/(wrapped)/components/DefinitionTauxTransfoModal"; import { useGlossaireContext } from "@/app/(wrapped)/glossaire/glossaireContext"; -import type { IndicateurType, PilotageReformeStats } from "@/app/(wrapped)/pilotage-reforme/types"; -import { ProgressBar } from "@/components/ProgressBar"; +import type { IndicateurType, PilotageReformeStats, TauxTransformation } from "@/app/(wrapped)/pilotage-reforme/types"; import { TooltipIcon } from "@/components/TooltipIcon"; import { themeColors } from "@/theme/themeColors"; -import { formatNumber } from "@/utils/formatUtils"; +import { formatNumber, formatPercentageFixedDigits } from "@/utils/formatUtils"; + +import { MultiProgressBar } from "./MultiProgressBar"; const EFFECTIF_FEATURE_FLAG = false; @@ -271,11 +272,11 @@ const StatCard = ({ const getValue = (type: IndicateurType) => { switch (type) { case "tauxInsertion": - return formatNumber((data?.annees[0].scoped.tauxInsertion ?? 0) * 100); + return formatPercentageFixedDigits(data?.annees[0].scoped.tauxInsertion, 1, '-'); case "tauxPoursuite": - return formatNumber((data?.annees[0].scoped.tauxPoursuite ?? 0) * 100); + return formatPercentageFixedDigits(data?.annees[0].scoped.tauxPoursuite, 1, '-'); default: - return formatNumber((data?.annees[0].scoped.tauxInsertion ?? 0) * 100); + return formatPercentageFixedDigits(data?.annees[0].scoped.tauxInsertion, 1, '-'); } }; @@ -300,7 +301,7 @@ const StatCard = ({ {tooltip} - {getValue(type) ? `${getValue(type)} %` : -} + {getValue(type)} {getDeltaAnneeNMoins1(type) != null ? : <>} @@ -318,13 +319,19 @@ const StatCard = ({ ); }; -const TauxTransfoCard = ({ tauxTransformation }: { tauxTransformation: number }) => { - const percentage = (tauxTransformation * 100) / 6; - const { isOpen, onOpen, onClose } = useDisclosure(); +const TauxTransfoCard = ( + { tauxTransformationCumule, + tauxTransformationCumulePrevisionnel, + onModalOpen + } : + { tauxTransformationCumule?: TauxTransformation, + tauxTransformationCumulePrevisionnel?: TauxTransformation, + onModalOpen: () => void + }) => { + const [blue, cyan, grey] = useToken("colors", ["bluefrance.113", "blueecume.675_hover", "grey.925"]); return ( <> - @@ -336,7 +343,7 @@ const TauxTransfoCard = ({ tauxTransformation }: { tauxTransformation: number }) color={themeColors.bluefrance[113]} alignItems="start" > - + - Taux de transformation prévisionnel - Rentrée {NEXT_RENTREE}{" "} - - - {formatNumber(tauxTransformation, 1)} % + Taux de transformation cumulé - + + - - - {formatNumber(percentage, 1)}% de l'objectif - + { tauxTransformationCumule && tauxTransformationCumulePrevisionnel && ( + typeof bar?.value !== "undefined").sort((a, b) => b.value! - a.value!).map((taux, index) => ({...taux, order: index + 1} as { + value: number; + label: string; + color: string; + tooltip?: string | undefined; + order: number; + }))} + max={Math.max(OBJECTIF_TAUX_TRANSFO_REFORME, + tauxTransformationCumulePrevisionnel?.taux ?? 0, + tauxTransformationCumule?.taux ?? 0)} + /> + )} - - - onOpen()} /> - Comprendre le calcul du taux de transformation - - ); }; -const IndicateursSortie = ({ data }: { data?: PilotageReformeStats }) => { +const IndicateursSortie = ({ data, onModalOpen }: { data?: PilotageReformeStats, onModalOpen: () => void }) => { const { openGlossaire } = useGlossaireContext(); return ( @@ -379,7 +394,11 @@ const IndicateursSortie = ({ data }: { data?: PilotageReformeStats }) => { INDICATEURS CLÉS DE LA RÉFORME - + { ); }; -export const IndicateursClesSection = ({ data, isLoading }: { data?: PilotageReformeStats; isLoading: boolean }) => { - return ( - <> - {isLoading ? ( - - ) : ( - - {EFFECTIF_FEATURE_FLAG && ( - - - - )} - - - - - )} - - ); +export const IndicateursClesSection = ( + { data, isLoading, onModalOpen }: + { data?: PilotageReformeStats; isLoading: boolean; onModalOpen: () => void }) => { + if(isLoading){ + return (); + } + + return (); + }; diff --git a/ui/app/(wrapped)/pilotage-reforme/components/MultiProgressBar.tsx b/ui/app/(wrapped)/pilotage-reforme/components/MultiProgressBar.tsx new file mode 100644 index 000000000..f4ca84421 --- /dev/null +++ b/ui/app/(wrapped)/pilotage-reforme/components/MultiProgressBar.tsx @@ -0,0 +1,222 @@ +import { Box, Text, Tooltip } from "@chakra-ui/react"; +import { useEffect, useRef, useState } from "react"; + +import { formatPercentageFixedDigits } from "@/utils/formatUtils"; + +type Bar = { + value: number; + color: string; + label: string; + order: number; + tooltip?: string; +}; + +function elementsOverlap(el1: HTMLElement, el2: HTMLElement): boolean { + const rect1 = el1.getBoundingClientRect(); + const rect2 = el2.getBoundingClientRect(); + + return !( + rect1.right < rect2.left || + rect1.left > rect2.right || + rect1.bottom < rect2.top || + rect1.top > rect2.bottom + ); +} + +function calculateTransform( + labelElement: HTMLElement, + containerElement: HTMLElement, + leftPosition: number +): string { + const labelRect = labelElement.getBoundingClientRect(); + const containerRect = containerElement.getBoundingClientRect(); + const labelWidth = labelRect.width; + const containerWidth = containerRect.width; + + const absolutePosition = (leftPosition / 100) * containerWidth; + + if (absolutePosition + labelWidth > containerWidth) { + const overflow = absolutePosition + labelWidth - containerWidth; + const translatePercentage = (overflow / labelWidth) * 100; + return `translateX(-${Math.min(translatePercentage, 100)}%)`; + } + + if (labelRect.left - containerRect.left < 0) { + const overflowPixels = containerRect.left - labelRect.left; + const overflowRatio = Math.min(overflowPixels / labelWidth, 1); + const translatePercentage = -100 * (1 - overflowRatio); + return `translateX(${translatePercentage}%)`; + } + + return "translateX(-100%)"; +} + +export const MultiProgressBar = ({ + bars, + max = 100 +}: { + bars: Bar[]; + max: number; +}) => { + const [visibleLabels, setVisibleLabels] = useState>(new Set()); + const [hoveredBar, setHoveredBar] = useState(null); + const [temporarilyHiddenLabels, setTemporarilyHiddenLabels] = useState>(new Set()); + + const labelRefs = useRef<{ [key: string]: HTMLElement }>({}); + const containerRef = useRef(null); + + // Calcule les labels qui doivent être visibles par défaut + useEffect(() => { + if (!containerRef.current) return; + + const visibleSet = new Set(); + const sortedBars = [...bars].sort((a, b) => a.order - b.order); + + // Initialiser tous les labels comme visibles + sortedBars.forEach(bar => visibleSet.add(bar.label)); + + // Vérifier les chevauchements + for (let i = 0; i < sortedBars.length; i++) { + const currentLabel = sortedBars[i].label; + const currentElement = labelRefs.current[currentLabel]; + + if (!currentElement || !visibleSet.has(currentLabel)) continue; + + for (let j = i + 1; j < sortedBars.length; j++) { + const nextLabel = sortedBars[j].label; + const nextElement = labelRefs.current[nextLabel]; + + if (!nextElement || !visibleSet.has(nextLabel)) continue; + + if (elementsOverlap(currentElement, nextElement)) { + // Le label avec l'ordre le plus bas devient invisible + visibleSet.delete(currentLabel); + break; + } + } + } + + setVisibleLabels(visibleSet); + }, [bars]); + + // Gère le masquage temporaire des labels qui se chevauchent avec le label survolé + useEffect(() => { + if (!hoveredBar || !containerRef.current) { + setTemporarilyHiddenLabels(new Set()); + return; + } + + const hoveredElement = labelRefs.current[hoveredBar]; + if (!hoveredElement) return; + + const hiddenSet = new Set(); + + // Vérifie les chevauchements avec le label survolé + bars.forEach(bar => { + if (bar.label === hoveredBar) return; + + const element = labelRefs.current[bar.label]; + if (!element) return; + + if (elementsOverlap(hoveredElement, element)) { + hiddenSet.add(bar.label); + } + }); + + setTemporarilyHiddenLabels(hiddenSet); + }, [hoveredBar, bars]); + + const isLabelVisible = (label: string) => { + if (hoveredBar === label) return true; + if (temporarilyHiddenLabels.has(label)) return false; + return visibleLabels.has(label); + }; + + return ( + + {/* Zone des labels */} + + {bars.map((bar) => { + const leftPosition = (bar.value / max) * 100; + return ( + { + if (el) labelRefs.current[bar.label] = el; + }} + maxWidth="100%" + whiteSpace="nowrap" + zIndex={bar.order} + > + + + + {formatPercentageFixedDigits(bar.value, 1, "-")} + + + + + {bar.label} + + + ); + })} + + + {/* Repères de position */} + + {bars.map((bar) => ( + + ))} + + + {/* Barres de progression */} + + {bars.map((bar) => ( + setHoveredBar(bar.label)} + onMouseLeave={() => setHoveredBar(null)} + /> + ))} + + + ); +}; diff --git a/ui/app/(wrapped)/pilotage-reforme/components/VueRegionAcademieSection.tsx b/ui/app/(wrapped)/pilotage-reforme/components/VueRegionAcademieSection.tsx index 09a52e4af..6f7cb0edb 100644 --- a/ui/app/(wrapped)/pilotage-reforme/components/VueRegionAcademieSection.tsx +++ b/ui/app/(wrapped)/pilotage-reforme/components/VueRegionAcademieSection.tsx @@ -1,16 +1,19 @@ -import {Box, Heading,Skeleton, Table, TableContainer, Tbody, Td, Th, Thead, Tr} from '@chakra-ui/react'; -import { Fragment } from "react"; +import { Box, Flex, Heading, Skeleton, Table, TableContainer, Tbody, Td, Text, Th, Thead, Tooltip, Tr } from '@chakra-ui/react'; +import { useMemo } from 'react'; -import type { Order, PilotageReformeStatsRegion } from "@/app/(wrapped)/pilotage-reforme/types"; +import type { Order, PilotageReformeStatsRegion, TauxTransformation } from "@/app/(wrapped)/pilotage-reforme/types"; +import { GlossaireShortcut } from '@/components/GlossaireShortcut'; import { OrderIcon } from "@/components/OrderIcon"; import { TooltipIcon } from "@/components/TooltipIcon"; -import { formatPercentage } from "@/utils/formatUtils"; +import { formatPercentageFixedDigits } from "@/utils/formatUtils"; const PILOTAGE_REFORME_STATS_REGIONS_COLUMNS = { libelleRegion: "Région", - tauxInsertion: "Emploi", - tauxPoursuite: "Poursuite", + tauxInsertion: "Taux d'emploi à 6 mois", + tauxPoursuite: "Taux de poursuite d'études", tauxChomage: "Taux de chômage", + tauxTransformationCumule: "Taux de transfo. cumulé", + tauxTransformationCumulePrevisionnel: "Taux de transfo. cumulé prévisionnel", }; const Loader = () => ( @@ -44,13 +47,57 @@ export const VueRegionAcademieSection = ({ order, codeRegion, handleOrder, + nationalStats, + onModalOpen, }: { data?: PilotageReformeStatsRegion; isLoading: boolean; order: Order; codeRegion?: string; handleOrder: (column: Order["orderBy"]) => void; + onModalOpen: () => void; + nationalStats: { + tauxTransformationCumule?: TauxTransformation; + tauxTransformationCumulePrevisionnel?: TauxTransformation; + tauxPoursuite?: number; + tauxInsertion?: number; + tauxChomage?: number; + } }) => { + const rows = useMemo(() => { return data?.statsRegions.map((region) => { + const trBgColor = region.codeRegion === codeRegion ? "blueecume.400_hover !important" : ""; + const tdBgColor = region.codeRegion === codeRegion ? "inherit !important" : ""; + const trColor = region.codeRegion === codeRegion ? "white" : "inherit"; + const color = region.codeRegion === codeRegion ? "inherit" : "bluefrance.113"; + + return ( + + + {region.libelleRegion} + + + + {formatPercentageFixedDigits(region.tauxTransformationCumule?.taux, 1, "-")} + + + + + {formatPercentageFixedDigits(region.tauxTransformationCumulePrevisionnel?.taux, 1, "-")} + + + + {formatPercentageFixedDigits(region.tauxPoursuite, 1, "-")} + + + {formatPercentageFixedDigits(region.tauxInsertion, 1, "-")} + + + {formatPercentageFixedDigits(region.tauxChomage, 1, "-")} + + + ); + });}, [data, codeRegion]); + return ( <> {PILOTAGE_REFORME_STATS_REGIONS_COLUMNS.libelleRegion} - handleOrder("tauxPoursuite")}> - - {PILOTAGE_REFORME_STATS_REGIONS_COLUMNS.tauxPoursuite} + handleOrder("tauxTransformationCumule")}> + + + + Taux de transfo. cumulé +
+ (Demandes validées) +
+ + Taux de transformation cumulé par région. +
+ } + onClick={() => onModalOpen()} + /> + + + handleOrder("tauxTransformationCumulePrevisionnel")}> + + + + Taux de transfo. cumulé +
+ (Projets inclus) +
+ + Taux de transformation cumulé par région. +
+ } + onClick={() => onModalOpen()} + /> + - handleOrder("tauxInsertion")}> - - {PILOTAGE_REFORME_STATS_REGIONS_COLUMNS.tauxInsertion} + handleOrder("tauxPoursuite")}> + + + + Taux de poursuite +
+ d'études +
+ + Tout élève inscrit à N+1 (réorientation et redoublement compris). + Cliquez pour plus d'infos. +
+ } + /> + - handleOrder("tauxChomage")}> + handleOrder("tauxInsertion")}> + + + + Taux d'emploi +
+ à 6 mois +
+ + La part de ceux qui sont en emploi 6 mois après leur sortie d’étude. + Cliquez pour plus d'infos. +
+ } + /> + + + handleOrder("tauxChomage")}> {PILOTAGE_REFORME_STATS_REGIONS_COLUMNS.tauxChomage} - + - - {data?.statsRegions.map((region) => { - const trBgColor = region.codeRegion === codeRegion ? "blueecume.400_hover !important" : ""; - - const tdBgColor = region.codeRegion === codeRegion ? "inherit !important" : ""; - - const trColor = region.codeRegion === codeRegion ? "white" : "inherit"; - - const color = region.codeRegion === codeRegion ? "inherit" : "bluefrance.113"; - - return ( - - - - {region.libelleRegion} - - - {formatPercentage(region.tauxPoursuite ?? 0)} - - - {formatPercentage(region.tauxInsertion ?? 0)} - - - {region.tauxChomage ?? "-"} % - - - - ); - })} - + {rows} + {/* National stats */} + + + NATIONAL + + + + {formatPercentageFixedDigits(nationalStats.tauxTransformationCumule?.taux, 1, "-")} + + + + + {formatPercentageFixedDigits(nationalStats.tauxTransformationCumulePrevisionnel?.taux, 1, "-")} + + + + {formatPercentageFixedDigits(nationalStats.tauxPoursuite, 1, "-")} + + + {formatPercentageFixedDigits(nationalStats.tauxInsertion, 1, "-")} + + + {formatPercentageFixedDigits(nationalStats.tauxChomage, 1, "-")} + + diff --git a/ui/app/(wrapped)/pilotage-reforme/page.tsx b/ui/app/(wrapped)/pilotage-reforme/page.tsx index d34420f42..547aceb40 100644 --- a/ui/app/(wrapped)/pilotage-reforme/page.tsx +++ b/ui/app/(wrapped)/pilotage-reforme/page.tsx @@ -1,38 +1,84 @@ "use client"; -import { Box, Container, Flex, SimpleGrid } from "@chakra-ui/react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { Box, Container, Flex, SimpleGrid, useDisclosure } from "@chakra-ui/react"; import { usePlausible } from "next-plausible"; -/* eslint-disable-next-line import/default */ -import qs from "qs"; -import { useState } from "react"; +import { useMemo } from "react"; import { client } from "@/api.client"; -import { createParameterizedUrl } from "@/utils/createParameterizedUrl"; import { withAuth } from "@/utils/security/withAuth"; +import { useStateParams } from "@/utils/useFilters"; import { CartoSection } from "./components/CartoSection"; +import { DefinitionTauxTransformationCumuleModal } from "./components/DefinitionTauxTransformationCumuleModal"; +import { EmptyDataBoard } from "./components/EmptyDataBoard"; import { EvolutionIndicateursClesSection } from "./components/EvolutionIndicateursClesSection"; import { FiltersSection } from "./components/FiltersSection"; import { IndicateursClesSection } from "./components/IndicateursClesSection"; import { VueRegionAcademieSection } from "./components/VueRegionAcademieSection"; -import type { Filters, FiltersRegions, IndicateurType, Order } from "./types"; +import type { Filters, FiltersRegions, Order, PilotageReformeStats, PilotageReformeStatsRegion } from "./types"; + +export function calcNationalStats(data: PilotageReformeStats | undefined, dataRegions: PilotageReformeStatsRegion | undefined) { + return { + tauxTransformationCumule: dataRegions?.statsRegions?.length + ? (() => { + const { totalEffectifs, totalPlacesTransformees } = dataRegions.statsRegions.reduce( + (acc, region) => ({ + totalEffectifs: acc.totalEffectifs + (region.tauxTransformationCumule?.effectifs ?? 0), + totalPlacesTransformees: acc.totalPlacesTransformees + (region.tauxTransformationCumule?.placesTransformees ?? 0), + }), + { totalEffectifs: 0, totalPlacesTransformees: 0 } + ); + return { + effectifs: totalEffectifs, + placesTransformees: totalPlacesTransformees, + taux: totalEffectifs > 0 ? totalPlacesTransformees / totalEffectifs : undefined, + }; + })() + : undefined, + + tauxTransformationCumulePrevisionnel: dataRegions?.statsRegions?.length + ? (() => { + const { totalEffectifs, totalPlacesTransformees } = dataRegions.statsRegions.reduce( + (acc, region) => ({ + totalEffectifs: acc.totalEffectifs + (region.tauxTransformationCumulePrevisionnel?.effectifs ?? 0), + totalPlacesTransformees: acc.totalPlacesTransformees + (region.tauxTransformationCumulePrevisionnel?.placesTransformees ?? 0), + }), + { totalEffectifs: 0, totalPlacesTransformees: 0 } + ); + return { + effectifs: totalEffectifs, + placesTransformees: totalPlacesTransformees, + taux: totalEffectifs > 0 ? totalPlacesTransformees / totalEffectifs : undefined, + }; + })() + : undefined, + + tauxPoursuite: data?.annees?.[0]?.nationale?.tauxPoursuite + ? data.annees[0].nationale.tauxPoursuite + : undefined, + tauxInsertion: data?.annees?.[0]?.nationale?.tauxInsertion + ? data.annees[0].nationale.tauxInsertion + : undefined, + tauxChomage: dataRegions?.statsRegions?.some((region) => region.tauxChomage) + ? (dataRegions.statsRegions.reduce((acc, region) => acc + (region.tauxChomage ?? 0), 0) ?? 0) / + (dataRegions.statsRegions.length ?? 1) + : undefined, + }; +} -export default withAuth("pilotage_reforme/lecture", function PilotageReforme() { - const router = useRouter(); - const queryParams = useSearchParams(); - const searchParams: { - filters?: Partial; - order?: Partial; - } = qs.parse(queryParams.toString(), { arrayLimit: Infinity }); +const usePilotageReformeHook = () => { + const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure(); + + const [searchParams, setSearchParams] = useStateParams<{filters: Filters, order: Order}>({ + defaultValues: { + filters: {}, + order: { order: "asc" }, + }, + }); const filters = searchParams.filters ?? {}; const order = searchParams.order ?? { order: "asc" }; - const setSearchParams = (params: { filters?: typeof filters; order?: typeof order }) => { - router.replace(createParameterizedUrl(location.pathname, { ...searchParams, ...params })); - }; - const trackEvent = usePlausible(); const filterTracker = (filterName: keyof Filters) => () => { trackEvent("pilotage-reforme:filtre", { @@ -43,11 +89,11 @@ export default withAuth("pilotage_reforme/lecture", function PilotageReforme() { const handleOrder = (column: Order["orderBy"]) => { trackEvent("pilotage-reforme:ordre", { props: { colonne: column } }); if (order?.orderBy !== column) { - setSearchParams({ order: { order: "desc", orderBy: column } }); + setSearchParams({ ...searchParams, order: { order: "desc", orderBy: column } }); return; } setSearchParams({ - ...filters, + ...searchParams, order: { order: order?.order === "asc" ? "desc" : "asc", orderBy: column, @@ -57,14 +103,15 @@ export default withAuth("pilotage_reforme/lecture", function PilotageReforme() { const handleFilters = (type: keyof Filters | keyof FiltersRegions, value: Filters[keyof Filters]) => { setSearchParams({ - filters: { ...filters, [type]: value }, + ...searchParams, + filters: { ...searchParams.filters, [type]: value }, }); }; const { data, isLoading } = client.ref("[GET]/pilotage-reforme/stats").useQuery( { query: { - ...filters, + ...searchParams.filters, }, }, { @@ -90,24 +137,49 @@ export default withAuth("pilotage_reforme/lecture", function PilotageReforme() { const isFiltered = filters.codeRegion; - const indicateurOptions = [ - { - label: "Taux d'emploi à 6 mois", - value: "tauxInsertion", - isDefault: true, - }, - { - label: "Taux de poursuite d'études", - value: "tauxPoursuite", - isDefault: false, - }, - ]; - - const [indicateur, setIndicateur] = useState("tauxInsertion"); - - const handleIndicateurChange = (indicateur: string): void => { - if (indicateur === "tauxInsertion" || indicateur === "tauxPoursuite") setIndicateur(indicateur); + const nationalStats = useMemo(() => calcNationalStats(data, dataRegions), [data, dataRegions]); + + const displayEmptyDataBoard = useMemo(() => { + return filters.codeNiveauDiplome && ["381", "481", "581", "241", "561", "461"].includes(filters.codeNiveauDiplome); + }, [filters.codeNiveauDiplome]); + + return { + filters, + order, + handleFilters, + handleOrder, + isFiltered, + data, + isLoading, + filterTracker, + dataRegions, + isLoadingRegions, + nationalStats, + isModalOpen, + onModalOpen, + onModalClose, + displayEmptyDataBoard }; +}; + +export default withAuth("pilotage_reforme/lecture", function PilotageReforme() { + const { + filters, + order, + handleFilters, + handleOrder, + isFiltered, + data, + isLoading, + filterTracker, + dataRegions, + isLoadingRegions, + nationalStats, + isModalOpen, + onModalOpen, + onModalClose, + displayEmptyDataBoard + } = usePilotageReformeHook(); return ( @@ -119,41 +191,46 @@ export default withAuth("pilotage_reforme/lecture", function PilotageReforme() { isLoading={isLoading} data={data} /> - - - - - + ): ( + + + + + + + + + + - - - - - - - + + + )} + ); }); diff --git a/ui/app/(wrapped)/pilotage-reforme/types.ts b/ui/app/(wrapped)/pilotage-reforme/types.ts index 7b211def4..ae5863222 100644 --- a/ui/app/(wrapped)/pilotage-reforme/types.ts +++ b/ui/app/(wrapped)/pilotage-reforme/types.ts @@ -15,4 +15,15 @@ export type PilotageReformeStats = (typeof client.infer)["[GET]/pilotage-reforme export type PilotageReformeStatsRegion = (typeof client.infer)["[GET]/pilotage-reforme/stats/regions"]; -export type IndicateurType = "tauxInsertion" | "tauxPoursuite"; +export type TauxTransformation = (typeof client.infer)["[GET]/pilotage-reforme/stats"]["tauxTransformationCumule"] + +export type IndicateurType = keyof Pick< + (typeof client.infer)["[GET]/pilotage-reforme/stats/regions"]["statsRegions"][number], + "tauxInsertion" | "tauxPoursuite" | "tauxTransformationCumule" | "tauxChomage" | "tauxTransformationCumulePrevisionnel" +>; + +export type IndicateurOption = { + label: string; + value: IndicateurType; + isDefault: boolean; +}; diff --git a/ui/components/CartoGraph.tsx b/ui/components/CartoGraph.tsx index 33be5dc1b..72888ba76 100644 --- a/ui/components/CartoGraph.tsx +++ b/ui/components/CartoGraph.tsx @@ -115,49 +115,101 @@ export const CartoGraph = ({ echarts.registerMap(scope, getGeoMap()); - // Utilisée pour supprimer les valeurs extrèmes - const removeMin = (array: number[]): number[] => { - const min = Math.min(...array); - return array.filter((value) => value != min); - }; - - const removeMax = (array: number[]): number[] => { - const max = Math.max(...array); - return array.filter((value) => value != max); - }; - //TODO : améliorer la gestion de la graduation dynamique const getPieces = (): { min: number; max: number; label: string; color: string; - }[] => { - const data = Array.from(graphData?.map((it) => it.value ?? -1) ?? []).filter((value) => value != -1); - const min = Math.min(...removeMin(data)); - const max = Math.max(...removeMax(data)); - const diff = max - min; - - const colorRange = colorPalette; - - const piecesStep = customPiecesSteps - ? customPiecesSteps - : [ - [0, min], - [min, Math.ceil(max - diff / 4)], - [Math.ceil(max - diff / 4), max], - [max, 100], - ]; - - return piecesStep.map((step, index, steps) => { - const isLastStep = index + 1 === steps.length; - return { - min: step[0], - max: step[1], - label: isLastStep ? `> ${step[0]}%` : `< ${step[1]}%`, - color: colorRange[index], - }; - }); +}[] => { + if(customPiecesSteps) { + const colorRange = colorPalette; + + return customPiecesSteps.map((step, index, steps) => { + const isLastStep = index + 1 === steps.length; + return { + min: step[0], + max: step[1], + label: isLastStep ? `> ${step[0]}%` : `< ${step[1]}%`, + color: colorRange[index], + }; + }); + } + + // Filtrer les valeurs valides et les trier + const validData = Array.from(graphData?.map((it) => it.value ?? -1) ?? []) + .filter((value) => value !== -1) + .sort((a, b) => a - b); + + if (validData.length === 0) return []; + + // Cas où toutes les valeurs sont identiques + if (validData[0] === validData[validData.length - 1]) { + return [{ + min: validData[0], + max: validData[0], + label: `${validData[0]}%`, + color: colorPalette[0] + }]; + } + + // Obtenir les valeurs uniques + const uniqueValues = Array.from(new Set(validData)); + + // Si nous avons 4 valeurs uniques ou moins, créer un ensemble pour chaque valeur + if (uniqueValues.length <= 4) { + return uniqueValues.map((value, index) => ({ + min: value, + max: value, + label: `${value}%`, + color: colorPalette[index] + })); + } + + // Pour plus de 4 valeurs, créer 4 quartiles + const getQuartileValue = (arr: number[], quartile: number): number => { + const position = (arr.length - 1) * (quartile / 4); + const base = Math.floor(position); + const rest = position - base; + + if (rest === 0) { + return Math.round(arr [base]); + } else { + return Math.round(arr[base] + rest * (arr[base + 1] - arr[base])); + } + }; + + // Calculer les quartiles + const q1 = getQuartileValue(validData, 1); // 25% + const q2 = getQuartileValue(validData, 2); // 50% + const q3 = getQuartileValue(validData, 3); // 75% + + return [ + { + min: validData[0], + max: q1, + label: `≤ ${q1}%`, + color: colorPalette[0] + }, + { + min: q1, + max: q2, + label: `≤ ${q2}%`, + color: colorPalette[1] + }, + { + min: q2, + max: q3, + label: `≤ ${q3}%`, + color: colorPalette[2] + }, + { + min: q3, + max: validData[validData.length - 1], + label: `> ${q3}%`, + color: colorPalette[3] + } + ]; }; const option = useMemo( diff --git a/ui/utils/__tests__/formatUtils.test.ts b/ui/utils/__tests__/formatUtils.test.ts index 6d6e96576..5de7ae4e2 100644 --- a/ui/utils/__tests__/formatUtils.test.ts +++ b/ui/utils/__tests__/formatUtils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatBoolean } from "@/utils/formatUtils"; +import { formatBoolean, formatPercentageFixedDigits } from "@/utils/formatUtils"; describe("formatBoolean", () => { it("doit retourner 'Oui' si la valeur est true", () => { @@ -13,3 +13,21 @@ describe("formatBoolean", () => { expect(formatBoolean(undefined)).toBe("Non"); }); }); + +describe("formatPercentageFixedDigits", () => { + it("doit retourner 0 % si la valeur est undefined", () => { + expect(formatPercentageFixedDigits(undefined)).toEqual("0 %"); + }); + + it("doit retourner 2.20% si la valeur est 2.2", () => { + expect(formatPercentageFixedDigits(0.022, 2)).toEqual("2,20\u00A0%"); + }); + + it("doit retourner 2.22% si la valeur est 2.22123", () => { + expect(formatPercentageFixedDigits(0.022123, 2)).toEqual("2,21\u00A0%"); + }); + + it("doit retourner 2.22% si la valeur est 2.218", () => { + expect(formatPercentageFixedDigits(0.02218, 2)).toEqual("2,22\u00A0%"); + }); +}); diff --git a/ui/utils/formatUtils.ts b/ui/utils/formatUtils.ts index 56dde883a..f618346e4 100644 --- a/ui/utils/formatUtils.ts +++ b/ui/utils/formatUtils.ts @@ -66,6 +66,19 @@ export const formatPercentage = ( }).format(value); }; +export const formatPercentageFixedDigits = ( + value?: number | null, + numberOfDigits: number = 0, + nullValue: string = "0 %" +): string => { + if (value === undefined || value === null || Number.isNaN(value)) return nullValue; + return new Intl.NumberFormat("fr-FR", { + style: "percent", + maximumFractionDigits: numberOfDigits, + minimumFractionDigits: numberOfDigits, + }).format(value); +}; + export const formatPercentageWithoutSign = ( value?: number, numberOfDigits: number = 0,