@tekkare/romulus 0.1.251 → 0.1.252

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/app.config.ts +6 -0
  3. package/layer/app/components/OipPubmedSummary.vue +223 -0
  4. package/layer/app/components/OipResourceDrawer.vue +331 -0
  5. package/layer/app/components/OipResourceDrawerLink.vue +40 -0
  6. package/layer/app/components/OipResourceField.vue +41 -0
  7. package/layer/app/components/OipResourceGenericCard.vue +54 -0
  8. package/layer/app/components/OipResourceSource.vue +50 -0
  9. package/layer/app/components/OipResourceTags.vue +65 -0
  10. package/layer/app/components/OipResourceView.vue +107 -0
  11. package/layer/app/components/oip-drugs/fiches/AssociationFiche.vue +775 -0
  12. package/layer/app/components/oip-drugs/fiches/BrandFiche.vue +1111 -0
  13. package/layer/app/components/oip-drugs/fiches/IndicationFiche.vue +182 -0
  14. package/layer/app/components/oip-drugs/fiches/JoFiche.vue +101 -0
  15. package/layer/app/components/oip-drugs/fiches/PndsFiche.vue +279 -0
  16. package/layer/app/components/oip-drugs/heart/JoCard.vue +314 -0
  17. package/layer/app/components/oip-drugs/heart/TextHighlight.vue +326 -0
  18. package/layer/app/components/oip-drugs/heart/ansm/BrandCard.vue +187 -0
  19. package/layer/app/components/oip-drugs/heart/ansm/CisDetail.vue +363 -0
  20. package/layer/app/components/oip-drugs/heart/ansm/CisTable.vue +167 -0
  21. package/layer/app/components/oip-drugs/heart/associations/NationalAssemblyCard.vue +373 -0
  22. package/layer/app/components/oip-drugs/heart/associations/NationalAssemblyFiche.vue +372 -0
  23. package/layer/app/components/oip-drugs/heart/associations/TranscriptionCard.vue +55 -0
  24. package/layer/app/components/oip-drugs/heart/elastic/SourceContainerElastic.vue +103 -0
  25. package/layer/app/components/oip-drugs/heart/france/BrandGroupCard.vue +450 -0
  26. package/layer/app/components/oip-drugs/heart/mitm/Card.vue +199 -0
  27. package/layer/app/components/oip-drugs/heart/mitm/Source.vue +23 -0
  28. package/layer/app/components/oip-drugs/heart/mitm/details/Overview.vue +381 -0
  29. package/layer/app/components/oip-drugs/heart/put/Card.vue +212 -0
  30. package/layer/app/components/oip-drugs/heart/results/AccesCard.vue +696 -0
  31. package/layer/app/components/oip-drugs/heart/results/AlertCard.vue +342 -0
  32. package/layer/app/components/oip-drugs/heart/results/AsmrCursor.vue +229 -0
  33. package/layer/app/components/oip-drugs/heart/results/EvalCard.vue +370 -0
  34. package/layer/app/components/oip-drugs/heart/results/IndicCard.vue +743 -0
  35. package/layer/app/components/oip-drugs/heart/results/MatchExplain.vue +243 -0
  36. package/layer/app/components/oip-drugs/heart/results/PrefRows.vue +105 -0
  37. package/layer/app/components/oip-drugs/heart/results/details/HasHistory.vue +487 -0
  38. package/layer/app/components/oip-drugs/heart/results/details/InternationalCountryCard.vue +461 -0
  39. package/layer/app/components/oip-drugs/heart/results/details/InternationalOverview.vue +200 -0
  40. package/layer/app/components/oip-drugs/heart/results/details/InternationalPriceDrawer.vue +647 -0
  41. package/layer/app/components/oip-drugs/heart/results/details/Monograph.vue +128 -0
  42. package/layer/app/components/oip-drugs/heart/results/details/OpinionDrawer.vue +535 -0
  43. package/layer/app/components/oip-drugs/heart/results/details/PresentationDrawer.vue +738 -0
  44. package/layer/app/components/oip-drugs/heart/results/details/Price.vue +227 -0
  45. package/layer/app/components/oip-drugs/heart/results/details/PriceOverview.vue +1261 -0
  46. package/layer/app/components/oip-drugs/heart/results/details/RecordDrawer.vue +494 -0
  47. package/layer/app/components/oip-drugs/heart/results/details/RecordList.vue +426 -0
  48. package/layer/app/components/oip-drugs/heart/results/les/Card.vue +340 -0
  49. package/layer/app/components/oip-drugs/heart/results/les/Drawer.vue +67 -0
  50. package/layer/app/components/oip-drugs/heart/results/les/IndicationDetail.vue +149 -0
  51. package/layer/app/components/oip-drugs/heart/results/les/Source.vue +20 -0
  52. package/layer/app/components/oip-drugs/heart/results/les/SourceTag.vue +39 -0
  53. package/layer/app/components/oip-drugs/heart/searchPage/DynamicSource.vue +29 -0
  54. package/layer/app/components/oip-drugs/medicam/market/Chart.vue +121 -0
  55. package/layer/app/components/oip-drugs/outpatient/ProductEvolution.vue +236 -0
  56. package/layer/app/components/oip-drugs/resources/Ansm.vue +75 -0
  57. package/layer/app/components/oip-drugs/resources/Association.vue +30 -0
  58. package/layer/app/components/oip-drugs/resources/Brand.vue +65 -0
  59. package/layer/app/components/oip-drugs/resources/Contribution.vue +216 -0
  60. package/layer/app/components/oip-drugs/resources/Hospital.vue +151 -0
  61. package/layer/app/components/oip-drugs/resources/Indication.vue +54 -0
  62. package/layer/app/components/oip-drugs/resources/Jo.vue +57 -0
  63. package/layer/app/components/oip-drugs/resources/Medicam.vue +140 -0
  64. package/layer/app/components/oip-drugs/resources/Mitm.vue +84 -0
  65. package/layer/app/components/oip-drugs/resources/NationalAssembly.vue +93 -0
  66. package/layer/app/components/oip-drugs/resources/Outpatient.vue +97 -0
  67. package/layer/app/components/oip-drugs/resources/Pnds.vue +24 -0
  68. package/layer/app/components/oip-drugs/resources/Price.vue +106 -0
  69. package/layer/app/components/oip-drugs/resources/Put.vue +143 -0
  70. package/layer/app/components/oip-science/appel-a-projet/Cards.vue +162 -0
  71. package/layer/app/components/oip-science/clinical-trials/Cards.vue +382 -0
  72. package/layer/app/components/oip-science/clinical-trials/CardsGlobal.vue +273 -0
  73. package/layer/app/components/oip-science/clinical-trials/LinkedList.vue +366 -0
  74. package/layer/app/components/oip-science/clinical-trials/PairBadge.vue +25 -0
  75. package/layer/app/components/oip-science/clinical-trials/PairBody.vue +182 -0
  76. package/layer/app/components/oip-science/clinical-trials/SitesDrawer.vue +95 -0
  77. package/layer/app/components/oip-science/common/Form.vue +73 -0
  78. package/layer/app/components/oip-science/common/MatchAvgTag.vue +283 -0
  79. package/layer/app/components/oip-science/common/NoData.vue +16 -0
  80. package/layer/app/components/oip-science/common/ScoreBadge.vue +35 -0
  81. package/layer/app/components/oip-science/common/Source.vue +31 -0
  82. package/layer/app/components/oip-science/common/TableTags.vue +279 -0
  83. package/layer/app/components/oip-science/common/Timeline.vue +104 -0
  84. package/layer/app/components/oip-science/common/ToolTips.vue +89 -0
  85. package/layer/app/components/oip-science/cross/publicationPubmed.vue +484 -0
  86. package/layer/app/components/oip-science/crossData/CrossDataLocations.vue +71 -0
  87. package/layer/app/components/oip-science/ctgov/Investigators.vue +148 -0
  88. package/layer/app/components/oip-science/ctgov/Locations.vue +102 -0
  89. package/layer/app/components/oip-science/ctgov/Publications.vue +52 -0
  90. package/layer/app/components/oip-science/ctgov/Sponsors.vue +66 -0
  91. package/layer/app/components/oip-science/ctgov/StudyDescription.vue +1136 -0
  92. package/layer/app/components/oip-science/ctgov-centers/ClinicalTrials.vue +40 -0
  93. package/layer/app/components/oip-science/ctgov-centers/Publications.vue +50 -0
  94. package/layer/app/components/oip-science/ctgov-investigators/ClinicalTrials.vue +43 -0
  95. package/layer/app/components/oip-science/ctgov-investigators/Informations.vue +171 -0
  96. package/layer/app/components/oip-science/ctgov-investigators/Publications.vue +44 -0
  97. package/layer/app/components/oip-science/elastic/DateRangeFilter.vue +209 -0
  98. package/layer/app/components/oip-science/elastic/Filter.vue +719 -0
  99. package/layer/app/components/oip-science/elastic/MatchExplain.vue +152 -0
  100. package/layer/app/components/oip-science/establishments/Cards.vue +183 -0
  101. package/layer/app/components/oip-science/establishments/ClinicalTrials.vue +62 -0
  102. package/layer/app/components/oip-science/establishments/Hcps.vue +227 -0
  103. package/layer/app/components/oip-science/establishments/Informations.vue +263 -0
  104. package/layer/app/components/oip-science/establishments/LinkedList.vue +153 -0
  105. package/layer/app/components/oip-science/establishments/MatchedLabels.vue +194 -0
  106. package/layer/app/components/oip-science/establishments/PublicFinance.vue +62 -0
  107. package/layer/app/components/oip-science/euct/Investigators.vue +125 -0
  108. package/layer/app/components/oip-science/euct/Locations.vue +102 -0
  109. package/layer/app/components/oip-science/euct/Products.vue +101 -0
  110. package/layer/app/components/oip-science/euct/Publications.vue +89 -0
  111. package/layer/app/components/oip-science/euct/Sponsors.vue +86 -0
  112. package/layer/app/components/oip-science/euct/StudyDescription.vue +1075 -0
  113. package/layer/app/components/oip-science/euct-centers/ClinicalTrials.vue +40 -0
  114. package/layer/app/components/oip-science/euct-centers/Publications.vue +55 -0
  115. package/layer/app/components/oip-science/euct-investigators/ClinicalTrials.vue +41 -0
  116. package/layer/app/components/oip-science/euct-investigators/Informations.vue +171 -0
  117. package/layer/app/components/oip-science/euct-investigators/Publications.vue +51 -0
  118. package/layer/app/components/oip-science/hcps/AppelsAProjet.vue +62 -0
  119. package/layer/app/components/oip-science/hcps/Cards.vue +237 -0
  120. package/layer/app/components/oip-science/hcps/ClinicalTrials.vue +78 -0
  121. package/layer/app/components/oip-science/hcps/Informations.vue +296 -0
  122. package/layer/app/components/oip-science/hcps/LinkedList.vue +153 -0
  123. package/layer/app/components/oip-science/hcps/MatchedLabels.vue +207 -0
  124. package/layer/app/components/oip-science/hcps/Pubmed.vue +128 -0
  125. package/layer/app/components/oip-science/local/SliderTabs.vue +99 -0
  126. package/layer/app/components/oip-science/local/SliderTabsFull.vue +135 -0
  127. package/layer/app/components/oip-science/pubmed/Authors.vue +128 -0
  128. package/layer/app/components/oip-science/pubmed/Cards.vue +417 -0
  129. package/layer/app/components/oip-science/pubmed/ClinicalTrials.vue +71 -0
  130. package/layer/app/components/oip-science/resources/Phrc.vue +39 -0
  131. package/layer/app/composables/useOipChapterScroll.ts +43 -0
  132. package/layer/app/composables/useOipResourceHooks.ts +27 -0
  133. package/layer/app/composables/useOipResourceNav.ts +66 -0
  134. package/layer/app/composables/useOipResourceViews.ts +117 -0
  135. package/layer/app/composables/useOipResources.ts +83 -0
  136. package/layer/app/oip-drugs/PORTING.md +120 -0
  137. package/layer/app/oip-drugs/composables/useAssociationDetail.ts +220 -0
  138. package/layer/app/oip-drugs/composables/useAssociationExport.ts +63 -0
  139. package/layer/app/oip-drugs/composables/useAssociationLookup.ts +105 -0
  140. package/layer/app/oip-drugs/composables/useCardPrefOptions.ts +201 -0
  141. package/layer/app/oip-drugs/composables/useCurrency.ts +121 -0
  142. package/layer/app/oip-drugs/composables/useDirectusSources.ts +224 -0
  143. package/layer/app/oip-drugs/composables/useHeartBrandMatches.ts +112 -0
  144. package/layer/app/oip-drugs/composables/useHeartDetailSearches.ts +479 -0
  145. package/layer/app/oip-drugs/composables/useHeartIntlApprovals.ts +661 -0
  146. package/layer/app/oip-drugs/composables/useHeartLesIndication.ts +168 -0
  147. package/layer/app/oip-drugs/composables/useHeartMatchExplain.ts +231 -0
  148. package/layer/app/oip-drugs/composables/useHeartReports.ts +239 -0
  149. package/layer/app/oip-drugs/composables/useHeartSearchFields.ts +233 -0
  150. package/layer/app/oip-drugs/composables/useMergeByKey.ts +23 -0
  151. package/layer/app/oip-drugs/composables/useOipDrugsElastic.ts +621 -0
  152. package/layer/app/oip-drugs/composables/usePageEmpty.ts +58 -0
  153. package/layer/app/oip-drugs/composables/v-useSalesSource.ts +64 -0
  154. package/layer/app/oip-drugs/drugs.css +500 -0
  155. package/layer/app/oip-drugs/esIndices.ts +322 -0
  156. package/layer/app/oip-drugs/kinds.ts +264 -0
  157. package/layer/app/oip-drugs/locales/en.json +1571 -0
  158. package/layer/app/oip-drugs/locales/fr.json +1571 -0
  159. package/layer/app/oip-drugs/monographs/blincyto-has.vue +289 -0
  160. package/layer/app/oip-drugs/monographs/darzalex-has.vue +276 -0
  161. package/layer/app/oip-drugs/monographs/dupixent-has.vue +216 -0
  162. package/layer/app/oip-drugs/monographs/enhertu-has.vue +280 -0
  163. package/layer/app/oip-drugs/monographs/has-sein-2023.vue +1649 -0
  164. package/layer/app/oip-drugs/monographs/ibrance-has.vue +277 -0
  165. package/layer/app/oip-drugs/monographs/imfinzi-has.vue +293 -0
  166. package/layer/app/oip-drugs/monographs/keytruda-has.vue +334 -0
  167. package/layer/app/oip-drugs/monographs/kisqali-has.vue +201 -0
  168. package/layer/app/oip-drugs/monographs/opdivo-has.vue +231 -0
  169. package/layer/app/oip-drugs/monographs/ozempic-has.vue +186 -0
  170. package/layer/app/oip-drugs/monographs/tecentriq-has.vue +312 -0
  171. package/layer/app/oip-drugs/monographs/yescarta-has.vue +272 -0
  172. package/layer/app/oip-drugs/types/elastic.ts +113 -0
  173. package/layer/app/oip-drugs/types/es/agencies.ts +152 -0
  174. package/layer/app/oip-drugs/types/es/directory.ts +54 -0
  175. package/layer/app/oip-drugs/types/es/docByIndex.ts +151 -0
  176. package/layer/app/oip-drugs/types/es/openmedic.ts +67 -0
  177. package/layer/app/oip-drugs/types/es/outpatient.ts +71 -0
  178. package/layer/app/oip-drugs/types/es/portal.ts +130 -0
  179. package/layer/app/oip-drugs/types/es/retail.ts +107 -0
  180. package/layer/app/oip-drugs/types/es/statistics.ts +29 -0
  181. package/layer/app/oip-drugs/types/heart/CurrencyRate.ts +16 -0
  182. package/layer/app/oip-drugs/types/heart/ansm.ts +106 -0
  183. package/layer/app/oip-drugs/types/heart/tag.ts +29 -0
  184. package/layer/app/oip-drugs/utils/associationFields.ts +118 -0
  185. package/layer/app/oip-drugs/utils/contributions.ts +36 -0
  186. package/layer/app/oip-drugs/utils/countryMapping.ts +387 -0
  187. package/layer/app/oip-drugs/utils/esHighlight.ts +21 -0
  188. package/layer/app/oip-drugs/utils/exportSheets.ts +57 -0
  189. package/layer/app/oip-drugs/utils/heartIntlPrice.ts +228 -0
  190. package/layer/app/oip-drugs/utils/heartStyleAndFormat.ts +262 -0
  191. package/layer/app/oip-drugs/utils/heartStyleAnsm.ts +71 -0
  192. package/layer/app/oip-drugs/utils/latestDate.ts +31 -0
  193. package/layer/app/oip-drugs/utils/p-heartCardTags.ts +53 -0
  194. package/layer/app/oip-drugs/utils/priceExport.ts +269 -0
  195. package/layer/app/oip-drugs/utils/q-hasRatings.ts +43 -0
  196. package/layer/app/oip-drugs/utils/s-openCard.ts +20 -0
  197. package/layer/app/oip-drugs/utils/sourceUuids.ts +155 -0
  198. package/layer/app/oip-drugs/utils/styleAndFormat.ts +40 -0
  199. package/layer/app/oip-drugs/utils/v-salesSeries.ts +156 -0
  200. package/layer/app/oip-drugs/utils/v-scopeTags.ts +23 -0
  201. package/layer/app/oip-science/PORTING.md +93 -0
  202. package/layer/app/oip-science/assets/smartSelection.json +428 -0
  203. package/layer/app/oip-science/composables/useAuthorMatching.ts +342 -0
  204. package/layer/app/oip-science/composables/useCTCenter.ts +107 -0
  205. package/layer/app/oip-science/composables/useCTInvestigators.ts +107 -0
  206. package/layer/app/oip-science/composables/useEstablishmentCrossData.ts +139 -0
  207. package/layer/app/oip-science/composables/useEstablishmentMatching.ts +176 -0
  208. package/layer/app/oip-science/composables/useEuct.ts +418 -0
  209. package/layer/app/oip-science/composables/useEuctCenter.ts +107 -0
  210. package/layer/app/oip-science/composables/useEuctInvestigators.ts +107 -0
  211. package/layer/app/oip-science/composables/useGlobalClinicalTrials.ts +624 -0
  212. package/layer/app/oip-science/composables/useLinkedTrials.ts +83 -0
  213. package/layer/app/oip-science/composables/useOipElastic.ts +658 -0
  214. package/layer/app/oip-science/composables/useOipSearchExplain.ts +166 -0
  215. package/layer/app/oip-science/composables/usePubmed.ts +487 -0
  216. package/layer/app/oip-science/composables/useSearchFields.ts +153 -0
  217. package/layer/app/oip-science/indexConfig.json +794 -0
  218. package/layer/app/oip-science/locales/en.json +483 -0
  219. package/layer/app/oip-science/locales/fr.json +483 -0
  220. package/layer/app/oip-science/science.css +1182 -0
  221. package/layer/app/oip-science/utils/a-openCard.ts +23 -0
  222. package/layer/app/oip-science/utils/b-openCard.ts +35 -0
  223. package/layer/app/oip-science/utils/c-filterContext.ts +81 -0
  224. package/layer/app/oip-science/utils/f-types.ts +241 -0
  225. package/layer/app/oip-science/utils/frDiseaseTerms.ts +269 -0
  226. package/layer/app/oip-science/utils/matchMethod.ts +53 -0
  227. package/layer/app/oip-science/utils/sliderTabs.ts +13 -0
  228. package/layer/app/oip-science/utils/sources.ts +15 -0
  229. package/layer/app/oip-science/utils/styleAndFormat.ts +608 -0
  230. package/layer/app/oip-science/utils/toPresets.ts +7 -0
  231. package/layer/app/oip-science/utils/valueTable.ts +126 -0
  232. package/layer/app/plugins/oip-science-i18n.ts +17 -0
  233. package/layer/app/utils/oipFicheRoutes.ts +118 -0
  234. package/layer/app/utils/oipResources.ts +440 -0
  235. package/layer/nuxt.config.ts +22 -2
  236. package/layer/public/reports/blincyto-has.html +292 -0
  237. package/layer/public/reports/blincyto-has.pdf +0 -0
  238. package/layer/public/reports/darzalex-has.html +279 -0
  239. package/layer/public/reports/darzalex-has.pdf +0 -0
  240. package/layer/public/reports/dupixent-has.html +217 -0
  241. package/layer/public/reports/dupixent-has.pdf +0 -0
  242. package/layer/public/reports/enhertu-has.html +283 -0
  243. package/layer/public/reports/enhertu-has.pdf +0 -0
  244. package/layer/public/reports/has-sein-2023.html +1639 -0
  245. package/layer/public/reports/ibrance-has.html +280 -0
  246. package/layer/public/reports/ibrance-has.pdf +0 -0
  247. package/layer/public/reports/imfinzi-has.html +297 -0
  248. package/layer/public/reports/imfinzi-has.pdf +0 -0
  249. package/layer/public/reports/keytruda-has.html +337 -0
  250. package/layer/public/reports/keytruda-has.pdf +0 -0
  251. package/layer/public/reports/kisqali-has.html +202 -0
  252. package/layer/public/reports/kisqali-has.pdf +0 -0
  253. package/layer/public/reports/opdivo-has.html +232 -0
  254. package/layer/public/reports/opdivo-has.pdf +0 -0
  255. package/layer/public/reports/ozempic-has.html +188 -0
  256. package/layer/public/reports/ozempic-has.pdf +0 -0
  257. package/layer/public/reports/tecentriq-has.html +315 -0
  258. package/layer/public/reports/tecentriq-has.pdf +0 -0
  259. package/layer/public/reports/yescarta-has.html +275 -0
  260. package/layer/public/reports/yescarta-has.pdf +0 -0
  261. package/layer/server/api/oip/es.post.ts +99 -0
  262. package/layer/server/api/oip/pdf.get.ts +70 -0
  263. package/layer/server/api/oip/reports.get.ts +98 -0
  264. package/layer/server/api/oip/resource-types.get.ts +22 -0
  265. package/package.json +1 -1
