From d93d5cdafd13788827fbc8015d4344d6830a7c2e Mon Sep 17 00:00:00 2001 From: Maud Royer Date: Thu, 31 Oct 2024 16:05:56 +0100 Subject: [PATCH] feat: atc page, main menu and breadcrumbs links Signed-off-by: Maud Royer --- src/app/(container)/atc/[code]/page.tsx | 110 +++++++++++++ src/app/(container)/medicament/[CIS]/page.tsx | 107 +++++++------ src/app/layout.tsx | 11 +- src/data/atc.ts | 150 ++++++++++++++---- src/displayUtils.ts | 27 +++- 5 files changed, 322 insertions(+), 83 deletions(-) create mode 100644 src/app/(container)/atc/[code]/page.tsx diff --git a/src/app/(container)/atc/[code]/page.tsx b/src/app/(container)/atc/[code]/page.tsx new file mode 100644 index 0000000..8f6edd2 --- /dev/null +++ b/src/app/(container)/atc/[code]/page.tsx @@ -0,0 +1,110 @@ +import Badge from "@codegouvfr/react-dsfr/Badge"; +import { fr } from "@codegouvfr/react-dsfr"; +import { ATC, getAtc1, getAtc2, getSubstancesByAtc } from "@/data/atc"; +import Card from "@codegouvfr/react-dsfr/Card"; +import Breadcrumb from "@codegouvfr/react-dsfr/Breadcrumb"; +import { notFound } from "next/navigation"; +import React from "react"; +import Link from "next/link"; +import { SubstanceNom } from "@/db/pdbmMySQL/types"; + +export const dynamic = "error"; +export const dynamicParams = true; + +const SubstanceItem = ({ item }: { item: SubstanceNom }) => ( +
  • + + {item.NomLib} + +
  • +); + +const SubClassItem = ({ item }: { item: ATC }) => ( +
  • + + {item.label} + +
  • +); + +export default async function Page({ + params: { code }, +}: { + params: { code: string }; +}) { + const atc1 = await getAtc1(code); + const atc2 = code.length === 3 && (await getAtc2(code)); + const currentAtc = atc2 || atc1; + + const items = atc2 + ? await getSubstancesByAtc(atc2) + : ( + await Promise.all( + atc1.children.map( + async (atc2): Promise<[ATC, SubstanceNom[] | undefined]> => [ + atc2, + await getSubstancesByAtc(atc2), + ], + ), + ) + ) + .filter(([_, substances]) => !!substances) + .map(([atc2]) => atc2); + + if (!items) notFound(); + + const ItemComponent = (atc2 ? SubstanceItem : SubClassItem) as ({ + item, + }: { + item: SubstanceNom | ATC; + }) => React.JSX.Element; + + return ( + <> + +
    +
    + + Classe de médicament + + +

    {currentAtc.label}

    + +
    + +
    +

    + {items.length}{" "} + {atc2 ? "substances actives" : "sous-classes de médicament"} +

    + +
      + {items.map((item: SubstanceNom | ATC, index) => ( + + ))} +
    +
    +
    + + ); +} diff --git a/src/app/(container)/medicament/[CIS]/page.tsx b/src/app/(container)/medicament/[CIS]/page.tsx index 9ed05d5..cf7793d 100644 --- a/src/app/(container)/medicament/[CIS]/page.tsx +++ b/src/app/(container)/medicament/[CIS]/page.tsx @@ -12,7 +12,6 @@ import JSZIP from "jszip"; import * as windows1252 from "windows-1252"; import HTMLParser, { HTMLElement } from "node-html-parser"; import { Nullable, sql } from "kysely"; -import { parse as csvParse } from "csv-parse/sync"; import { pdbmMySQL } from "@/db/pdbmMySQL"; import liste_CIS_MVP from "@/liste_CIS_MVP.json"; @@ -20,12 +19,12 @@ import DsfrLeafletSection from "@/app/(container)/medicament/[CIS]/DsfrLeafletSe import { isHtmlElement } from "@/app/(container)/medicament/[CIS]/leafletUtils"; import { dateShortFormat, - displayComposants, + displayCompleteComposants, + displaySimpleComposants, formatSpecName, getSpecialiteGroupName, } from "@/displayUtils"; import Breadcrumb from "@codegouvfr/react-dsfr/Breadcrumb"; -import { readFileSync } from "node:fs"; import { Presentation, PresentationComm, @@ -37,7 +36,7 @@ import { Specialite, SubstanceNom, } from "@/db/pdbmMySQL/types"; -import { getAtcLabels } from "@/data/atc"; +import { atcData, getAtc1, getAtc2 } from "@/data/atc"; import { notFound } from "next/navigation"; export const dynamic = "error"; @@ -156,14 +155,14 @@ const getSpecialite = cache(async (CIS: string) => { }; }); -const atcData = csvParse( - readFileSync( - path.join(process.cwd(), "src", "data", "CIS-ATC_2024-04-07.csv"), - ), -) as string[][]; -function getAtc(CIS: string) { +function getAtcCode(CIS: string) { const atc = atcData.find((row) => row[0] === CIS); - return atc ? atc[1] : null; + + if (!atc) { + throw new Error(`Could not find ATC code for CIS ${CIS}`); + } + + return atc[1]; } /** @@ -313,57 +312,57 @@ export default async function Page({ const { specialite, composants, presentations, delivrance } = await getSpecialite(CIS); const leaflet = await getLeaflet(CIS); - const atc = getAtc(CIS); - const atcLabels = atc ? await getAtcLabels(atc) : null; - const [, subClass, substance] = atcLabels ? atcLabels : [null, null, null]; + const atcCode = getAtcCode(CIS); + const atc1 = await getAtc1(atcCode); + const atc2 = await getAtc2(atcCode); return ( <> - {atcLabels && ( - ({ - label, - linkProps: { href: `/rechercher?s=${label}` }, - })), - ]} - currentPageLabel={formatSpecName(specialite.SpecDenom01).replace( - formatSpecName(getSpecialiteGroupName(specialite)), - "", - )} - /> - )} +

    {formatSpecName(specialite.SpecDenom01)}

      - {subClass && ( - - {subClass} - - )} - {substance && ( - - {substance} - + + {atc2.label} + + {displaySimpleComposants(composants).map( + (substance: SubstanceNom) => ( + + {substance.NomLib} + + ), )} {specialite.SpecGeneId ? ( - Substance active {displayComposants(composants)} + Substance active {displayCompleteComposants(composants)}
      {presentations.map((p) => ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d362140..0eaa764 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,17 +14,19 @@ import "@/customIcons/customIcons.css"; import "@/components/dsfr-custom-alt.css"; import MuiDsfrThemeProvider from "@codegouvfr/react-dsfr/mui"; import { headerFooterDisplayItem } from "@codegouvfr/react-dsfr/Display"; +import { getAtc } from "@/data/atc"; import { StartHotjar } from "@/app/StartHotjar"; export const metadata: Metadata = { title: "Info Médicament", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const atcs = await getAtc(); const lang = "fr"; return ( @@ -75,6 +77,13 @@ export default function RootLayout({ serviceTagline="La référence officielle sur les données des médicaments" quickAccessItems={[headerFooterDisplayItem]} navigation={[ + { + text: "Parcourir", + menuLinks: atcs.map((atc) => ({ + linkProps: { href: `/atc/${atc.code}` }, + text: atc.label, + })), + }, { text: "Par ordre alphabétique", menuLinks: [ diff --git a/src/data/atc.ts b/src/data/atc.ts index 9ea0296..22023b9 100644 --- a/src/data/atc.ts +++ b/src/data/atc.ts @@ -1,8 +1,59 @@ import "server-only"; +import { notFound } from "next/navigation"; +import { cache } from "react"; + import atcOfficialLabels from "@/data/ATC 2024 02 15.json"; import { getGristTableData } from "@/data/grist"; +import { parse as csvParse } from "csv-parse/sync"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { SubstanceNom } from "@/db/pdbmMySQL/types"; +import liste_CIS_MVP from "@/liste_CIS_MVP.json"; +import { pdbmMySQL } from "@/db/pdbmMySQL"; + +export interface ATC1 extends ATC { + children: ATC[]; +} + +export interface ATC { + code: string; + label: string; + description: string; + children?: ATC[]; +} + +export const getAtc = cache(async function (): Promise { + const data = await getGristTableData("Table_Niveau_1", [ + "Lettre_1_ATC_1", + "Libelles_niveau_1", + "Definition_Classe", + ]); + const childrenData = await getGristTableData("Table_Niveau_2", [ + "Libelles_niveau_2", + "Lettre_2_ATC2", + ]); + return await Promise.all( + data.map(async (record: any) => { + const children = await Promise.all( + childrenData + .filter((record: any) => + record.fields.Lettre_2_ATC2.startsWith( + record.fields.Lettre_1_ATC_1, + ), + ) + .map(async (record: any) => getAtc2(record.fields.Lettre_2_ATC2)), + ); + return { + code: record.fields.Lettre_1_ATC_1 as string, + label: record.fields.Libelles_niveau_1 as string, + description: record.fields.Definition_Classe as string, + children, + }; + }), + ); +}); -async function getAtcLabel1(code: string): Promise { +export const getAtc1 = cache(async function (code: string): Promise { const data = await getGristTableData("Table_Niveau_1", [ "Lettre_1_ATC_1", "Libelles_niveau_1", @@ -12,24 +63,38 @@ async function getAtcLabel1(code: string): Promise { const record = data.find( (record) => record.fields.Lettre_1_ATC_1 === code.slice(0, 1), ); - if (!record) { - throw new Error(`ATC code not found: ${code.slice(0, 1)}`); - } + if (!record) notFound(); - return record.fields.Libelles_niveau_1 as string; -} + const childrenData = await getGristTableData("Table_Niveau_2", [ + "Libelles_niveau_2", + "Lettre_2_ATC2", + ]); + const children = await Promise.all( + childrenData + .filter((record: any) => + record.fields.Lettre_2_ATC2.startsWith(code.slice(0, 1)), + ) + .map(async (record: any) => await getAtc2(record.fields.Lettre_2_ATC2)), + ); -async function getAtcLabel2(code: string): Promise { - const atcData = await getGristTableData("Table_Niveau_2", [ + return { + code: record.fields.Lettre_1_ATC_1 as string, + label: record.fields.Libelles_niveau_1 as string, + description: record.fields.Definition_Classe as string, + children, + }; +}); + +export const getAtc2 = cache(async function (code: string): Promise { + const data = await getGristTableData("Table_Niveau_2", [ "Libelles_niveau_2", "Lettre_2_ATC2", ]); - const record = atcData.find( - (record) => record.fields.Lettre_2_ATC2 === code.slice(0, 3), + const record = data.find( + (record: any) => record.fields.Lettre_2_ATC2 === code.slice(0, 3), ); - if (!record) { - throw new Error(`ATC code not found: ${code.slice(0, 3)}`); - } + + if (!record) notFound(); const libeleId = record.fields.Libelles_niveau_2; const libeleData = await getGristTableData("Intitules_possibles", [ @@ -37,23 +102,54 @@ async function getAtcLabel2(code: string): Promise { ]); const libeleRecord = libeleData.find((record) => record.id === libeleId); - if (!libeleRecord) { - throw new Error(`ATC code not found: ${code.slice(0, 3)}`); - } + if (!libeleRecord) notFound(); - return libeleRecord.fields.Libelles_niveau_2 as string; -} - -async function getAtcOfficialLabel(code: string): Promise { - if (!(code.slice(0, 7) in atcOfficialLabels)) { - throw new Error(`ATC code not found: ${code.slice(0, 7)}`); - } - - return (atcOfficialLabels as Record)[code.slice(0, 7)]; -} + return { + code: record.fields.Lettre_2_ATC2 as string, + label: libeleRecord.fields.Libelles_niveau_2 as string, + description: "", + children: Object.keys(atcOfficialLabels) + .filter((key) => key.startsWith(code)) + .map((key) => ({ + code: key, + label: (atcOfficialLabels as Record)[key], + description: "", + })), + }; +}); export async function getAtcLabels(atc: string): Promise { return Promise.all( - [getAtcLabel1, getAtcLabel2, getAtcOfficialLabel].map((f) => f(atc)), + [ + async (code: string) => (await getAtc1(code)).label, + async (code: string) => (await getAtc2(code)).label, + ].map((f) => f(atc)), ); } + +export const atcData = csvParse( + readFileSync( + path.join(process.cwd(), "src", "data", "CIS-ATC_2024-04-07.csv"), + ), +) as [CIS: string, ATC: string][]; + +export const getSubstancesByAtc = cache(async function ( + atc2: ATC, +): Promise { + const CIS = (atc2.children as ATC[]) + .map((atc3) => atcData.filter((row) => row[1] === atc3.code)) + .map((rows) => rows.map((row) => row[0])) + .flat() + .filter((cis) => liste_CIS_MVP.includes(cis)); + + if (!CIS.length) return; + + return pdbmMySQL + .selectFrom("Subs_Nom") + .leftJoin("Composant", "Composant.NomId", "Subs_Nom.NomId") + .where("Composant.SpecId", "in", CIS) + .selectAll("Subs_Nom") + .groupBy(["Subs_Nom.NomId", "Subs_Nom.NomLib", "Subs_Nom.SubsId"]) + .orderBy("Subs_Nom.NomLib") + .execute(); +}); diff --git a/src/displayUtils.ts b/src/displayUtils.ts index 21fcf86..eeeac02 100644 --- a/src/displayUtils.ts +++ b/src/displayUtils.ts @@ -35,7 +35,32 @@ export function groupSpecialites( return Array.from(groups.entries()); } -export function displayComposants( +export function displaySimpleComposants( + composants: (SpecComposant & SubstanceNom)[], +): SubstanceNom[] { + const groups = new Map(); + for (const composant of composants) { + if (groups.has(composant.CompNum)) { + groups.get(composant.CompNum)?.push(composant); + } else { + groups.set(composant.CompNum, [composant]); + } + } + + return Array.from(groups.values()) + .map((composants: (SpecComposant & SubstanceNom)[]) => + composants.filter( + (composant) => composant.NatuId === ComposantNatureId.Fraction, + ).length + ? composants.filter( + (composant) => composant.NatuId === ComposantNatureId.Fraction, + ) + : composants, + ) + .flat(); +} + +export function displayCompleteComposants( composants: (SpecComposant & SubstanceNom)[], ): string { const groups = new Map();