From 9c8a03e31a8fb2d6e64c3de5688e025a60ba7919 Mon Sep 17 00:00:00 2001 From: Gaetan BUSATO Date: Tue, 17 Dec 2024 09:55:24 +0100 Subject: [PATCH] feat(formation): ajout de la page formation (#492) feat(formation): ajout de la page formation --- .gitignore | 7 + server/src/modules/data/index.ts | 10 + .../dependencies/getFilters.dep.ts | 44 ++ .../dependencies/getFormations.dep.ts | 105 +++++ .../dependencies/getNsf.dep.ts | 4 + .../dependencies/index.ts | 3 + .../getDomaineDeFormation.route.ts | 24 + .../getDomaineDeFormation.usecase.ts | 39 ++ .../dependencies/getNsfsAndFormations.ts | 65 +++ .../getDomainesDeFormation.route.ts | 24 + .../getDomainesDeFormation.usecase.ts | 8 + .../__tests__/getFormation.test.ts | 78 ++++ .../dependencies/getFormation.dep.ts | 166 +++++++ .../getFormationMailleEtab.dep.ts | 83 ++++ .../getFormation/dependencies/index.ts | 1 + .../getFormation/getFormation.route.ts | 24 + .../getFormation/getFormation.usecase.ts | 27 ++ .../dependencies/getBoundaries.dep.ts | 41 ++ .../dependencies/getEtablissements.dep.ts | 167 +++++++ .../dependencies/getFormation.dep.ts | 18 + .../dependencies/index.ts | 3 + .../getFormationCarteEtablissements.route.ts | 23 + ...getFormationCarteEtablissements.usecase.ts | 38 ++ .../getDomaineDeFormationIndicateurs.test.ts | 411 ++++++++++++++++++ .../dependencies/getEffectifs.dep.ts | 29 ++ .../dependencies/getEtablissements.dep.ts | 28 ++ .../dependencies/getFormation.dep.ts | 18 + .../getFormationMailleEtab.dep.ts | 77 ++++ .../getSoldePlacesTransformee.dep.ts | 50 +++ .../dependencies/getTauxIJ.dep.ts | 73 ++++ .../dependencies/getTauxPressions.dep.ts | 68 +++ .../dependencies/getTauxRemplissage.dep.ts | 68 +++ .../dependencies/index.ts | 6 + .../getFormationIndicateurs.route.ts | 24 + .../getFormationIndicateurs.usecase.ts | 73 ++++ .../getFormations/getFormations.usecase.ts | 4 +- .../modules/import/services/ban/ban.api.ts | 4 +- .../franceTravail/franceTravail.api.ts | 4 +- .../inserJeunesApi/inserJeunes.api.ts | 4 +- shared/routes/routes.ts | 34 +- ...get.domaine-de-formation.codeNsf.schema.ts | 55 +++ .../get.domaine-de-formation.schema.ts | 19 + .../get.formation.cfd.indicators.schema.ts | 67 +++ .../schemas/get.formation.cfd.map.schema.ts | 61 +++ .../schemas/get.formation.cfd.schema.ts | 34 ++ .../routes/schemas/get.formations.schema.ts | 2 +- ui/app/(wrapped)/components/Nav.tsx | 5 + .../domaine-de-formation/[codeNsf]/client.tsx | 57 +++ .../FiltersSection/FiltersSection.tsx | 130 ++++++ .../FormationSection/FormationHeader.tsx | 23 + .../FormationSection/FormationTabs.tsx | 46 ++ .../FormationSection/ListeFormations.tsx | 132 ++++++ .../FormationSection/TabFilters.tsx | 46 ++ .../ExportListEtablissements.tsx | 101 +++++ .../EtablissementsTab/List/CustomListItem.tsx | 52 +++ .../Tabs/EtablissementsTab/List/index.tsx | 36 ++ .../Map/ActiveEtablissementLayers.tsx | 145 ++++++ .../Map/ActiveEtablissementPopup.tsx | 33 ++ .../EtablissementsTab/Map/CustomControls.tsx | 92 ++++ .../Map/EtablissementsLayers.tsx | 203 +++++++++ .../Tabs/EtablissementsTab/Map/index.tsx | 85 ++++ .../Tabs/EtablissementsTab/MapActions.tsx | 114 +++++ .../components/EtablissementItemContent.tsx | 159 +++++++ .../components/FormationAbsente.tsx | 21 + .../Tabs/EtablissementsTab/index.tsx | 208 +++++++++ .../Tabs/IndicateursTab/DevenirBarGraph.tsx | 164 +++++++ .../Tabs/IndicateursTab/DonneesManquantes.tsx | 90 ++++ .../DonneesManquantesApprentissageCard.tsx | 51 +++ .../EffectifsEtAttractiviteGraphs.tsx | 260 +++++++++++ .../IndicateursTab/ExportListIndicateurs.tsx | 192 ++++++++ .../Tabs/IndicateursTab/NotInScope.tsx | 37 ++ .../SoldeDePlacesTransformeesCard.tsx | 71 +++ .../SoldeDePlacesTransformeesGraph.tsx | 88 ++++ .../Tabs/IndicateursTab/TauxIJCard.tsx | 139 ++++++ .../TauxPressionRemplissageCard.tsx | 191 ++++++++ .../TauxPressionRemplissageGraph.tsx | 165 +++++++ .../Tabs/IndicateursTab/displayIndicators.ts | 30 ++ .../Tabs/IndicateursTab/index.tsx | 171 ++++++++ .../components/FormationSection/index.tsx | 114 +++++ .../components/HeaderSection/AccesRapide.tsx | 22 + .../HeaderSection/HeaderSection.tsx | 36 ++ .../LiensUtilesSection/LiensUtilesSection.tsx | 112 +++++ .../[codeNsf]/context/formationContext.tsx | 251 +++++++++++ .../domaine-de-formation/[codeNsf]/page.tsx | 175 ++++++++ .../domaine-de-formation/[codeNsf]/types.ts | 62 +++ .../(wrapped)/domaine-de-formation/page.tsx | 3 + .../intentions/perdir/saisie/page.client.tsx | 2 +- .../main/commentaireEtAvis/UpdateAvisForm.tsx | 2 +- .../panorama/domaine-de-formation/client.tsx | 63 +++ .../components/selectNsf.tsx | 109 +++++ .../panorama/domaine-de-formation/page.tsx | 32 ++ .../panorama/domaine-de-formation/types.tsx | 4 + .../components/LineChart.tsx | 12 +- .../carto/components/ExportList.tsx | 8 +- .../components/CustomListItem.tsx | 36 +- .../header/components/AccesRapideSection.tsx | 30 +- .../components/header/components/Filieres.tsx | 41 +- .../liens-utiles/components/LiensUtiles.tsx | 21 +- ui/app/(wrapped)/panorama/layout.tsx | 18 +- ui/components/BadgeFormationRenovee.tsx | 29 ++ ui/components/BadgeScope.tsx | 36 ++ ui/public/illustrations/loupe-recherche.svg | 17 + ui/public/illustrations/team-at-work.svg | 110 +++++ ui/theme/Badge.theme.ts | 6 + ui/theme/themeColors.ts | 5 + ui/utils/formatLibelle.tsx | 29 +- ui/utils/formatUtils.ts | 22 +- ui/utils/getNsfIcon.ts | 33 ++ ui/utils/liensDares.ts | 15 + ui/utils/useFilters.tsx | 27 +- 110 files changed, 6877 insertions(+), 157 deletions(-) create mode 100644 server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getFilters.dep.ts create mode 100644 server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getFormations.dep.ts create mode 100644 server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getNsf.dep.ts create mode 100644 server/src/modules/data/usecases/getDomaineDeFormation/dependencies/index.ts create mode 100644 server/src/modules/data/usecases/getDomaineDeFormation/getDomaineDeFormation.route.ts create mode 100644 server/src/modules/data/usecases/getDomaineDeFormation/getDomaineDeFormation.usecase.ts create mode 100644 server/src/modules/data/usecases/getDomainesDeFormation/dependencies/getNsfsAndFormations.ts create mode 100644 server/src/modules/data/usecases/getDomainesDeFormation/getDomainesDeFormation.route.ts create mode 100644 server/src/modules/data/usecases/getDomainesDeFormation/getDomainesDeFormation.usecase.ts create mode 100644 server/src/modules/data/usecases/getFormation/__tests__/getFormation.test.ts create mode 100644 server/src/modules/data/usecases/getFormation/dependencies/getFormation.dep.ts create mode 100644 server/src/modules/data/usecases/getFormation/dependencies/getFormationMailleEtab.dep.ts create mode 100644 server/src/modules/data/usecases/getFormation/dependencies/index.ts create mode 100644 server/src/modules/data/usecases/getFormation/getFormation.route.ts create mode 100644 server/src/modules/data/usecases/getFormation/getFormation.usecase.ts create mode 100644 server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getBoundaries.dep.ts create mode 100644 server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getEtablissements.dep.ts create mode 100644 server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getFormation.dep.ts create mode 100644 server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/index.ts create mode 100644 server/src/modules/data/usecases/getFormationCarteEtablissements/getFormationCarteEtablissements.route.ts create mode 100644 server/src/modules/data/usecases/getFormationCarteEtablissements/getFormationCarteEtablissements.usecase.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/__tests__/getDomaineDeFormationIndicateurs.test.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getEffectifs.dep.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getEtablissements.dep.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getFormation.dep.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getFormationMailleEtab.dep.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getSoldePlacesTransformee.dep.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxIJ.dep.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxPressions.dep.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxRemplissage.dep.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/dependencies/index.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/getFormationIndicateurs.route.ts create mode 100644 server/src/modules/data/usecases/getFormationIndicateurs/getFormationIndicateurs.usecase.ts create mode 100644 shared/routes/schemas/get.domaine-de-formation.codeNsf.schema.ts create mode 100644 shared/routes/schemas/get.domaine-de-formation.schema.ts create mode 100644 shared/routes/schemas/get.formation.cfd.indicators.schema.ts create mode 100644 shared/routes/schemas/get.formation.cfd.map.schema.ts create mode 100644 shared/routes/schemas/get.formation.cfd.schema.ts create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/client.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FiltersSection/FiltersSection.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/FormationHeader.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/FormationTabs.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/ListeFormations.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/TabFilters.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/ExportListEtablissements.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/List/CustomListItem.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/List/index.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/ActiveEtablissementLayers.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/ActiveEtablissementPopup.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/CustomControls.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/EtablissementsLayers.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/index.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/MapActions.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/components/EtablissementItemContent.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/components/FormationAbsente.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/index.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DevenirBarGraph.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DonneesManquantes.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DonneesManquantesApprentissageCard.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/EffectifsEtAttractiviteGraphs.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/ExportListIndicateurs.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/NotInScope.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/SoldeDePlacesTransformeesCard.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/SoldeDePlacesTransformeesGraph.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxIJCard.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxPressionRemplissageCard.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxPressionRemplissageGraph.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/displayIndicators.ts create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/index.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/index.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/HeaderSection/AccesRapide.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/HeaderSection/HeaderSection.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/LiensUtilesSection/LiensUtilesSection.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/page.tsx create mode 100644 ui/app/(wrapped)/domaine-de-formation/[codeNsf]/types.ts create mode 100644 ui/app/(wrapped)/domaine-de-formation/page.tsx create mode 100644 ui/app/(wrapped)/panorama/domaine-de-formation/client.tsx create mode 100644 ui/app/(wrapped)/panorama/domaine-de-formation/components/selectNsf.tsx create mode 100644 ui/app/(wrapped)/panorama/domaine-de-formation/page.tsx create mode 100644 ui/app/(wrapped)/panorama/domaine-de-formation/types.tsx create mode 100644 ui/components/BadgeFormationRenovee.tsx create mode 100644 ui/components/BadgeScope.tsx create mode 100644 ui/public/illustrations/loupe-recherche.svg create mode 100644 ui/public/illustrations/team-at-work.svg create mode 100644 ui/utils/getNsfIcon.ts create mode 100644 ui/utils/liensDares.ts diff --git a/.gitignore b/.gitignore index bd6afb083..a96cefb17 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,10 @@ seed.gz tsconfig.tsbuildinfo .infra_ + + +# Vs code config +.vscode/terminals.json + +# Cursor +.cursorrules diff --git a/server/src/modules/data/index.ts b/server/src/modules/data/index.ts index 6d3cb061e..f7ee4e54e 100644 --- a/server/src/modules/data/index.ts +++ b/server/src/modules/data/index.ts @@ -9,8 +9,13 @@ import { getDataForPanoramaRegionRoute } from "./usecases/getDataForPanoramaRegi import { getDemandesRestitutionIntentionsRoute } from "./usecases/getDemandesRestitutionIntentions/getDemandesRestitutionIntentions.route"; import { getDepartementRoute } from "./usecases/getDepartement/getDepartement.route"; import { getDepartementsRoute } from "./usecases/getDepartements/getDepartements.route"; +import { getDomaineDeFormationRoute } from "./usecases/getDomaineDeFormation/getDomaineDeFormation.route"; +import { getDomainesDeFormationRoute } from "./usecases/getDomainesDeFormation/getDomainesDeFormation.route"; import { getEtablissementRoute } from "./usecases/getEtablissement/getEtablissement.route"; +import { getFormationRoute } from "./usecases/getFormation/getFormation.route"; +import { getFormationCarteEtablissementsRoute } from "./usecases/getFormationCarteEtablissements/getFormationCarteEtablissements.route"; import { getFormationEtablissementsRoutes } from "./usecases/getFormationEtablissements/getFormationEtablissements.routes"; +import { getFormationIndicateursRoute } from "./usecases/getFormationIndicateurs/getFormationIndicateurs.route"; import { getFormationsRoute } from "./usecases/getFormations/getFormations.routes"; import { getFormationsPilotageIntentionsRoute } from "./usecases/getFormationsPilotageIntentions/getFormationsPilotageIntentions.route"; import { getHeaderEtablissementRoute } from "./usecases/getHeaderEtablissement/getHeaderEtablissement.route"; @@ -63,5 +68,10 @@ export const registerDataModule = (server: Server) => { ...searchFiliereRoute(server), ...searchCampusRoute(server), ...getRepartitionPilotageIntentionsRoute(server), + ...getDomainesDeFormationRoute(server), + ...getFormationRoute(server), + ...getDomaineDeFormationRoute(server), + ...getFormationIndicateursRoute(server), + ...getFormationCarteEtablissementsRoute(server), }; }; diff --git a/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getFilters.dep.ts b/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getFilters.dep.ts new file mode 100644 index 000000000..a02939c53 --- /dev/null +++ b/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getFilters.dep.ts @@ -0,0 +1,44 @@ +import { getKbdClient } from "@/db/db"; +import { + isInPerimetreIJAcademie, + isInPerimetreIJDepartement, + isInPerimetreIJRegion, +} from "@/modules/data/utils/isInPerimetreIJ"; + +export const getFilters = async () => { + const regions = await getKbdClient() + .selectFrom("region") + .select(["region.libelleRegion as label", "region.codeRegion as value"]) + .where(isInPerimetreIJRegion) + .orderBy("region.libelleRegion", "asc") + .execute(); + + const academies = await getKbdClient() + .selectFrom("academie") + .select([ + "academie.libelleAcademie as label", + "academie.codeAcademie as value", + "academie.codeRegion as codeRegion", + ]) + .where(isInPerimetreIJAcademie) + .orderBy("academie.libelleAcademie", "asc") + .execute(); + + const departements = await getKbdClient() + .selectFrom("departement") + .select([ + "departement.libelleDepartement as label", + "departement.codeDepartement as value", + "departement.codeAcademie as codeAcademie", + "departement.codeRegion as codeRegion", + ]) + .where(isInPerimetreIJDepartement) + .orderBy("departement.libelleDepartement", "asc") + .execute(); + + return { + regions, + academies, + departements, + }; +}; diff --git a/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getFormations.dep.ts b/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getFormations.dep.ts new file mode 100644 index 000000000..cfd6d6bf5 --- /dev/null +++ b/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getFormations.dep.ts @@ -0,0 +1,105 @@ +import { sql } from "kysely"; +import { CURRENT_RENTREE } from "shared"; +import type { formationSchema } from "shared/routes/schemas/get.domaine-de-formation.codeNsf.schema"; +import { getDateRentreeScolaire } from "shared/utils/getRentreeScolaire"; +import type { z } from "zod"; + +import { getKbdClient } from "@/db/db"; +import { notHistoriqueUnlessCoExistant } from "@/modules/data/utils/notHistorique"; +import { cleanNull } from "@/utils/noNull"; + +export const getFormations = async ({ + codeNsf, + codeRegion, + codeDepartement, + codeAcademie, +}: { + codeNsf: string; + codeRegion?: string; + codeDepartement?: string; + codeAcademie?: string; +}) => + getKbdClient() + .with("formations", (wb) => + wb + .selectFrom("formationView") + .innerJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formationView.codeNiveauDiplome") + .where("formationView.codeNsf", "=", codeNsf) + .where((w) => notHistoriqueUnlessCoExistant(w, CURRENT_RENTREE)) + .select((sb) => [ + sb.ref("formationView.codeNsf").as("codeNsf"), + sb.ref("formationView.cfd").as("cfd"), + sb.ref("formationView.libelleFormation").as("libelleFormation"), + sb.ref("formationView.dateOuverture").as("dateOuverture"), + sb.ref("niveauDiplome.libelleNiveauDiplome").as("libelleNiveauDiplome"), + sb.ref("niveauDiplome.codeNiveauDiplome").as("codeNiveauDiplome"), + sb.ref("formationView.typeFamille").as("typeFamille"), + ]) + .orderBy("formationView.libelleFormation", "asc") + .distinct() + ) + .with("formation_renovee", (wb) => + wb + .selectFrom("formations") + .leftJoin("formationHistorique", "formationHistorique.cfd", "formations.cfd") + .where("formations.dateOuverture", "<=", sql`${getDateRentreeScolaire(CURRENT_RENTREE)}`) + .where("formationHistorique.ancienCFD", "in", (eb) => eb.selectFrom("formationEtablissement").select("cfd")) + .select("formationHistorique.cfd") + .distinct() + ) + .with("formation_etab", (wb) => + wb + .selectFrom("formations") + .leftJoin("formationEtablissement", "formations.cfd", "formationEtablissement.cfd") + .innerJoin("dataEtablissement", "dataEtablissement.uai", "formationEtablissement.uai") + .selectAll("formations") + .select((sb) => [ + sb.ref("formationEtablissement.uai").as("uai"), + sb.ref("formationEtablissement.voie").as("voie"), + sb.ref("dataEtablissement.codeRegion").as("codeRegion"), + sb.ref("dataEtablissement.codeAcademie").as("codeAcademie"), + sb.ref("dataEtablissement.codeDepartement").as("codeDepartement"), + ]) + .$call((q) => { + if (codeRegion) { + return q.where("codeRegion", "=", codeRegion); + } + return q; + }) + .$call((q) => { + if (codeAcademie) { + return q.where("codeAcademie", "=", codeAcademie); + } + return q; + }) + .$call((q) => { + if (codeDepartement) { + return q.where("codeDepartement", "=", codeDepartement); + } + return q; + }) + ) + .selectFrom("formations") + .leftJoin("formation_etab", "formations.cfd", "formation_etab.cfd") + .selectAll("formations") + .select((sb) => [ + sb.fn.count("formation_etab.uai").as("nbEtab"), + sql`bool_or(voie = 'apprentissage' OR voie IS NULL)`.as("apprentissage"), + sql`bool_or(voie = 'scolaire' OR voie IS NULL)`.as("scolaire"), + sb.fn + .coalesce(sql`formations.cfd IN (SELECT cfd FROM formation_renovee)`, sql`false`) + .as("isFormationRenovee"), + ]) + .distinct() + .groupBy([ + "formations.cfd", + "formations.libelleFormation", + "formations.libelleNiveauDiplome", + "formations.codeNiveauDiplome", + "formations.typeFamille", + "formations.codeNsf", + "formations.dateOuverture", + ]) + .$castTo>() + .execute() + .then(cleanNull); diff --git a/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getNsf.dep.ts b/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getNsf.dep.ts new file mode 100644 index 000000000..0386ad535 --- /dev/null +++ b/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/getNsf.dep.ts @@ -0,0 +1,4 @@ +import { getKbdClient } from "@/db/db"; + +export const getNsf = async (codeNsf: string) => + getKbdClient().selectFrom("nsf").where("codeNsf", "=", codeNsf).selectAll().executeTakeFirst(); diff --git a/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/index.ts b/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/index.ts new file mode 100644 index 000000000..f29fe2802 --- /dev/null +++ b/server/src/modules/data/usecases/getDomaineDeFormation/dependencies/index.ts @@ -0,0 +1,3 @@ +export { getFilters } from "./getFilters.dep"; +export { getFormations } from "./getFormations.dep"; +export { getNsf } from "./getNsf.dep"; diff --git a/server/src/modules/data/usecases/getDomaineDeFormation/getDomaineDeFormation.route.ts b/server/src/modules/data/usecases/getDomaineDeFormation/getDomaineDeFormation.route.ts new file mode 100644 index 000000000..67a1d0ca4 --- /dev/null +++ b/server/src/modules/data/usecases/getDomaineDeFormation/getDomaineDeFormation.route.ts @@ -0,0 +1,24 @@ +import { createRoute } from "@http-wizard/core"; +import { ROUTES } from "shared/routes/routes"; + +import type { Server } from "@/server/server"; + +import { getDomaineDeFormation } from "./getDomaineDeFormation.usecase"; + +const ROUTE = ROUTES["[GET]/domaine-de-formation/:codeNsf"]; + +export const getDomaineDeFormationRoute = (server: Server) => { + return createRoute(ROUTE.url, { + method: ROUTE.method, + schema: ROUTE.schema, + }).handle((props) => + server.route({ + ...props, + handler: async (request, response) => { + const { codeNsf } = request.params; + const result = await getDomaineDeFormation(codeNsf, request.query); + response.status(200).send(result); + }, + }) + ); +}; diff --git a/server/src/modules/data/usecases/getDomaineDeFormation/getDomaineDeFormation.usecase.ts b/server/src/modules/data/usecases/getDomaineDeFormation/getDomaineDeFormation.usecase.ts new file mode 100644 index 000000000..bd75e6b9e --- /dev/null +++ b/server/src/modules/data/usecases/getDomaineDeFormation/getDomaineDeFormation.usecase.ts @@ -0,0 +1,39 @@ +import Boom from "@hapi/boom"; +import type { QueryFilters } from "shared/routes/schemas/get.domaine-de-formation.codeNsf.schema"; + +import { getFilters, getFormations, getNsf } from "./dependencies"; + +const getDomaineDeFormationFactory = + ( + deps = { + getNsf, + getFilters, + getFormations, + } + ) => + async (codeNsf: string, queryFilters: QueryFilters) => { + const { codeRegion, codeDepartement, codeAcademie } = queryFilters; + const [nsf, filters, formations] = await Promise.all([ + deps.getNsf(codeNsf), + deps.getFilters(), + deps.getFormations({ + codeNsf, + codeRegion, + codeDepartement, + codeAcademie, + }), + ]); + + if (!nsf) { + throw Boom.notFound(`Le domaine de formation avec le code ${codeNsf} est inconnue`); + } + + return { + codeNsf, + libelleNsf: nsf.libelleNsf, + filters, + formations, + }; + }; + +export const getDomaineDeFormation = getDomaineDeFormationFactory(); diff --git a/server/src/modules/data/usecases/getDomainesDeFormation/dependencies/getNsfsAndFormations.ts b/server/src/modules/data/usecases/getDomainesDeFormation/dependencies/getNsfsAndFormations.ts new file mode 100644 index 000000000..6882c28c8 --- /dev/null +++ b/server/src/modules/data/usecases/getDomainesDeFormation/dependencies/getNsfsAndFormations.ts @@ -0,0 +1,65 @@ +import { sql } from "kysely"; +import { CURRENT_RENTREE } from "shared"; +import type { NsfOption } from "shared/routes/schemas/get.domaine-de-formation.schema"; + +import { getKbdClient } from "@/db/db"; +import { openForRentreeScolaire } from "@/modules/data/utils/openForRentreeScolaire"; +import { getNormalizedSearchArray } from "@/modules/utils/normalizeSearch"; +import { cleanNull } from "@/utils/noNull"; + +export const getNsfsAndFormations = async (search?: string) => { + let baseQuery = getKbdClient() + .selectFrom("formationView") + .leftJoin("nsf", "nsf.codeNsf", "formationView.codeNsf") + .select((sb) => [ + sb.ref("nsf.libelleNsf").as("label"), + sb.ref("formationView.codeNsf").as("value"), + sb.ref("nsf.codeNsf").as("nsf"), + sb.val("nsf").as("type"), + ]) + .where("nsf.libelleNsf", "is not", null) + .where((eb) => openForRentreeScolaire(eb, CURRENT_RENTREE)); + + if (search) { + const searchArray = getNormalizedSearchArray(search); + + baseQuery = baseQuery + .where((w) => + w.and( + searchArray.map((search_word) => w(sql`unaccent(${w.ref("nsf.libelleNsf")})`, "ilike", `%${search_word}%`)) + ) + ) + .union( + getKbdClient() + .selectFrom("formationView") + .leftJoin("nsf", "nsf.codeNsf", "formationView.codeNsf") + .leftJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formationView.codeNiveauDiplome") + .where("nsf.libelleNsf", "is not", null) + .select((sb) => [ + sql`concat( ${sb.ref( + "niveauDiplome.libelleNiveauDiplome" + )}, ' - ', ${sb.ref("formationView.libelleFormation")})`.as("label"), + sb.ref("formationView.cfd").as("value"), + sb.ref("nsf.codeNsf").as("nsf"), + sb.val("formation").as("type"), + ]) + .where((w) => + w.and( + searchArray.map((search_word) => + w( + sql`concat( + unaccent(${w.ref("formationView.libelleFormation")}), + ' ', + unaccent(${w.ref("formationView.cfd")}) + )`, + "ilike", + `%${search_word}%` + ) + ) + ) + ) + ); + } + + return baseQuery.orderBy("label", "asc").distinct().$castTo().execute().then(cleanNull); +}; diff --git a/server/src/modules/data/usecases/getDomainesDeFormation/getDomainesDeFormation.route.ts b/server/src/modules/data/usecases/getDomainesDeFormation/getDomainesDeFormation.route.ts new file mode 100644 index 000000000..b3a6f311b --- /dev/null +++ b/server/src/modules/data/usecases/getDomainesDeFormation/getDomainesDeFormation.route.ts @@ -0,0 +1,24 @@ +import { createRoute } from "@http-wizard/core"; +import { ROUTES } from "shared/routes/routes"; + +import type { Server } from "@/server/server"; + +import { getDomainesDeFormation } from "./getDomainesDeFormation.usecase"; + +const ROUTE = ROUTES["[GET]/domaine-de-formation"]; + +export const getDomainesDeFormationRoute = (server: Server) => { + return createRoute(ROUTE.url, { + method: ROUTE.method, + schema: ROUTE.schema, + }).handle((props) => + server.route({ + ...props, + handler: async (request, response) => { + const { search } = request.query; + const result = await getDomainesDeFormation(search); + response.status(200).send(result); + }, + }) + ); +}; diff --git a/server/src/modules/data/usecases/getDomainesDeFormation/getDomainesDeFormation.usecase.ts b/server/src/modules/data/usecases/getDomainesDeFormation/getDomainesDeFormation.usecase.ts new file mode 100644 index 000000000..0eb166dc0 --- /dev/null +++ b/server/src/modules/data/usecases/getDomainesDeFormation/getDomainesDeFormation.usecase.ts @@ -0,0 +1,8 @@ +import { getNsfsAndFormations } from "./dependencies/getNsfsAndFormations"; + +const getDomainesDeFormationFactory = + (deps = { getNsfsAndFormations }) => + async (search?: string) => + deps.getNsfsAndFormations(search); + +export const getDomainesDeFormation = getDomainesDeFormationFactory(); diff --git a/server/src/modules/data/usecases/getFormation/__tests__/getFormation.test.ts b/server/src/modules/data/usecases/getFormation/__tests__/getFormation.test.ts new file mode 100644 index 000000000..883070b45 --- /dev/null +++ b/server/src/modules/data/usecases/getFormation/__tests__/getFormation.test.ts @@ -0,0 +1,78 @@ +import { usePg } from "@tests/pg.test.utils"; +import type { getFormationCfdSchema } from "shared/routes/schemas/get.formation.cfd.schema"; +import { beforeAll, describe, expect, it } from "vitest"; +import type { z } from "zod"; + +import type { Server } from "@/server/server.js"; +import createServer from "@/server/server.js"; + +usePg(); + +type Response = z.infer<(typeof getFormationCfdSchema.response)[200]>; + +describe("GET /api/formation/:cfd", () => { + let app: Server; + + beforeAll(async () => { + app = await createServer(); + await app.ready(); + + return async () => app.close(); + }, 15_000); + + it("doit retourner une erreur 404 si le cfd n'existe pas", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/formation/1234567890", + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ + message: "La formation avec le cfd 1234567890 est inconnue", + name: "Not Found", + statusCode: 404, + }); + }); + + it("doit retourner les informations d'une formation", async () => { + const cfdBTS = "32024111"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfdBTS}`, + }); + + expect(response.statusCode).toBe(200); + const result = await response.json(); + expect(result.cfd).toBe(cfdBTS); + expect(result.libelle).toBe("BTS - Innovation textile option a structures"); + expect(result.isBTS).toBe(true); + }); + + it("doit retourner l'information que le BTS - Innovation textile option a structures est un BTS", async () => { + const cfdBTS = "32024111"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfdBTS}`, + }); + + expect(response.statusCode).toBe(200); + const result = await response.json(); + expect(result.cfd).toBe(cfdBTS); + expect(result.libelle).toBe("BTS - Innovation textile option a structures"); + expect(result.isBTS).toBe(true); + }); + + it("doit retourner qu'un CFD n'est pas enseigné dans une région", async () => { + const cfdNotInScope = "32322112"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfdNotInScope}`, + }); + + expect(response.statusCode).toBe(200); + const result = await response.json(); + expect(result.cfd).toBe(cfdNotInScope); + expect(result.libelle).toBe("BTSA - Sciences et technologies des aliments spe produits laitiers"); + expect(result.isInScope).toBe(false); + }); +}); diff --git a/server/src/modules/data/usecases/getFormation/dependencies/getFormation.dep.ts b/server/src/modules/data/usecases/getFormation/dependencies/getFormation.dep.ts new file mode 100644 index 000000000..6f00e47b3 --- /dev/null +++ b/server/src/modules/data/usecases/getFormation/dependencies/getFormation.dep.ts @@ -0,0 +1,166 @@ +import { sql } from "kysely"; +import { CURRENT_RENTREE } from "shared"; +import { getDateRentreeScolaire } from "shared/utils/getRentreeScolaire"; + +import { getKbdClient } from "@/db/db"; +import { notHistoriqueUnlessCoExistant } from "@/modules/data/utils/notHistorique"; +import { cleanNull } from "@/utils/noNull"; + +import { getFormationMailleEtab } from "./getFormationMailleEtab.dep"; + +function getListOfCfdWhichAreTransition( + transitionType: "transitionNumerique" | "transitionEcologique" | "transitionDemographique", + cfd: string +) { + return getKbdClient() + .selectFrom( + getKbdClient() + .selectFrom("formationView") + .innerJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formationView.codeNiveauDiplome") + .leftJoin("formationRome", "formationRome.cfd", "formationView.cfd") + .leftJoin("rome", "rome.codeRome", "formationRome.codeRome") + .select((sb) => [ + sb.ref("formationView.cfd").as("cfd"), + sb.ref("niveauDiplome.libelleNiveauDiplome").as("libelleNiveauDiplome"), + sb.ref("formationView.libelleFormation").as("libelleFormation"), + sql`count(distinct ${sb.ref("formationRome.codeRome")})`.as("nbRomesTotal"), + sql`count(distinct case when ${sb.ref( + `rome.${transitionType}` + )} then ${sb.ref("formationRome.codeRome")} end)`.as(`nbRomes${transitionType}`), + ]) + .groupBy(["formationView.cfd", "niveauDiplome.libelleNiveauDiplome", "formationView.libelleFormation"]) + .as("count_romes_transition") + ) + .select((sb) => [sb.ref("count_romes_transition.cfd").as("cfd")]) + .where("count_romes_transition.cfd", "=", cfd) + .where("nbRomesTotal", ">", 0) + .whereRef("nbRomesTotal", "=", `nbRomes${transitionType}`); +} + +export const getFormation = async ({ + cfd, + codeRegion, + codeDepartement, + codeAcademie, +}: { + cfd: string; + codeRegion?: string; + codeDepartement?: string; + codeAcademie?: string; +}) => { + // Utilisation de la fonction générique + const listOfCfdWhichAreTransitionNumerique = getListOfCfdWhichAreTransition("transitionNumerique", cfd); + const listOfCfdWhichAreTransitionEcologique = getListOfCfdWhichAreTransition("transitionEcologique", cfd); + const listOfCfdWhichAreTransitionDemographique = getListOfCfdWhichAreTransition("transitionDemographique", cfd); + + const getFormation = () => + getKbdClient() + .selectFrom("formationView") + .innerJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formationView.codeNiveauDiplome") + .innerJoin("nsf", "nsf.codeNsf", "formationView.codeNsf") + .select([ + "libelleNsf", + "libelleNiveauDiplome", + "formationView.codeNiveauDiplome", + "formationView.libelleFormation", + "formationView.cfd", + "formationView.codeNsf", + "dateOuverture", + "dateFermeture", + "typeFamille", + ]) + .where((eb) => notHistoriqueUnlessCoExistant(eb, CURRENT_RENTREE)) + .orderBy(["libelleNsf", "libelleNiveauDiplome", "libelleFormation"]) + .distinct(); + + const getFormationRenovee = () => + getKbdClient() + .selectFrom("formationHistorique") + .leftJoin("formationView", "formationHistorique.cfd", "formationView.cfd") + .where("formationHistorique.cfd", "=", cfd) + .where("formationView.dateOuverture", "<=", sql`${getDateRentreeScolaire(CURRENT_RENTREE)}`) + .where("formationHistorique.ancienCFD", "in", (eb) => eb.selectFrom("formationEtablissement").select("cfd")) + .select("formationView.cfd") + .distinct(); + + return getKbdClient() + .with("formation", () => getFormation()) + .with("formation_renovee", () => getFormationRenovee()) + .with("formation_maille_etab", () => + getFormationMailleEtab({ + codeRegion, + codeDepartement, + codeAcademie, + }) + ) + .with("inTransitionNumerique", () => listOfCfdWhichAreTransitionNumerique) + .with("inTransitionEcologique", () => listOfCfdWhichAreTransitionEcologique) + .with("inTransitionDemographique", () => listOfCfdWhichAreTransitionDemographique) + .selectFrom("formation") + .leftJoin("inTransitionNumerique", "formation.cfd", "inTransitionNumerique.cfd") + .leftJoin("inTransitionEcologique", "formation.cfd", "inTransitionEcologique.cfd") + .leftJoin("inTransitionDemographique", "formation.cfd", "inTransitionDemographique.cfd") + .leftJoin("formation_maille_etab", "formation.cfd", "formation_maille_etab.cfd") + .where("formation.cfd", "=", cfd) + .select((sb) => [ + sql`concat( ${sb.ref( + "formation.libelleNiveauDiplome" + )}, ' - ', ${sb.ref("formation.libelleFormation")})`.as("libelle"), + sb.ref("formation.cfd").as("cfd"), + sb.ref("formation.codeNiveauDiplome").as("codeNiveauDiplome"), + sb.ref("formation.typeFamille").as("typeFamille"), + sb.ref("formation.codeNsf").as("codeNsf"), + sql`case when left(${sb.ref("formation.cfd")}, 3) = '320' then true else false end`.as("isBTS"), + sb + .case() + .when("formation.cfd", "in", (sb) => sb.selectFrom("inTransitionNumerique").select("cfd")) + .then(true) + .else(false) + .end() + .as("isTransitionNumerique"), + sb + .case() + .when("formation.cfd", "in", (sb) => sb.selectFrom("inTransitionEcologique").select("cfd")) + .then(true) + .else(false) + .end() + .as("isTransitionEcologique"), + sb + .case() + .when("formation.cfd", "in", (sb) => sb.selectFrom("inTransitionDemographique").select("cfd")) + .then(true) + .else(false) + .end() + .as("isTransitionDemographique"), + sql`bool_or(${sb.ref("formation_maille_etab.voie")} = 'apprentissage' OR ${sb.ref( + "formation_maille_etab.voie" + )} IS NULL)`.as("isApprentissage"), + sql`bool_or(${sb.ref( + "formation_maille_etab.voie" + )} = 'scolaire' OR ${sb.ref("formation_maille_etab.voie")} IS NULL)`.as("isScolaire"), + sb + .case() + .when(sb.fn.count("formation_maille_etab.uai"), ">", 0) + .then(true) + .else(false) + .end() + .as("isInScope"), + sb + .case() + .when("formation.cfd", "in", (sb) => sb.selectFrom("formation_renovee").select("cfd")) + .then(true) + .else(false) + .end() + .as("isFormationRenovee"), + ]) + .groupBy([ + "formation.cfd", + "formation.libelleNiveauDiplome", + "formation.libelleFormation", + "formation.codeNiveauDiplome", + "formation.typeFamille", + "formation.codeNsf", + ]) + .executeTakeFirst() + .then(cleanNull); +}; diff --git a/server/src/modules/data/usecases/getFormation/dependencies/getFormationMailleEtab.dep.ts b/server/src/modules/data/usecases/getFormation/dependencies/getFormationMailleEtab.dep.ts new file mode 100644 index 000000000..d8188f1b3 --- /dev/null +++ b/server/src/modules/data/usecases/getFormation/dependencies/getFormationMailleEtab.dep.ts @@ -0,0 +1,83 @@ +import { sql } from "kysely"; +import { CURRENT_RENTREE } from "shared"; +import { getDateRentreeScolaire } from "shared/utils/getRentreeScolaire"; + +import { getKbdClient } from "@/db/db"; + +export const getFormationMailleEtab = ({ + codeRegion, + codeAcademie, + codeDepartement, +}: { + codeRegion?: string; + codeAcademie?: string; + codeDepartement?: string; +}) => { + const formations = getKbdClient() + .selectFrom("formationView") + .innerJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formationView.codeNiveauDiplome") + .innerJoin("nsf", "nsf.codeNsf", "formationView.codeNsf") + .select([ + "libelleNsf", + "libelleNiveauDiplome", + "formationView.codeNiveauDiplome", + "formationView.libelleFormation", + "formationView.cfd", + "formationView.codeNsf", + "dateFermeture", + "typeFamille", + ]) + .where((eb) => + eb.or([ + eb("dateFermeture", "is", null), + eb("dateFermeture", ">", sql`${getDateRentreeScolaire(CURRENT_RENTREE)}`), + ]) + ) + .orderBy(["libelleNsf", "libelleNiveauDiplome", "libelleFormation"]) + .distinct(); + + return getKbdClient() + .with("formations", () => formations) + .selectFrom("formations") + .leftJoin("formationEtablissement", "formations.cfd", "formationEtablissement.cfd") + .innerJoin("dataEtablissement", "dataEtablissement.uai", "formationEtablissement.uai") + .select((sb) => [ + sb.ref("formations.libelleNiveauDiplome").as("libelleNiveauDiplome"), + sb.ref("formations.libelleFormation").as("libelleFormation"), + sb.ref("formations.cfd").as("cfd"), + sb.val("formations.codeNiveauDiplome").as("codeNiveauDiplome"), + sb.ref("formations.typeFamille").as("typeFamille"), + sb.ref("formations.codeNsf").as("codeNsf"), + sb.ref("formationEtablissement.uai").as("uai"), + sb.ref("formationEtablissement.voie").as("voie"), + sb.ref("formationEtablissement.id").as("id"), + sb.ref("dataEtablissement.codeRegion").as("codeRegion"), + sb.ref("dataEtablissement.codeAcademie").as("codeAcademie"), + sb.ref("dataEtablissement.codeDepartement").as("codeDepartement"), + ]) + .orderBy([ + "formations.codeNsf", + "formations.libelleNiveauDiplome", + "formations.libelleFormation", + "formations.typeFamille", + "formations.codeNsf", + ]) + .$call((q) => { + if (codeRegion) { + return q.where("codeRegion", "=", codeRegion); + } + return q; + }) + .$call((q) => { + if (codeAcademie) { + return q.where("codeAcademie", "=", codeAcademie); + } + return q; + }) + .$call((q) => { + if (codeDepartement) { + return q.where("codeDepartement", "=", codeDepartement); + } + return q; + }); +}; diff --git a/server/src/modules/data/usecases/getFormation/dependencies/index.ts b/server/src/modules/data/usecases/getFormation/dependencies/index.ts new file mode 100644 index 000000000..6cd99eebc --- /dev/null +++ b/server/src/modules/data/usecases/getFormation/dependencies/index.ts @@ -0,0 +1 @@ +export { getFormation } from "./getFormation.dep"; diff --git a/server/src/modules/data/usecases/getFormation/getFormation.route.ts b/server/src/modules/data/usecases/getFormation/getFormation.route.ts new file mode 100644 index 000000000..c0ca188af --- /dev/null +++ b/server/src/modules/data/usecases/getFormation/getFormation.route.ts @@ -0,0 +1,24 @@ +import { createRoute } from "@http-wizard/core"; +import { ROUTES } from "shared/routes/routes"; + +import type { Server } from "@/server/server"; + +import { getFormationUsecase } from "./getFormation.usecase"; + +const ROUTE = ROUTES["[GET]/formation/:cfd"]; + +export const getFormationRoute = (server: Server) => { + return createRoute(ROUTE.url, { + method: ROUTE.method, + schema: ROUTE.schema, + }).handle((props) => + server.route({ + ...props, + handler: async (request, response) => { + const { cfd } = request.params; + const result = await getFormationUsecase(cfd, request.query); + response.status(200).send(result); + }, + }) + ); +}; diff --git a/server/src/modules/data/usecases/getFormation/getFormation.usecase.ts b/server/src/modules/data/usecases/getFormation/getFormation.usecase.ts new file mode 100644 index 000000000..1870ec9d6 --- /dev/null +++ b/server/src/modules/data/usecases/getFormation/getFormation.usecase.ts @@ -0,0 +1,27 @@ +import Boom from "@hapi/boom"; +import type { QueryFilters } from "shared/routes/schemas/get.formation.cfd.schema"; + +import { getFormation } from "./dependencies"; + +const getFormationFactory = + ( + deps = { + getFormation, + } + ) => + async (cfd: string, { codeAcademie, codeRegion, codeDepartement }: QueryFilters) => { + const formation = await deps.getFormation({ + cfd, + codeRegion, + codeDepartement, + codeAcademie, + }); + + if (!formation) { + throw Boom.notFound(`La formation avec le cfd ${cfd} est inconnue`); + } + + return formation; + }; + +export const getFormationUsecase = getFormationFactory(); diff --git a/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getBoundaries.dep.ts b/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getBoundaries.dep.ts new file mode 100644 index 000000000..a96d9b033 --- /dev/null +++ b/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getBoundaries.dep.ts @@ -0,0 +1,41 @@ +import { getKbdClient } from "@/db/db"; + +export const getBoundaries = ({ + codeRegion, + codeDepartement, + codeAcademie, +}: { + codeRegion?: string; + codeDepartement?: string; + codeAcademie?: string; +}) => + getKbdClient() + .selectFrom("etablissement") + .select((sb) => [ + sb.fn.coalesce(sb.fn.min("latitude"), sb.val(0)).as("latMin"), + sb.fn.coalesce(sb.fn.max("latitude"), sb.val(0)).as("latMax"), + sb.fn.coalesce(sb.fn.min("longitude"), sb.val(0)).as("lngMin"), + sb.fn.coalesce(sb.fn.max("longitude"), sb.val(0)).as("lngMax"), + ]) + .$call((qb) => { + if (!codeRegion && !codeDepartement && !codeAcademie) { + // Si aucune région n'est selectionné la carte doit être centré sur la france métropolitaine + // Pour cela, l'ensemble des régions situé dans la france métropolitaine sont selectionnés + // 11 = Ile-De-France = codeRegion minimum + // 93 = PACA = codeRegion maximum + qb = qb.where((wb) => wb.and([wb("codeRegion", ">=", "11"), wb("codeRegion", "<=", "93")])); + } + + if (codeRegion) { + qb = qb.where("codeRegion", "=", codeRegion); + } + if (codeDepartement) { + qb = qb.where("codeDepartement", "=", codeDepartement); + } + if (codeAcademie) { + qb = qb.where("codeAcademie", "=", codeAcademie); + } + + return qb; + }) + .executeTakeFirstOrThrow(); diff --git a/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getEtablissements.dep.ts b/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getEtablissements.dep.ts new file mode 100644 index 000000000..7b2acd5a4 --- /dev/null +++ b/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getEtablissements.dep.ts @@ -0,0 +1,167 @@ +import { sql } from "kysely"; +import type { Etablissement, EtablissementsOrderBy } from "shared/routes/schemas/get.formation.cfd.map.schema"; + +import { getKbdClient } from "@/db/db"; +import { selectTauxDevenirFavorable } from "@/modules/data/utils/tauxDevenirFavorable"; +import { selectTauxInsertion6mois } from "@/modules/data/utils/tauxInsertion6mois"; +import { selectTauxPoursuite } from "@/modules/data/utils/tauxPoursuite"; +import { selectTauxPression } from "@/modules/data/utils/tauxPression"; +import { cleanNull } from "@/utils/noNull"; + +export const getEtablissements = async ({ + cfd, + codeRegion, + codeDepartement, + codeAcademie, + orderBy, + includeAll, +}: { + cfd: string; + codeRegion?: string; + codeDepartement?: string; + codeAcademie?: string; + orderBy?: EtablissementsOrderBy; + includeAll: boolean; +}) => + getKbdClient() + .with("taux_ij_formation_etab", (db) => + db + .selectFrom("formationEtablissement") + .leftJoin("indicateurSortie", (join) => + join + .onRef("indicateurSortie.formationEtablissementId", "=", "formationEtablissement.id") + .on("millesimeSortie", "=", "2021_2022") + ) + .select([ + "cfd", + "uai", + "codeDispositif", + "voie", + sql`${sql.ref("cfd")} || ${sql.ref("uai")} || coalesce(${sql.ref("codeDispositif")},'') || ${sql.ref( + "voie" + )}`.as("offre"), + "millesimeSortie", + selectTauxInsertion6mois("indicateurSortie").as("tauxInsertion"), + selectTauxPoursuite("indicateurSortie").as("tauxPoursuite"), + selectTauxDevenirFavorable("indicateurSortie").as("tauxDevenirFavorable"), + "effectifSortie", + "nbPoursuiteEtudes", + "nbSortants", + "nbInsertion6mois", + ]) + ) + .with("carto", (db) => + db + .selectFrom("etablissement") + .leftJoin("formationEtablissement", "etablissement.uai", "formationEtablissement.uai") + .leftJoin("indicateurEntree", "formationEtablissement.id", "indicateurEntree.formationEtablissementId") + .leftJoin("dataFormation", "formationEtablissement.cfd", "dataFormation.cfd") + .leftJoin("nsf", "nsf.codeNsf", "dataFormation.codeNsf") + .leftJoin("niveauDiplome", "dataFormation.codeNiveauDiplome", "niveauDiplome.codeNiveauDiplome") + .leftJoin("dispositif", "dispositif.codeDispositif", "formationEtablissement.codeDispositif") + .leftJoin("taux_ij_formation_etab", (join) => + join.onRef( + "taux_ij_formation_etab.offre", + "=", + sql`${sql.ref("formationEtablissement.cfd")} || ${sql.ref( + "formationEtablissement.uai" + )} || coalesce(${sql.ref( + "formationEtablissement.codeDispositif" + )},'') || ${sql.ref("formationEtablissement.voie")}` + ) + ) + .select((sb) => [ + "etablissement.uai", + sql`trim(split_part(split_part(split_part(split_part(${sb.ref( + "etablissement.libelleEtablissement" + )},' - Lycée',1),' -Lycée',1),',',1),' : ',1))`.as("libelleEtablissement"), + "etablissement.commune", + "etablissement.latitude", + "etablissement.longitude", + "etablissement.sourceGeoloc", + "formationEtablissement.cfd", + "formationEtablissement.codeDispositif", + "formationEtablissement.voie", + "indicateurEntree.rentreeScolaire", + "indicateurEntree.capacites", + "indicateurEntree.premiersVoeux", + "taux_ij_formation_etab.tauxInsertion", + "taux_ij_formation_etab.tauxPoursuite", + "taux_ij_formation_etab.tauxDevenirFavorable", + "dataFormation.libelleFormation", + "niveauDiplome.libelleNiveauDiplome", + "libelleNsf", + "codeDepartement", + "codeAcademie", + "codeRegion", + sb.fn.coalesce(sb.ref("dispositif.libelleDispositif"), sql`''`).as("libelleDispositif"), + "secteur", + sql`(effectifs->>${sb.ref("anneeDebut")})::integer`.as("effectifs"), + selectTauxPression("indicateurEntree", "niveauDiplome", true).as("tauxPression"), + ]) + .where((eb) => + eb.or([eb("rentreeScolaire", "=", "2023"), eb("formationEtablissement.voie", "=", "apprentissage")]) + ) + ) + .selectFrom("carto") + .select((sb) => [ + sb.ref("carto.uai").as("uai"), + sb.ref("carto.libelleEtablissement").as("libelleEtablissement"), + sb.ref("carto.commune").as("commune"), + sb.ref("carto.codeAcademie").as("codeAcademie"), + sb.ref("carto.codeRegion").as("codeRegion"), + sb.ref("carto.latitude").as("latitude"), + sb.ref("carto.longitude").as("longitude"), + sb.ref("carto.secteur").as("secteur"), + sb.ref("carto.codeDepartement").as("codeDepartement"), + sb.fn.sum("carto.tauxInsertion").as("tauxInsertion"), + sb.fn.sum("carto.tauxDevenirFavorable").as("tauxDevenirFavorable"), + sb.fn.sum("carto.tauxPoursuite").as("tauxPoursuite"), + sb.fn.sum("carto.effectifs").as("effectifs"), + sb.fn.sum("carto.tauxPression").as("tauxPression"), + sql`bool_or(${sb.ref("carto.voie")} = 'apprentissage' OR ${sb.ref("carto.voie")} IS NULL)`.as( + "isApprentissage" + ), + sql`bool_or(${sb.ref("carto.voie")} = 'scolaire' OR ${sb.ref("carto.voie")} IS NULL)`.as("isScolaire"), + sql`case when left(${sb.ref("carto.cfd")}, 3) = '320' then true else false end`.as("isBTS"), + sql`array_agg(distinct ${sb.ref("carto.libelleDispositif")})`.as("libellesDispositifs"), + ]) + .where("carto.cfd", "=", cfd) + .where("carto.libelleEtablissement", "is not", null) + .where((wb) => wb.and([wb("carto.longitude", "is not", null), wb("carto.latitude", "is not", null)])) + .$call((qb) => { + if (includeAll) { + return qb; + } + + if (codeRegion) { + qb = qb.where("carto.codeRegion", "=", codeRegion); + } + + if (codeAcademie) { + qb = qb.where("carto.codeAcademie", "=", codeAcademie); + } + + if (codeDepartement) { + qb = qb.where("carto.codeDepartement", "=", codeDepartement); + } + + return qb; + }) + .groupBy([ + "carto.cfd", + "carto.uai", + "carto.libelleEtablissement", + "carto.commune", + "carto.codeAcademie", + "carto.codeRegion", + "carto.latitude", + "carto.longitude", + "carto.secteur", + "carto.codeDepartement", + ]) + .$if(orderBy === "departement_commune", (qb) => qb.orderBy(["carto.codeDepartement asc", "carto.commune asc"])) + .$if(orderBy === "libelle", (qb) => qb.orderBy("carto.libelleEtablissement", "asc")) + .$castTo() + .execute() + .then((result) => cleanNull(result)); diff --git a/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getFormation.dep.ts b/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getFormation.dep.ts new file mode 100644 index 000000000..ce78ed4be --- /dev/null +++ b/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/getFormation.dep.ts @@ -0,0 +1,18 @@ +import { CURRENT_RENTREE } from "shared"; + +import { getKbdClient } from "@/db/db"; +import { notHistoriqueUnlessCoExistant } from "@/modules/data/utils/notHistorique"; +import { cleanNull } from "@/utils/noNull"; + +export const getFormation = async ({ cfd }: { cfd: string }) => { + return getKbdClient() + .selectFrom("formationView") + .innerJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formationView.codeNiveauDiplome") + .innerJoin("nsf", "nsf.codeNsf", "formationView.codeNsf") + .select(["formationView.libelleFormation", "formationView.cfd"]) + .where((wb) => notHistoriqueUnlessCoExistant(wb, CURRENT_RENTREE)) + .where("formationView.cfd", "=", cfd) + .select((sb) => [sb.ref("formationView.libelleFormation").as("libelle"), sb.ref("formationView.cfd").as("cfd")]) + .executeTakeFirst() + .then(cleanNull); +}; diff --git a/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/index.ts b/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/index.ts new file mode 100644 index 000000000..f32431674 --- /dev/null +++ b/server/src/modules/data/usecases/getFormationCarteEtablissements/dependencies/index.ts @@ -0,0 +1,3 @@ +export { getBoundaries } from "./getBoundaries.dep"; +export { getEtablissements } from "./getEtablissements.dep"; +export { getFormation } from "./getFormation.dep"; diff --git a/server/src/modules/data/usecases/getFormationCarteEtablissements/getFormationCarteEtablissements.route.ts b/server/src/modules/data/usecases/getFormationCarteEtablissements/getFormationCarteEtablissements.route.ts new file mode 100644 index 000000000..eda41bc8b --- /dev/null +++ b/server/src/modules/data/usecases/getFormationCarteEtablissements/getFormationCarteEtablissements.route.ts @@ -0,0 +1,23 @@ +import { createRoute } from "@http-wizard/core"; +import { ROUTES } from "shared/routes/routes"; + +import type { Server } from "@/server/server"; + +import { getFormationCarteEtablissementsUsecase } from "./getFormationCarteEtablissements.usecase"; + +const ROUTE = ROUTES["[GET]/formation/:cfd/map"]; + +export const getFormationCarteEtablissementsRoute = (server: Server) => { + return createRoute(ROUTE.url, { + method: ROUTE.method, + schema: ROUTE.schema, + }).handle((props) => + server.route({ + ...props, + handler: async (request, response) => { + const result = await getFormationCarteEtablissementsUsecase({ ...request.params }, { ...request.query }); + response.status(200).send(result); + }, + }) + ); +}; diff --git a/server/src/modules/data/usecases/getFormationCarteEtablissements/getFormationCarteEtablissements.usecase.ts b/server/src/modules/data/usecases/getFormationCarteEtablissements/getFormationCarteEtablissements.usecase.ts new file mode 100644 index 000000000..6c6a373da --- /dev/null +++ b/server/src/modules/data/usecases/getFormationCarteEtablissements/getFormationCarteEtablissements.usecase.ts @@ -0,0 +1,38 @@ +import Boom from "@hapi/boom"; +import type { Params, QueryFilters } from "shared/routes/schemas/get.formation.cfd.map.schema"; + +import { getBoundaries, getEtablissements, getFormation } from "./dependencies"; + +const getFormationCarteEtablissementsFactory = + ( + deps = { + getFormation, + getEtablissements, + getBoundaries, + } + ) => + async ({ cfd }: Params, { codeAcademie, codeRegion, codeDepartement, orderBy, includeAll }: QueryFilters) => { + const [formation, etablissements, bbox] = await Promise.all([ + deps.getFormation({ cfd }), + deps.getEtablissements({ + cfd, + codeAcademie, + codeRegion, + codeDepartement, + orderBy, + includeAll, + }), + deps.getBoundaries({ codeAcademie, codeRegion, codeDepartement }), + ]); + + if (!formation) { + throw Boom.notFound(`La formation avec le cfd ${cfd} est inconnue`); + } + + return { + etablissements, + bbox, + }; + }; + +export const getFormationCarteEtablissementsUsecase = getFormationCarteEtablissementsFactory(); diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/__tests__/getDomaineDeFormationIndicateurs.test.ts b/server/src/modules/data/usecases/getFormationIndicateurs/__tests__/getDomaineDeFormationIndicateurs.test.ts new file mode 100644 index 000000000..199d65bf0 --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/__tests__/getDomaineDeFormationIndicateurs.test.ts @@ -0,0 +1,411 @@ +import type { getFormationCfdIndicatorsSchema } from "shared/routes/schemas/get.formation.cfd.indicators.schema"; +import { beforeAll, describe, expect, it } from "vitest"; +import type { z } from "zod"; + +import { usePg } from "@/../tests/pg.test.utils"; +import type { Server } from "@/server/server"; +import createServer from "@/server/server.js"; + +usePg(); + +type Response = z.infer<(typeof getFormationCfdIndicatorsSchema.response)[200]>; + +describe("GET /api/formation/:cfd/indicators", () => { + const cfd = "40031213"; + let app: Server; + + beforeAll(async () => { + app = await createServer(); + await app.ready(); + + return async () => app.close(); + }, 15_000); + + it("doit retourner une erreur 404 si le cfd n'existe pas", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/formation/1234567890/indicators", + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ + message: "La formation avec le cfd 1234567890 est inconnue", + name: "Not Found", + statusCode: 404, + }); + }); + + describe("Nombre d'établissements", () => { + it("doit retourner le nombre d'établissement nationaux", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators`, + }); + + expect(response.statusCode).toBe(200); + const result = await response.json(); + + expect(result.etablissements).toEqual( + expect.arrayContaining([ + { rentreeScolaire: "2020", nbEtablissements: 785 }, + { rentreeScolaire: "2021", nbEtablissements: 797 }, + { rentreeScolaire: "2022", nbEtablissements: 802 }, + { rentreeScolaire: "2023", nbEtablissements: 806 }, + ]) + ); + }); + + it("doit retourner le nombre d'établissement regionaux (84)", async () => { + const codeRegion = "84"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators?codeRegion=${codeRegion}`, + }); + + expect(response.statusCode).toBe(200); + const result = await response.json(); + + expect(result.etablissements).toEqual( + expect.arrayContaining([ + { rentreeScolaire: "2020", nbEtablissements: 89 }, + { rentreeScolaire: "2021", nbEtablissements: 92 }, + { rentreeScolaire: "2022", nbEtablissements: 94 }, + { rentreeScolaire: "2023", nbEtablissements: 95 }, + ]) + ); + }); + + it("doit retourner le nombre d'établissement du département (10)", async () => { + const codeRegion = "84"; + const codeAcademie = "10"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators?codeRegion=${codeRegion}&codeAcademie=${codeAcademie}`, + }); + + expect(response.statusCode).toBe(200); + const result = await response.json(); + + expect(result.etablissements).toEqual( + expect.arrayContaining([ + { rentreeScolaire: "2020", nbEtablissements: 36 }, + { rentreeScolaire: "2021", nbEtablissements: 37 }, + { rentreeScolaire: "2022", nbEtablissements: 38 }, + { rentreeScolaire: "2023", nbEtablissements: 39 }, + ]) + ); + }); + + it("doit retourner le nombre d'établissement du département (069)", async () => { + const codeRegion = "84"; + const codeAcademie = "10"; + const codeDepartement = "069"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators?codeRegion=${codeRegion}&codeAcademie=${codeAcademie}&codeDepartement=${codeDepartement}`, + }); + + expect(response.statusCode).toBe(200); + const result = await response.json(); + + expect(result.etablissements).toEqual( + expect.arrayContaining([ + { rentreeScolaire: "2020", nbEtablissements: 18 }, + { rentreeScolaire: "2021", nbEtablissements: 18 }, + { rentreeScolaire: "2022", nbEtablissements: 19 }, + { rentreeScolaire: "2023", nbEtablissements: 20 }, + ]) + ); + }); + }); + + describe("Effectifs", () => { + it("doit retourner les effectifs nationaux", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators`, + }); + + expect(response.statusCode).toBe(200); + const result = await response.json(); + + expect(result.effectifs).toEqual( + expect.arrayContaining([ + { rentreeScolaire: "2020", effectif: 23361 }, + { rentreeScolaire: "2021", effectif: 22823 }, + { rentreeScolaire: "2022", effectif: 22658 }, + { rentreeScolaire: "2023", effectif: 23268 }, + ]) + ); + }); + + it("doit retourner les effectifs regionaux (84)", async () => { + const codeRegion = "84"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators?codeRegion=${codeRegion}`, + }); + + expect(response.statusCode).toBe(200); + const result = await response.json(); + + expect(result.effectifs).toEqual( + expect.arrayContaining([ + { rentreeScolaire: "2020", effectif: 2001 }, + { rentreeScolaire: "2021", effectif: 2071 }, + { rentreeScolaire: "2022", effectif: 2024 }, + { rentreeScolaire: "2023", effectif: 2088 }, + ]) + ); + }); + + it("doit retourner les effectifs regionaux (84) et académie (10)", async () => { + const codeRegion = "84"; + const codeAcademie = "10"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators?codeRegion=${codeRegion}&codeAcademie=${codeAcademie}`, + }); + + expect(response.statusCode).toBe(200); + const result = await response.json(); + + expect(result.effectifs).toEqual( + expect.arrayContaining([ + { rentreeScolaire: "2020", effectif: 752 }, + { rentreeScolaire: "2021", effectif: 776 }, + { rentreeScolaire: "2022", effectif: 819 }, + { rentreeScolaire: "2023", effectif: 827 }, + ]) + ); + }); + + it("doit retourner les effectifs regionaux (84) et académie (10) et département (069)", async () => { + const codeRegion = "84"; + const codeAcademie = "10"; + const codeDepartement = "069"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators?codeRegion=${codeRegion}&codeAcademie=${codeAcademie}&codeDepartement=${codeDepartement}`, + }); + + expect(response.statusCode).toBe(200); + const result = await response.json(); + + expect(result.effectifs).toEqual( + expect.arrayContaining([ + { rentreeScolaire: "2020", effectif: 434 }, + { rentreeScolaire: "2021", effectif: 444 }, + { rentreeScolaire: "2022", effectif: 466 }, + { rentreeScolaire: "2023", effectif: 480 }, + ]) + ); + }); + }); + + describe("Taux IJ", () => { + it("doit retourner les taux IJ nationaux", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators`, + }); + const result = await response.json(); + + expect(result.tauxIJ.tauxPoursuite).toEqual( + expect.arrayContaining([ + { apprentissage: 0.2967, libelle: "2019+20", scolaire: 0.509 }, + { apprentissage: 0.4036, libelle: "2020+21", scolaire: 0.5379 }, + { apprentissage: 0.3954, libelle: "2021+22", scolaire: 0.5504 }, + ]) + ); + + expect(result.tauxIJ.tauxInsertion).toEqual( + expect.arrayContaining([ + { apprentissage: 0.5937, libelle: "2019+20", scolaire: 0.3496 }, + { apprentissage: 0.6249, libelle: "2020+21", scolaire: 0.3531 }, + { apprentissage: 0.6137, libelle: "2021+22", scolaire: 0.4291 }, + ]) + ); + + expect(result.tauxIJ.tauxDevenirFavorable).toEqual( + expect.arrayContaining([ + { apprentissage: 0.7143, libelle: "2019+20", scolaire: 0.6807 }, + { apprentissage: 0.7755, libelle: "2020+21", scolaire: 0.7011 }, + { apprentissage: 0.7664, libelle: "2021+22", scolaire: 0.7433 }, + ]) + ); + }); + + it("doit retourner les taux IJ regionaux (84)", async () => { + const codeRegion = "84"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators?codeRegion=${codeRegion}`, + }); + const result = await response.json(); + + expect(result.tauxIJ.tauxPoursuite).toEqual( + expect.arrayContaining([ + { apprentissage: 0.2518, libelle: "2019+20", scolaire: 0.5095 }, + { apprentissage: 0.375, libelle: "2020+21", scolaire: 0.5478 }, + { apprentissage: 0.4047, libelle: "2021+22", scolaire: 0.5769 }, + ]) + ); + + expect(result.tauxIJ.tauxInsertion).toEqual( + expect.arrayContaining([ + { apprentissage: 0.6362, libelle: "2019+20", scolaire: 0.4757 }, + { apprentissage: 0.7, libelle: "2020+21", scolaire: 0.468 }, + { apprentissage: 0.6719, libelle: "2021+22", scolaire: 0.4935 }, + ]) + ); + + expect(result.tauxIJ.tauxDevenirFavorable).toEqual( + expect.arrayContaining([ + { apprentissage: 0.7278, libelle: "2019+20", scolaire: 0.7428 }, + { apprentissage: 0.8125, libelle: "2020+21", scolaire: 0.7594 }, + { apprentissage: 0.8047, libelle: "2021+22", scolaire: 0.7857 }, + ]) + ); + }); + + it("doit retourner les taux IJ regionaux (84) même si une académie est spécifiée", async () => { + const codeRegion = "84"; + const codeAcademie = "10"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators?codeRegion=${codeRegion}&codeAcademie=${codeAcademie}`, + }); + const result = await response.json(); + + expect(result.tauxIJ.tauxPoursuite).toEqual( + expect.arrayContaining([ + { apprentissage: 0.2518, libelle: "2019+20", scolaire: 0.5095 }, + { apprentissage: 0.375, libelle: "2020+21", scolaire: 0.5478 }, + { apprentissage: 0.4047, libelle: "2021+22", scolaire: 0.5769 }, + ]) + ); + + expect(result.tauxIJ.tauxInsertion).toEqual( + expect.arrayContaining([ + { apprentissage: 0.6362, libelle: "2019+20", scolaire: 0.4757 }, + { apprentissage: 0.7, libelle: "2020+21", scolaire: 0.468 }, + { apprentissage: 0.6719, libelle: "2021+22", scolaire: 0.4935 }, + ]) + ); + + expect(result.tauxIJ.tauxDevenirFavorable).toEqual( + expect.arrayContaining([ + { apprentissage: 0.7278, libelle: "2019+20", scolaire: 0.7428 }, + { apprentissage: 0.8125, libelle: "2020+21", scolaire: 0.7594 }, + { apprentissage: 0.8047, libelle: "2021+22", scolaire: 0.7857 }, + ]) + ); + }); + + it("doit retourner les taux IJ regionaux (84) même si un département est spécifié", async () => { + const codeRegion = "84"; + const codeAcademie = "10"; + const codeDepartement = "069"; + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators?codeRegion=${codeRegion}&codeAcademie=${codeAcademie}&codeDepartement=${codeDepartement}`, + }); + const result = await response.json(); + + expect(result.tauxIJ.tauxPoursuite).toEqual( + expect.arrayContaining([ + { apprentissage: 0.2518, libelle: "2019+20", scolaire: 0.5095 }, + { apprentissage: 0.375, libelle: "2020+21", scolaire: 0.5478 }, + { apprentissage: 0.4047, libelle: "2021+22", scolaire: 0.5769 }, + ]) + ); + + expect(result.tauxIJ.tauxInsertion).toEqual( + expect.arrayContaining([ + { apprentissage: 0.6362, libelle: "2019+20", scolaire: 0.4757 }, + { apprentissage: 0.7, libelle: "2020+21", scolaire: 0.468 }, + { apprentissage: 0.6719, libelle: "2021+22", scolaire: 0.4935 }, + ]) + ); + + expect(result.tauxIJ.tauxDevenirFavorable).toEqual( + expect.arrayContaining([ + { apprentissage: 0.7278, libelle: "2019+20", scolaire: 0.7428 }, + { apprentissage: 0.8125, libelle: "2020+21", scolaire: 0.7594 }, + { apprentissage: 0.8047, libelle: "2021+22", scolaire: 0.7857 }, + ]) + ); + }); + }); + + describe("Taux pression", () => { + it.each` + scope | codeRegion | codeAcademie | codeDepartement | tauxPression | rs + ${"national"} | ${undefined} | ${undefined} | ${undefined} | ${1.2097078497513} | ${"2021"} + ${"national"} | ${undefined} | ${undefined} | ${undefined} | ${1.23653261430784} | ${"2022"} + ${"national"} | ${undefined} | ${undefined} | ${undefined} | ${1.24674508908177} | ${"2023"} + ${"région"} | ${"84"} | ${undefined} | ${undefined} | ${1.52631578947368} | ${"2021"} + ${"région"} | ${"84"} | ${undefined} | ${undefined} | ${1.5998322147651} | ${"2022"} + ${"région"} | ${"84"} | ${undefined} | ${undefined} | ${1.61234991423671} | ${"2023"} + ${"académie"} | ${"84"} | ${"10"} | ${undefined} | ${1.66306695464363} | ${"2021"} + ${"académie"} | ${"84"} | ${"10"} | ${undefined} | ${1.69574468085106} | ${"2022"} + ${"académie"} | ${"84"} | ${"10"} | ${undefined} | ${1.91121495327103} | ${"2023"} + ${"département"} | ${"84"} | ${"10"} | ${"069"} | ${1.84677419354839} | ${"2021"} + ${"département"} | ${"84"} | ${"10"} | ${"069"} | ${1.73977695167286} | ${"2022"} + ${"département"} | ${"84"} | ${"10"} | ${"069"} | ${1.97619047619048} | ${"2023"} + `( + "doit retourner les taux pression $scope pour la rentrée scolaire $rs", + async ({ scope, codeRegion, codeAcademie, codeDepartement, tauxPression, rs }) => { + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators?codeRegion=${codeRegion}&codeAcademie=${codeAcademie}&codeDepartement=${codeDepartement}`, + }); + + const result = await response.json(); + + console.log(result.tauxPressions); + + const tauxPressionFound = result.tauxPressions.find( + (tp) => tp.rentreeScolaire === rs && tp.scope === scope && tp.value === tauxPression + ); + + expect(tauxPressionFound).toBeDefined(); + } + ); + }); + + describe("Taux de remplissage", () => { + it.each` + scope | codeRegion | codeAcademie | codeDepartement | tauxRemplissage | rs + ${"national"} | ${undefined} | ${undefined} | ${undefined} | ${0.974633596392334} | ${"2021"} + ${"national"} | ${undefined} | ${undefined} | ${undefined} | ${0.977941593422172} | ${"2022"} + ${"national"} | ${undefined} | ${undefined} | ${undefined} | ${0.994687464677292} | ${"2023"} + ${"région"} | ${"84"} | ${undefined} | ${undefined} | ${1.00417710944027} | ${"2021"} + ${"région"} | ${"84"} | ${undefined} | ${undefined} | ${1.08305369127517} | ${"2022"} + ${"région"} | ${"84"} | ${undefined} | ${undefined} | ${1.11663807890223} | ${"2023"} + ${"académie"} | ${"84"} | ${"10"} | ${undefined} | ${1.00647948164147} | ${"2021"} + ${"académie"} | ${"84"} | ${"10"} | ${undefined} | ${1.16170212765957} | ${"2022"} + ${"académie"} | ${"84"} | ${"10"} | ${undefined} | ${1.27102803738318} | ${"2023"} + ${"département"} | ${"84"} | ${"10"} | ${"069"} | ${1.10483870967742} | ${"2021"} + ${"département"} | ${"84"} | ${"10"} | ${"069"} | ${1.20074349442379} | ${"2022"} + ${"département"} | ${"84"} | ${"10"} | ${"069"} | ${1.30952380952381} | ${"2023"} + `( + "doit retourner les taux pression $scope pour la rentrée scolaire $rs", + async ({ scope, codeRegion, codeAcademie, codeDepartement, tauxRemplissage, rs }) => { + const response = await app.inject({ + method: "GET", + url: `/api/formation/${cfd}/indicators?codeRegion=${codeRegion}&codeAcademie=${codeAcademie}&codeDepartement=${codeDepartement}`, + }); + + const result = await response.json(); + + const tauxRemplissageFound = result.tauxRemplissages.find( + (tp) => tp.rentreeScolaire === rs && tp.scope === scope && tp.value === tauxRemplissage + ); + + expect(tauxRemplissageFound).toBeDefined(); + } + ); + }); +}); diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getEffectifs.dep.ts b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getEffectifs.dep.ts new file mode 100644 index 000000000..3beca80b2 --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getEffectifs.dep.ts @@ -0,0 +1,29 @@ +import { sql } from "kysely"; + +import { getKbdClient } from "@/db/db"; + +import { getFormationMailleEtab } from "./getFormationMailleEtab.dep"; + +export const getEffectifs = async ({ + cfd, + codeRegion, + codeDepartement, + codeAcademie, +}: { + cfd: string; + codeRegion?: string; + codeAcademie?: string; + codeDepartement?: string; +}) => + getKbdClient() + .with("maille_etab", () => getFormationMailleEtab({ codeRegion, codeAcademie, codeDepartement })) + .selectFrom("maille_etab") + .innerJoin("indicateurEntree", "indicateurEntree.formationEtablissementId", "maille_etab.id") + .where("voie", "=", "scolaire") + .where("cfd", "=", cfd) + .groupBy(["rentreeScolaire", "libelleFormation"]) + .select((eb) => [ + eb.ref("rentreeScolaire").as("rentreeScolaire"), + sql`sum(coalesce((effectifs->>${eb.ref("anneeDebut")})::integer,0))`.as("effectif"), + ]) + .execute(); diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getEtablissements.dep.ts b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getEtablissements.dep.ts new file mode 100644 index 000000000..db69cf6da --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getEtablissements.dep.ts @@ -0,0 +1,28 @@ +import { sql } from "kysely"; + +import { getKbdClient } from "@/db/db"; + +import { getFormationMailleEtab } from "./getFormationMailleEtab.dep"; + +export const getEtablissements = async ({ + cfd, + codeRegion, + codeAcademie, + codeDepartement, +}: { + cfd: string; + codeRegion?: string; + codeAcademie?: string; + codeDepartement?: string; +}) => + getKbdClient() + .with("maille_etab", () => getFormationMailleEtab({ codeRegion, codeAcademie, codeDepartement })) + .selectFrom("maille_etab") + .innerJoin("indicateurEntree", "indicateurEntree.formationEtablissementId", "maille_etab.id") + .where("cfd", "=", cfd) + .groupBy(["rentreeScolaire", "libelleFormation"]) + .select((eb) => [ + eb.ref("rentreeScolaire").as("rentreeScolaire"), + sql`count(distinct ${eb.ref("uai")})`.as("nbEtablissements"), + ]) + .execute(); diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getFormation.dep.ts b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getFormation.dep.ts new file mode 100644 index 000000000..ce78ed4be --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getFormation.dep.ts @@ -0,0 +1,18 @@ +import { CURRENT_RENTREE } from "shared"; + +import { getKbdClient } from "@/db/db"; +import { notHistoriqueUnlessCoExistant } from "@/modules/data/utils/notHistorique"; +import { cleanNull } from "@/utils/noNull"; + +export const getFormation = async ({ cfd }: { cfd: string }) => { + return getKbdClient() + .selectFrom("formationView") + .innerJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formationView.codeNiveauDiplome") + .innerJoin("nsf", "nsf.codeNsf", "formationView.codeNsf") + .select(["formationView.libelleFormation", "formationView.cfd"]) + .where((wb) => notHistoriqueUnlessCoExistant(wb, CURRENT_RENTREE)) + .where("formationView.cfd", "=", cfd) + .select((sb) => [sb.ref("formationView.libelleFormation").as("libelle"), sb.ref("formationView.cfd").as("cfd")]) + .executeTakeFirst() + .then(cleanNull); +}; diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getFormationMailleEtab.dep.ts b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getFormationMailleEtab.dep.ts new file mode 100644 index 000000000..7bfa5a891 --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getFormationMailleEtab.dep.ts @@ -0,0 +1,77 @@ +import { CURRENT_RENTREE } from "shared"; + +import { getKbdClient } from "@/db/db"; +import { notHistoriqueUnlessCoExistant } from "@/modules/data/utils/notHistorique"; + +export const getFormationMailleEtab = ({ + codeRegion, + codeAcademie, + codeDepartement, +}: { + codeRegion?: string; + codeAcademie?: string; + codeDepartement?: string; +}) => { + const formations = getKbdClient() + .selectFrom("formationView") + .innerJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formationView.codeNiveauDiplome") + .innerJoin("nsf", "nsf.codeNsf", "formationView.codeNsf") + .select([ + "libelleNsf", + "libelleNiveauDiplome", + "formationView.codeNiveauDiplome", + "formationView.libelleFormation", + "formationView.cfd", + "formationView.codeNsf", + "dateFermeture", + "typeFamille", + ]) + .where((eb) => notHistoriqueUnlessCoExistant(eb, CURRENT_RENTREE)) + .orderBy(["libelleNsf", "libelleNiveauDiplome", "libelleFormation"]) + .distinct(); + + return getKbdClient() + .with("formations", () => formations) + .selectFrom("formations") + .leftJoin("formationEtablissement", "formations.cfd", "formationEtablissement.cfd") + .innerJoin("dataEtablissement", "dataEtablissement.uai", "formationEtablissement.uai") + .select((sb) => [ + sb.ref("formations.libelleNiveauDiplome").as("libelleNiveauDiplome"), + sb.ref("formations.libelleFormation").as("libelleFormation"), + sb.ref("formations.cfd").as("cfd"), + sb.ref("formations.codeNiveauDiplome").as("codeNiveauDiplome"), + sb.ref("formations.typeFamille").as("typeFamille"), + sb.ref("formations.codeNsf").as("codeNsf"), + sb.ref("formationEtablissement.uai").as("uai"), + sb.ref("formationEtablissement.voie").as("voie"), + sb.ref("formationEtablissement.id").as("id"), + sb.ref("dataEtablissement.codeRegion").as("codeRegion"), + sb.ref("dataEtablissement.codeAcademie").as("codeAcademie"), + sb.ref("dataEtablissement.codeDepartement").as("codeDepartement"), + ]) + .orderBy([ + "formations.codeNsf", + "formations.libelleNiveauDiplome", + "formations.libelleFormation", + "formations.typeFamille", + "formations.codeNsf", + ]) + .$call((q) => { + if (codeRegion) { + return q.where("codeRegion", "=", codeRegion); + } + return q; + }) + .$call((q) => { + if (codeAcademie) { + return q.where("codeAcademie", "=", codeAcademie); + } + return q; + }) + .$call((q) => { + if (codeDepartement) { + return q.where("codeDepartement", "=", codeDepartement); + } + return q; + }); +}; diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getSoldePlacesTransformee.dep.ts b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getSoldePlacesTransformee.dep.ts new file mode 100644 index 000000000..ec2d1ff39 --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getSoldePlacesTransformee.dep.ts @@ -0,0 +1,50 @@ +import { sql } from "kysely"; +import { DemandeStatutEnum } from "shared/enum/demandeStatutEnum"; + +import { getKbdClient } from "@/db/db"; +import { + countDifferenceCapaciteApprentissage, + countDifferenceCapaciteScolaire, +} from "@/modules/utils/countCapaciteNew"; +import { cleanNull } from "@/utils/noNull"; + +export const getSoldePlacesTransformee = async ({ + cfd, + codeRegion, + codeAcademie, + codeDepartement, +}: { + cfd: string; + codeRegion?: string; + codeAcademie?: string; + codeDepartement?: string; +}) => + getKbdClient() + .selectFrom("latestDemandeIntentionView as demande") + .leftJoin("dataEtablissement", "dataEtablissement.uai", "demande.uai") + .where("statut", "=", DemandeStatutEnum["demande validée"]) + .where("cfd", "=", cfd) + .select((eb) => [ + eb.ref("demande.rentreeScolaire").as("rentreeScolaire"), + sql`sum(${countDifferenceCapaciteScolaire({ eb })})`.as("scolaire"), + sql`sum(${countDifferenceCapaciteApprentissage({ eb })})`.as("apprentissage"), + ]) + .$call((qb) => { + if (codeRegion) { + qb = qb.where("dataEtablissement.codeRegion", "=", codeRegion); + } + + if (codeAcademie) { + qb = qb.where("dataEtablissement.codeAcademie", "=", codeAcademie); + } + + if (codeDepartement) { + qb = qb.where("dataEtablissement.codeDepartement", "=", codeDepartement); + } + + return qb; + }) + .groupBy(["rentreeScolaire"]) + .orderBy(["rentreeScolaire"]) + .execute() + .then(cleanNull); diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxIJ.dep.ts b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxIJ.dep.ts new file mode 100644 index 000000000..bd311c86d --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxIJ.dep.ts @@ -0,0 +1,73 @@ +import { sql } from "kysely"; +import * as _ from "lodash-es"; +import type { TauxIJKey, TauxIJValue } from "shared/routes/schemas/get.formation.cfd.indicators.schema"; + +import { getKbdClient } from "@/db/db"; +import { selectTauxDevenirFavorableAgg } from "@/modules/data/utils/tauxDevenirFavorable"; +import { selectTauxInsertion6moisAgg } from "@/modules/data/utils/tauxInsertion6mois"; +import { selectTauxPoursuiteAgg } from "@/modules/data/utils/tauxPoursuite"; +import { cleanNull } from "@/utils/noNull"; + +export const getTauxIJ = async ({ cfd, codeRegion }: { cfd: string; codeRegion?: string }) => { + const indicateursIJ = await getKbdClient() + .selectFrom("formationView as formation") + .leftJoin("niveauDiplome", "niveauDiplome.codeNiveauDiplome", "formation.codeNiveauDiplome") + .leftJoin("indicateurRegionSortie", (join) => + join + .onRef("indicateurRegionSortie.cfd", "=", "formation.cfd") + .onRef("indicateurRegionSortie.voie", "=", "formation.voie") + ) + .where("formation.cfd", "=", cfd) + .$call((q) => { + if (!codeRegion) { + return q; + } + + return q.where("indicateurRegionSortie.codeRegion", "=", codeRegion); + }) + .select((sb) => [ + sb.ref("formation.cfd").as("cfd"), + sb.ref("formation.voie").as("voie"), + sb.ref("formation.libelleFormation").as("libelle"), + sb.ref("indicateurRegionSortie.millesimeSortie").as("millesimeSortie"), + sql`round(${selectTauxPoursuiteAgg("indicateurRegionSortie")}, 4)`.as("tauxPoursuite"), + sql`round(${selectTauxInsertion6moisAgg("indicateurRegionSortie")}, 4)`.as("tauxInsertion"), + sql`round(${selectTauxDevenirFavorableAgg("indicateurRegionSortie")}, 4)`.as("tauxDevenirFavorable"), + ]) + .groupBy([ + "formation.cfd", + "formation.voie", + "formation.libelleFormation", + "indicateurRegionSortie.millesimeSortie", + ]) + .execute(); + + const millesimes = _.uniq(indicateursIJ.filter((i) => i.millesimeSortie).map((i) => i.millesimeSortie as string)); + + const tauxIJ: Record = { + tauxPoursuite: [], + tauxInsertion: [], + tauxDevenirFavorable: [], + }; + + millesimes.forEach((millesime) => { + const indicateurs = indicateursIJ.filter((i) => i.millesimeSortie === millesime); + const tauxApprentissage = indicateurs.find((i) => i.voie === "apprentissage"); + const tauxScolaire = indicateurs.find((i) => i.voie === "scolaire"); + + // Création de l'object tauxIJ, pour chaque taux, création d'un tableau contenant les valeurs + // pour chaque années + ["tauxPoursuite", "tauxInsertion", "tauxDevenirFavorable"].forEach((taux) => { + tauxIJ[taux as TauxIJKey] = [ + ...tauxIJ[taux as TauxIJKey], + { + libelle: millesime.replace("_20", "+"), + apprentissage: tauxApprentissage?.[taux as TauxIJKey], + scolaire: tauxScolaire?.[taux as TauxIJKey], + }, + ].map(cleanNull); + }); + }); + + return tauxIJ; +}; diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxPressions.dep.ts b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxPressions.dep.ts new file mode 100644 index 000000000..2c7879d64 --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxPressions.dep.ts @@ -0,0 +1,68 @@ +import { ScopeEnum } from "shared"; + +import { getKbdClient } from "@/db/db"; +import { selectTauxPressionAgg } from "@/modules/data/utils/tauxPression"; +import { cleanNull } from "@/utils/noNull"; + +import { getFormationMailleEtab } from "./getFormationMailleEtab.dep"; + +export const getTauxPressions = async ({ + cfd, + codeRegion, + codeAcademie, + codeDepartement, +}: { + cfd: string; + codeRegion?: string; + codeAcademie?: string; + codeDepartement?: string; +}) => { + const scopes: { + scope: string; + codeRegion?: string; + codeAcademie?: string; + codeDepartement?: string; + }[] = [{ scope: ScopeEnum.national }]; + + if (codeRegion) scopes.push({ scope: ScopeEnum.région, codeRegion }); + if (codeAcademie) scopes.push({ scope: ScopeEnum.académie, codeAcademie }); + if (codeDepartement) scopes.push({ scope: ScopeEnum.département, codeDepartement }); + + const results = await Promise.all( + scopes.map(async ({ scope, codeRegion, codeAcademie, codeDepartement }) => + getKbdClient() + .with("maille_etab", () => + getFormationMailleEtab({ + codeRegion, + codeAcademie, + codeDepartement, + }) + ) + .with("quatre_dernieres_rentrees", (wb) => + wb + .selectFrom("indicateurEntree") + .select("rentreeScolaire") + .distinct() + .orderBy("rentreeScolaire", "desc") + .limit(4) + ) + .selectFrom("maille_etab") + .innerJoin("indicateurEntree", "indicateurEntree.formationEtablissementId", "maille_etab.id") + .where("cfd", "=", cfd) + .where("indicateurEntree.rentreeScolaire", "in", (sb) => + sb.selectFrom("quatre_dernieres_rentrees").select("rentreeScolaire") + ) + .groupBy(["rentreeScolaire", "libelleFormation"]) + .select((eb) => [ + eb.ref("rentreeScolaire").as("rentreeScolaire"), + eb.val(scope).as("scope"), + selectTauxPressionAgg("indicateurEntree", "maille_etab").as("value"), + ]) + .orderBy("rentreeScolaire", "asc") + .execute() + .then(cleanNull) + ) + ); + + return results.flat(); +}; diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxRemplissage.dep.ts b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxRemplissage.dep.ts new file mode 100644 index 000000000..0add15898 --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/getTauxRemplissage.dep.ts @@ -0,0 +1,68 @@ +import { ScopeEnum } from "shared"; + +import { getKbdClient } from "@/db/db"; +import { selectTauxRemplissageAgg } from "@/modules/data/utils/tauxRemplissage"; +import { cleanNull } from "@/utils/noNull"; + +import { getFormationMailleEtab } from "./getFormationMailleEtab.dep"; + +export const getTauxRemplissages = async ({ + cfd, + codeRegion, + codeAcademie, + codeDepartement, +}: { + cfd: string; + codeRegion?: string; + codeAcademie?: string; + codeDepartement?: string; +}) => { + const scopes: { + scope: string; + codeRegion?: string; + codeAcademie?: string; + codeDepartement?: string; + }[] = [{ scope: ScopeEnum.national }]; + + if (codeRegion) scopes.push({ scope: ScopeEnum.région, codeRegion }); + if (codeAcademie) scopes.push({ scope: ScopeEnum.académie, codeAcademie }); + if (codeDepartement) scopes.push({ scope: ScopeEnum.département, codeDepartement }); + + const results = await Promise.all( + scopes.map(async ({ scope, codeRegion, codeAcademie, codeDepartement }) => + getKbdClient() + .with("maille_etab", () => + getFormationMailleEtab({ + codeRegion, + codeAcademie, + codeDepartement, + }) + ) + .with("quatre_dernieres_rentrees", (wb) => + wb + .selectFrom("indicateurEntree") + .select("rentreeScolaire") + .distinct() + .orderBy("rentreeScolaire", "desc") + .limit(4) + ) + .selectFrom("maille_etab") + .innerJoin("indicateurEntree", "indicateurEntree.formationEtablissementId", "maille_etab.id") + .where("cfd", "=", cfd) + .where("indicateurEntree.rentreeScolaire", "in", (sb) => + sb.selectFrom("quatre_dernieres_rentrees").select("rentreeScolaire") + ) + .groupBy(["rentreeScolaire", "libelleFormation"]) + .select((eb) => [ + eb.ref("rentreeScolaire").as("rentreeScolaire"), + eb.val(scope).as("scope"), + selectTauxRemplissageAgg("indicateurEntree").as("value"), + ]) + .orderBy("rentreeScolaire", "asc") + .execute() + .then(cleanNull) + ) + ); + + return results.flat(); +}; diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/index.ts b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/index.ts new file mode 100644 index 000000000..a82cc0ba8 --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/dependencies/index.ts @@ -0,0 +1,6 @@ +export { getEffectifs } from "./getEffectifs.dep"; +export { getEtablissements } from "./getEtablissements.dep"; +export { getFormation } from "./getFormation.dep"; +export { getSoldePlacesTransformee } from "./getSoldePlacesTransformee.dep"; +export { getTauxIJ } from "./getTauxIJ.dep"; +export { getTauxPressions } from "./getTauxPressions.dep"; diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/getFormationIndicateurs.route.ts b/server/src/modules/data/usecases/getFormationIndicateurs/getFormationIndicateurs.route.ts new file mode 100644 index 000000000..478177bb1 --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/getFormationIndicateurs.route.ts @@ -0,0 +1,24 @@ +import { createRoute } from "@http-wizard/core"; +import { ROUTES } from "shared/routes/routes"; + +import type { Server } from "@/server/server"; + +import { getFormationIndicateursUseCase } from "./getFormationIndicateurs.usecase"; + +const ROUTE = ROUTES["[GET]/formation/:cfd/indicators"]; + +export const getFormationIndicateursRoute = (server: Server) => { + return createRoute(ROUTE.url, { + method: ROUTE.method, + schema: ROUTE.schema, + }).handle((props) => + server.route({ + ...props, + handler: async (request, response) => { + const { cfd } = request.params; + const result = await getFormationIndicateursUseCase(cfd, request.query); + response.status(200).send(result); + }, + }) + ); +}; diff --git a/server/src/modules/data/usecases/getFormationIndicateurs/getFormationIndicateurs.usecase.ts b/server/src/modules/data/usecases/getFormationIndicateurs/getFormationIndicateurs.usecase.ts new file mode 100644 index 000000000..ea3b0fcdc --- /dev/null +++ b/server/src/modules/data/usecases/getFormationIndicateurs/getFormationIndicateurs.usecase.ts @@ -0,0 +1,73 @@ +import Boom from "@hapi/boom"; +import type { QueryFilters } from "shared/routes/schemas/get.formation.cfd.indicators.schema"; + +import { + getEffectifs, + getEtablissements, + getFormation, + getSoldePlacesTransformee, + getTauxIJ, + getTauxPressions, +} from "./dependencies"; +import { getTauxRemplissages } from "./dependencies/getTauxRemplissage.dep"; + +const getFormationIndicateursFactory = + ( + deps = { + getFormation, + getTauxIJ, + getEffectifs, + getEtablissements, + getTauxPressions, + getTauxRemplissages, + getSoldePlacesTransformee, + } + ) => + async (cfd: string, { codeAcademie, codeRegion, codeDepartement }: QueryFilters) => { + const [formation, tauxIJ, effectifs, etablissements, tauxPressions, tauxRemplissages, soldePlacesTransformee] = + await Promise.all([ + deps.getFormation({ cfd }), + deps.getTauxIJ({ cfd, codeRegion }), + deps.getEffectifs({ cfd, codeRegion, codeDepartement, codeAcademie }), + deps.getEtablissements({ + cfd, + codeRegion, + codeDepartement, + codeAcademie, + }), + deps.getTauxPressions({ + cfd, + codeRegion, + codeAcademie, + codeDepartement, + }), + deps.getTauxRemplissages({ + cfd, + codeRegion, + codeAcademie, + codeDepartement, + }), + deps.getSoldePlacesTransformee({ + cfd, + codeRegion, + codeAcademie, + codeDepartement, + }), + ]); + + if (!formation) { + throw Boom.notFound(`La formation avec le cfd ${cfd} est inconnue`); + } + + return { + ...formation, + tauxIJ, + effectifs, + etablissements, + tauxPressions, + tauxRemplissages, + soldePlacesTransformee, + }; + }; + +export const getFormationIndicateursUseCase = getFormationIndicateursFactory(); diff --git a/server/src/modules/data/usecases/getFormations/getFormations.usecase.ts b/server/src/modules/data/usecases/getFormations/getFormations.usecase.ts index b55ef5ebe..d1956e419 100644 --- a/server/src/modules/data/usecases/getFormations/getFormations.usecase.ts +++ b/server/src/modules/data/usecases/getFormations/getFormations.usecase.ts @@ -1,5 +1,5 @@ import type { MILLESIMES_IJ } from "shared"; -import type { getFormationSchema } from "shared/routes/schemas/get.formations.schema"; +import type { getFormationsSchema } from "shared/routes/schemas/get.formations.schema"; import type { z } from "zod"; import { getFormationsRenoveesRentreeScolaireQuery } from "@/modules/data/queries/getFormationsRenovees/getFormationsRenovees"; @@ -8,7 +8,7 @@ import { getStatsSortieParNiveauDiplomeQuery } from "@/modules/data/queries/getS import { getFiltersQuery } from "./deps/getFiltersQuery.dep"; import { getFormationsQuery } from "./deps/getFormationsQuery.dep"; -export interface Filters extends z.infer { +export interface Filters extends z.infer { millesimeSortie: (typeof MILLESIMES_IJ)[number]; } diff --git a/server/src/modules/import/services/ban/ban.api.ts b/server/src/modules/import/services/ban/ban.api.ts index a63d96dc0..8b0b012ed 100644 --- a/server/src/modules/import/services/ban/ban.api.ts +++ b/server/src/modules/import/services/ban/ban.api.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import axios, { isAxiosError } from "axios"; import rateLimit from "axios-rate-limit"; import type { BANResponse } from "./banResponse"; @@ -45,7 +45,7 @@ export const findAddress = async ({ return response.data; } catch (err) { console.error(`Une erreur est survenue lors de la recherche de l'adresse: ${search}`); - if (axios.isAxiosError(err)) { + if (isAxiosError(err)) { console.error(err.message); } return null; diff --git a/server/src/modules/import/services/franceTravail/franceTravail.api.ts b/server/src/modules/import/services/franceTravail/franceTravail.api.ts index 2832cb6b7..e093cae35 100644 --- a/server/src/modules/import/services/franceTravail/franceTravail.api.ts +++ b/server/src/modules/import/services/franceTravail/franceTravail.api.ts @@ -1,7 +1,7 @@ import type { AxiosError } from "axios"; import axios from "axios"; import rateLimit from "axios-rate-limit"; -import axiosRetry, { isNetworkError } from "axios-retry"; +import axiosRetry, { exponentialDelay, isNetworkError } from "axios-retry"; import config from "@/config"; @@ -55,7 +55,7 @@ axiosRetry(instance, { retries: 3, retryCondition, shouldResetTimeout: true, - retryDelay: axiosRetry.exponentialDelay, + retryDelay: exponentialDelay, }); const setInstanceBearerToken = (token: string) => { diff --git a/server/src/modules/import/services/inserJeunesApi/inserJeunes.api.ts b/server/src/modules/import/services/inserJeunesApi/inserJeunes.api.ts index f93057637..94ff74d33 100644 --- a/server/src/modules/import/services/inserJeunesApi/inserJeunes.api.ts +++ b/server/src/modules/import/services/inserJeunesApi/inserJeunes.api.ts @@ -1,5 +1,5 @@ import type { AxiosError } from "axios"; -import axiosRetry, { isNetworkError } from "axios-retry"; +import axiosRetry, { exponentialDelay, isNetworkError } from "axios-retry"; import config from "@/config"; @@ -38,7 +38,7 @@ async function retryCondition(error: AxiosError) { axiosRetry(instance, { retries: 20, - retryDelay: axiosRetry.exponentialDelay, + retryDelay: exponentialDelay, retryCondition, shouldResetTimeout: true, }); diff --git a/shared/routes/routes.ts b/shared/routes/routes.ts index c43b223e9..0079c59bf 100644 --- a/shared/routes/routes.ts +++ b/shared/routes/routes.ts @@ -22,6 +22,8 @@ import { searchDiplomeSchema } from "./schemas/get.diplome.search.search.schema" import { searchDisciplineSchema } from "./schemas/get.discipline.search.search.schema"; import { redirectDneSchema } from "./schemas/get.dne_connect.schema"; import { getDneUrlSchema } from "./schemas/get.dne_url.schema"; +import { getDomaineDeFormationCodeNsfSchema } from "./schemas/get.domaine-de-formation.codeNsf.schema"; +import { getDomaineDeFormationSchema } from "./schemas/get.domaine-de-formation.schema"; import { searchDomaineProfessionnelSchema } from "./schemas/get.domaine-professionnel.search.search.schema"; import { getEditoSchema } from "./schemas/get.edito.schema"; import { searchEtablissementPerdirSchema } from "./schemas/get.etablissement.perdir.search.search.schema"; @@ -33,7 +35,10 @@ import { getDataForEtablissementMapSchema } from "./schemas/get.etablissement.ua import { getEtablissementSchema } from "./schemas/get.etablissement.uai.schema"; import { getFormationEtablissementsSchema } from "./schemas/get.etablissements.schema"; import { searchFiliereSchema } from "./schemas/get.filiere.search.search.schema"; -import { getFormationSchema } from "./schemas/get.formations.schema"; +import { getFormationCfdIndicatorsSchema } from "./schemas/get.formation.cfd.indicators.schema"; +import { getFormationCfdMapSchema } from "./schemas/get.formation.cfd.map.schema"; +import { getFormationCfdSchema } from "./schemas/get.formation.cfd.schema"; +import { getFormationsSchema } from "./schemas/get.formations.schema"; import { getGlossaireEntrySchema } from "./schemas/get.glossaire.id.schema"; import { getGlossaireSchema } from "./schemas/get.glossaire.schema"; import { getHomeSchema } from "./schemas/get.home.schema"; @@ -253,7 +258,7 @@ export const ROUTES = { "[GET]/formations": { url: "/formations", method: "GET", - schema: getFormationSchema, + schema: getFormationsSchema, }, "[GET]/pilotage-intentions/formations": { url: "/pilotage-intentions/formations", @@ -520,4 +525,29 @@ export const ROUTES = { method: "PUT", schema: uploadIntentionFilesSchema, }, + "[GET]/formation/:cfd": { + url: "/formation/:cfd", + method: "GET", + schema: getFormationCfdSchema, + }, + "[GET]/formation/:cfd/map": { + url: "/formation/:cfd/map", + method: "GET", + schema: getFormationCfdMapSchema, + }, + "[GET]/formation/:cfd/indicators": { + url: "/formation/:cfd/indicators", + method: "GET", + schema: getFormationCfdIndicatorsSchema, + }, + "[GET]/domaine-de-formation/:codeNsf": { + url: "/domaine-de-formation/:codeNsf", + method: "GET", + schema: getDomaineDeFormationCodeNsfSchema, + }, + "[GET]/domaine-de-formation": { + url: "/domaine-de-formation", + method: "GET", + schema: getDomaineDeFormationSchema, + }, } satisfies IRoutesDefinition; diff --git a/shared/routes/schemas/get.domaine-de-formation.codeNsf.schema.ts b/shared/routes/schemas/get.domaine-de-formation.codeNsf.schema.ts new file mode 100644 index 000000000..580e6f838 --- /dev/null +++ b/shared/routes/schemas/get.domaine-de-formation.codeNsf.schema.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +import { OptionSchema } from "../../schema/optionSchema"; + +const filtersSchema = z.object({ + regions: z.array(OptionSchema), + academies: z.array( + OptionSchema.extend({ + codeRegion: z.string(), + }) + ), + departements: z.array( + OptionSchema.extend({ + codeRegion: z.string(), + codeAcademie: z.string(), + }) + ), +}); + +export const formationSchema = z.object({ + codeNsf: z.string(), + cfd: z.string(), + codeNiveauDiplome: z.string(), + libelleFormation: z.string(), + libelleNiveauDiplome: z.string(), + typeFamille: z.string().optional(), + nbEtab: z.number(), + apprentissage: z.boolean(), + scolaire: z.boolean(), + isFormationRenovee: z.boolean(), + dateOuverture: z.date(), +}); + +const queryFiltersSchema = z.object({ + codeRegion: z.string().optional(), + codeAcademie: z.string().optional(), + codeDepartement: z.string().optional(), +}); + +export type QueryFilters = z.infer; + +export const getDomaineDeFormationCodeNsfSchema = { + params: z.object({ + codeNsf: z.string(), + }), + querystring: queryFiltersSchema, + response: { + 200: z.object({ + codeNsf: z.string(), + libelleNsf: z.string(), + filters: filtersSchema, + formations: z.array(formationSchema), + }), + }, +}; diff --git a/shared/routes/schemas/get.domaine-de-formation.schema.ts b/shared/routes/schemas/get.domaine-de-formation.schema.ts new file mode 100644 index 000000000..62663b435 --- /dev/null +++ b/shared/routes/schemas/get.domaine-de-formation.schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const NsfOptionSchema = z.object({ + label: z.string().optional(), + value: z.string(), + nsf: z.string().optional(), + type: z.enum(["nsf", "formation"]), +}); + +export type NsfOption = z.infer; + +export const getDomaineDeFormationSchema = { + querystring: z.object({ + search: z.string().trim().toLowerCase().optional(), + }), + response: { + 200: z.array(NsfOptionSchema), + }, +}; diff --git a/shared/routes/schemas/get.formation.cfd.indicators.schema.ts b/shared/routes/schemas/get.formation.cfd.indicators.schema.ts new file mode 100644 index 000000000..4c5ce4b08 --- /dev/null +++ b/shared/routes/schemas/get.formation.cfd.indicators.schema.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +const queryFiltersSchema = z.object({ + codeRegion: z.string().optional(), + codeAcademie: z.string().optional(), + codeDepartement: z.string().optional(), +}); + +const tauxIJValueSchema = z.object({ + libelle: z.string(), + apprentissage: z.number().optional(), + scolaire: z.number().optional(), +}); + +const tauxIJSchema = z.object({ + tauxPoursuite: z.array(tauxIJValueSchema), + tauxInsertion: z.array(tauxIJValueSchema), + tauxDevenirFavorable: z.array(tauxIJValueSchema), +}); + +const effectifsSchema = z.object({ + rentreeScolaire: z.string(), + effectif: z.number(), +}); + +const soldePlacesTransformeeSchema = z.object({ + rentreeScolaire: z.number(), + scolaire: z.number(), + apprentissage: z.number(), +}); + +const etablissementsSchema = z.object({ + rentreeScolaire: z.string(), + nbEtablissements: z.number(), +}); + +export const tauxSchema = z.object({ + value: z.number().optional(), + rentreeScolaire: z.string(), + scope: z.string(), +}); + +export type QueryFilters = z.infer; +export type Effectifs = z.infer; +export type TauxIJ = z.infer; +export type TauxIJValue = z.infer; +export type TauxIJKey = keyof TauxIJ; +export type Etablissements = z.infer; + +export const getFormationCfdIndicatorsSchema = { + params: z.object({ + cfd: z.string(), + }), + querystring: queryFiltersSchema, + response: { + 200: z.object({ + cfd: z.string(), + libelle: z.string(), + tauxIJ: tauxIJSchema, + effectifs: z.array(effectifsSchema), + etablissements: z.array(etablissementsSchema), + tauxPressions: z.array(tauxSchema), + tauxRemplissages: z.array(tauxSchema), + soldePlacesTransformee: z.array(soldePlacesTransformeeSchema), + }), + }, +}; diff --git a/shared/routes/schemas/get.formation.cfd.map.schema.ts b/shared/routes/schemas/get.formation.cfd.map.schema.ts new file mode 100644 index 000000000..209bb24de --- /dev/null +++ b/shared/routes/schemas/get.formation.cfd.map.schema.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +export const EtablissementSchema = z.object({ + uai: z.string(), + latitude: z.number(), + longitude: z.number(), + commune: z.string(), + libelleEtablissement: z.string(), + libellesDispositifs: z.array(z.string()).transform((val) => val.filter(Boolean)), + tauxInsertion: z.number().optional(), + tauxDevenirFavorable: z.number().optional(), + tauxPoursuite: z.number().optional(), + tauxPression: z.number().optional(), + effectifs: z.number().optional(), + codeDepartement: z.string(), + secteur: z.string(), + isApprentissage: z.boolean(), + isScolaire: z.boolean(), + isBTS: z.boolean(), +}); + +export type Etablissement = z.infer; + +export const QueryFiltersSchema = z.object({ + cfd: z.string().array().optional(), + codeRegion: z.string().optional(), + codeDepartement: z.string().optional(), + codeAcademie: z.string().optional(), + orderBy: z.enum(["libelle", "departement_commune"]).default("libelle"), + includeAll: z + .enum(["true", "false"]) + .transform((val) => val === "true") + .optional() + .default("false"), +}); + +export type EtablissementsOrderBy = z.infer["orderBy"]; + +export type QueryFilters = z.infer; + +export const ParamsSchema = z.object({ + cfd: z.string(), +}); + +export type Params = z.infer; + +export const getFormationCfdMapSchema = { + params: ParamsSchema, + querystring: QueryFiltersSchema, + response: { + 200: z.object({ + etablissements: z.array(EtablissementSchema), + bbox: z.object({ + latMin: z.number(), + latMax: z.number(), + lngMin: z.number(), + lngMax: z.number(), + }), + }), + }, +}; diff --git a/shared/routes/schemas/get.formation.cfd.schema.ts b/shared/routes/schemas/get.formation.cfd.schema.ts new file mode 100644 index 000000000..a7a25c26c --- /dev/null +++ b/shared/routes/schemas/get.formation.cfd.schema.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +const queryFiltersSchema = z.object({ + codeRegion: z.string().optional(), + codeAcademie: z.string().optional(), + codeDepartement: z.string().optional(), +}); + +export type QueryFilters = z.infer; + +export const getFormationCfdSchema = { + params: z.object({ + cfd: z.string(), + }), + querystring: queryFiltersSchema, + response: { + 200: z.object({ + cfd: z.string(), + libelle: z.string(), + codeNiveauDiplome: z.string().optional(), + typeFamille: z.string().optional(), + codeNsf: z.string().optional(), + libelleRome: z.string().optional(), + isTransitionDemographique: z.boolean().optional(), + isTransitionEcologique: z.boolean().optional(), + isTransitionNumerique: z.boolean().optional(), + isBTS: z.boolean(), + isApprentissage: z.boolean(), + isScolaire: z.boolean(), + isInScope: z.boolean(), + isFormationRenovee: z.boolean(), + }), + }, +}; diff --git a/shared/routes/schemas/get.formations.schema.ts b/shared/routes/schemas/get.formations.schema.ts index 9ceac06e3..296ea046c 100644 --- a/shared/routes/schemas/get.formations.schema.ts +++ b/shared/routes/schemas/get.formations.schema.ts @@ -46,7 +46,7 @@ export const FormationLineSchema = z.object({ dateFermeture: z.string().optional(), }); -export const getFormationSchema = { +export const getFormationsSchema = { querystring: z.object({ cfd: z.array(z.string()).optional(), codeRegion: z.array(z.string()).optional(), diff --git a/ui/app/(wrapped)/components/Nav.tsx b/ui/app/(wrapped)/components/Nav.tsx index 4b3e33880..f8df3a37e 100644 --- a/ui/app/(wrapped)/components/Nav.tsx +++ b/ui/app/(wrapped)/components/Nav.tsx @@ -233,6 +233,11 @@ export const Nav = () => { Lien métier formation + + + Domaine de formation + + diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/client.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/client.tsx new file mode 100644 index 000000000..456f93446 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/client.tsx @@ -0,0 +1,57 @@ +"use client"; + +import type { ScopeZone } from "shared"; + +import { FiltersSection } from "./components/FiltersSection/FiltersSection"; +import { FormationSection } from "./components/FormationSection"; +import { HeaderSection } from "./components/HeaderSection/HeaderSection"; +import { LiensUtilesSection } from "./components/LiensUtilesSection/LiensUtilesSection"; +import { FormationContextProvider } from "./context/formationContext"; +import type { Academie, Departement, FormationListItem, FormationsCounter, NsfOptions, Region } from "./types"; + +type Props = { + codeNsf: string; + libelleNsf: string; + formations: FormationListItem[]; + cfd: string; + regions: Region[]; + academies: Academie[]; + departements: Departement[]; + scope: ScopeZone; + counter: FormationsCounter; + defaultNsfs: NsfOptions; + formationsByLibelleNiveauDiplome: Record; +}; + +export const PageDomaineDeFormationClient = ({ + codeNsf, + libelleNsf, + formations, + cfd, + regions, + academies, + departements, + counter, + scope, + defaultNsfs, + formationsByLibelleNiveauDiplome, +}: Props) => { + return ( + + + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FiltersSection/FiltersSection.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FiltersSection/FiltersSection.tsx new file mode 100644 index 000000000..d5de607b4 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FiltersSection/FiltersSection.tsx @@ -0,0 +1,130 @@ +import { Button, Container, Flex, Select } from "@chakra-ui/react"; +import { Icon } from "@iconify/react"; +import { useRouter, useSearchParams } from "next/navigation"; +import type { OptionSchema } from "shared/schema/optionSchema"; + +import { useFormationContext } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext"; +import type { NsfOptions } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { SelectNsf } from "@/app/(wrapped)/panorama/domaine-de-formation/components/selectNsf"; + +export const FiltersSection = ({ + regionOptions, + academieOptions, + departementOptions, + defaultNsfs, + currentNsf, +}: { + regionOptions: OptionSchema[]; + academieOptions: OptionSchema[]; + departementOptions: OptionSchema[]; + defaultNsfs: NsfOptions; + currentNsf: string; +}) => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const { + currentFilters, + codeNsf, + handleRegionChange, + handleAcademieChange, + handleDepartementChange, + handleResetFilters, + handleCfdChange, + } = useFormationContext(); + + return ( + + + + nsf.value === currentNsf) ?? null} + w={"100%"} + flex={2} + isClearable={true} + routeSelectedNsf={(selected) => { + const params = new URLSearchParams(searchParams); + if (selected.type === "formation") { + if (selected.nsf === codeNsf) { + handleCfdChange(selected.value); + } else { + params.set("cfd", selected.value); + router.push(`/domaine-de-formation/${selected.nsf}?${params.toString()}`); + } + } else { + params.delete("cfd"); + router.push(`/domaine-de-formation/${selected.value}?${params.toString()}`); + } + }} + /> + + + + + + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/FormationHeader.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/FormationHeader.tsx new file mode 100644 index 000000000..643517dde --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/FormationHeader.tsx @@ -0,0 +1,23 @@ +import { Flex, Heading, HStack } from "@chakra-ui/react"; + +import type { Formation } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { BadgeFormationRenovee } from "@/components/BadgeFormationRenovee"; +import type { TypeFamilleKeys } from "@/components/BadgeTypeFamille"; +import { BadgeTypeFamille } from "@/components/BadgeTypeFamille"; + +export const FormationHeader = ({ data, exportButton }: { data: Formation; exportButton?: React.ReactNode }) => { + return ( + + + + {data.libelle} + + {exportButton} + + + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/FormationTabs.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/FormationTabs.tsx new file mode 100644 index 000000000..2150dc5f3 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/FormationTabs.tsx @@ -0,0 +1,46 @@ +import { Button, Flex, Tab, TabList, Tabs, Text } from "@chakra-ui/react"; +import { Icon } from "@iconify/react"; + +import type { FormationTab } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; + +export const FormationTabs = ({ + selectedTab, + changeTab, +}: { + selectedTab: FormationTab; + changeTab: (tab: FormationTab) => void; +}) => { + return ( + + + changeTab("etablissements")}> + + + Etablissements + + + changeTab("indicateurs")}> + + + Indicateurs + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/ListeFormations.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/ListeFormations.tsx new file mode 100644 index 000000000..9b98f468d --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/ListeFormations.tsx @@ -0,0 +1,132 @@ +import type { BoxProps } from "@chakra-ui/react"; +import { Badge, Box, Divider, Flex, forwardRef, List, ListItem, Text, Tooltip } from "@chakra-ui/react"; +import _ from "lodash"; +import { CURRENT_RENTREE } from "shared"; + +import type { FormationListItem } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { formatAnneeCommuneLibelle } from "@/app/(wrapped)/panorama/etablissement/components/analyse-detaillee/formatData"; +import { BadgeFormationRenovee } from "@/components/BadgeFormationRenovee"; +import type { TypeFamilleKeys } from "@/components/BadgeTypeFamille"; +import { BadgeTypeFamille } from "@/components/BadgeTypeFamille"; +import { themeColors } from "@/theme/themeColors"; + +const LabelNumberOfFormations = ({ formations }: { formations: number }) => ( + + {formations} Formation + {formations > 1 ? "s" : ""} + +); + +const getBackgroundColor = (formation: FormationListItem, selectedCfd: string) => { + if (formation.cfd === selectedCfd) { + return "bluefrance.925"; + } + + if (formation.nbEtab === 0) { + return "grey.925"; + } + + return undefined; +}; + +const getFontColor = (formation: FormationListItem, selectedCfd: string) => { + if (formation.cfd === selectedCfd) { + return "bluefrance.113"; + } + + if (formation.nbEtab === 0) { + return "grey.625"; + } + + return undefined; +}; + +type ListeFormationsProps = BoxProps & { + formationsByLibelleNiveauDiplome: Record; + selectCfd: (cfd: string) => void; + selectedCfd: string; +}; + +export const ListeFormations = forwardRef( + ({ formationsByLibelleNiveauDiplome, selectCfd, selectedCfd, ...rest }, ref) => { + return ( + + + arr.length)} + /> + Rentrée {CURRENT_RENTREE} + + + + {Object.entries(formationsByLibelleNiveauDiplome).map(([libelleNiveauDiplome, formations]) => ( + + {`${libelleNiveauDiplome} (${formations.length})`} + + {formations.map((formation) => ( + { + selectCfd(formation.cfd); + }} + bgColor={getBackgroundColor(formation, selectedCfd)} + _hover={{ + backgroundColor: selectedCfd === formation.cfd ? "bluefrance.925_hover" : "grey.1000_active", + }} + fontWeight={selectedCfd === formation.cfd ? "bold" : ""} + position={"relative"} + > + + + + {formatAnneeCommuneLibelle(formation.libelleFormation)} + + + + + + + + + ))} + + + ))} + + + ); + } +); diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/TabFilters.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/TabFilters.tsx new file mode 100644 index 000000000..9c22cd783 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/TabFilters.tsx @@ -0,0 +1,46 @@ +import { Flex, Select } from "@chakra-ui/react"; + +import { useFormationContext } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext"; +import type { FormationsCounter, Presence, Voie } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; + +import { FormationTabs } from "./FormationTabs"; + +export const TabFilters = ({ counter }: { counter: FormationsCounter }) => { + const { handlePresenceChange, handleVoieChange, currentFilters, handleTabFormationChange } = useFormationContext(); + + return ( + + + + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/ExportListEtablissements.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/ExportListEtablissements.tsx new file mode 100644 index 000000000..d41ff5bf5 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/ExportListEtablissements.tsx @@ -0,0 +1,101 @@ +import { usePlausible } from "next-plausible"; + +import type { Etablissement, Formation } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { ExportMenuButton } from "@/components/ExportMenuButton"; +import type { ExportColumns } from "@/utils/downloadExport"; +import { downloadCsv, downloadExcel } from "@/utils/downloadExport"; +import { formatExportFilename } from "@/utils/formatExportFilename"; +import { formatCommuneLibelleWithCodeDepartement, formatLibelleFormation, formatSecteur } from "@/utils/formatLibelle"; +import { formatArray } from "@/utils/formatUtils"; + +const extractDatas = ( + etablissement: Etablissement, + formation: Formation, + domaineDeFormation: { codeNsf: string; libelleNsf: string } +) => { + const { libelleEtablissement, commune, codeDepartement, ...restEtablissement } = etablissement; + const { libelle: libelleFormation, ...restFormation } = formation; + + return { + codeNsf: domaineDeFormation.codeNsf, + libelleNsf: domaineDeFormation.libelleNsf, + libelleFormation: formatLibelleFormation({ + libellesDispositifs: etablissement.libellesDispositifs, + libelleFormation: formation.libelle, + }), + ...restFormation, + libelleEtablissement, + commune: formatCommuneLibelleWithCodeDepartement({ + commune: etablissement.commune, + codeDepartement: etablissement.codeDepartement, + }), + ...restEtablissement, + secteur: formatSecteur(etablissement.secteur), + voie: formatArray([ + etablissement.isApprentissage ? "Apprentissage" : "", + etablissement.isScolaire ? "Scolaire" : "", + ]), + }; +}; + +const columns: ExportColumns> = { + codeNsf: "Code NSF", + libelleNsf: "Domaine de formation", + libelleFormation: "Formation", + cfd: "CFD", + isBTS: "BTS", + isApprentissage: "Apprentissage", + isScolaire: "Scolaire", + isTransitionDemographique: "Transition démographique", + isTransitionEcologique: "Transition écologique", + isTransitionNumerique: "Transition numérique", + uai: "UAI", + commune: "Commune", + latitude: "Latitude", + longitude: "Longitude", + libelleEtablissement: "Libellé établissement", + tauxInsertion: "Taux d'emploi à 6 mois", + tauxDevenirFavorable: "Taux de devenir", + tauxPoursuite: "Taux de poursuite d'études", + tauxPression: "Taux de pression", + effectifs: "Effectifs", + secteur: "Secteur", +}; + +export const ExportListEtablissements = ({ + etablissements, + formation, + domaineDeFormation, +}: { + etablissements: Etablissement[]; + formation: Formation; + domaineDeFormation: { codeNsf: string; libelleNsf: string }; +}) => { + const trackEvent = usePlausible(); + + const onExportCsv = async () => { + if (!etablissements.length) return; + + trackEvent("domaine-de-formation-etablissements:export-csv"); + + downloadCsv( + formatExportFilename("domaine-de-formation_etablissements"), + etablissements.map((etablissement) => extractDatas(etablissement, formation, domaineDeFormation)), + columns + ); + }; + + const onExportExcel = async () => { + if (!etablissements.length) return; + + trackEvent("domaine-de-formation-etablissements:export-excel"); + + downloadExcel( + formatExportFilename("domaine-de-formation_etablissements"), + etablissements.map((etablissement) => extractDatas(etablissement, formation, domaineDeFormation)), + columns + ); + }; + + return ; +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/List/CustomListItem.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/List/CustomListItem.tsx new file mode 100644 index 000000000..2de9d09cb --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/List/CustomListItem.tsx @@ -0,0 +1,52 @@ +import { Divider, ListItem } from "@chakra-ui/react"; +import { usePlausible } from "next-plausible"; + +import { EtablissementItemContent } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/components/EtablissementItemContent"; +import type { Etablissement } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; + +export const CustomListItem = ({ + etablissement, + withDivider = true, +}: { + etablissement: Etablissement; + withDivider: boolean; +}) => { + const trackEvent = usePlausible(); + + const backgroundColor = (() => { + return "transparent"; + })(); + + const onEtablissementHover = () => { + trackEvent("cartographie-etablissement:interaction", { + props: { + type: "cartographie-etablissement-list-hover", + uai: etablissement.uai, + }, + }); + }; + + const onEtablissementClick = () => { + trackEvent("cartographie-etablissement:interaction", { + props: { + type: "cartographie-etablissement-list-click", + uai: etablissement.uai, + }, + }); + }; + + return ( + <> + onEtablissementHover()} + onClick={() => onEtablissementClick()} + > + + + {withDivider && } + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/List/index.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/List/index.tsx new file mode 100644 index 000000000..d646a60dd --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/List/index.tsx @@ -0,0 +1,36 @@ +import { List as ChakraList, ListItem, Skeleton } from "@chakra-ui/react"; +import { useMemo } from "react"; + +import type { Etablissement } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; + +import { CustomListItem } from "./CustomListItem"; + +export const List = ({ isLoading, etablissements }: { isLoading: boolean; etablissements: Etablissement[] }) => { + const etablissementsListItems = useMemo(() => { + return (etablissements ?? []).map((etablissement, i) => ( + + )); + }, [etablissements]); + + return ( + + {isLoading ? ( + + + + ) : ( + etablissementsListItems + )} + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/ActiveEtablissementLayers.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/ActiveEtablissementLayers.tsx new file mode 100644 index 000000000..77400d31c --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/ActiveEtablissementLayers.tsx @@ -0,0 +1,145 @@ +import { usePlausible } from "next-plausible"; +import { useEffect } from "react"; +import type { MapGeoJSONFeature, MapMouseEvent, SymbolLayer } from "react-map-gl/maplibre"; +import { Layer, Source, useMap } from "react-map-gl/maplibre"; + +import type { Etablissement } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; + +import { MAP_IMAGES } from "./CustomControls"; + +export const ActiveEtablissementLayers = ({ + etablissements, + hoverUai, + setActiveUai, +}: { + etablissements: Etablissement[]; + hoverUai: string | null; + setActiveUai: (uai: string | null) => void; +}) => { + const { current: map } = useMap(); + const trackEvent = usePlausible(); + + const activeEtablissement = etablissements.find((e) => e.uai === hoverUai); + + const etablissementPoint = { + type: "Feature", + geometry: { + type: "Point", + coordinates: [activeEtablissement?.longitude, activeEtablissement?.latitude], + }, + properties: { + uai: activeEtablissement?.uai, + // Attention, ici on utilise une string puisque les expressions de filtre de maplibre + // ne permettent pas de vérifier les occurences dans un tableau + voies: [ + activeEtablissement?.isApprentissage ? "apprentissage" : "", + activeEtablissement?.isScolaire ? "scolaire" : "", + ] + .filter(Boolean) + .join(","), + }, + }; + + const scolaireInvertedSinglePointLayer: SymbolLayer = { + id: "single-scolaire-activeEtablissement-inverted", + type: "symbol", + source: "activeEtablissement", + filter: ["all", ["in", "voies", "scolaire"], ["==", "uai", hoverUai ?? ""]], + layout: { + "icon-image": MAP_IMAGES.MAP_POINT_SCOLAIRE_INVERTED.name, + "icon-overlap": "always", + }, + }; + + const apprentissageInvertedSinglePointLayer: SymbolLayer = { + id: "single-apprentissage-activeEtablissement-inverted", + type: "symbol", + source: "activeEtablissement", + filter: ["all", ["in", "voies", "apprentissage"], ["==", "uai", hoverUai ?? ""]], + layout: { + "icon-image": MAP_IMAGES.MAP_POINT_APPRENTISSAGE_INVERTED.name, + "icon-overlap": "always", + }, + }; + + const scolaireApprentissageInvertedSinglePointLayer: SymbolLayer = { + id: "single-scolaire-apprentissage-activeEtablissement-inverted", + type: "symbol", + source: "activeEtablissement", + filter: ["all", ["in", "voies", "apprentissage,scolaire"], ["==", "uai", hoverUai ?? ""]], + layout: { + "icon-image": MAP_IMAGES.MAP_POINT_SCOLAIRE_APPRENTISSAGE_INVERTED.name, + "icon-overlap": "always", + }, + }; + + const onSinglePointClick = async ( + e: MapMouseEvent & { + features?: MapGeoJSONFeature[]; + } + ) => { + if (map !== undefined) { + const features = map.queryRenderedFeatures(e.point, { + layers: [ + scolaireInvertedSinglePointLayer.id, + apprentissageInvertedSinglePointLayer.id, + scolaireApprentissageInvertedSinglePointLayer.id, + ], + }); + + if (features.length > 0 && features[0] !== undefined) { + setActiveUai(features[0].properties.uai); + trackEvent("cartographie-etablissement:interaction", { + props: { + type: "cartographie-etablissement-click", + uai: features[0].properties.uai, + }, + }); + } + } + }; + + useEffect(() => { + if (map !== undefined) { + map.on("load", async () => { + const singlePointLayers = [ + scolaireInvertedSinglePointLayer, + apprentissageInvertedSinglePointLayer, + scolaireApprentissageInvertedSinglePointLayer, + ]; + + singlePointLayers.forEach((layer) => { + map.off("click", layer.id, onSinglePointClick); + map.on("click", layer.id, onSinglePointClick); + }); + }); + } + }, [ + map, + scolaireInvertedSinglePointLayer, + apprentissageInvertedSinglePointLayer, + scolaireApprentissageInvertedSinglePointLayer, + onSinglePointClick, + ]); + + return ( + <> + + {/** + * We have to set a key to each layers to force re-rendere when the hoverUai changes + */} + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/ActiveEtablissementPopup.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/ActiveEtablissementPopup.tsx new file mode 100644 index 000000000..0aab88081 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/ActiveEtablissementPopup.tsx @@ -0,0 +1,33 @@ +import { Popup } from "react-map-gl/maplibre"; + +import { EtablissementItemContent } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/components/EtablissementItemContent"; +import type { Etablissement } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; + +export const ActiveEtablissementPopup = ({ + etablissements, + activeUai, + onClose, +}: { + etablissements: Etablissement[]; + activeUai: string | null; + onClose: () => void; +}) => { + const etablissement = etablissements.find((e) => e.uai === activeUai); + + if (!etablissement) { + return null; + } + + return ( + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/CustomControls.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/CustomControls.tsx new file mode 100644 index 000000000..d862a65ba --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/CustomControls.tsx @@ -0,0 +1,92 @@ +import type { MapEventType } from "maplibre-gl"; +import { usePlausible } from "next-plausible"; +import { useEffect } from "react"; +import type { MapRef } from "react-map-gl/maplibre"; +import { useMap } from "react-map-gl/maplibre"; + +export const MAP_IMAGES = { + MAP_POINT_APPRENTISSAGE: { + path: "/map/map_point_apprentissage.png", + name: "map_point_apprentissage", + }, + MAP_POINT_APPRENTISSAGE_INVERTED: { + path: "/map/map_point_apprentissage_inverted.png", + name: "map_point_apprentissage_inverted", + }, + MAP_POINT_SCOLAIRE_APPRENTISSAGE: { + path: "/map/map_point_scolaire_apprentissage.png", + name: "map_point_scolaire_apprentissage", + }, + MAP_POINT_SCOLAIRE_APPRENTISSAGE_INVERTED: { + path: "/map/map_point_scolaire_apprentissage_inverted.png", + name: "map_point_scolaire_apprentissage_inverted", + }, + MAP_POINT_SCOLAIRE: { + path: "/map/map_point_scolaire.png", + name: "map_point_scolaire", + }, + MAP_POINT_SCOLAIRE_INVERTED: { + path: "/map/map_point_scolaire_inverted.png", + name: "map_point_scolaire_inverted", + }, +}; + +export const CustomControls = ({ + setBbox, + setMap, +}: { + setBbox: (bbox: { latMin: number; lngMin: number; latMax: number; lngMax: number }) => void; + setMap: (map: MapRef) => void; +}) => { + const { current: map } = useMap(); + const trackEvent = usePlausible(); + + const loadImageOnMap = async (image: { path: string; name: string }) => { + if (!map?.isStyleLoaded()) { + setTimeout(async () => loadImageOnMap(image), 200); + } else { + const loadedImage = await map.loadImage(image.path); + // Ensure getImage is accessible because race condition might trigger a user error + if (map?.getImage !== undefined && map.hasImage(image.name)) { + map.updateImage(image.name, loadedImage.data); + } else { + map.addImage(image.name, loadedImage.data); + } + } + }; + + const onZoomEnd = (e: MapEventType & { computer?: boolean }) => { + if (map !== undefined && !e.computer) { + const bounds = map.getBounds(); + setBbox({ + lngMin: bounds.toArray()[0][0], + latMin: bounds.toArray()[0][1], + lngMax: bounds.toArray()[1][0], + latMax: bounds.toArray()[1][1], + }); + trackEvent("cartographie-etablissement:interaction", { + props: { + type: "cartographie-zoom", + }, + }); + } + }; + + // Lors de l'initialisation de la carte + useEffect(() => { + if (map !== undefined) { + map.on("load", () => { + map.off("moveend", onZoomEnd); + map.on("moveend", onZoomEnd); + }); + + map.on("style.load", async () => { + await Promise.all(Object.values(MAP_IMAGES).map(async (image) => await loadImageOnMap(image))); + }); + + setMap(map); + } + }, [map, loadImageOnMap, setMap, onZoomEnd]); + + return <>; +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/EtablissementsLayers.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/EtablissementsLayers.tsx new file mode 100644 index 000000000..af89932a0 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/EtablissementsLayers.tsx @@ -0,0 +1,203 @@ +import { usePlausible } from "next-plausible"; +import { useEffect } from "react"; +import type { CircleLayer, GeoJSONSource, MapGeoJSONFeature, MapMouseEvent, SymbolLayer } from "react-map-gl/maplibre"; +import { Layer, Source, useMap } from "react-map-gl/maplibre"; + +import type { Etablissement } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { themeDefinition } from "@/theme/theme"; + +import { MAP_IMAGES } from "./CustomControls"; + +export const EtablissementsLayers = ({ + etablissements, + hoverUai, + setHoverUai, + setActiveUai, +}: { + etablissements: Etablissement[]; + hoverUai: string | null; + setHoverUai: (uai: string | null) => void; + setActiveUai: (uai: string | null) => void; +}) => { + const { current: map } = useMap(); + const trackEvent = usePlausible(); + + const geojson = { + type: "FeatureCollection", + features: etablissements.map((e) => ({ + geometry: { + type: "Point", + coordinates: [e.longitude, e.latitude], + }, + properties: { + uai: e.uai, + // Attention, ici on utilise une string puisque les expressions de filtre de maplibre + // ne permettent pas de vérifier les occurences dans un tableau + voies: [e.isApprentissage ? "apprentissage" : "", e.isScolaire ? "scolaire" : ""].filter(Boolean).join(","), + }, + })), + }; + + const clusterLayer: CircleLayer = { + id: "cluster-etablissements", + type: "circle", + source: "etablissements", + filter: ["has", "point_count"], + paint: { + "circle-color": [ + "step", + ["get", "point_count"], + themeDefinition.colors.bluefrance[113], + 100, + themeDefinition.colors.bluefrance[113], + 750, + themeDefinition.colors.bluefrance[113], + ], + "circle-radius": ["step", ["get", "point_count"], 20, 100, 30, 750, 40], + }, + }; + + const clusterLabelLayer: SymbolLayer = { + id: "cluster-count-etablissements", + type: "symbol", + source: "etablissements", + filter: ["has", "point_count"], + layout: { + "text-field": "{point_count_abbreviated}", + "text-size": 12, + "text-overlap": "always", + }, + paint: { + "text-color": "white", + }, + }; + + const scolaireSinglePointLayer: SymbolLayer = { + id: "single-scolaire-etablissements", + type: "symbol", + source: "etablissements", + filter: ["all", ["!has", "point_count"], ["in", "voies", "scolaire"], ["!=", "uai", hoverUai ?? ""]], + layout: { + "icon-image": MAP_IMAGES.MAP_POINT_SCOLAIRE.name, + "icon-overlap": "always", + }, + }; + + const apprentissageSinglePointLayer: SymbolLayer = { + id: "single-apprentissage-etablissements", + type: "symbol", + source: "etablissements", + filter: ["all", ["!has", "point_count"], ["in", "voies", "apprentissage"], ["!=", "uai", hoverUai ?? ""]], + layout: { + "icon-image": MAP_IMAGES.MAP_POINT_APPRENTISSAGE.name, + "icon-overlap": "always", + }, + }; + + const scolaireApprentissageSinglePointLayer: SymbolLayer = { + id: "single-scolaire-apprentissage-etablissements", + type: "symbol", + source: "etablissements", + filter: ["all", ["!has", "point_count"], ["in", "voies", "apprentissage,scolaire"], ["!=", "uai", hoverUai ?? ""]], + layout: { + "icon-image": MAP_IMAGES.MAP_POINT_SCOLAIRE_APPRENTISSAGE.name, + "icon-overlap": "always", + }, + }; + + const onClusterClick = async ( + e: MapMouseEvent & { + features?: MapGeoJSONFeature[]; + } + ) => { + if (map !== undefined) { + const features = map.queryRenderedFeatures(e.point, { + layers: [clusterLayer.id], + }); + const clusterId = features[0].properties.cluster_id; + + const source = (await map.getSource("etablissements")) as GeoJSONSource; + + if (source && "getClusterExpansionZoom" in source) { + const clusterZoom = await source.getClusterExpansionZoom(clusterId); + map.easeTo({ + center: + "coordinates" in features[0].geometry ? (features[0].geometry.coordinates as [number, number]) : [-1, -1], + zoom: clusterZoom, + }); + trackEvent("cartographie-etablissement:interaction", { + props: { + type: "cartographie-etablissement-cluster-click", + uai: features[0].properties.uai, + }, + }); + } + } + }; + + const onSinglePointOver = async ( + e: MapMouseEvent & { + features?: MapGeoJSONFeature[]; + } + ) => { + if (map !== undefined) { + const features = map.queryRenderedFeatures(e.point, { + layers: [ + scolaireSinglePointLayer.id, + apprentissageSinglePointLayer.id, + scolaireApprentissageSinglePointLayer.id, + ], + }); + + if (features.length > 0 && features[0] !== undefined) { + setHoverUai(features[0].properties.uai); + setActiveUai(features[0].properties.uai); + trackEvent("cartographie-formation:interaction", { + props: { + type: "cartographie-formation-hover", + uai: features[0].properties.uai, + }, + }); + } + } + }; + + useEffect(() => { + if (map !== undefined) { + map.on("load", async () => { + map.off("click", clusterLayer.id, onClusterClick); + map.on("click", clusterLayer.id, onClusterClick); + + const singlePointLayers = [ + scolaireSinglePointLayer, + scolaireApprentissageSinglePointLayer, + apprentissageSinglePointLayer, + ]; + + singlePointLayers.forEach((layer) => { + map.off("click", layer.id, onSinglePointOver); + map.on("click", layer.id, onSinglePointOver); + }); + }); + } + }, [ + map, + onSinglePointOver, + onClusterClick, + clusterLayer, + scolaireSinglePointLayer, + apprentissageSinglePointLayer, + scolaireApprentissageSinglePointLayer, + ]); + + return ( + <> + + + + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/index.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/index.tsx new file mode 100644 index 000000000..004c25663 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/Map/index.tsx @@ -0,0 +1,85 @@ +import "maplibre-gl/dist/maplibre-gl.css"; + +import { Skeleton } from "@chakra-ui/react"; +import { useState } from "react"; +import type { MapRef } from "react-map-gl/maplibre"; +import MapGLMap, { NavigationControl, ScaleControl } from "react-map-gl/maplibre"; + +import type { Etablissement } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; + +import { ActiveEtablissementLayers } from "./ActiveEtablissementLayers"; +import { ActiveEtablissementPopup } from "./ActiveEtablissementPopup"; +import { CustomControls } from "./CustomControls"; +import { EtablissementsLayers } from "./EtablissementsLayers"; + +interface MapProps { + isLoading: boolean; + etablissements: Etablissement[]; + setMap: (map: MapRef) => void; + setBbox: (bbox: { latMin: number; lngMin: number; latMax: number; lngMax: number }) => void; + hoverUai: string | null; + setHoverUai: (uai: string | null) => void; + setActiveUai: (uai: string | null) => void; + activeUai: string | null; + bbox: { latMin: number; lngMin: number; latMax: number; lngMax: number }; +} + +const AVAILABLE_STYLES = [ + "https://openmaptiles.geo.data.gouv.fr/styles/osm-bright/style.json", + "https://data.geopf.fr/annexes/ressources/vectorTiles/styles/PLAN.IGN/standard.json", + "https://data.geopf.fr/annexes/ressources/vectorTiles/styles/PLAN.IGN/attenue.json", +]; + +export const MapEtablissements = ({ + isLoading, + etablissements, + setMap, + setBbox, + hoverUai, + setHoverUai, + setActiveUai, + activeUai, + bbox, +}: MapProps) => { + const [style] = useState(AVAILABLE_STYLES[0]); + + if (isLoading) { + return ; + } + + return ( + + + + setActiveUai(null)} + /> + + + + + ); +}; + +MapEtablissements.displayName = "MapEtablissements"; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/MapActions.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/MapActions.tsx new file mode 100644 index 000000000..1263b71dd --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/MapActions.tsx @@ -0,0 +1,114 @@ +import { Box, Button, Checkbox, Divider, Flex, Menu, MenuButton, MenuItem, MenuList, Text } from "@chakra-ui/react"; +import { Icon } from "@iconify/react"; + +import { useFormationContext } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext"; + +export const MapActions = ({ + nbEtablissementsInZone, + handleRecenter, +}: { + nbEtablissementsInZone: number; + handleRecenter: () => void; +}) => { + const { currentFilters, handleIncludeAllChange, handleOrderByChange, handleViewChange } = useFormationContext(); + const { includeAll } = currentFilters.etab; + return ( + + + + {currentFilters.etab.view === "map" && ( + + )} + {currentFilters.etab.view === "list" && ( + + )} + {currentFilters.etab.view === "map" && ( + + )} + {currentFilters.etab.view === "list" && ( + + } + > + Trier + + + handleOrderByChange("departement_commune")} + > + {currentFilters.etab.orderBy == "departement_commune" ? ( + + ) : ( + + )} + Par département et commune + + handleOrderByChange("libelle")} + > + {currentFilters.etab.orderBy == "libelle" ? ( + + ) : ( + + )} + Par nom d'établissement + + + + )} + + + + {nbEtablissementsInZone} résultat(s) dans la zone affichée + + + + + { + handleIncludeAllChange(e.target.checked); + }} + variant="accessible" + pl={"13px"} + > + inclure les établissements des territoires limitrophes + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/components/EtablissementItemContent.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/components/EtablissementItemContent.tsx new file mode 100644 index 000000000..c1376d639 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/components/EtablissementItemContent.tsx @@ -0,0 +1,159 @@ +import { Badge, Box, Divider, HStack, Link, Text, Tooltip, VStack } from "@chakra-ui/react"; +import { InlineIcon } from "@iconify/react"; +import { useState } from "react"; +import { CURRENT_IJ_MILLESIME, CURRENT_RENTREE } from "shared"; + +import type { Etablissement } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { TableBadge } from "@/components/TableBadge"; +import { themeDefinition } from "@/theme/theme"; +import { formatCommuneLibelleWithCodeDepartement, formatDispositifs, formatSecteur } from "@/utils/formatLibelle"; +import { formatNumberToString, formatPercentage } from "@/utils/formatUtils"; +import { getTauxPressionStyle } from "@/utils/getBgScale"; + +export const EtablissementItemContent = ({ etablissement }: { etablissement: Etablissement }) => { + const [hover, setHover] = useState(false); + const isScolaire = etablissement.isScolaire; + const isApprentissage = etablissement.isApprentissage; + + const tooltipLabelEffectif = (() => { + if (etablissement.effectifs !== undefined) { + if (isScolaire && !isApprentissage) { + return `Effectif - en entrée (rentrée ${CURRENT_RENTREE})`; + } + + if (isScolaire && isApprentissage) { + return `Effectif voie scolaire - en entrée (rentrée ${CURRENT_RENTREE}) `; + } + } + + if (isApprentissage && !isScolaire) { + return `Données indisponibles pour l'apprentissage`; + } + + return `Données indisponibles`; + })(); + + const tooltilLabelTauxDevenir = (() => { + if (etablissement.tauxDevenirFavorable !== undefined) { + return `Taux de devenir favorable à 6 mois (Millesime ${CURRENT_IJ_MILLESIME.split("_").join("+")})`; + } + + if (isApprentissage && !isScolaire) { + return `Données indisponibles pour l'apprentissage`; + } + + return `Données indisponibles`; + })(); + + const tooltipLabelTauxPression = (() => { + if (etablissement.tauxPression !== undefined) { + if (etablissement.isBTS) { + if (isScolaire && isApprentissage) { + return `Taux de demande voie scolaire (rentrée ${CURRENT_RENTREE})`; + } + + return `Taux de demande (rentrée ${CURRENT_RENTREE})`; + } + + if (isScolaire && isApprentissage) { + return `Taux de pression voie scolaire (rentrée ${CURRENT_RENTREE})`; + } + + return `Taux de pression (rentrée ${CURRENT_RENTREE})`; + } + + if (isApprentissage && !isScolaire) { + return `Données indisponibles pour l'apprentissage`; + } + + return "Données indisponibles"; + })(); + + return ( + + + + setHover(true)} + onMouseLeave={() => setHover(false)} + > + {etablissement.libelleEtablissement} + + {hover && } + + + {formatDispositifs(etablissement.libellesDispositifs).map((libelle) => ( + + {libelle} + + ))} + + + + } + > + + {formatCommuneLibelleWithCodeDepartement({ + commune: etablissement.commune, + codeDepartement: etablissement.codeDepartement, + })} + + {etablissement.secteur && {formatSecteur(etablissement.secteur)}} + + + + + {etablissement.isScolaire && ( + + + + + Scolaire + + )} + {etablissement.isApprentissage && ( + + + + + Apprentissage + + )} + + } + color={themeDefinition.colors.grey[425]} + > + + + + {formatNumberToString(etablissement.effectifs, 0, "-")} + + + + + + {formatPercentage(etablissement.tauxDevenirFavorable, 0, "-")} + + + + + + + {formatNumberToString(etablissement.tauxPression, 2, "-")} + + + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/components/FormationAbsente.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/components/FormationAbsente.tsx new file mode 100644 index 000000000..f33445d67 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/components/FormationAbsente.tsx @@ -0,0 +1,21 @@ +import { Flex, Text } from "@chakra-ui/react"; + +import { Callout } from "@/components/Callout"; + +export const FormationAbsente = ({ nbEtablissements }: { nbEtablissements: number | undefined }) => { + if (typeof nbEtablissements !== "number" || nbEtablissements > 0) { + return null; + } + + return ( + + Formation absente du territoire choisi + Voici les établissements les plus proches qui proposent cette formation hors du territoire + + } + /> + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/index.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/index.tsx new file mode 100644 index 000000000..f9b5c4bbb --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/EtablissementsTab/index.tsx @@ -0,0 +1,208 @@ +import { Box, Flex } from "@chakra-ui/react"; +import { createRef, useEffect, useState } from "react"; +import type { MapRef } from "react-map-gl/maplibre"; +import type { Etablissement } from "shared/routes/schemas/get.formation.cfd.map.schema"; + +import { client } from "@/api.client"; +import { FormationHeader } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/FormationHeader"; +import { useFormationContext } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext"; + +import { FormationAbsente } from "./components/FormationAbsente"; +import { ExportListEtablissements } from "./ExportListEtablissements"; +import { List } from "./List"; +import { MapEtablissements } from "./Map"; +import { MapActions } from "./MapActions"; + +const COORDOONNEES_FRANCE = { + latMin: 41.19, + latMax: 51.0521, + lngMin: 5.0904, + lngMax: 9.15, +}; + +const useEtablissementsTab = () => { + const { currentFilters, handleClearBbox, handleSetBbox } = useFormationContext(); + const { cfd, codeRegion, codeAcademie, codeDepartement } = currentFilters; + const mapContainer = createRef(); + const [activeUai, setActiveUai] = useState(null); + const [hoverUai, setHoverUai] = useState(null); + const [map, setMap] = useState(); + + const [mapDimensions, setMapDimensions] = useState<{ + height: number; + width: number; + }>({ + height: 0, + width: 0, + }); + const [defaultBbox, setDefaultBbox] = useState(COORDOONNEES_FRANCE); + const [displayedEtablissements, setDisplayedEtablissements] = useState([]); + + const { data: dataFormation, isLoading: isLoadingFormation } = client.ref("[GET]/formation/:cfd").useQuery( + { + params: { cfd }, + query: { codeRegion, codeAcademie, codeDepartement }, + }, + { + keepPreviousData: true, + staleTime: 10000000, + enabled: !!cfd, + } + ); + + const { data: dataEtablissementsMap, isLoading: isLoadingEtablissements } = client + .ref("[GET]/formation/:cfd/map") + .useQuery( + { + params: { cfd }, + query: { + codeRegion, + codeAcademie, + codeDepartement, + orderBy: currentFilters.etab.orderBy, + includeAll: currentFilters.etab.includeAll, + }, + }, + { + keepPreviousData: true, + staleTime: 10000000, + enabled: !!cfd && !!mapDimensions.height && !!mapDimensions.width, + } + ); + + useEffect(() => { + if (mapContainer.current) { + if ( + mapContainer.current.clientHeight !== mapDimensions.height || + mapContainer.current.clientWidth !== mapDimensions.width + ) { + setMapDimensions({ + height: mapContainer.current.clientHeight, + width: mapContainer.current.clientWidth, + }); + } + } + }, [mapContainer, mapDimensions.height, mapDimensions.width]); + + useEffect(() => { + if (dataEtablissementsMap?.bbox) { + setDefaultBbox(dataEtablissementsMap.bbox); + if (currentFilters.etab.bbox === undefined) { + map?.fitBounds( + [ + [dataEtablissementsMap.bbox.lngMin, dataEtablissementsMap.bbox.latMin], + [dataEtablissementsMap.bbox.lngMax, dataEtablissementsMap.bbox.latMax], + ], + { + padding: 20, + }, + { + computer: true, + } + ); + } + } + }, [dataEtablissementsMap, map, currentFilters.etab.bbox]); + + useEffect(() => { + if (dataEtablissementsMap?.etablissements) { + setDisplayedEtablissements( + dataEtablissementsMap.etablissements.filter((etablissement) => { + const isInBbox = + etablissement.latitude >= (currentFilters.etab.bbox ?? defaultBbox).latMin && + etablissement.latitude <= (currentFilters.etab.bbox ?? defaultBbox).latMax && + etablissement.longitude >= (currentFilters.etab.bbox ?? defaultBbox).lngMin && + etablissement.longitude <= (currentFilters.etab.bbox ?? defaultBbox).lngMax; + + return isInBbox; + }) + ); + } + }, [dataEtablissementsMap?.etablissements, currentFilters.etab.bbox, defaultBbox]); + + const handleRecenter = () => { + map?.fitBounds([ + [defaultBbox.lngMin, defaultBbox.latMin], + [defaultBbox.lngMax, defaultBbox.latMax], + ]); + handleClearBbox(); + }; + + return { + dataFormation, + currentFilters, + mapContainer, + mapDimensions, + etablissements: displayedEtablissements, + isLoading: isLoadingEtablissements && isLoadingFormation, + bbox: currentFilters.etab.bbox ?? defaultBbox, + setBbox: handleSetBbox, + map, + setMap: (map: MapRef) => setMap(map), + activeUai, + setActiveUai: (uai: string | null) => setActiveUai(uai), + hoverUai, + setHoverUai: (uai: string | null) => setHoverUai(uai), + handleRecenter, + }; +}; + +export const EtablissementsTab = () => { + const { currentFilters, codeNsf, libelleNsf } = useFormationContext(); + const { + cfd, + etab: { view }, + } = currentFilters; + + const { + dataFormation, + mapContainer, + etablissements, + isLoading, + setMap, + setBbox, + activeUai, + setActiveUai, + hoverUai, + setHoverUai, + bbox, + handleRecenter, + } = useEtablissementsTab(); + + if (isLoading || !cfd || !dataFormation) { + return null; + } + + return ( + + + } + /> + + + {view === "map" && ( + + + + )} + {view === "list" && } + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DevenirBarGraph.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DevenirBarGraph.tsx new file mode 100644 index 000000000..ebb5c2538 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DevenirBarGraph.tsx @@ -0,0 +1,164 @@ +import { Box, useToken } from "@chakra-ui/react"; +import * as echarts from "echarts"; +import { useLayoutEffect, useMemo, useRef } from "react"; + +import type { TauxIJValues } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { formatPercentage } from "@/utils/formatUtils"; + +export type DevenirBarGraphData = { + [key: string]: { + annee: string; + libelleAnnee: string; + apprentissage?: number; + scolaire?: number; + }; +}; + +const getXAxisData = (datas: TauxIJValues) => { + return datas.map((data) => data.libelle); +}; + +export const DevenirBarGraph = function ({ + datas, + hasVoieScolaire, + hasVoieApprentissage, +}: { + datas: TauxIJValues; + hasVoieScolaire: boolean; + hasVoieApprentissage: boolean; +}) { + const chartRef = useRef(); + const containerRef = useRef(null); + const blue = useToken("colors", "blueCumulus.526"); + const mustard = useToken("colors", "yellowMoutarde.679"); + + const series: { + name: string; + data: (number | undefined)[]; + color: string; + }[] = []; + + if (hasVoieScolaire) { + series.push({ + name: `Voie scolaire ${datas.some((d) => d.scolaire !== undefined) ? "" : "(indisponible)"}`, + data: datas.map((data) => data.scolaire), + color: blue, + }); + } + + if (hasVoieApprentissage) { + series.push({ + name: `Apprentissage ${datas.some((d) => d.apprentissage !== undefined) ? "" : "(indisponible)"}`, + data: datas.map((data) => data.apprentissage), + color: mustard, + }); + } + + const option = useMemo( + () => ({ + textStyle: { + fontFamily: "Marianne, Arial", + }, + animationDelay: 1, + responsive: true, + maintainAspectRatio: true, + tooltip: { + trigger: "axis", + axisPointer: { + type: "shadow", + }, + valueFormatter: (value) => formatPercentage(value as number, 2, "-"), + }, + legend: { + show: true, + icon: "square", + orient: "vertical", + left: 0, + bottom: 15, + padding: 0, + itemWidth: 25, + textStyle: { + color: "black", + fontSize: 14, + }, + }, + xAxis: { + show: true, + type: "category", + data: getXAxisData(datas), + axisLabel: { + color: "black", + }, + axisLine: { + show: false, + lineStyle: { + color: "black", + width: 0.5, + }, + onZero: false, + }, + axisTick: { + show: false, + }, + }, + yAxis: { + show: false, + type: "value", + axisLabel: { + formatter: (value: number | undefined) => { + return formatPercentage(value as number, 2, "-"); + }, + color: "black", + fontWeight: 700, + }, + splitNumber: 3, + }, + grid: { + containLabel: true, + width: "70%", + bottom: 0, + right: 0, + top: 25, + }, + series: series.map((s) => ({ + ...s, + type: "bar", + barMaxWidth: 50, + itemStyle: { + borderRadius: [4, 4, 0, 0], + }, + label: { + distance: 5, + show: true, + position: "top", + formatter: ({ value }) => { + if (value === undefined) return "-"; + return formatPercentage(value as number); + }, + rich: { + percent: { + fontSize: "11px", + }, + }, + fontSize: "13px", + fontWeight: 700, + }, + })), + }), + [series, datas] + ); + + useLayoutEffect(() => { + if (!containerRef.current) return; + if (!chartRef.current) { + chartRef.current = echarts.init(containerRef.current); + } + chartRef.current.setOption(option, true); + }, [chartRef, option]); + + return ( + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DonneesManquantes.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DonneesManquantes.tsx new file mode 100644 index 000000000..d7f11a276 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DonneesManquantes.tsx @@ -0,0 +1,90 @@ +import { Button, Flex, Text } from "@chakra-ui/react"; +import { Icon } from "@iconify/react"; +import Link from "next/link"; + +import type { + Formation, + FormationIndicateurs, + TauxAttractiviteType, + TauxIJType, +} from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { Callout } from "@/components/Callout"; + +import { + displayEffectifsDatas, + displayEtablissementsDatas, + displayIJDatas, + displaySoldeDePlacesTransformees, + displayTauxAttractiviteDatas, +} from "./displayIndicators"; +import { getTauxAttractiviteDatas } from "./TauxPressionRemplissageCard"; + +const displayTauxAttractivite = (data: FormationIndicateurs, tauxAttractiviteSelected: TauxAttractiviteType) => { + return displayTauxAttractiviteDatas(getTauxAttractiviteDatas(data, tauxAttractiviteSelected)); +}; + +export const DonneesManquantes = ({ + codeNsf, + codeRegion, + cfd, + tauxIJSelected, + tauxAttractiviteSelected, + formation, + indicateurs, +}: { + formation: Formation; + codeNsf: string; + codeRegion?: string; + cfd: string; + indicateurs: FormationIndicateurs; + tauxIJSelected: TauxIJType; + tauxAttractiviteSelected: TauxAttractiviteType; +}) => { + if ( + !formation.isScolaire || + (displaySoldeDePlacesTransformees(indicateurs) && + displayEffectifsDatas(indicateurs) && + displayEtablissementsDatas(indicateurs) && + displayIJDatas(indicateurs, tauxIJSelected) && + displayTauxAttractivite(indicateurs, tauxAttractiviteSelected)) + ) { + return null; + } + + return ( + + Données manquantes + + Certaines données ne sont pas disponibles pour le territoire choisi ({" "} + + + Pourquoi ? + + {" "} + ) . + + + } + actionButton={ + + + + } + /> + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DonneesManquantesApprentissageCard.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DonneesManquantesApprentissageCard.tsx new file mode 100644 index 000000000..2b5e9c94c --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/DonneesManquantesApprentissageCard.tsx @@ -0,0 +1,51 @@ +import { Button, Flex, Text } from "@chakra-ui/react"; +import { Icon } from "@iconify/react"; +import Link from "next/link"; + +import type { Formation } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { Callout } from "@/components/Callout"; + +export const DonneesManquantesApprentissageCard = ({ + codeNsf, + codeRegion, + cfd, + formation, +}: { + codeNsf: string; + codeRegion?: string; + cfd: string; + formation: Formation; +}) => { + if (formation.isScolaire) { + return null; + } + + return ( + + Données manquantes en apprentissage + + Les données relatives aux effectifs ne sont pas disponibles car la formation est enseignée seulement en + apprentissage sur le territoire choisi. + + + } + actionButton={ + + + + } + /> + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/EffectifsEtAttractiviteGraphs.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/EffectifsEtAttractiviteGraphs.tsx new file mode 100644 index 000000000..c99ab4eee --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/EffectifsEtAttractiviteGraphs.tsx @@ -0,0 +1,260 @@ +import { Box, Flex, Text, useToken } from "@chakra-ui/react"; +import * as echarts from "echarts"; +import { useCallback, useLayoutEffect, useMemo, useRef } from "react"; +import type { ScopeZone } from "shared"; + +import type { + Formation, + FormationIndicateurs, + TauxAttractiviteType, +} from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { BadgeScope } from "@/components/BadgeScope"; +import { GlossaireShortcut } from "@/components/GlossaireShortcut"; + +import { displayEffectifsDatas, displayEtablissementsDatas } from "./displayIndicators"; +import { TauxPressionRemplissageCard } from "./TauxPressionRemplissageCard"; + +const getXAxisData = ( + data: { + label: string; + value: number; + }[] +) => { + if (data !== undefined) { + return data.map((data) => data.label).reverse(); + } + return []; +}; + +const VerticalBarChart = ({ + data, +}: { + data: { + label: string; + value: number; + }[]; +}) => { + const chartRef = useRef(); + const containerRef = useRef(null); + const gris = useToken("colors", "grey.850"); + const bf113 = useToken("colors", "bluefrance.113"); + const be850a = useToken("colors", "bluefrance.850_active"); + const be850 = useToken("colors", "bluefrance.850"); + + const getColors = useCallback( + (index: number) => { + if (index === 1) return bf113; + if (index === 2) return be850a; + if (index === 3) return be850; + if (index === 4) return gris; + }, + [bf113, be850, be850a, gris] + ); + + const option = useMemo( + () => ({ + textStyle: { + fontFamily: "Marianne, Arial", + }, + animationDelay: 0.5, + responsive: true, + maintainAspectRatio: true, + tooltip: { + trigger: "item", + axisPointer: { + type: "shadow", + }, + formatter: "{a} : {c}", + }, + legend: { + data: getXAxisData(data), + icon: "square", + orient: "vertical", + left: 0, + bottom: 0, + padding: 0, + itemWidth: 15, + itemStyle: { + color: "inherit", + }, + textStyle: { + color: "black", + fontSize: 12, + }, + }, + grid: { + bottom: 1, + top: 25, + width: "70%", + right: 0, + height: "100%", + }, + xAxis: { + type: "category", + show: true, + axisLabel: { + show: false, + }, + axisLine: { + show: false, + lineStyle: { + color: "black", + width: 0.5, + }, + onZero: false, + }, + axisTick: { + show: false, + }, + }, + yAxis: { + type: "value", + show: false, // Hide Y-axis + }, + series: data + .sort((a, b) => a.label.localeCompare(b.label)) + .map((serie, index) => ({ + data: [serie.value], + name: serie.label, + type: "bar", + color: getColors(data.length - index), + maxBarWidth: "50px", + itemStyle: { + borderRadius: [4, 4, 0, 0], + }, + label: { + distance: 10, + show: true, + position: "top", + formatter: "{c}", + rich: { + percent: { + fontSize: "11px", + }, + }, + fontSize: "13px", + fontWeight: 700, + }, + })), + }), + [data, getColors] + ); + + useLayoutEffect(() => { + if (!containerRef.current) return; + if (!chartRef.current) { + chartRef.current = echarts.init(containerRef.current); + } + chartRef.current.setOption(option, true); + }, [data, option]); + + return ( + + + + ); +}; + +export const EffectifsEtAttractiviteGraphs = ({ + formation, + indicateurs, + scope, + tauxAttractiviteSelected, + handleChangeTauxAttractivite, +}: { + formation: Formation; + indicateurs: FormationIndicateurs; + scope: ScopeZone; + tauxAttractiviteSelected: TauxAttractiviteType; + handleChangeTauxAttractivite: (e: React.ChangeEvent) => void; +}) => { + return ( + + + + + + + Etablissements + + + + + {displayEtablissementsDatas(indicateurs) ? ( + ({ + label: `RS ${rentreeScolaire}`, + value: nbEtablissements, + }))} + /> + ) : ( + + Indisponible + + )} + + + + + + Effectif en entrée + + + + + Effectifs en entrée en première année de formation. + Cliquez pour plus d'infos. + + } + /> + + {displayEffectifsDatas(indicateurs) ? ( + ({ + label: `RS ${rentreeScolaire}`, + value: effectif, + }))} + /> + ) : ( + + Indisponible + + )} + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/ExportListIndicateurs.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/ExportListIndicateurs.tsx new file mode 100644 index 000000000..e7e2013a5 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/ExportListIndicateurs.tsx @@ -0,0 +1,192 @@ +import { usePlausible } from "next-plausible"; + +import type { + Formation, + FormationIndicateurs, + TauxIJType, + TauxIJValues, + TauxPressionValue, + TauxRemplissageValue, +} from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { ExportMenuButton } from "@/components/ExportMenuButton"; +import type { ExportColumns } from "@/utils/downloadExport"; +import { downloadCsv, downloadExcel } from "@/utils/downloadExport"; +import { formatExportFilename } from "@/utils/formatExportFilename"; + +const transformEffectifs = (effectifs: { rentreeScolaire: string; effectif: number }[]) => { + return effectifs.reduce( + (acc, { rentreeScolaire, effectif }) => ({ + ...acc, + [`effectif ${rentreeScolaire}`]: effectif, + }), + {} as Record + ); +}; + +const transformEtablissements = (etablissements: { rentreeScolaire: string; nbEtablissements: number }[]) => { + return etablissements.reduce( + (acc, { rentreeScolaire, nbEtablissements }) => ({ + ...acc, + [`etablissements ${rentreeScolaire}`]: nbEtablissements, + }), + {} as Record + ); +}; + +const transformTauxIJ = (taux: TauxIJType, tauxIJ: TauxIJValues) => { + return tauxIJ.reduce( + (acc, { libelle, scolaire, apprentissage }) => ({ + ...acc, + [`${taux} ${libelle} - scolaire`]: scolaire, + [`${taux} ${libelle} - apprentissage`]: apprentissage, + }), + {} as Record + ); +}; + +const transformTauxPressions = (tauxPressions: TauxPressionValue[]) => { + return tauxPressions.reduce( + (acc, { rentreeScolaire, scope, value }) => ({ + ...acc, + [`tauxPressions ${rentreeScolaire} - ${scope}`]: value, + }), + {} as Record + ); +}; + +const transformTauxRemplissages = (tauxRemplissages: TauxRemplissageValue[]) => { + return tauxRemplissages.reduce( + (acc, { rentreeScolaire, scope, value }) => ({ + ...acc, + [`tauxRemplissages ${rentreeScolaire} - ${scope}`]: value, + }), + {} as Record + ); +}; + +const extractDatas = ({ formation, indicateurs }: { formation: Formation; indicateurs: FormationIndicateurs }) => { + const { libelle, ...restOfFormation } = formation; + + const { tauxIJ, tauxPressions, tauxRemplissages, effectifs, etablissements } = indicateurs; + + const datas = { + ...restOfFormation, + libelleFormation: libelle, + ...transformEtablissements(etablissements), + ...transformEffectifs(effectifs), + ...transformTauxIJ("tauxInsertion", tauxIJ.tauxInsertion), + ...transformTauxIJ("tauxPoursuite", tauxIJ.tauxPoursuite), + ...transformTauxIJ("tauxDevenirFavorable", tauxIJ.tauxDevenirFavorable), + ...transformTauxPressions(tauxPressions), + ...transformTauxRemplissages(tauxRemplissages), + }; + + return datas; +}; + +const columns: ExportColumns> = { + cfd: "CFD", + isFormationRenovee: "Formation renovée", + isBTS: "BTS", + isApprentissage: "Apprentissage", + isScolaire: "Scolaire", + isTransitionDemographique: "Transition démographique", + isTransitionEcologique: "Transition écologique", + isTransitionNumerique: "Transition numérique", + libelleFormation: "Formation", + ["etablissements 2020"]: "Etablissements 2020", + ["etablissements 2021"]: "Etablissements 2021", + ["etablissements 2022"]: "Etablissements 2022", + ["etablissements 2023"]: "Etablissements 2023", + ["effectif 2020"]: "Effectif 2020", + ["effectif 2021"]: "Effectif 2021", + ["effectif 2022"]: "Effectif 2022", + ["effectif 2023"]: "Effectif 2023", + ["tauxInsertion 2020 - scolaire"]: "Taux d'insertion 2020 - scolaire", + ["tauxInsertion 2020 - apprentissage"]: "Taux d'insertion 2020 - apprentissage", + ["tauxPoursuite 2020 - scolaire"]: "Taux de poursuite 2020 - scolaire", + ["tauxDevenirFavorable 2020 - scolaire"]: "Taux de devenir favorable 2020 - scolaire", + ["tauxPressions 2023 - scolaire"]: "Taux de pressions 2023 - scolaire", + ["tauxRemplissages 2023 - scolaire"]: "Taux de remplissages 2023 - scolaire", + ["tauxDevenirFavorable 2019+20 - apprentissage"]: "Taux de devenir favorable 2019+20 - apprentissage", + ["tauxDevenirFavorable 2019+20 - scolaire"]: "Taux de devenir favorable 2019+20 - scolaire", + ["tauxDevenirFavorable 2020+21 - apprentissage"]: "Taux de devenir favorable 2020+21 - apprentissage", + ["tauxDevenirFavorable 2020+21 - scolaire"]: "Taux de devenir favorable 2020+21 - scolaire", + ["tauxDevenirFavorable 2021+22 - apprentissage"]: "Taux de devenir favorable 2021+22 - apprentissage", + ["tauxInsertion 2019+20 - apprentissage"]: "Taux d'insertion 2019+20 - apprentissage", + ["tauxInsertion 2019+20 - scolaire"]: "Taux d'insertion 2019+20 - scolaire", + ["tauxInsertion 2020+21 - apprentissage"]: "Taux d'insertion 2020+21 - apprentissage", + ["tauxInsertion 2020+21 - scolaire"]: "Taux d'insertion 2020+21 - scolaire", + ["tauxInsertion 2021+22 - apprentissage"]: "Taux d'insertion 2021+22 - apprentissage", + ["tauxInsertion 2021+22 - scolaire"]: "Taux d'insertion 2021+22 - scolaire", + ["tauxPoursuite 2019+20 - apprentissage"]: "Taux de poursuite 2019+20 - apprentissage", + ["tauxPoursuite 2019+20 - scolaire"]: "Taux de poursuite 2019+20 - scolaire", + ["tauxPoursuite 2020+21 - apprentissage"]: "Taux de poursuite 2020+21 - apprentissage", + ["tauxPoursuite 2020+21 - scolaire"]: "Taux de poursuite 2020+21 - scolaire", + ["tauxPoursuite 2021+22 - apprentissage"]: "Taux de poursuite 2021+22 - apprentissage", + ["tauxPoursuite 2021+22 - scolaire"]: "Taux de poursuite 2021+22 - scolaire", + ["tauxPressions 2020 - académie"]: "Taux de pressions 2020 - académie", + ["tauxPressions 2020 - département"]: "Taux de pressions 2020 - département", + ["tauxPressions 2020 - national"]: "Taux de pressions 2020 - national", + ["tauxPressions 2020 - région"]: "Taux de pressions 2020 - région", + ["tauxPressions 2021 - académie"]: "Taux de pressions 2021 - académie", + ["tauxPressions 2021 - département"]: "Taux de pressions 2021 - département", + ["tauxPressions 2021 - national"]: "Taux de pressions 2021 - national", + ["tauxPressions 2021 - région"]: "Taux de pressions 2021 - région", + ["tauxPressions 2022 - académie"]: "Taux de pressions 2022 - académie", + ["tauxPressions 2022 - département"]: "Taux de pressions 2022 - département", + ["tauxPressions 2022 - national"]: "Taux de pressions 2022 - national", + ["tauxPressions 2022 - région"]: "Taux de pressions 2022 - région", + ["tauxPressions 2023 - académie"]: "Taux de pressions 2023 - académie", + ["tauxPressions 2023 - département"]: "Taux de pressions 2023 - département", + ["tauxPressions 2023 - national"]: "Taux de pressions 2023 - national", + ["tauxPressions 2023 - région"]: "Taux de pressions 2023 - région", + ["tauxRemplissages 2020 - académie"]: "Taux de remplissages 2020 - académie", + ["tauxRemplissages 2020 - département"]: "Taux de remplissages 2020 - département", + ["tauxRemplissages 2020 - national"]: "Taux de remplissages 2020 - national", + ["tauxRemplissages 2020 - région"]: "Taux de remplissages 2020 - région", + ["tauxRemplissages 2021 - académie"]: "Taux de remplissages 2021 - académie", + ["tauxRemplissages 2021 - département"]: "Taux de remplissages 2021 - département", + ["tauxRemplissages 2021 - national"]: "Taux de remplissages 2021 - national", + ["tauxRemplissages 2021 - région"]: "Taux de remplissages 2021 - région", + ["tauxRemplissages 2022 - académie"]: "Taux de remplissages 2022 - académie", + ["tauxRemplissages 2022 - département"]: "Taux de remplissages 2022 - département", + ["tauxRemplissages 2022 - national"]: "Taux de remplissages 2022 - national", + ["tauxRemplissages 2022 - région"]: "Taux de remplissages 2022 - région", + ["tauxRemplissages 2023 - académie"]: "Taux de remplissages 2023 - académie", + ["tauxRemplissages 2023 - département"]: "Taux de remplissages 2023 - département", + ["tauxRemplissages 2023 - national"]: "Taux de remplissages 2023 - national", + ["tauxRemplissages 2023 - région"]: "Taux de remplissages 2023 - région", +}; + +export const ExportListIndicateurs = ({ + formation, + indicateurs, +}: { + formation: Formation; + indicateurs: FormationIndicateurs; +}) => { + const trackEvent = usePlausible(); + + const onExportCsv = async () => { + trackEvent("domaine-de-formation-indicateurs:export-csv"); + + downloadCsv( + formatExportFilename("domaine-de-formation_etablissements"), + [extractDatas({ formation, indicateurs })], + columns + ); + }; + + const onExportExcel = async () => { + trackEvent("domaine-de-formation-indicateurs:export-excel"); + + downloadExcel( + formatExportFilename("domaine-de-formation_etablissements"), + [extractDatas({ formation, indicateurs })], + columns + ); + }; + + return ; +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/NotInScope.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/NotInScope.tsx new file mode 100644 index 000000000..142e00808 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/NotInScope.tsx @@ -0,0 +1,37 @@ +import { Flex, Heading, Img, Link, Text } from "@chakra-ui/react"; + +export const NotInScope = ({ cfd }: { cfd: string }) => { + return ( + + + + Formation absente + + + Cette formation n'est pas enseignée dans le territoire choisi. Vous pouvez{" "} + + consulter ses données régionales ou nationales + {" "} + dans la console des formations ou{" "} + + afficher la liste des établissements + {" "} + qui la proposent dans la console établissements. + + Illustration d'une loupe + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/SoldeDePlacesTransformeesCard.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/SoldeDePlacesTransformeesCard.tsx new file mode 100644 index 000000000..784a26cde --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/SoldeDePlacesTransformeesCard.tsx @@ -0,0 +1,71 @@ +import { Text, useToken } from "@chakra-ui/react"; +import type { ScopeZone } from "shared"; + +import type { FormationIndicateurs } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { DashboardCard } from "@/app/(wrapped)/panorama/etablissement/components/DashboardCard"; +import { BadgeScope } from "@/components/BadgeScope"; +import { TooltipIcon } from "@/components/TooltipIcon"; + +import { displaySoldeDePlacesTransformees } from "./displayIndicators"; +import { SoldeDePlacesTransformeesGraph } from "./SoldeDePlacesTransformeesGraph"; + +const labelColor = (data: number) => { + if (data > 0) { + return "green"; + } + + if (data < 0) { + return "red"; + } + + return "black"; +}; + +const dataStyle = (data: number) => ({ + itemStyle: { + borderRadius: [data >= 0 ? 4 : 0, data >= 0 ? 4 : 0, data >= 0 ? 0 : 4, data >= 0 ? 0 : 4], + }, + label: { + color: labelColor(data), + }, +}); + +export const SoldeDePlacesTransformeesCard = ({ scope, data }: { scope: ScopeZone; data: FormationIndicateurs }) => { + const [blue, mustard] = useToken("colors", ["blueCumulus.526", "yellowMoutarde.679"]); + return ( + } + tooltip={} + w={"50%"} + > + {displaySoldeDePlacesTransformees(data) ? ( + ({ + value: s.scolaire, + ...dataStyle(s.scolaire), + })), + }, + { + name: "Apprentissage", + color: mustard, + type: "bar", + data: data.soldePlacesTransformee.map((s) => ({ + value: s.apprentissage, + ...dataStyle(s.apprentissage), + })), + }, + ]} + xAxisData={data.soldePlacesTransformee.map((s) => `RS ${s.rentreeScolaire.toString()}`)} + /> + ) : ( + Indisponible + )} + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/SoldeDePlacesTransformeesGraph.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/SoldeDePlacesTransformeesGraph.tsx new file mode 100644 index 000000000..8ccb496ff --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/SoldeDePlacesTransformeesGraph.tsx @@ -0,0 +1,88 @@ +import { AspectRatio, Box } from "@chakra-ui/react"; +import * as echarts from "echarts"; +import { useLayoutEffect, useMemo, useRef } from "react"; + +export const SoldeDePlacesTransformeesGraph = ({ + xAxisData, + series, +}: { + xAxisData: string[]; + series: echarts.BarSeriesOption[]; +}) => { + const chartRef = useRef(); + const containerRef = useRef(null); + + const option = useMemo( + () => ({ + textStyle: { + fontFamily: "Marianne, Arial", + }, + legend: { + show: true, + icon: "square", + orient: "vertical", + left: 0, + bottom: 0, + padding: 0, + itemWidth: 25, + textStyle: { + color: "black", + fontSize: 14, + }, + }, + xAxis: { + type: "category", + data: xAxisData, + axisLabel: { + color: "black", + margin: 15, + }, + }, + yAxis: { + type: "value", + show: false, + }, + grid: { + containLabel: true, + width: "65%", + bottom: 0, + right: 0, + top: 25, + }, + series: series.map((s) => ({ + ...s, + type: "bar", + barMaxWidth: 50, + label: { + distance: 5, + show: true, + position: "outside", + rich: { + percent: { + fontSize: "11px", + }, + }, + fontSize: "13px", + fontWeight: 700, + }, + })), + }), + [series, xAxisData] + ); + + useLayoutEffect(() => { + if (!containerRef.current) return; + if (!chartRef.current) { + chartRef.current = echarts.init(containerRef.current); + } + chartRef.current.setOption(option, true); + }, [chartRef, option]); + + return ( + + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxIJCard.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxIJCard.tsx new file mode 100644 index 000000000..2883a7a0c --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxIJCard.tsx @@ -0,0 +1,139 @@ +import { Box, Flex, Select, Text } from "@chakra-ui/react"; +import type { ChangeEvent } from "react"; +import type { ScopeZone } from "shared"; +import { ScopeEnum } from "shared"; + +import type { Formation, FormationIndicateurs, TauxIJType } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { BadgeScope } from "@/components/BadgeScope"; +import { GlossaireShortcut } from "@/components/GlossaireShortcut"; + +import { DevenirBarGraph } from "./DevenirBarGraph"; +import { displayIJDatas } from "./displayIndicators"; + +const GlossaireIcon = ({ tauxIJSelected }: { tauxIJSelected: TauxIJType }) => { + if (tauxIJSelected === "tauxInsertion") { + return ( + + La part de ceux qui sont en emploi 6 mois après leur sortie d’étude. + Cliquez pour plus d'infos. + + } + /> + ); + } + if (tauxIJSelected === "tauxPoursuite") { + return ( + + Tout élève inscrit à N+1 (réorientation et redoublement compris). + Cliquez pour plus d'infos. + + } + /> + ); + } + if (tauxIJSelected === "tauxDevenirFavorable") { + return ( + + + (nombre d'élèves inscrits en formation + nombre d'élèves en emploi) / nombre d'élèves en entrée en + dernière année de formation. + + Cliquez pour plus d'infos. + + } + /> + ); + } + return null; +}; + +const tauxIJOptions = [ + { + label: "Taux d'emploi à 6 mois", + value: "tauxInsertion", + }, + { + label: "Taux de poursuite d'études", + value: "tauxPoursuite", + }, + { + label: "Taux de devenir favorable", + value: "tauxDevenirFavorable", + }, +]; + +export const TauxIJCard = ({ + scope, + handleChangeTauxIJ, + tauxIJSelected, + formation, + indicateurs, +}: { + scope: ScopeZone; + handleChangeTauxIJ: (e: ChangeEvent) => void; + tauxIJSelected: TauxIJType; + formation: Formation; + indicateurs: FormationIndicateurs; +}) => { + return ( + + + + + + + + + {displayIJDatas(indicateurs, tauxIJSelected) ? ( + + ) : ( + + Indisponible + + )} + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxPressionRemplissageCard.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxPressionRemplissageCard.tsx new file mode 100644 index 000000000..d94de7327 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxPressionRemplissageCard.tsx @@ -0,0 +1,191 @@ +import { Box, Flex, Select, Text, useToken } from "@chakra-ui/react"; +import _ from "lodash"; +import { useMemo } from "react"; +import type { ScopeZone } from "shared"; +import { ScopeEnum } from "shared"; + +import type { + Formation, + FormationIndicateurs, + TauxAttractiviteType, +} from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { GlossaireShortcut } from "@/components/GlossaireShortcut"; +import { formatNumber } from "@/utils/formatUtils"; + +import { displayTauxAttractiviteDatas } from "./displayIndicators"; +import { TauxPressionRemplissageGraph } from "./TauxPressionRemplissageGraph"; + +const tauxAttractiviteOptions = (isBTS: boolean) => [ + { + label: "Taux de remplissage", + value: "tauxRemplissage", + }, + { + label: isBTS ? "Taux de demande" : "Taux de pression", + value: "tauxPression", + }, +]; + +export const getTauxAttractiviteDatas = ( + datas: FormationIndicateurs, + tauxAttractiviteSelected: TauxAttractiviteType +): Record> => { + const scopedDatas: Record> = {}; + const scopes = [ScopeEnum.national, ScopeEnum.région, ScopeEnum.académie, ScopeEnum.département]; + const values = tauxAttractiviteSelected === "tauxRemplissage" ? datas.tauxRemplissages : datas.tauxPressions; + + scopes.forEach((scope) => { + const scopeValues = values.filter((d) => d.scope === scope); + if (scopeValues.length > 0) { + scopedDatas[scope] = scopeValues.map(({ value }) => + typeof value === "undefined" ? undefined : formatNumber(value, 2) + ); + } + }); + + return scopedDatas; +}; + +const GlossaireIcon = ({ + isBTS, + tauxAttractiviteSelected, +}: { + isBTS: boolean; + tauxAttractiviteSelected: TauxAttractiviteType; +}) => { + if (tauxAttractiviteSelected === "tauxRemplissage") { + return ( + + Le ratio entre l’effectif d’entrée en formation et sa capacité. + Cliquez pour plus d'infos. + + } + /> + ); + } + + if (tauxAttractiviteSelected === "tauxPression") { + if (isBTS) { + return ( + + Le ratio entre le nombre de voeux et la capacité de la formation dans l'établissement. + Cliquez pour plus d'infos. + + } + glossaireEntryKey="taux-de-demande" + /> + ); + } else { + return ( + + + Le ratio entre le nombre de premiers voeux et la capacité de la formation dans l'établissement. + + Cliquez pour plus d'infos. + + } + glossaireEntryKey="taux-de-pression" + /> + ); + } + } + + return null; +}; + +export const TauxPressionRemplissageCard = ({ + formation, + indicateurs, + scope, + tauxAttractiviteSelected, + handleChangeTauxAttractivite, +}: { + formation: Formation; + indicateurs: FormationIndicateurs; + scope: ScopeZone; + tauxAttractiviteSelected: TauxAttractiviteType; + handleChangeTauxAttractivite: (e: React.ChangeEvent) => void; +}) => { + const blue = useToken("colors", "bluefrance.113"); + const green = useToken("colors", "greenArchipel.557"); + const orange = useToken("colors", "orangeTerreBattue.645"); + const purple = useToken("colors", "purpleGlycine.494"); + + const colors: Record = { + [ScopeEnum.national]: purple, + [ScopeEnum.région]: green, + [ScopeEnum.académie]: blue, + [ScopeEnum.département]: orange, + }; + + const lineChartColumns = useMemo(() => { + const values = + tauxAttractiviteSelected === "tauxRemplissage" ? indicateurs.tauxRemplissages : indicateurs.tauxPressions; + + return _.uniq(values.map(({ rentreeScolaire }) => `RS ${rentreeScolaire}`)).sort( + (a, b) => parseInt(a) - parseInt(b) + ); + }, [tauxAttractiviteSelected, indicateurs.tauxPressions, indicateurs.tauxRemplissages]); + + const lineChartDatas = useMemo( + () => getTauxAttractiviteDatas(indicateurs, tauxAttractiviteSelected), + [indicateurs, tauxAttractiviteSelected] + ); + + return ( + + + + + + + + {displayTauxAttractiviteDatas(lineChartDatas) ? ( + + ) : ( + + Indisponible + + )} + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxPressionRemplissageGraph.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxPressionRemplissageGraph.tsx new file mode 100644 index 000000000..e08540582 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/TauxPressionRemplissageGraph.tsx @@ -0,0 +1,165 @@ +import { AspectRatio, Box } from "@chakra-ui/react"; +import * as echarts from "echarts"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; + +import { formatNumberToString, formatPercentage } from "@/utils/formatUtils"; + +export const TauxPressionRemplissageGraph = ({ + title, + data, + categories, + colors, + defaultMainKey, + isPercentage = false, +}: { + title: string; + data: Record>; + categories?: string[]; + colors: Record; + defaultMainKey?: string; + isPercentage?: boolean; +}) => { + const chartRef = useRef(); + const containerRef = useRef(null); + + const [mainKey, setMainKey] = useState(defaultMainKey ?? ""); + + const option = useMemo( + () => ({ + textStyle: { + fontFamily: "Marianne, Arial", + }, + animationDelay: 1, + tooltip: { + trigger: "axis", + textStyle: { + color: "inherit", + }, + }, + toolbox: { + top: -5, + feature: { + saveAsImage: { + title: "Télécharger sous format .png", + name: title, + type: "png", + backgroundColor: "transparent", + }, + }, + }, + legend: { + selected: { + [mainKey]: true, + }, + data: Object.keys(data), + icon: "none", + orient: "vertical", + right: "0%", + bottom: 0, + itemGap: 8, + itemStyle: { + color: "inherit", + }, + align: "left", + textStyle: { + color: "inherit", + fontSize: 12, + rich: { + bold: { + fontWeight: 700, + fontSize: 14, + }, + }, + }, + formatter: (name) => { + if (name === mainKey) { + return `{bold|${name.charAt(0).toUpperCase() + name.slice(1)}}`; + } + return name.charAt(0).toUpperCase() + name.slice(1); + }, + }, + grid: { + left: "-10%", + top: "20%", + right: "20%", + bottom: 0, + containLabel: true, + }, + xAxis: { + type: "category", + data: categories, + show: true, + axisLine: { + show: false, + }, + axisTick: { + alignWithLabel: true, + show: false, + fontSize: 14, + }, + }, + yAxis: { + show: false, + scale: true, + type: "value", + axisLabel: { + formatter: "{value}", + align: "left", + }, + }, + series: Object.keys(data).map((key) => { + return { + type: "line", + name: key, + data: Object.values(data[key]).map((value) => value), + color: colors[key] ?? "inherit", + showSymbol: true, + symbol: "circle", + symbolSize: categories?.length && categories.length > 1 ? 0 : 6, + label: { + show: key === mainKey, + position: "top", + color: "inherit", + distance: 5, + formatter: ({ value }) => + isPercentage ? formatPercentage(value as number, 2, "-") : formatNumberToString(value as number, 2, "-"), + fontSize: 14, + fontWeight: 700, + }, + lineStyle: { + opacity: 0.75, + width: key === mainKey ? 2.5 : 1.5, + cap: "round", + }, + tooltip: { + valueFormatter: (value) => + isPercentage ? formatPercentage(value as number, 2, "-") : formatNumberToString(value as number, 2, "-"), + }, + connectNulls: true, + }; + }), + }), + [data, mainKey, categories, colors, isPercentage, title] + ); + + useLayoutEffect(() => { + if (!containerRef.current) return; + if (!chartRef.current) { + chartRef.current = echarts.init(containerRef.current); + } + chartRef.current.setOption(option, true); + chartRef.current.on("legendselectchanged", (params) => { + if (typeof params === "object" && params !== null && "name" in params && typeof params.name === "string") { + setMainKey(params.name); + } + }); + }, [data, mainKey, option]); + + return ( + + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/displayIndicators.ts b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/displayIndicators.ts new file mode 100644 index 000000000..136ed715e --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/displayIndicators.ts @@ -0,0 +1,30 @@ +import type { FormationIndicateurs, TauxIJType } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; + +export const displaySoldeDePlacesTransformees = (data: FormationIndicateurs): boolean => { + return data?.soldePlacesTransformee !== undefined && data?.soldePlacesTransformee.length > 0; +}; + +export const displayIJDatas = (data: FormationIndicateurs, tauxIJSelected: TauxIJType) => { + return ( + data.tauxIJ[tauxIJSelected].length > 0 && + data.tauxIJ[tauxIJSelected].some( + (data) => typeof data.apprentissage !== "undefined" || typeof data.scolaire !== "undefined" + ) + ); +}; + +export const displayEffectifsDatas = (data: FormationIndicateurs) => { + return data.effectifs.length > 0; +}; + +export const displayEtablissementsDatas = (data: FormationIndicateurs) => { + return data.etablissements.length > 0; +}; + +export const displayTauxAttractiviteDatas = (data: Record>) => { + return ( + Object.keys(data).length > 0 && + Object.values(data).some((d) => d.length > 0) && + Object.values(data).some((d) => d.some((v) => v !== undefined)) + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/index.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/index.tsx new file mode 100644 index 000000000..dce3d196c --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/Tabs/IndicateursTab/index.tsx @@ -0,0 +1,171 @@ +import { Badge, Flex, Heading } from "@chakra-ui/react"; +import type { ChangeEvent } from "react"; +import { useState } from "react"; + +import { client } from "@/api.client"; +import { FormationHeader } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/FormationHeader"; +import { useFormationContext } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext"; +import type { TauxAttractiviteType, TauxIJType } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; + +import { DonneesManquantes } from "./DonneesManquantes"; +import { DonneesManquantesApprentissageCard } from "./DonneesManquantesApprentissageCard"; +import { EffectifsEtAttractiviteGraphs } from "./EffectifsEtAttractiviteGraphs"; +import { ExportListIndicateurs } from "./ExportListIndicateurs"; +import { NotInScope } from "./NotInScope"; +import { SoldeDePlacesTransformeesCard } from "./SoldeDePlacesTransformeesCard"; +import { TauxIJCard } from "./TauxIJCard"; + +const useIndicateursTab = ({ + cfd, + codeRegion, + codeAcademie, + codeDepartement, +}: { + cfd: string; + codeRegion?: string; + codeAcademie?: string; + codeDepartement?: string; +}) => { + const [tauxIJSelected, setTauxIJSelected] = useState("tauxDevenirFavorable"); + const [tauxAttractiviteSelected, setTauxAttractiviteSelected] = useState("tauxPression"); + const { data: dataFormation, isLoading: isLoadingFormation } = client.ref("[GET]/formation/:cfd").useQuery( + { + params: { cfd }, + query: { codeRegion, codeAcademie, codeDepartement }, + }, + { + keepPreviousData: true, + staleTime: 10000000, + enabled: !!cfd, + } + ); + + const { data: dataIndicateurs, isLoading: isLoadingIndicateurs } = client + .ref("[GET]/formation/:cfd/indicators") + .useQuery( + { + params: { cfd }, + query: { codeRegion, codeAcademie, codeDepartement }, + }, + { + keepPreviousData: true, + staleTime: 10000000, + enabled: !!cfd, + } + ); + + const handleChangeTauxIJ = (e: ChangeEvent) => { + setTauxIJSelected(e.target.value as TauxIJType); + }; + + const handleChangeTauxAttractivite = (e: ChangeEvent) => { + setTauxAttractiviteSelected(e.target.value as TauxAttractiviteType); + }; + + return { + isLoadingFormation, + isLoadingIndicateurs, + tauxIJSelected, + handleChangeTauxIJ, + tauxAttractiviteSelected, + handleChangeTauxAttractivite, + dataFormation, + dataIndicateurs, + }; +}; + +export const IndicateursTab = () => { + const { currentFilters, scope, codeNsf } = useFormationContext(); + const { codeRegion, codeAcademie, codeDepartement, cfd } = currentFilters; + + const { + tauxIJSelected, + handleChangeTauxIJ, + tauxAttractiviteSelected, + handleChangeTauxAttractivite, + isLoadingIndicateurs, + isLoadingFormation, + dataFormation, + dataIndicateurs, + } = useIndicateursTab({ + cfd, + codeRegion, + codeAcademie, + codeDepartement, + }); + + if (!cfd || isLoadingFormation || isLoadingIndicateurs || !dataFormation || !dataIndicateurs) { + return null; + } + + return ( + + } + /> + {dataFormation.isInScope ? ( + <> + + + DEVENIR DES ÉLÈVES + + + + + {dataFormation.isScolaire && ( + + + + EFFECTIFS ET ATTRACTIVITÉ + + VOIE SCOLAIRE + + + + )} + + + + + TRANSFORMATION DE LA CARTE + + + + + + + + + + ) : ( + + )} + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/index.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/index.tsx new file mode 100644 index 000000000..64f7032a7 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/FormationSection/index.tsx @@ -0,0 +1,114 @@ +import type { BoxProps } from "@chakra-ui/react"; +import { Box, Container, Divider, Flex, forwardRef, Heading } from "@chakra-ui/react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { useFormationContext } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext"; +import type { + FormationListItem, + FormationsCounter, + FormationTab, +} from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; + +import { ListeFormations } from "./ListeFormations"; +import { TabFilters } from "./TabFilters"; +import { EtablissementsTab } from "./Tabs/EtablissementsTab"; +import { IndicateursTab } from "./Tabs/IndicateursTab"; + +type TabContentProps = BoxProps & { + tab: FormationTab; +}; + +const TabContent = forwardRef(({ tab, ...rest }, ref) => { + const content = useMemo(() => { + switch (tab) { + case "etablissements": + return ; + case "indicateurs": + default: + return ; + } + }, [tab]); + + return ( + + {content} + + ); +}); + +const useFormationSection = ( + formations: FormationListItem[], + formationsByLibelleNiveauDiplome: Record +) => { + const { currentFilters, handleCfdChange } = useFormationContext(); + const tabContentRef = useRef(null); + const [tabContentHeight, setTabContentHeight] = useState(undefined); + + useEffect(() => { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setTabContentHeight(entry.contentRect.height); + } + }); + + if (tabContentRef.current) { + observer.observe(tabContentRef.current); + } + + return () => { + observer.disconnect(); + }; + }, [tabContentRef]); + + useEffect(() => { + const cfdInListOfFormations = formations.find((f) => f.cfd === currentFilters.cfd); + + if (!cfdInListOfFormations) { + const firstFormation = formationsByLibelleNiveauDiplome[Object.keys(formationsByLibelleNiveauDiplome)[0]][0]; + handleCfdChange(firstFormation.cfd); + } + }, [currentFilters, formations, handleCfdChange, formationsByLibelleNiveauDiplome]); + + return { + currentFilters, + handleCfdChange, + tabContentRef, + tabContentHeight, + }; +}; + +export const FormationSection = ({ + formations, + counter, + formationsByLibelleNiveauDiplome, +}: { + formations: FormationListItem[]; + counter: FormationsCounter; + formationsByLibelleNiveauDiplome: Record; +}) => { + const { currentFilters, handleCfdChange, tabContentRef, tabContentHeight } = useFormationSection( + formations, + formationsByLibelleNiveauDiplome + ); + + return ( + + + + Offre de formation dans ce domaine + + + + + + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/HeaderSection/AccesRapide.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/HeaderSection/AccesRapide.tsx new file mode 100644 index 000000000..2f3e845f1 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/HeaderSection/AccesRapide.tsx @@ -0,0 +1,22 @@ +import { HStack, StackDivider } from "@chakra-ui/react"; + +import { useFormationContext } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext"; +import { ShortLink } from "@/components/ShortLink"; + +export const AccesRapide = () => { + const { currentFilters, codeNsf } = useFormationContext(); + return ( + } color={"bluefrance.113"}> + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/HeaderSection/HeaderSection.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/HeaderSection/HeaderSection.tsx new file mode 100644 index 000000000..b2aed3113 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/HeaderSection/HeaderSection.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Container, Flex, Heading, Icon, Img } from "@chakra-ui/react"; +import { Icon as Iconify } from "@iconify/react"; + +import { getNsfIcon } from "@/utils/getNsfIcon"; + +import { AccesRapide } from "./AccesRapide"; + +export const HeaderSection = ({ codeNsf, libelleNsf }: { codeNsf: string; libelleNsf: string }) => { + return ( + + + + + + + + {libelleNsf} + + + + + Illustration équipe en collaboration + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/LiensUtilesSection/LiensUtilesSection.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/LiensUtilesSection/LiensUtilesSection.tsx new file mode 100644 index 000000000..1893ae347 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/components/LiensUtilesSection/LiensUtilesSection.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { Box, Container, Divider, Flex, Heading, SimpleGrid } from "@chakra-ui/react"; +import { usePlausible } from "next-plausible"; +import { ScopeEnum } from "shared"; + +import { useFormationContext } from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext"; +import { InfoCard } from "@/app/(wrapped)/panorama/components/InfoCard"; +import { liensDares } from "@/utils/liensDares"; + +const useLiensUtilesSection = () => { + const trackEvent = usePlausible(); + + const linkTracker = (filterName: string) => () => { + trackEvent("panorama-formation:liens-utile", { + props: { filter_name: filterName }, + }); + }; + + return { + linkTracker, + }; +}; + +function formatCodeDepartement(codeDepartement: string | undefined): string { + if (!codeDepartement) { + return ""; + } + + return codeDepartement?.startsWith("0") ? codeDepartement.substring(1) : codeDepartement; +} + +export const LiensUtilesSection = () => { + const { currentFilters, scope } = useFormationContext(); + const { codeRegion, codeDepartement } = currentFilters; + const { linkTracker } = useLiensUtilesSection(); + + return ( + + + Liens utiles + + + Les enjeux de demain pour mieux anticiper les formations insérantes : accédez ici à une multitude + d'informations pour enrichir vos analyses. + + + + + + {codeRegion && liensDares[codeRegion] && ( + + )} + + + + + ); +}; diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext.tsx new file mode 100644 index 000000000..8461ca9c3 --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/context/formationContext.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { createContext, useContext, useMemo } from "react"; +import type { ScopeZone } from "shared"; + +import type { + Academie, + Bbox, + Departement, + EtablissementsOrderBy, + EtablissementsView, + Filters, + FormationTab, + Presence, + Region, + Voie, +} from "@/app/(wrapped)/domaine-de-formation/[codeNsf]/types"; +import { useStateParams } from "@/utils/useFilters"; + +type InputFormationContextType = { + codeNsf: string; + libelleNsf: string; + scope: ScopeZone; + regions: Region[]; + academies: Academie[]; + departements: Departement[]; +}; + +type FormationContextType = InputFormationContextType & { + currentFilters: Filters; + handleResetFilters: () => void; + handleRegionChange: (codeRegion: string) => void; + handleAcademieChange: (codeAcademie: string) => void; + handleDepartementChange: (codeDepartement: string) => void; + handlePresenceChange: (presence: Presence) => void; + handleVoieChange: (voie: Voie) => void; + handleTabFormationChange: (formationTab: FormationTab) => void; + handleIncludeAllChange: (includeAll: boolean) => void; + handleViewChange: (view: EtablissementsView) => void; + handleOrderByChange: (orderBy: EtablissementsOrderBy) => void; + handleCfdChange: (cfd: string) => void; + handleClearBbox: () => void; + handleSetBbox: (bbox?: Bbox) => void; +}; + +type FormationContextProps = { + children: React.ReactNode; + value: InputFormationContextType; + defaultCfd: string; +}; + +export const FormationContext = createContext({} as FormationContextType); + +export function FormationContextProvider({ children, value, defaultCfd }: Readonly) { + const [currentFilters, setCurrentFilters] = useStateParams({ + defaultValues: { + presence: "", + voie: "", + formationTab: "etablissements", + cfd: defaultCfd, + etab: { + includeAll: true, + view: "map", + orderBy: "departement_commune", + }, + }, + }); + + const handleResetFilters = () => { + setCurrentFilters((prev) => ({ + ...prev, + codeRegion: "", + codeDepartement: "", + codeAcademie: "", + })); + }; + + const handleRegionChange = (codeRegion: string) => + setCurrentFilters((prev) => ({ + ...prev, + codeRegion, + codeDepartement: "", + codeAcademie: "", + etab: { + ...prev.etab, + bbox: undefined, + }, + })); + + const handleAcademieChange = (codeAcademie: string) => { + const academie = value.academies.find((a) => a.value === codeAcademie); + + if (academie) { + setCurrentFilters((prev) => ({ + ...prev, + codeRegion: academie.codeRegion, + codeAcademie, + codeDepartement: "", + etab: { + ...prev.etab, + bbox: undefined, + }, + })); + } else { + setCurrentFilters((prev) => ({ + ...prev, + codeAcademie: "", + codeDepartement: "", + etab: { + ...prev.etab, + bbox: undefined, + }, + })); + } + }; + + const handleDepartementChange = (codeDepartement: string) => { + const departement = value.departements.find((d) => d.value === codeDepartement); + + if (departement) { + setCurrentFilters((prev) => ({ + ...prev, + codeRegion: departement.codeRegion, + codeAcademie: departement.codeAcademie, + codeDepartement, + etab: { + ...prev.etab, + bbox: undefined, + }, + })); + } else { + setCurrentFilters((prev) => ({ + ...prev, + codeDepartement: "", + etab: { + ...prev.etab, + bbox: undefined, + }, + })); + } + }; + + const handlePresenceChange = (presence: Presence) => { + setCurrentFilters((prev) => ({ + ...prev, + presence, + })); + }; + + const handleVoieChange = (voie: Voie) => { + setCurrentFilters((prev) => ({ + ...prev, + voie, + })); + }; + + const handleTabFormationChange = (formationTab: FormationTab) => { + setCurrentFilters((prev) => ({ + ...prev, + formationTab, + })); + }; + + const handleIncludeAllChange = (includeAll: boolean) => { + setCurrentFilters((prev) => ({ + ...prev, + etab: { ...prev.etab, includeAll }, + })); + }; + + const handleViewChange = (view: EtablissementsView) => { + setCurrentFilters((prev) => ({ + ...prev, + etab: { ...prev.etab, view }, + })); + }; + + const handleOrderByChange = (orderBy: EtablissementsOrderBy) => { + setCurrentFilters((prev) => ({ + ...prev, + etab: { ...prev.etab, orderBy }, + })); + }; + + const handleCfdChange = (cfd: string) => { + setCurrentFilters((prev) => ({ + ...prev, + cfd, + })); + }; + + const handleClearBbox = () => { + setCurrentFilters((prev) => ({ + ...prev, + etab: { ...prev.etab, bbox: undefined }, + })); + }; + + const handleSetBbox = (bbox?: Bbox) => { + setCurrentFilters((prev) => ({ + ...prev, + etab: { ...prev.etab, bbox }, + })); + }; + + const context = useMemo( + () => ({ + currentFilters, + handleResetFilters, + handleRegionChange, + handleAcademieChange, + handleDepartementChange, + handlePresenceChange, + handleVoieChange, + handleTabFormationChange, + handleIncludeAllChange, + handleViewChange, + handleOrderByChange, + handleCfdChange, + handleClearBbox, + scope: value.scope, + codeNsf: value.codeNsf, + libelleNsf: value.libelleNsf, + regions: value.regions, + academies: value.academies, + departements: value.departements, + handleSetBbox, + }), + [ + currentFilters, + handleResetFilters, + handleRegionChange, + handleAcademieChange, + handleDepartementChange, + handlePresenceChange, + handleVoieChange, + handleTabFormationChange, + handleIncludeAllChange, + handleViewChange, + handleOrderByChange, + handleCfdChange, + handleClearBbox, + handleSetBbox, + value, + ] + ); + + return {children}; +} + +export const useFormationContext = () => useContext(FormationContext); diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/page.tsx b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/page.tsx new file mode 100644 index 000000000..ace9dd18e --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/page.tsx @@ -0,0 +1,175 @@ +import { isAxiosError } from "axios"; +import _ from "lodash"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { ScopeEnum } from "shared"; + +import { serverClient } from "@/api.client"; + +import { PageDomaineDeFormationClient } from "./client"; +import type { Filters, FormationListItem, FormationsCounter, QueryFilters } from "./types"; + +const fetchNsf = async (codeNsf: string, filters: QueryFilters) => { + const headersList = Object.fromEntries(headers().entries()); + try { + return await serverClient + .ref("[GET]/domaine-de-formation/:codeNsf") + .query({ params: { codeNsf }, query: filters }, { headers: headersList }); + } catch (e) { + if (isAxiosError(e)) { + console.error({ status: e.response?.status, message: e.response?.data }); + } + + return null; + } +}; + +const fetchDefaultNsfs = async () => { + const headersList = Object.fromEntries(headers().entries()); + try { + return await serverClient + .ref("[GET]/domaine-de-formation") + .query({ query: { search: undefined } }, { headers: headersList }); + } catch (e) { + if (isAxiosError(e)) { + console.error({ status: e.response?.status, message: e.response?.data }); + } + + return []; + } +}; + +const findDefaultCfd = ( + defaultCfd: string | undefined, + formations: FormationListItem[], + formationByCodeNiveauDiplome: Record +): string => { + if (defaultCfd) { + const isInList = formations.find((f) => f.cfd === defaultCfd); + + if (isInList) { + return defaultCfd; + } + } + const firstFormations = formationByCodeNiveauDiplome[Object.keys(formationByCodeNiveauDiplome)[0]]; + + const formationWithAtLeastOneEtab = firstFormations.filter((f) => f.nbEtab > 0); + + return formationWithAtLeastOneEtab[0]?.cfd; +}; + +const defineScope = ( + codeRegion: string | undefined, + codeAcademie: string | undefined, + codeDepartement: string | undefined +) => { + if (codeDepartement) { + return ScopeEnum.département; + } + + if (codeAcademie) { + return ScopeEnum.académie; + } + + if (codeRegion) { + return ScopeEnum.région; + } + + return ScopeEnum.national; +}; + +const groupFromationsByLibelleNiveauDiplome = ( + formations: FormationListItem[] +): Record => { + return _.chain(formations) + .orderBy("ordreFormation", "desc") + .groupBy("libelleNiveauDiplome") + .toPairs() + .sortBy([0]) + .fromPairs() + .mapValues((value) => value.sort((a, b) => a.libelleFormation.localeCompare(b.libelleFormation))) + .value(); +}; + +type Params = { + params: Promise<{ + codeNsf: string; + }>; + searchParams: Promise>; +}; + +export default async function PageDomaineDeFormation({ params, searchParams }: Readonly) { + const { codeNsf } = await params; + const { codeRegion, codeAcademie, codeDepartement, cfd, presence, voie } = await searchParams; + + const results = await fetchNsf(codeNsf, { + codeRegion, + codeAcademie, + codeDepartement, + }); + + const defaultNsfs = await fetchDefaultNsfs(); + + if (!results) { + return redirect(`/panorama/domaine-de-formation?wrongNsf=${codeNsf}`); + } + + const regions = results.filters.regions; + let academies = results.filters.academies; + let departements = results.filters.departements; + + if (codeRegion) { + academies = academies.filter((academie) => academie.codeRegion === codeRegion); + departements = departements.filter((departement) => departement.codeRegion === codeRegion); + + if (codeAcademie) { + departements = departements.filter((departement) => departement.codeAcademie === codeAcademie); + } + } + + const formations = results.formations + .filter((formation) => (presence === "dispensees" ? formation.nbEtab > 0 : true)) + .filter((formation) => (presence === "absentes" ? formation.nbEtab === 0 : true)) + .filter((formation) => (voie === "apprentissage" ? formation.apprentissage : true)) + .filter((formation) => (voie === "scolaire" ? formation.scolaire : true)); + + const formationsByPresence = results.formations + .filter((formation) => (presence === "dispensees" ? formation.nbEtab > 0 : true)) + .filter((formation) => (presence === "absentes" ? formation.nbEtab === 0 : true)); + + const formationsByVoie = results.formations + .filter((formation) => (voie === "apprentissage" ? formation.apprentissage : true)) + .filter((formation) => (voie === "scolaire" ? formation.scolaire : true)); + + const counter: FormationsCounter = { + inScope: formationsByVoie.filter((f) => f.nbEtab > 0).length, + outsideScope: formationsByVoie.filter((f) => f.nbEtab === 0).length, + scolaire: formationsByPresence.filter((f) => f.scolaire).length, + apprentissage: formationsByPresence.filter((f) => f.apprentissage).length, + allVoies: formationsByPresence.length, + allScopes: results.formations.length, + }; + + const scope = defineScope(codeRegion, codeAcademie, codeDepartement); + + const formationsByLibelleNiveauDiplome: Record = + groupFromationsByLibelleNiveauDiplome(formations); + + const selectedCfd = findDefaultCfd(cfd, formations, formationsByLibelleNiveauDiplome); + + return ( + + ); +} diff --git a/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/types.ts b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/types.ts new file mode 100644 index 000000000..ebd7b07df --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/[codeNsf]/types.ts @@ -0,0 +1,62 @@ +import type { client } from "@/api.client"; + +export type DomaineDeFormationFilters = (typeof client.infer)["[GET]/domaine-de-formation/:codeNsf"]["filters"]; +export type DomaineDeFormationResult = (typeof client.infer)["[GET]/domaine-de-formation/:codeNsf"]; +export type FormationListItem = (typeof client.infer)["[GET]/domaine-de-formation/:codeNsf"]["formations"][number]; + +export type NsfOptions = (typeof client.infer)["[GET]/domaine-de-formation"]; +export type NsfOption = NsfOptions[number]; + +export type Presence = "" | "dispensees" | "absentes"; +export type Voie = "" | "scolaire" | "apprentissage"; +export type FormationTab = "etablissements" | "tableauComparatif" | "indicateurs"; +export type QueryFilters = (typeof client.inferArgs)["[GET]/domaine-de-formation/:codeNsf"]["query"]; + +export type EtablissementsView = "map" | "list"; +export type EtablissementsOrderBy = "departement_commune" | "libelle"; +export type Bbox = { + latMin: number; + lngMin: number; + latMax: number; + lngMax: number; +}; +export type Filters = { + cfd: string; + codeRegion?: string; + codeDepartement?: string; + codeAcademie?: string; + presence: Presence; + voie: Voie; + formationTab: FormationTab; + etab: { + includeAll: boolean; + view: EtablissementsView; + orderBy: EtablissementsOrderBy; + bbox?: Bbox; + }; +}; + +export type Region = DomaineDeFormationFilters["regions"][number]; +export type Academie = DomaineDeFormationFilters["academies"][number]; +export type Departement = DomaineDeFormationFilters["departements"][number]; + +export type Formation = (typeof client.infer)["[GET]/formation/:cfd"]; +export type FormationIndicateurs = (typeof client.infer)["[GET]/formation/:cfd/indicators"]; +export type TauxIJType = keyof (typeof client.infer)["[GET]/formation/:cfd/indicators"]["tauxIJ"]; +export type TauxIJValues = (typeof client.infer)["[GET]/formation/:cfd/indicators"]["tauxIJ"]["tauxInsertion"]; +export type TauxIJValue = (typeof client.infer)["[GET]/formation/:cfd/indicators"]["tauxIJ"]["tauxInsertion"][number]; +export type TauxPressionValue = (typeof client.infer)["[GET]/formation/:cfd/indicators"]["tauxPressions"][number]; +export type TauxRemplissageValue = (typeof client.infer)["[GET]/formation/:cfd/indicators"]["tauxRemplissages"][number]; + +export type TauxAttractiviteType = "tauxPression" | "tauxRemplissage"; + +export type FormationsCounter = { + inScope: number; + outsideScope: number; + scolaire: number; + apprentissage: number; + allVoies: number; + allScopes: number; +}; + +export type Etablissement = (typeof client.infer)["[GET]/formation/:cfd/map"]["etablissements"][number]; diff --git a/ui/app/(wrapped)/domaine-de-formation/page.tsx b/ui/app/(wrapped)/domaine-de-formation/page.tsx new file mode 100644 index 000000000..9c38c4cae --- /dev/null +++ b/ui/app/(wrapped)/domaine-de-formation/page.tsx @@ -0,0 +1,3 @@ +import { redirect } from "next/navigation"; + +export default () => redirect("/panorama/domaine-de-formation"); diff --git a/ui/app/(wrapped)/intentions/perdir/saisie/page.client.tsx b/ui/app/(wrapped)/intentions/perdir/saisie/page.client.tsx index 84bb4d49f..96e33f1e3 100644 --- a/ui/app/(wrapped)/intentions/perdir/saisie/page.client.tsx +++ b/ui/app/(wrapped)/intentions/perdir/saisie/page.client.tsx @@ -97,7 +97,7 @@ export const PageClient = () => { title: `La demande ${notFound} n'a pas été trouvée`, }); } - }, [notFound]); + }, [notFound, toast]); const trackEvent = usePlausible(); diff --git a/ui/app/(wrapped)/intentions/perdir/synthese/[numero]/main/commentaireEtAvis/UpdateAvisForm.tsx b/ui/app/(wrapped)/intentions/perdir/synthese/[numero]/main/commentaireEtAvis/UpdateAvisForm.tsx index 303bc682a..73d75ce66 100644 --- a/ui/app/(wrapped)/intentions/perdir/synthese/[numero]/main/commentaireEtAvis/UpdateAvisForm.tsx +++ b/ui/app/(wrapped)/intentions/perdir/synthese/[numero]/main/commentaireEtAvis/UpdateAvisForm.tsx @@ -114,7 +114,7 @@ export const UpdateAvisForm = chakra( defaultValue={ value ? { - value: value, + value: `${value}`, // @ts-expect-error TODO label: value?.toUpperCase() ?? "", } diff --git a/ui/app/(wrapped)/panorama/domaine-de-formation/client.tsx b/ui/app/(wrapped)/panorama/domaine-de-formation/client.tsx new file mode 100644 index 000000000..4ab8c6fff --- /dev/null +++ b/ui/app/(wrapped)/panorama/domaine-de-formation/client.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { + Alert, + AlertDescription, + AlertIcon, + AspectRatio, + Container, + Flex, + Img, + Text, + VisuallyHidden, +} from "@chakra-ui/react"; +import { useRouter } from "next/navigation"; + +import { SelectNsf } from "./components/selectNsf"; +import type { NsfOptions } from "./types"; + +export const PanoramaDomaineDeFormationClient = ({ + defaultNsf, + wrongNsf, +}: { + defaultNsf: NsfOptions; + wrongNsf?: string; +}) => { + const router = useRouter(); + return ( + + + + Rechercher un domaine de formation (NSF) ou par formation + + + { + if (selected.type === "formation") { + router.push(`/domaine-de-formation/${selected.nsf}?cfd=${selected.value}`); + } else { + router.push(`/domaine-de-formation/${selected.value}`); + } + }} + /> + {wrongNsf && ( + + + + Aucun domaine de formation trouvé pour le NSF {wrongNsf}. + + + )} + + + Illustration équipe en collaboration + + + + ); +}; diff --git a/ui/app/(wrapped)/panorama/domaine-de-formation/components/selectNsf.tsx b/ui/app/(wrapped)/panorama/domaine-de-formation/components/selectNsf.tsx new file mode 100644 index 000000000..262edc7b1 --- /dev/null +++ b/ui/app/(wrapped)/panorama/domaine-de-formation/components/selectNsf.tsx @@ -0,0 +1,109 @@ +import { chakra, Flex } from "@chakra-ui/react"; +import type { CSSProperties } from "react"; +import { useState } from "react"; +import type { CSSObjectWithLabel, StylesConfig } from "react-select"; +import Select from "react-select"; + +import { client } from "@/api.client"; +import type { NsfOption, NsfOptions } from "@/app/(wrapped)/panorama/domaine-de-formation/types"; + +const selectStyle: StylesConfig = { + control: (styles: CSSObjectWithLabel) => ({ + ...styles, + width: "100%", + zIndex: "2", + borderColor: "#dddddd", + }), + input: (styles) => ({ ...styles, width: "100%" }), + menu: (styles) => ({ ...styles, width: "100%" }), + container: (styles) => ({ ...styles, width: "100%" }), + groupHeading: (styles) => ({ + ...styles, + fontWeight: "bold", + color: "#161616", + fontSize: "14px", + }), + placeholder: (styles) => ({ + ...styles, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }), +}; + +export const SelectNsf = chakra( + ({ + defaultNsfs, + defaultSelected = null, + hideLabel = false, + className, + label, + isClearable = true, + routeSelectedNsf, + }: { + defaultNsfs: NsfOptions; + defaultSelected: NsfOption | null; + hideLabel?: boolean; + className?: string; + label?: string; + isClearable?: boolean; + routeSelectedNsf: (selected: NsfOption) => void; + }) => { + const [search, setSearch] = useState(""); + + const { data, isLoading, refetch } = client + .ref("[GET]/domaine-de-formation") + .useQuery({ query: { search } }, { enabled: !!search, initialData: defaultNsfs }); + + const onNsfSelected = (selected: NsfOption | null) => { + if (selected) { + routeSelectedNsf(selected); + } + }; + + const labelStyle: CSSProperties = { + fontWeight: "bold", + display: hideLabel ? "none" : "block", + }; + + return ( + + {label && ( + + )} + +