@@ -0,0 +1,1261 @@
1
+ <!--
2
+ Prix d'une marque, presentation par presentation.
3
+
4
+ Le tableau precedent alignait huit colonnes sur une ligne par presentation :
5
+ on y lisait des nombres, pas des prix. Ici l'entete resume la marque (combien
6
+ de presentations tarifees, la fourchette, la derniere mise a jour, la
7
+ tendance) et chaque presentation devient une carte : son PFHT en grand, sa
8
+ derniere variation, puis le prix public et l'ecart avec le PFHT.
9
+ -->
10
+ <script setup lang="ts">
11
+ import { rmTooltipTrigger } from "@tekkare/romulus/utils/chart";
12
+ import { HEART_SOURCE_UUIDS } from "../../../../../oip-drugs/utils/sourceUuids";
13
+ import { heartCodeLabel, heartPriceChannel } from "../../../../../oip-drugs/utils/heartStyleAndFormat";
14
+
15
+ const props = defineProps<{
16
+ /** Les entrees de prix de la marque (une par presentation). */
17
+ product: any[];
18
+ /** Nom de la marque, pour la ligne de portee. */
19
+ brand?: string;
20
+ }>();
21
+
22
+ const { locale } = useI18n();
23
+
24
+ const money = (value: number | null | undefined) =>
25
+ typeof value === "number"
26
+ ? `${value.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
27
+ : "-";
28
+
29
+ const percent = (value: number | null | undefined) =>
30
+ typeof value === "number"
31
+ ? `${value > 0 ? "+" : ""}${value.toLocaleString("fr-FR", { maximumFractionDigits: 1 })} %`
32
+ : "";
33
+
34
+ /** Derniere variation du PFHT, en euros (price_type_id 1 = prix fabricant). */
35
+ function factoryVariation(entry: any): number | null {
36
+ const history = (entry?.changed_history || [])
37
+ // Le champ de l'index est `status_changed` : ecrit `status_change`, la
38
+ // comparaison portait sur `undefined` et laissait passer les lignes sans
39
+ // changement, qui masquaient la derniere vraie variation.
40
+ .filter((h: any) => h.price_type_id === 1 && h.status_changed !== "N")
41
+ .sort(
42
+ (a: any, b: any) =>
43
+ new Date(b.application_date).getTime() - new Date(a.application_date).getTime(),
44
+ );
45
+ return history[0]?.price_variation ?? null;
46
+ }
47
+
48
+ /**
49
+ * Le tarif applicable de chaque presentation.
50
+ *
51
+ * `reimbursement_history` n'est pas trie : prendre la premiere entree ferait
52
+ * remonter un ancien retrait sans prix a la place du tarif courant.
53
+ */
54
+ /** La date du tarif le plus recent de la marque, pour les pieds de la carte. */
55
+ const lastPriceDates = computed(() =>
56
+ (props.product || []).flatMap((entry: any) =>
57
+ (entry?.reimbursement_history ?? []).map((h: any) => h?.application_date),
58
+ ),
59
+ );
60
+
61
+ /**
62
+ * Date d'inscription la plus ancienne, tous circuits confondus.
63
+ *
64
+ * L'index ne remplit `commercialization_date` que sur les presentations de
65
+ * ville : aucun des 3 551 documents UCD ne la porte, et une marque
66
+ * hospitaliere comme OPDIVO n'affichait donc aucune date. Cote hopital, la
67
+ * mise a disposition se lit dans `homologation_history` (l'inscription au
68
+ * circuit : agrement collectivites, retrocession). Ce n'est pas la meme notion
69
+ * qu'une commercialisation : elle porte donc son propre libelle.
70
+ */
71
+ function earliestListing(entry: any) {
72
+ const dates = (Array.isArray(entry?.homologation_history) ? entry.homologation_history : [])
73
+ .map((h: any) => h?.start_date)
74
+ .filter(Boolean)
75
+ .sort();
76
+ return dates[0] ?? null;
77
+ }
78
+
79
+ const rows = computed(() =>
80
+ (props.product || [])
81
+ .map((entry: any) => {
82
+ const history = Array.isArray(entry?.reimbursement_history)
83
+ ? [...entry.reimbursement_history].sort(
84
+ (a: any, b: any) =>
85
+ new Date(b.application_date).getTime() - new Date(a.application_date).getTime(),
86
+ )
87
+ : [];
88
+ const current = history[0] ?? {};
89
+ const factory = current.factory_price ?? null;
90
+ const variation = factoryVariation(entry);
91
+ const previous =
92
+ typeof factory === "number" && typeof variation === "number"
93
+ ? factory - variation
94
+ : null;
95
+
96
+ return {
97
+ key: `${entry?.local_drug_id ?? ""}-${entry?.label ?? ""}`,
98
+ // Le document complet : le tiroir y lit tout ce que la carte resume.
99
+ source: entry,
100
+ label: entry?.label ?? entry?.local_drug_id ?? "-",
101
+ code: entry?.local_drug_id ?? null,
102
+ // CIP en ville, UCD a l'hopital : le meme champ porte les deux.
103
+ codeLabel: heartCodeLabel(entry?.distribution_channel),
104
+ manufacturer: entry?.manufacturer ?? null,
105
+ // Ville ou hopital : seul `distribution_channel` le dit.
106
+ channel: heartPriceChannel(entry?.distribution_channel, locale.value),
107
+ // L'index ecrit "-" quand la date manque, et ne porte commercialisation
108
+ // ni date que sur les presentations de ville.
109
+ marketedOn:
110
+ entry?.commercialization_date && entry.commercialization_date !== "-"
111
+ ? entry.commercialization_date
112
+ : null,
113
+ // Repli hopital : faute de commercialisation, la date d'inscription.
114
+ listedOn:
115
+ entry?.commercialization_date && entry.commercialization_date !== "-"
116
+ ? null
117
+ : earliestListing(entry),
118
+ notMarketed: entry?.commercialization_status === "Non commercialisée",
119
+ date: current.application_date ?? null,
120
+ factory,
121
+ publicPrice: current.public_price ?? null,
122
+ // Taux de prise en charge, ecrit en chaine (« 0.65 ») et seulement en
123
+ // ville : aucune presentation UCD ne le porte, le rendu le passe.
124
+ reimbursementRate:
125
+ current.reimbursement_rate !== undefined &&
126
+ current.reimbursement_rate !== null &&
127
+ current.reimbursement_rate !== "" &&
128
+ !Number.isNaN(Number(current.reimbursement_rate))
129
+ ? Number(current.reimbursement_rate) * 100
130
+ : null,
131
+ variation,
132
+ previous,
133
+ // Le pourcentage se deduit du prix precedent : l'index ne le porte pas.
134
+ variationPct:
135
+ typeof variation === "number" && previous
136
+ ? (variation / previous) * 100
137
+ : null,
138
+ // Ce que la distribution ajoute au prix fabricant.
139
+ margin:
140
+ typeof current.public_price === "number" && typeof factory === "number"
141
+ ? current.public_price - factory
142
+ : null,
143
+ marginPct:
144
+ typeof current.public_price === "number" && factory
145
+ ? ((current.public_price - factory) / factory) * 100
146
+ : null,
147
+ };
148
+ })
149
+ .filter((row: any) => row.factory !== null || row.publicPrice !== null)
150
+ .sort(byDosage),
151
+ );
152
+
153
+ /**
154
+ * Dosage lu dans le libelle (« 150 MG/ML », « 30MG », « 0,5 G »), ramene en
155
+ * milligrammes. Les presentations d'une marque arrivaient dans l'ordre de
156
+ * l'index, 150 mg et 30 mg meles : on les range par dosage, puis ville avant
157
+ * hopital, puis par libelle, pour que les memes dosages se lisent ensemble.
158
+ */
159
+ const DOSE_UNITS: Record<string, number> = { MCG: 0.001, µG: 0.001, UG: 0.001, MG: 1, G: 1000 };
160
+ function dosageOf(label: string): number {
161
+ const match = String(label ?? "").toUpperCase().match(/(\d+(?:[.,]\d+)?)\s*(MCG|µG|UG|MG|G)\b/);
162
+ if (!match) return Number.POSITIVE_INFINITY;
163
+ return parseFloat(match[1]!.replace(",", ".")) * (DOSE_UNITS[match[2]!] ?? 1);
164
+ }
165
+ function byDosage(a: any, b: any): number {
166
+ const dose = dosageOf(a.label) - dosageOf(b.label);
167
+ if (dose !== 0 && !Number.isNaN(dose)) return dose;
168
+ const channel = String(a.channel?.key ?? "").localeCompare(String(b.channel?.key ?? ""));
169
+ if (channel !== 0) return -channel;
170
+ return String(a.label).localeCompare(String(b.label));
171
+ }
172
+
173
+ /**
174
+ * Historique du PFHT, presentation par presentation.
175
+ *
176
+ * `reimbursement_history` porte un tarif par date d'application : c'est deja la
177
+ * serie, il suffit de la remettre dans l'ordre. Un prix tient jusqu'au tarif
178
+ * suivant, d'où le trace en marches plutot qu'en droites : une interpolation
179
+ * laisserait croire a une baisse continue entre deux arretes.
180
+ */
181
+ const SERIES_COLORS = ["#2017ad", "#1dacfc", "#a17def", "#fd9147", "#22c55e"];
182
+
183
+ const series = computed(() =>
184
+ (props.product || [])
185
+ .map((entry: any, index: number) => {
186
+ const points = (Array.isArray(entry?.reimbursement_history) ? entry.reimbursement_history : [])
187
+ .filter((h: any) => typeof h?.factory_price === "number" && h?.application_date)
188
+ .map((h: any) => ({ date: new Date(h.application_date).getTime(), price: h.factory_price }))
189
+ .sort((a: any, b: any) => a.date - b.date);
190
+
191
+ return {
192
+ label: entry?.label ?? entry?.local_drug_id ?? "-",
193
+ color: SERIES_COLORS[index % SERIES_COLORS.length],
194
+ points,
195
+ };
196
+ })
197
+ .filter((line: any) => line.points.length > 0),
198
+ );
199
+
200
+ /**
201
+ * Tous les tarifs publies de la marque, une ligne par tarif.
202
+ *
203
+ * Le graphe dit la tendance, il ne donne aucun chiffre : pour lire « combien,
204
+ * quand, sur quelle presentation », il fallait ouvrir le tiroir d'une
205
+ * presentation apres l'autre et comparer de tete. Le tableau met les memes
206
+ * lignes que l'onglet « Historique tarifaire » du tiroir, mais pour toutes les
207
+ * presentations a la fois, sous le graphe qu'il detaille.
208
+ *
209
+ * L'evolution se calcule DANS une presentation : elle compare au tarif
210
+ * precedent de la meme presentation, jamais a la ligne du dessus, qui peut
211
+ * appartenir a une autre. `changed_history` n'est pas utilise ici pour la meme
212
+ * raison que dans le tiroir : il ne decoupe ni aux memes dates ni aux memes
213
+ * circuits que `reimbursement_history`, et donnerait un ecart qui ne relie
214
+ * aucun des deux PFHT affiches.
215
+ */
216
+ /**
217
+ * L'index ecrit « nan » (et parfois « - ») la ou la valeur manque : ce sont
218
+ * des trous du fichier source remontes tels quels, pas des libelles. Ecrits
219
+ * dans une cellule, ils se lisent comme une donnee.
220
+ */
221
+ function clean(value: unknown): string | null {
222
+ const text = String(value ?? "").trim();
223
+ if (!text || text === "-" || text.toLowerCase() === "nan") return null;
224
+ return text;
225
+ }
226
+
227
+ /** La meme chose, prete a etre affichee : un tiret cadratin a la place du vide. */
228
+ function text(value: unknown): string {
229
+ return clean(value) ?? "-";
230
+ }
231
+
232
+ const historyRows = computed(() =>
233
+ (props.product || [])
234
+ .flatMap((entry: any) => {
235
+ const history = (
236
+ Array.isArray(entry?.reimbursement_history) ? [...entry.reimbursement_history] : []
237
+ ).sort(
238
+ (a: any, b: any) =>
239
+ new Date(b?.application_date).getTime() - new Date(a?.application_date).getTime(),
240
+ );
241
+
242
+ return history.map((row: any, index: number) => {
243
+ const price = typeof row?.factory_price === "number" ? row.factory_price : null;
244
+ const previous =
245
+ history
246
+ .slice(index + 1)
247
+ .find((older: any) => typeof older?.factory_price === "number")?.factory_price ?? null;
248
+ const variation = price !== null && previous !== null ? price - previous : null;
249
+
250
+ return {
251
+ key: `${entry?.local_drug_id ?? ""}-${row?.application_date ?? ""}-${index}`,
252
+ presentation: entry?.label ?? entry?.local_drug_id ?? "-",
253
+ code: entry?.local_drug_id ?? null,
254
+ // Le tri de la colonne porte sur cette valeur : une date ISO se
255
+ // compare comme du texte, une date affichee ne se compare pas.
256
+ date: row?.application_date ?? "",
257
+ factory: price,
258
+ variation,
259
+ variationPct: variation !== null && previous ? (variation / previous) * 100 : null,
260
+ publicPrice: typeof row?.public_price === "number" ? row.public_price : null,
261
+ marge: typeof row?.marge === "number" ? row.marge : null,
262
+ channel: text(row?.distribution_type),
263
+ documentarySource: text(row?.documentary_source),
264
+ sourceDate: clean(row?.source_date),
265
+ };
266
+ });
267
+ })
268
+ // Du plus recent au plus ancien, toutes presentations melangees : c'est
269
+ // l'ordre dans lequel on lit un historique.
270
+ .sort((a: any, b: any) => String(b.date).localeCompare(String(a.date))),
271
+ );
272
+
273
+ /**
274
+ * La marge de retrocession n'existe que sur les tarifs hopital. Comme dans le
275
+ * tiroir, la colonne disparait quand aucune ligne n'en porte.
276
+ */
277
+ const hasMarge = computed(() => historyRows.value.some((row: any) => row.marge !== null));
278
+
279
+ const historyColumns = computed(() => {
280
+ const en = locale.value === "en";
281
+ return [
282
+ {
283
+ key: "presentation",
284
+ label: en ? "Presentation" : "Présentation",
285
+ sortable: true,
286
+ rowHeader: true,
287
+ minWidth: "220px",
288
+ },
289
+ { key: "date", label: en ? "Applies from" : "Applicable au", sortable: true, width: "120px" },
290
+ { key: "factory", label: "PFHT", numeric: true, sortable: true },
291
+ { key: "variation", label: en ? "Change" : "Évolution", numeric: true, sortable: true },
292
+ { key: "publicPrice", label: en ? "Public price" : "Prix public", numeric: true, sortable: true },
293
+ ...(hasMarge.value
294
+ ? [{ key: "marge", label: en ? "Margin" : "Marge", numeric: true, sortable: true }]
295
+ : []),
296
+ { key: "channel", label: en ? "Channel" : "Circuit", sortable: true },
297
+ { key: "documentarySource", label: en ? "Source" : "Source" },
298
+ ];
299
+ });
300
+
301
+ /** Geometrie du graphe : bornes, echelles et chemins en marches.
302
+
303
+ La largeur suit celle de la carte pour qu'une unite de viewBox vaille un
304
+ pixel : sinon le SVG est agrandi par la mise en page et les libelles des
305
+ axes, ecrits en unites de viewBox, arrivent deux fois trop gros. */
306
+ const chartWrap = ref<HTMLElement | null>(null);
307
+ const chartW = ref(720);
308
+
309
+ const CHART = computed(() => ({
310
+ w: chartW.value,
311
+ h: 240,
312
+ top: 16,
313
+ right: 16,
314
+ bottom: 28,
315
+ left: 56,
316
+ }));
317
+
318
+ let chartObserver: ResizeObserver | null = null;
319
+
320
+ onMounted(() => {
321
+ if (!chartWrap.value) return;
322
+ chartObserver = new ResizeObserver(([entry]) => {
323
+ const width = entry?.contentRect?.width;
324
+ if (width) chartW.value = Math.round(width);
325
+ });
326
+ chartObserver.observe(chartWrap.value);
327
+ });
328
+
329
+ onBeforeUnmount(() => chartObserver?.disconnect());
330
+
331
+ const chart = computed(() => {
332
+ const points = series.value.flatMap((line: any) => line.points);
333
+ if (points.length < 2) return null;
334
+
335
+ const dates = points.map((p: any) => p.date);
336
+ const prices = points.map((p: any) => p.price);
337
+ const minDate = Math.min(...dates);
338
+ // La derniere marche court jusqu'a aujourd'hui : le tarif est toujours en
339
+ // vigueur, la ligne ne doit pas s'arreter a sa date de prise d'effet.
340
+ const maxDate = Math.max(Math.max(...dates), Date.now());
341
+ const maxPrice = Math.max(...prices);
342
+ // L'echelle part de zero : tronquer la base amplifie visuellement des ecarts
343
+ // de quelques pour cent et fait passer une baisse de 6 % pour un effondrement.
344
+ const low = 0;
345
+ const high = maxPrice * 1.15;
346
+
347
+ const x = (date: number) =>
348
+ CHART.value.left +
349
+ ((date - minDate) / (maxDate - minDate || 1)) * (CHART.value.w - CHART.value.left - CHART.value.right);
350
+ const y = (price: number) =>
351
+ CHART.value.top + (1 - (price - low) / (high - low || 1)) * (CHART.value.h - CHART.value.top - CHART.value.bottom);
352
+
353
+ const lines = series.value.map((line: any) => {
354
+ const steps: string[] = [];
355
+ line.points.forEach((point: any, index: number) => {
356
+ const px = x(point.date);
357
+ const py = y(point.price);
358
+ if (index === 0) steps.push(`M ${px} ${py}`);
359
+ else steps.push(`H ${px}`, `V ${py}`);
360
+ });
361
+ steps.push(`H ${x(maxDate)}`);
362
+ return {
363
+ ...line,
364
+ path: steps.join(" "),
365
+ dots: line.points.map((point: any) => ({ ...point, cx: x(point.date), cy: y(point.price) })),
366
+ };
367
+ });
368
+
369
+ // Trois reperes de prix, et une annee tous les deux ans au plus dense.
370
+ const priceTicks = [low, (low + high) / 2, high].map((value) => ({ value, y: y(value) }));
371
+ const years: { label: string; x: number }[] = [];
372
+ const firstYear = new Date(minDate).getFullYear();
373
+ const lastYear = new Date(maxDate).getFullYear();
374
+ const stride = Math.max(1, Math.ceil((lastYear - firstYear) / 6));
375
+ for (let year = firstYear; year <= lastYear; year += stride) {
376
+ years.push({ label: String(year), x: x(new Date(`${year}-01-01`).getTime()) });
377
+ }
378
+
379
+ return { lines, priceTicks, years, minDate, maxDate };
380
+ });
381
+
382
+ /**
383
+ * Survol du graphe : on lit la date sous le curseur et, pour chaque
384
+ * presentation, le dernier tarif entre en vigueur avant cette date, le prix
385
+ * qui s'appliquait ce jour-la, pas le point le plus proche.
386
+ *
387
+ * Sous dix courbes, l'infobulle ne garde que la courbe la plus proche du
388
+ * curseur : le lecteur qui pose le pointeur sur un tarif veut ce tarif, pas
389
+ * la liste de toute la gamme. Meme regle que les graphes Romulus
390
+ * (`rmTooltipTrigger`). Au-dela, les courbes se serrent au point qu'on ne
391
+ * vise plus rien : la colonne entiere reprend la main.
392
+ */
393
+ const hover = ref<{
394
+ x: number;
395
+ date: number;
396
+ items: { label: string; color: string; price: number }[];
397
+ } | null>(null);
398
+
399
+ function onChartMove(event: MouseEvent) {
400
+ const box = (event.currentTarget as SVGElement).getBoundingClientRect();
401
+ if (!chart.value || !box.width) return;
402
+
403
+ // Le SVG est mis a l'echelle par la mise en page : on repasse en unites de
404
+ // viewBox avant de convertir en date.
405
+ const vx = ((event.clientX - box.left) / box.width) * CHART.value.w;
406
+ const ratio =
407
+ (vx - CHART.value.left) / (CHART.value.w - CHART.value.left - CHART.value.right);
408
+ const clamped = Math.min(1, Math.max(0, ratio));
409
+ const date = chart.value.minDate + clamped * (chart.value.maxDate - chart.value.minDate);
410
+
411
+ // Les points du trace portent deja leur ordonnee : de quoi mesurer la
412
+ // distance verticale au curseur sans rejouer l'echelle des prix.
413
+ const vy = ((event.clientY - box.top) / box.height) * CHART.value.h;
414
+
415
+ const items = chart.value.lines
416
+ .map((line: any) => {
417
+ const applicable = [...line.dots]
418
+ .filter((dot: any) => dot.date <= date)
419
+ .sort((a: any, b: any) => b.date - a.date)[0];
420
+ return applicable
421
+ ? { label: line.label, color: line.color, price: applicable.price, y: applicable.cy }
422
+ : null;
423
+ })
424
+ .filter(Boolean) as { label: string; color: string; price: number; y: number }[];
425
+
426
+ const single =
427
+ items.length > 1 && rmTooltipTrigger(items.length) === "item"
428
+ ? [items.reduce((best, item) => (Math.abs(item.y - vy) < Math.abs(best.y - vy) ? item : best))]
429
+ : items;
430
+
431
+ hover.value = items.length
432
+ ? {
433
+ x: CHART.value.left + clamped * (CHART.value.w - CHART.value.left - CHART.value.right),
434
+ date,
435
+ items: single,
436
+ }
437
+ : null;
438
+ }
439
+
440
+ const priced = computed(() => rows.value.filter((row: any) => row.factory !== null));
441
+
442
+ const range = computed(() => {
443
+ const values = priced.value.map((row: any) => row.factory as number);
444
+ if (!values.length) return null;
445
+ return { min: Math.min(...values), max: Math.max(...values) };
446
+ });
447
+
448
+ const lastUpdate = computed(() => {
449
+ const dates = rows.value
450
+ .map((row: any) => row.date)
451
+ .filter(Boolean)
452
+ .sort((a: string, b: string) => new Date(b).getTime() - new Date(a).getTime());
453
+ return dates[0] ?? null;
454
+ });
455
+
456
+ /** Tendance recente : le sens des dernieres variations de PFHT. */
457
+ /** Presentation ouverte dans le tiroir de detail. */
458
+ const drawerProduct = ref<any | null>(null);
459
+
460
+ const trend = computed(() => {
461
+ const variations = rows.value
462
+ .map((row: any) => row.variation)
463
+ .filter((value: any) => typeof value === "number" && value !== 0);
464
+ if (!variations.length) return { key: "flat", label: locale.value === "en" ? "Stable" : "Stable" };
465
+ const total = variations.reduce((sum: number, value: number) => sum + value, 0);
466
+ if (total < 0) return { key: "down", label: locale.value === "en" ? "Falling" : "En baisse" };
467
+ if (total > 0) return { key: "up", label: locale.value === "en" ? "Rising" : "En hausse" };
468
+ return { key: "flat", label: "Stable" };
469
+ });
470
+ </script>
471
+
472
+ <template>
473
+ <section class="pv">
474
+ <!-- Certaines marques n'ont aucun tarif publie : un bandeau de zeros suivi
475
+ de cartes vides donnait l'impression d'un chargement rate. On le dit
476
+ une fois, et on s'arrete la. -->
477
+ <template v-if="!rows.length">
478
+ <p class="pv_none">
479
+ <RmIcon name="Info" :size="16" />
480
+ {{
481
+ locale === "en"
482
+ ? `No published tariff for the brand ${brand ?? ""}.`
483
+ : `Aucun tarif publié pour la marque ${brand ?? ""}.`
484
+ }}
485
+ </p>
486
+ <p class="pv_source">
487
+ <oip-drugs-heart-elastic-source-container-elastic
488
+ :uuid="HEART_SOURCE_UUIDS.PRICE_TRACKER"
489
+ :last-label="$t('oipDrugs.heart.sources.lastPrice')"
490
+ :last-date="lastPriceDates"
491
+ />
492
+ </p>
493
+ </template>
494
+
495
+ <template v-else>
496
+ <p class="pv_scope">
497
+ <RmIcon name="Info" :size="14" />
498
+ {{
499
+ locale === "en"
500
+ ? `Price tracking covers every presentation (CIP) of the brand ${brand ?? ""}.`
501
+ : `Le suivi des prix couvre toutes les présentations (CIP) de la marque ${brand ?? ""}.`
502
+ }}
503
+ </p>
504
+
505
+ <!-- Resume de la marque : ce qu'on veut savoir avant d'entrer dans le detail. -->
506
+ <div class="pv_kpis">
507
+ <div class="pv_kpi">
508
+ <p class="pv_kpi_value">{{ priced.length }}</p>
509
+ <p class="pv_kpi_label">
510
+ {{ locale === "en" ? "Priced presentations" : "Présentations tarifées" }}
511
+ </p>
512
+ </div>
513
+
514
+ <div class="pv_kpi">
515
+ <p class="pv_kpi_value">
516
+ <template v-if="range">
517
+ {{ money(range.min) }}<span v-if="range.max !== range.min"> – {{ money(range.max) }}</span>
518
+ </template>
519
+ <template v-else>-</template>
520
+ </p>
521
+ <p class="pv_kpi_label">{{ locale === "en" ? "Ex-factory range" : "Fourchette PFHT" }}</p>
522
+ </div>
523
+
524
+ <div class="pv_kpi">
525
+ <p class="pv_kpi_value">
526
+ <RmDate v-if="lastUpdate" :date="lastUpdate" :lang="locale" />
527
+ <template v-else>-</template>
528
+ </p>
529
+ <p class="pv_kpi_label">
530
+ {{ locale === "en" ? "Latest tariff update" : "Dernière màj tarifaire" }}
531
+ </p>
532
+ </div>
533
+
534
+ <div class="pv_kpi">
535
+ <p class="pv_kpi_value pv_trend" :class="`is_${trend.key}`">
536
+ <RmIcon
537
+ :name="trend.key === 'down' ? 'TrendDown' : trend.key === 'up' ? 'TrendUp' : 'Minus'"
538
+ :size="16"
539
+ />
540
+ {{ trend.label }}
541
+ </p>
542
+ <p class="pv_kpi_label">{{ locale === "en" ? "Recent trend" : "Tendance récente" }}</p>
543
+ </div>
544
+ </div>
545
+
546
+ <div class="pv_panel">
547
+ <h3 class="pv_panel_title">
548
+ {{ locale === "en" ? "Price per presentation" : "Prix par présentation" }}
549
+ </h3>
550
+ <p class="pv_panel_sub">
551
+ {{
552
+ locale === "en"
553
+ ? "Ex-factory (PFHT) and public price of the latest applicable tariff. Change, previous price and margin are derived from these amounts."
554
+ : "Prix fabricant HT (PFHT) et prix public du dernier tarif applicable. Variation, prix précédent et écart sont calculés à partir de ces montants."
555
+ }}
556
+ </p>
557
+
558
+ <div class="pv_grid">
559
+ <article
560
+ v-for="row in rows"
561
+ :key="row.key"
562
+ class="pv_card"
563
+ @click="drawerProduct = row.source"
564
+ >
565
+ <header class="pv_card_head">
566
+ <div class="pv_card_titleline">
567
+ <h4 class="pv_card_title">{{ row.label }}</h4>
568
+ <RmTag v-if="row.channel" :variant="row.channel.variant">
569
+ {{ row.channel.label }}
570
+ </RmTag>
571
+ </div>
572
+ <p v-if="row.date" class="pv_card_date">
573
+ <RmIcon name="Calendar" :size="14" />
574
+ {{ locale === "en" ? "Tariff of" : "Tarif au" }}
575
+ <RmDate :date="row.date" :lang="locale" />
576
+ </p>
577
+ </header>
578
+
579
+ <div class="pv_card_price">
580
+ <p class="pv_price">
581
+ {{ money(row.factory) }} <span class="pv_price_unit">PFHT</span>
582
+ </p>
583
+
584
+ <div v-if="typeof row.variation === 'number' && row.variation !== 0" class="pv_var">
585
+ <p class="pv_var_value" :class="row.variation < 0 ? 'is_down' : 'is_up'">
586
+ <RmIcon :name="row.variation < 0 ? 'TrendDown' : 'TrendUp'" :size="14" />
587
+ {{ row.variation > 0 ? "+" : "" }}{{ money(row.variation) }}
588
+ <span v-if="row.variationPct !== null">({{ percent(row.variationPct) }})</span>
589
+ </p>
590
+ <p v-if="row.previous !== null" class="pv_var_prev">
591
+ {{ locale === "en" ? "vs previous tariff" : "vs tarif précédent" }}
592
+ ({{ money(row.previous) }})
593
+ </p>
594
+ </div>
595
+ </div>
596
+
597
+ <div class="pv_card_grid">
598
+ <div>
599
+ <p class="pv_field_label">
600
+ {{ locale === "en" ? "Public price incl. VAT" : "Prix public TTC" }}
601
+ </p>
602
+ <p class="pv_field_value">{{ money(row.publicPrice) }}</p>
603
+ </div>
604
+
605
+ <div v-if="row.reimbursementRate !== null">
606
+ <p class="pv_field_label">
607
+ {{ locale === "en" ? "Reimbursement rate" : "Taux de remboursement" }}
608
+ </p>
609
+ <p class="pv_field_value">
610
+ {{ row.reimbursementRate.toLocaleString("fr-FR", { maximumFractionDigits: 0 }) }} %
611
+ </p>
612
+ </div>
613
+
614
+ <div v-if="row.margin !== null">
615
+ <p class="pv_field_label">
616
+ {{ locale === "en" ? "Ex-factory → public" : "Écart PFHT → public" }}
617
+ </p>
618
+ <p class="pv_field_value">
619
+ {{ row.margin > 0 ? "+" : "" }}{{ money(row.margin) }}
620
+ <span v-if="row.marginPct !== null" class="pv_field_muted">
621
+ ({{ percent(row.marginPct) }})
622
+ </span>
623
+ </p>
624
+ </div>
625
+
626
+ <div v-if="row.manufacturer">
627
+ <p class="pv_field_label">{{ locale === "en" ? "Manufacturer" : "Fabricant" }}</p>
628
+ <p class="pv_field_value">{{ row.manufacturer }}</p>
629
+ </div>
630
+
631
+ <div v-if="row.marketedOn">
632
+ <p class="pv_field_label">
633
+ {{ locale === "en" ? "Marketing date" : "Date de commercialisation" }}
634
+ </p>
635
+ <p class="pv_field_value">
636
+ <RmDate :date="row.marketedOn" :lang="locale" />
637
+ <span v-if="row.notMarketed" class="pv_field_muted">
638
+ ({{ locale === "en" ? "no longer marketed" : "non commercialisée" }})
639
+ </span>
640
+ </p>
641
+ </div>
642
+
643
+ <div v-else-if="row.listedOn">
644
+ <p class="pv_field_label">
645
+ {{ locale === "en" ? "Listed since" : "Inscrit depuis" }}
646
+ </p>
647
+ <p class="pv_field_value">
648
+ <RmDate :date="row.listedOn" :lang="locale" />
649
+ </p>
650
+ </div>
651
+
652
+ <div v-if="row.code">
653
+ <p class="pv_field_label">{{ row.codeLabel }}</p>
654
+ <p class="pv_field_value pv_code">{{ row.code }}</p>
655
+ </div>
656
+ </div>
657
+ </article>
658
+ </div>
659
+
660
+ <p class="pv_source">
661
+ <oip-drugs-heart-elastic-source-container-elastic
662
+ :uuid="HEART_SOURCE_UUIDS.PRICE_TRACKER"
663
+ :last-label="$t('oipDrugs.heart.sources.lastPrice')"
664
+ :last-date="lastPriceDates"
665
+ />
666
+ </p>
667
+ </div>
668
+
669
+ <div class="pv_panel">
670
+ <h3 class="pv_panel_title">
671
+ {{ locale === "en" ? "Tariff history (ex-factory)" : "Historique tarifaire (PFHT)" }}
672
+ </h3>
673
+ <p class="pv_panel_sub">
674
+ {{
675
+ locale === "en"
676
+ ? "Ex-factory price over time, per presentation."
677
+ : "Évolution du prix fabricant HT dans le temps, par présentation."
678
+ }}
679
+ </p>
680
+
681
+ <template v-if="chart">
682
+ <div ref="chartWrap" class="pv_chart_wrap">
683
+ <svg
684
+ class="pv_chart"
685
+ :viewBox="`0 0 ${CHART.w} ${CHART.h}`"
686
+ role="img"
687
+ @mousemove="onChartMove"
688
+ @mouseleave="hover = null"
689
+ >
690
+ <!-- Reperes de prix : trois lignes suffisent a situer les marches. -->
691
+ <g class="pv_grid_lines">
692
+ <template v-for="tick in chart.priceTicks" :key="tick.value">
693
+ <line :x1="CHART.left" :x2="CHART.w - CHART.right" :y1="tick.y" :y2="tick.y" />
694
+ <text :x="CHART.left - 8" :y="tick.y + 4" text-anchor="end">
695
+ {{ Math.round(tick.value).toLocaleString("fr-FR") }} €
696
+ </text>
697
+ </template>
698
+ </g>
699
+
700
+ <g class="pv_axis">
701
+ <text
702
+ v-for="year in chart.years"
703
+ :key="year.label"
704
+ :x="year.x"
705
+ :y="CHART.h - 8"
706
+ text-anchor="middle"
707
+ >
708
+ {{ year.label }}
709
+ </text>
710
+ </g>
711
+
712
+ <g v-for="line in chart.lines" :key="line.label">
713
+ <path :d="line.path" :stroke="line.color" fill="none" stroke-width="2" />
714
+ <circle
715
+ v-for="dot in line.dots"
716
+ :key="dot.date"
717
+ :cx="dot.cx"
718
+ :cy="dot.cy"
719
+ r="3.5"
720
+ :fill="line.color"
721
+ />
722
+ </g>
723
+
724
+ <line
725
+ v-if="hover"
726
+ class="pv_cursor"
727
+ :x1="hover.x"
728
+ :x2="hover.x"
729
+ :y1="CHART.top"
730
+ :y2="CHART.h - CHART.bottom"
731
+ />
732
+ </svg>
733
+
734
+ <!-- Infobulle posee en pourcentage : le SVG est redimensionne par la
735
+ mise en page, ses unites ne sont pas des pixels d'ecran. -->
736
+ <div
737
+ v-if="hover"
738
+ class="pv_tip"
739
+ :class="{ is_left: hover.x > CHART.w * 0.6 }"
740
+ :style="{ left: `${(hover.x / CHART.w) * 100}%` }"
741
+ >
742
+ <p class="pv_tip_date">{{ new Date(hover.date).toLocaleDateString("fr-FR") }}</p>
743
+ <p v-for="item in hover.items" :key="item.label" class="pv_tip_row">
744
+ <span class="pv_legend_dot" :style="{ background: item.color }" />
745
+ <span class="pv_tip_label">{{ item.label }}</span>
746
+ <b>{{ money(item.price) }}</b>
747
+ </p>
748
+ </div>
749
+ </div>
750
+
751
+ <ul class="pv_legend">
752
+ <li v-for="line in chart.lines" :key="line.label">
753
+ <span class="pv_legend_dot" :style="{ background: line.color }" />
754
+ {{ line.label }}
755
+ </li>
756
+ </ul>
757
+ </template>
758
+
759
+ <p v-else class="pv_empty">
760
+ {{
761
+ locale === "en"
762
+ ? "Not enough tariff records to draw a trend for this brand."
763
+ : "Pas assez de tarifs successifs pour tracer une évolution sur cette marque."
764
+ }}
765
+ </p>
766
+
767
+ <!-- Le detail du graphe : les memes tarifs, en chiffres, toutes
768
+ presentations confondues. Le tiroir d'une presentation montre les
769
+ siens ; ici on compare les presentations entre elles sans l'ouvrir. -->
770
+ <template v-if="historyRows.length">
771
+ <h4 class="pv_table_title">
772
+ {{ locale === "en" ? "Every published tariff" : "Tous les tarifs publiés" }}
773
+ </h4>
774
+ <p class="pv_panel_sub">
775
+ {{
776
+ locale === "en"
777
+ ? "One row per published tariff, all presentations together. The change compares with the previous tariff of the same presentation."
778
+ : "Une ligne par tarif publié, toutes présentations confondues. L'évolution compare au tarif précédent de la même présentation."
779
+ }}
780
+ </p>
781
+
782
+ <RmTable
783
+ :columns="historyColumns"
784
+ :rows="historyRows"
785
+ :caption="locale === 'en' ? 'Tariff history, all presentations' : 'Historique tarifaire, toutes présentations'"
786
+ row-key="key"
787
+ compact
788
+ search="auto"
789
+ :search-placeholder="
790
+ locale === 'en'
791
+ ? 'Search a presentation, a channel...'
792
+ : 'Rechercher une présentation, un circuit...'
793
+ "
794
+ :page-size="25"
795
+ max-height="520px"
796
+ :empty-text="locale === 'en' ? 'No published tariff' : 'Aucun tarif publié'"
797
+ >
798
+ <template #presentation="{ row }">
799
+ <span class="pv_table_name">{{ row.presentation }}</span>
800
+ <span v-if="row.code" class="pv_table_code">{{ row.code }}</span>
801
+ </template>
802
+
803
+ <template #date="{ value }">
804
+ <RmDate v-if="value" :date="value" :lang="locale" />
805
+ <template v-else>-</template>
806
+ </template>
807
+
808
+ <template #factory="{ value }">{{ money(value) }}</template>
809
+ <template #publicPrice="{ value }">{{ money(value) }}</template>
810
+ <template #marge="{ value }">{{ money(value) }}</template>
811
+
812
+ <!-- Un tarif reconduit au meme prix (le cas courant) laisse la
813
+ cellule vide plutot que d'ecrire « 0,00 € (0 %) » sur toute la
814
+ hauteur du tableau : les cartes du dessus font deja ce choix. -->
815
+ <template #variation="{ row }">
816
+ <template v-if="!row.variation">-</template>
817
+ <span
818
+ v-else
819
+ class="pv_table_var"
820
+ :class="row.variation > 0 ? 'is_up' : 'is_down'"
821
+ >
822
+ {{ row.variation > 0 ? "+" : "" }}{{ money(row.variation) }}
823
+ <span v-if="row.variationPct !== null" class="pv_field_muted">
824
+ ({{ percent(row.variationPct) }})
825
+ </span>
826
+ </span>
827
+ </template>
828
+
829
+ <template #documentarySource="{ row }">
830
+ {{ row.documentarySource }}
831
+ <span v-if="row.sourceDate" class="pv_field_muted">
832
+ · <RmDate :date="row.sourceDate" :lang="locale" />
833
+ </span>
834
+ </template>
835
+ </RmTable>
836
+ </template>
837
+
838
+ <p class="pv_source">
839
+ <oip-drugs-heart-elastic-source-container-elastic
840
+ :uuid="HEART_SOURCE_UUIDS.PRICE_TRACKER"
841
+ :last-label="$t('oipDrugs.heart.sources.lastPrice')"
842
+ :last-date="lastPriceDates"
843
+ />
844
+ </p>
845
+ </div>
846
+ </template>
847
+
848
+ <!-- Tout ce que l'index sait de la presentation choisie. -->
849
+ <oip-drugs-heart-results-details-presentation-drawer
850
+ :product="drawerProduct"
851
+ @close="drawerProduct = null"
852
+ />
853
+ </section>
854
+ </template>
855
+
856
+ <style scoped>
857
+ .pv {
858
+ display: flex;
859
+ flex-direction: column;
860
+ gap: 16px;
861
+ box-sizing: border-box;
862
+ width: 100%;
863
+ padding-inline: 32px;
864
+ }
865
+
866
+ .pv_scope {
867
+ display: flex;
868
+ align-items: center;
869
+ gap: 6px;
870
+ color: var(--medium);
871
+ font-size: 13px;
872
+ }
873
+
874
+ /* Titre du tableau de detail : un cran sous le titre de panneau, pour dire
875
+ qu'il detaille le graphe au-dessus plutot que d'ouvrir un autre sujet. */
876
+ .pv_table_title {
877
+ margin-top: 8px;
878
+ color: var(--dark);
879
+ font-size: 14px;
880
+ font-weight: 600;
881
+ }
882
+
883
+ /* Meme code couleur que la variation des cartes : une baisse de prix se lit
884
+ en vert, une hausse en rouge. */
885
+ .pv_table_var.is_down {
886
+ color: var(--system-success-hover);
887
+ }
888
+
889
+ .pv_table_var.is_up {
890
+ color: var(--system-alert-hover);
891
+ }
892
+
893
+ .pv_table_name {
894
+ font-weight: 500;
895
+ }
896
+
897
+ /* Le code tombe sous le libelle : deux presentations d'une meme marque ne se
898
+ distinguent parfois que par lui. */
899
+ .pv_table_code {
900
+ display: block;
901
+ color: var(--medium);
902
+ font-size: 11px;
903
+ font-variant-numeric: tabular-nums;
904
+ }
905
+
906
+ /* Bandeau de synthese : quatre mesures, un filet degrade en tete. */
907
+ .pv_kpis {
908
+ display: grid;
909
+ /* Quatre colonnes de meme largeur, puis deux, puis une : avec `auto-fit`, la
910
+ quatrieme mesure tombait seule sur une deuxieme ligne, pleine largeur, et
911
+ le bandeau paraissait casse. */
912
+ grid-template-columns: repeat(4, minmax(0, 1fr));
913
+ border: 1px solid var(--light);
914
+ border-radius: 12px;
915
+ background: var(--white);
916
+ overflow: hidden;
917
+ box-shadow: var(--small-shadow);
918
+ }
919
+
920
+ .pv_kpis::before {
921
+ content: "";
922
+ grid-column: 1 / -1;
923
+ height: 3px;
924
+ background: linear-gradient(to right, var(--primary), color-mix(in srgb, var(--primary) 35%, var(--white)));
925
+ }
926
+
927
+ .pv_kpi {
928
+ display: flex;
929
+ flex-direction: column;
930
+ align-items: center;
931
+ gap: 6px;
932
+ padding: 20px 16px;
933
+ border-left: 1px solid var(--light);
934
+ text-align: center;
935
+ }
936
+
937
+ .pv_kpi:first-of-type {
938
+ border-left: none;
939
+ }
940
+
941
+ @media (max-width: 1100px) {
942
+ .pv_kpis {
943
+ grid-template-columns: repeat(2, minmax(0, 1fr));
944
+ }
945
+
946
+ .pv_kpi:nth-of-type(odd) {
947
+ border-left: none;
948
+ }
949
+ }
950
+
951
+ @media (max-width: 640px) {
952
+ .pv_kpis {
953
+ grid-template-columns: 1fr;
954
+ }
955
+
956
+ .pv_kpi {
957
+ border-left: none;
958
+ }
959
+ }
960
+
961
+ .pv_none {
962
+ display: flex;
963
+ align-items: center;
964
+ gap: 8px;
965
+ padding: 16px;
966
+ border: 1px solid var(--light);
967
+ border-radius: 12px;
968
+ background: var(--white);
969
+ color: var(--dark);
970
+ font-size: 14px;
971
+ }
972
+
973
+ .pv_kpi_value {
974
+ color: var(--primary);
975
+ font-size: 19px;
976
+ font-weight: 800;
977
+ white-space: nowrap;
978
+ }
979
+
980
+ .pv_kpi_label {
981
+ color: var(--medium);
982
+ font-size: 11px;
983
+ font-weight: 600;
984
+ letter-spacing: 0.04em;
985
+ text-transform: uppercase;
986
+ white-space: nowrap;
987
+ }
988
+
989
+ .pv_trend {
990
+ display: inline-flex;
991
+ align-items: center;
992
+ gap: 6px;
993
+ }
994
+
995
+ .pv_trend.is_down {
996
+ color: var(--system-success-hover);
997
+ }
998
+
999
+ .pv_trend.is_up {
1000
+ color: var(--system-alert-hover);
1001
+ }
1002
+
1003
+ .pv_trend.is_flat {
1004
+ color: var(--dark);
1005
+ }
1006
+
1007
+ .pv_panel {
1008
+ display: flex;
1009
+ flex-direction: column;
1010
+ gap: 6px;
1011
+ padding: 20px;
1012
+ border: 1px solid var(--light);
1013
+ border-radius: 12px;
1014
+ background: var(--white);
1015
+ }
1016
+
1017
+ .pv_panel_title {
1018
+ font-size: 17px;
1019
+ font-weight: 800;
1020
+ }
1021
+
1022
+ .pv_panel_sub {
1023
+ color: var(--medium);
1024
+ font-size: 14px;
1025
+ }
1026
+
1027
+ .pv_grid {
1028
+ display: grid;
1029
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
1030
+ gap: 16px;
1031
+ margin-top: 12px;
1032
+ }
1033
+
1034
+ /* La carte ouvre le detail complet de la presentation. */
1035
+ .pv_card {
1036
+ cursor: pointer;
1037
+ transition: border-color 0.15s linear, box-shadow 0.15s linear;
1038
+ }
1039
+
1040
+ .pv_card:hover {
1041
+ border-color: color-mix(in srgb, var(--primary) 35%, var(--light));
1042
+ box-shadow: var(--medium-shadow);
1043
+ }
1044
+
1045
+ .pv_card {
1046
+ display: flex;
1047
+ flex-direction: column;
1048
+ gap: 12px;
1049
+ padding: 16px;
1050
+ border: 1px solid var(--light);
1051
+ border-radius: 12px;
1052
+ background: var(--demi-fond);
1053
+ }
1054
+
1055
+ .pv_card_titleline {
1056
+ display: flex;
1057
+ align-items: flex-start;
1058
+ justify-content: space-between;
1059
+ gap: 8px;
1060
+ }
1061
+
1062
+ /* Le tag garde sa largeur : un libelle long ne doit pas l'ecraser. */
1063
+ .pv_card_titleline :deep(.rm-tag) {
1064
+ flex: 0 0 auto;
1065
+ white-space: nowrap;
1066
+ }
1067
+
1068
+ .pv_card_title {
1069
+ font-size: 15px;
1070
+ font-weight: 800;
1071
+ text-transform: uppercase;
1072
+ }
1073
+
1074
+ .pv_card_date {
1075
+ display: flex;
1076
+ align-items: center;
1077
+ gap: 6px;
1078
+ margin-top: 4px;
1079
+ color: var(--medium);
1080
+ font-size: 13px;
1081
+ }
1082
+
1083
+ .pv_card_price {
1084
+ display: flex;
1085
+ align-items: flex-start;
1086
+ justify-content: space-between;
1087
+ flex-wrap: wrap;
1088
+ gap: 12px;
1089
+ }
1090
+
1091
+ .pv_price {
1092
+ color: var(--primary);
1093
+ font-size: 26px;
1094
+ font-weight: 800;
1095
+ }
1096
+
1097
+ .pv_price_unit {
1098
+ margin-left: 4px;
1099
+ color: var(--medium);
1100
+ font-size: 13px;
1101
+ font-weight: 600;
1102
+ }
1103
+
1104
+ .pv_var {
1105
+ text-align: right;
1106
+ }
1107
+
1108
+ .pv_var_value {
1109
+ display: inline-flex;
1110
+ align-items: center;
1111
+ gap: 6px;
1112
+ font-size: 14px;
1113
+ font-weight: 700;
1114
+ }
1115
+
1116
+ .pv_var_value.is_down {
1117
+ color: var(--system-success-hover);
1118
+ }
1119
+
1120
+ .pv_var_value.is_up {
1121
+ color: var(--system-alert-hover);
1122
+ }
1123
+
1124
+ .pv_var_prev {
1125
+ color: var(--medium);
1126
+ font-size: 13px;
1127
+ }
1128
+
1129
+ .pv_card_grid {
1130
+ display: grid;
1131
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1132
+ gap: 12px 16px;
1133
+ padding-top: 12px;
1134
+ border-top: 1px solid var(--light);
1135
+ }
1136
+
1137
+ .pv_field_label {
1138
+ color: var(--medium);
1139
+ font-size: 12px;
1140
+ font-weight: 600;
1141
+ letter-spacing: 0.04em;
1142
+ text-transform: uppercase;
1143
+ }
1144
+
1145
+ .pv_field_value {
1146
+ margin-top: 2px;
1147
+ font-size: 15px;
1148
+ }
1149
+
1150
+ .pv_field_muted {
1151
+ color: var(--medium);
1152
+ font-size: 13px;
1153
+ }
1154
+
1155
+ .pv_code {
1156
+ font-family: "Courier New", monospace;
1157
+ font-size: 14px;
1158
+ }
1159
+
1160
+ .pv_source {
1161
+ margin-top: 12px;
1162
+ font-size: 13px;
1163
+ }
1164
+
1165
+ .pv_chart_wrap {
1166
+ position: relative;
1167
+ margin-top: 12px;
1168
+ }
1169
+
1170
+ .pv_chart {
1171
+ display: block;
1172
+ width: 100%;
1173
+ height: auto;
1174
+ }
1175
+
1176
+ .pv_cursor {
1177
+ stroke: var(--medium);
1178
+ stroke-width: 1;
1179
+ stroke-dasharray: 4 4;
1180
+ }
1181
+
1182
+ .pv_tip {
1183
+ position: absolute;
1184
+ top: 8px;
1185
+ z-index: 2;
1186
+ min-width: 220px;
1187
+ padding: 10px 12px;
1188
+ border-radius: 8px;
1189
+ background: var(--darkest);
1190
+ color: var(--white);
1191
+ font-size: 13px;
1192
+ pointer-events: none;
1193
+ transform: translateX(12px);
1194
+ }
1195
+
1196
+ /* Au-dela des deux tiers du graphe, l'infobulle bascule a gauche du curseur
1197
+ plutot que de sortir de la carte. */
1198
+ .pv_tip.is_left {
1199
+ transform: translateX(calc(-100% - 12px));
1200
+ }
1201
+
1202
+ .pv_tip_date {
1203
+ margin-bottom: 6px;
1204
+ color: var(--light);
1205
+ font-size: 12px;
1206
+ }
1207
+
1208
+ .pv_tip_row {
1209
+ display: flex;
1210
+ align-items: center;
1211
+ gap: 8px;
1212
+ padding: 2px 0;
1213
+ }
1214
+
1215
+ .pv_tip_label {
1216
+ flex: 1 1 auto;
1217
+ overflow: hidden;
1218
+ text-overflow: ellipsis;
1219
+ white-space: nowrap;
1220
+ max-width: 220px;
1221
+ }
1222
+
1223
+ .pv_grid_lines line {
1224
+ stroke: var(--light);
1225
+ stroke-width: 1;
1226
+ }
1227
+
1228
+ .pv_grid_lines text,
1229
+ .pv_axis text {
1230
+ fill: var(--medium);
1231
+ font-size: 12px;
1232
+ }
1233
+
1234
+ .pv_legend {
1235
+ display: flex;
1236
+ flex-wrap: wrap;
1237
+ gap: 8px 20px;
1238
+ margin-top: 8px;
1239
+ list-style: none;
1240
+ color: var(--medium);
1241
+ font-size: 12px;
1242
+ }
1243
+
1244
+ .pv_legend li {
1245
+ display: flex;
1246
+ align-items: center;
1247
+ gap: 8px;
1248
+ }
1249
+
1250
+ .pv_legend_dot {
1251
+ width: 10px;
1252
+ height: 10px;
1253
+ border-radius: 999px;
1254
+ }
1255
+
1256
+ .pv_empty {
1257
+ margin-top: 12px;
1258
+ color: var(--medium);
1259
+ font-size: 14px;
1260
+ }
1261
+ </style>