@tekkare/romulus 0.1.254 → 0.1.256

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.
@@ -0,0 +1,766 @@
1
+ <!--
2
+ Vue de ressource `VaccinePatient` : la fiche d'un cas decrit apres une
3
+ vaccination, telle qu'OIP Case Atlas la montre (sa page /vaccines/[id], et
4
+ le tiroir d'un projet dans n'importe quelle app du portail).
5
+
6
+ Meme forme que la fiche patient (cf. PatientFiche) : onglets Resume et
7
+ Publications, chapitres a gauche, une carte par section. Les sections
8
+ suivent les questions d'un lecteur de pharmacovigilance : quel vaccin, ce
9
+ qui s'est passe et quand, ce qu'en concluent les auteurs, ce qui s'est passe
10
+ a la dose suivante, sur quel terrain. Chaque jugement vient avec la phrase
11
+ de l'article qui le porte.
12
+
13
+ `id` : le `record_id` (« 41827021:p1 ») ou l'`_id`. L'onglet et le document
14
+ remontent comme pour la fiche patient (`update:tab`, `record`).
15
+ -->
16
+ <template>
17
+ <div class="oip-patients">
18
+ <RmPageHeader
19
+ :have-return="!!backTo"
20
+ :back-to="backTo"
21
+ :eyebrow="fr ? 'Cas après vaccination' : 'Vaccine cases'"
22
+ :tabs="profile ? headerTabs : []"
23
+ :tabs-label="fr ? 'Sections de la fiche' : 'Record sections'"
24
+ @tab="setTab"
25
+ >
26
+ <template #title>
27
+ <span class="record_title">{{ profile ? profile.event_term || (fr ? "Événement non nommé" : "Event not named") : "" }}</span>
28
+ <slot name="title-link" />
29
+ </template>
30
+ <!-- Ranger la fiche dans le projet actif, par le bouton que l'app prete (cf. useOipResourceHooks). -->
31
+ <template v-if="hooks.projectButton && profile?._id" #actions>
32
+ <component :is="hooks.projectButton" :resource-id="profile._id" type="VaccinePatient" :label="profile.event_term" />
33
+ </template>
34
+ <div v-if="profile" class="oip_list gap-8">
35
+ <p class="record_subline">{{ subline }}</p>
36
+ <div v-if="headerTags.length" class="oip_row gap-8 wrap">
37
+ <RmTag v-for="tag in headerTags" :key="tag.label" :variant="tag.variant">{{ tag.label }}</RmTag>
38
+ </div>
39
+ </div>
40
+ </RmPageHeader>
41
+
42
+ <RmLoadingState v-if="loading" :lines="6" />
43
+
44
+ <RmErrorState
45
+ v-else-if="error"
46
+ icon="CloudWarning"
47
+ :title="fr ? 'Fiche indisponible' : 'Record unavailable'"
48
+ :text="fr ? 'L\'index des cas après vaccination n\'a pas répondu.' : 'The vaccine case index did not answer.'"
49
+ :action-label="fr ? 'Réessayer' : 'Retry'"
50
+ @action="reload()"
51
+ />
52
+
53
+ <RmEmptyState
54
+ v-else-if="!profile"
55
+ icon="Prohibit"
56
+ :title="fr ? 'Fiche introuvable' : 'Record not found'"
57
+ :text="fr ? `Aucune fiche ne porte l'identifiant ${id}.` : `No record has the id ${id}.`"
58
+ />
59
+
60
+ <div v-else-if="activeTab === 'publications'" class="oip_list gap-24 record_content">
61
+ <OipPatientsPublications
62
+ v-if="profile.pmid"
63
+ :lang="lang"
64
+ :title="fr ? 'Article source' : 'Source paper'"
65
+ :query="{ terms: { PubMed_ID: [String(profile.pmid)] } }"
66
+ />
67
+ <RmEmptyState v-else icon="Prohibit" :title="fr ? 'Aucun PMID sur cette fiche' : 'No PMID on this record'" />
68
+ </div>
69
+
70
+ <div v-else class="oip_row gap-16 record_layout">
71
+ <RmChaptersNav :chapters="chapters" />
72
+
73
+ <div class="oip_list gap-16 record_content">
74
+ <RmCallout v-if="flagNotes.length" tone="warning" icon="Warning" :text="flagNotes.join('. ')" />
75
+
76
+ <RmSectionCard v-if="summary" id="in-short">
77
+ <template #header>
78
+ <div class="oip_row v-center gap-8">
79
+ <RmIcon name="FileText" :size="18" />
80
+ <h2 class="record_section_title">{{ fr ? "En bref" : "In short" }}</h2>
81
+ </div>
82
+ </template>
83
+ <p class="record_text">{{ summary }}</p>
84
+ </RmSectionCard>
85
+
86
+ <RmSectionCard id="vaccine">
87
+ <template #header>
88
+ <div class="oip_row v-center gap-8">
89
+ <RmIcon name="Syringe" :size="18" />
90
+ <h2 class="record_section_title">{{ exposures.length > 1 ? (fr ? "Vaccins reçus" : "Vaccines received") : fr ? "Vaccin reçu" : "Vaccine received" }}</h2>
91
+ </div>
92
+ </template>
93
+ <div class="oip_list gap-16">
94
+ <dl class="record_facts">
95
+ <div v-for="fact in vaccineFacts" :key="fact.title" class="oip_row v-start gap-8">
96
+ <RmIcon :name="fact.icon" :size="16" class="record_fact_icon" />
97
+ <div>
98
+ <dt class="record_fact_label">{{ fact.title }}</dt>
99
+ <dd class="record_fact_value">{{ fact.value }}</dd>
100
+ </div>
101
+ </div>
102
+ </dl>
103
+ <div v-for="(e, i) in exposures" :key="i" class="record_quote_block">
104
+ <p class="record_subtitle">
105
+ {{ e.product_label || (fr ? "Vaccin non nommé" : "Unnamed vaccine") }}
106
+ <span v-if="exposureDetails(e)" class="record_muted"> · {{ exposureDetails(e) }}</span>
107
+ </p>
108
+ <blockquote v-if="e.evidence" class="record_quote">{{ e.evidence }}</blockquote>
109
+ </div>
110
+ </div>
111
+ </RmSectionCard>
112
+
113
+ <RmSectionCard id="event">
114
+ <template #header>
115
+ <div class="oip_row v-center gap-8">
116
+ <RmIcon name="Heartbeat" :size="18" />
117
+ <h2 class="record_section_title">{{ fr ? "Ce qui s'est passé" : "What happened" }}</h2>
118
+ </div>
119
+ </template>
120
+ <div class="oip_list gap-16">
121
+ <dl class="record_facts">
122
+ <div v-for="fact in eventFacts" :key="fact.title" class="oip_row v-start gap-8">
123
+ <RmIcon :name="fact.icon" :size="16" class="record_fact_icon" />
124
+ <div>
125
+ <dt class="record_fact_label">{{ fact.title }}</dt>
126
+ <dd class="record_fact_value">{{ fact.value }}</dd>
127
+ </div>
128
+ </div>
129
+ </dl>
130
+ <blockquote v-if="event.evidence" class="record_quote">{{ event.evidence }}</blockquote>
131
+ </div>
132
+ </RmSectionCard>
133
+
134
+ <RmSectionCard id="causality">
135
+ <template #header>
136
+ <div class="oip_row v-center gap-8">
137
+ <RmIcon name="Scales" :size="18" />
138
+ <h2 class="record_section_title">{{ fr ? "Avis des auteurs et dose suivante" : "Authors' view and next dose" }}</h2>
139
+ </div>
140
+ </template>
141
+ <!-- Une ligne par question : le libelle a gauche, la reponse en
142
+ pastille, puis la phrase de l'article qui la porte. -->
143
+ <dl class="record_verdicts">
144
+ <div class="record_verdict">
145
+ <dt class="record_verdict_label">{{ fr ? "Lien avec le vaccin, selon les auteurs" : "Link to the vaccine, per the authors" }}</dt>
146
+ <dd class="record_verdict_value">
147
+ <div class="oip_row v-center gap-8 wrap">
148
+ <RmTag :variant="causalityVariant">{{ vaccineLabelOf(CAUSALITY_LABELS, profile.causality_assessment || "not_assessed", lang) }}</RmTag>
149
+ <span v-if="causality.scale" class="record_muted">{{ fr ? "Échelle" : "Scale" }} {{ causality.scale }}</span>
150
+ </div>
151
+ <blockquote v-if="causality.evidence" class="record_quote">{{ causality.evidence }}</blockquote>
152
+ <p v-else class="record_muted">{{ fr ? "L'article ne se prononce pas." : "The paper does not say." }}</p>
153
+ </dd>
154
+ </div>
155
+ <div class="record_verdict">
156
+ <dt class="record_verdict_label">{{ fr ? "Dose suivante" : "Next dose" }}</dt>
157
+ <dd class="record_verdict_value">
158
+ <div class="oip_row v-center gap-8 wrap">
159
+ <RmTag :variant="rechallengeVariant">{{ vaccineLabelOf(RECHALLENGE_LABELS, profile.rechallenge || "unknown", lang) }}</RmTag>
160
+ </div>
161
+ <blockquote v-if="causality.rechallenge_text" class="record_quote">{{ causality.rechallenge_text }}</blockquote>
162
+ <p v-else class="record_muted">{{ fr ? "L'article ne parle pas des doses suivantes." : "The paper says nothing about further doses." }}</p>
163
+ </dd>
164
+ </div>
165
+ <div v-if="ruledOut.length" class="record_verdict">
166
+ <dt class="record_verdict_label">{{ fr ? "Autres causes écartées" : "Other causes ruled out" }}</dt>
167
+ <dd class="record_verdict_value">
168
+ <ul class="record_list">
169
+ <li v-for="(item, i) in ruledOut" :key="i">
170
+ <span class="record_text record_strong">{{ item.name }}</span>
171
+ <blockquote v-if="item.evidence" class="record_quote">{{ item.evidence }}</blockquote>
172
+ </li>
173
+ </ul>
174
+ </dd>
175
+ </div>
176
+ </dl>
177
+ </RmSectionCard>
178
+
179
+ <RmSectionCard id="person">
180
+ <template #header>
181
+ <div class="oip_row v-center gap-8">
182
+ <RmIcon name="User" :size="18" />
183
+ <h2 class="record_section_title">{{ fr ? "La personne" : "The person" }}</h2>
184
+ </div>
185
+ </template>
186
+ <dl class="record_facts">
187
+ <div v-for="fact in personFacts" :key="fact.title" class="oip_row v-start gap-8">
188
+ <RmIcon :name="fact.icon" :size="16" class="record_fact_icon" />
189
+ <div>
190
+ <dt class="record_fact_label">{{ fact.title }}</dt>
191
+ <dd class="record_fact_value">{{ fact.value }}</dd>
192
+ </div>
193
+ </div>
194
+ </dl>
195
+ </RmSectionCard>
196
+
197
+ <RmSectionCard v-if="presentSigns.length || absentSigns.length" id="signs">
198
+ <template #header>
199
+ <div class="oip_row v-center gap-8">
200
+ <RmIcon name="FirstAidKit" :size="18" />
201
+ <h2 class="record_section_title">{{ fr ? "Signes et symptômes" : "Signs and symptoms" }}</h2>
202
+ <RmTag v-if="presentSigns.length" variant="gray">{{ presentSigns.length }}</RmTag>
203
+ </div>
204
+ </template>
205
+ <div class="oip_list gap-16">
206
+ <div v-if="presentSigns.length" class="oip_row gap-8 wrap">
207
+ <RmTag v-for="sign in presentSigns" :key="sign" variant="gray">{{ sign }}</RmTag>
208
+ </div>
209
+ <template v-if="absentSigns.length">
210
+ <p class="record_subtitle">{{ fr ? "Explicitement absents" : "Specifically absent" }}</p>
211
+ <div class="oip_row gap-8 wrap">
212
+ <RmTag v-for="sign in absentSigns" :key="sign" variant="transparent">{{ sign }}</RmTag>
213
+ </div>
214
+ </template>
215
+ </div>
216
+ </RmSectionCard>
217
+
218
+ <RmSectionCard v-if="biomarkers.length" id="tests">
219
+ <template #header>
220
+ <div class="oip_row v-center gap-8">
221
+ <RmIcon name="TestTube" :size="18" />
222
+ <h2 class="record_section_title">{{ fr ? "Résultats d'examens" : "Test results" }}</h2>
223
+ </div>
224
+ </template>
225
+ <ul class="record_list">
226
+ <li v-for="(b, i) in biomarkers" :key="i" class="oip_row v-center gap-8 wrap">
227
+ <span class="record_text record_strong">{{ b.name }}</span>
228
+ <span v-if="b.value" class="record_mono">{{ b.value }}<template v-if="b.unit"> {{ b.unit }}</template></span>
229
+ <RmTag v-if="b.direction === 'high' || b.direction === 'low'" :variant="b.direction === 'high' ? 'orange' : 'blue'">
230
+ {{ b.direction === "high" ? (fr ? "élevé" : "high") : fr ? "bas" : "low" }}
231
+ </RmTag>
232
+ <span v-if="b.reference_range && b.reference_range !== 'null'" class="record_muted">
233
+ ({{ fr ? "normale" : "reference" }} {{ b.reference_range }})
234
+ </span>
235
+ <span v-if="b.specimen" class="record_muted">· {{ b.specimen }}</span>
236
+ </li>
237
+ </ul>
238
+ </RmSectionCard>
239
+
240
+ <RmSectionCard v-if="timeline.length" id="timeline">
241
+ <template #header>
242
+ <div class="oip_row v-center gap-8">
243
+ <RmIcon name="ListBullets" :size="18" />
244
+ <h2 class="record_section_title">{{ fr ? "Chronologie" : "What happened, in order" }}</h2>
245
+ </div>
246
+ </template>
247
+ <ol class="record_timeline">
248
+ <li v-for="(step, i) in timeline" :key="i" class="record_timeline_step">
249
+ <span class="record_timeline_dot" />
250
+ <span class="record_mono record_timeline_when">{{ step.when }}</span>
251
+ <span class="record_text">{{ step.event }}</span>
252
+ </li>
253
+ </ol>
254
+ </RmSectionCard>
255
+
256
+ <RmSectionCard id="source">
257
+ <template #header>
258
+ <div class="oip_row v-center gap-8">
259
+ <RmIcon name="BookOpen" :size="18" />
260
+ <h2 class="record_section_title">{{ fr ? "Source" : "Where this came from" }}</h2>
261
+ </div>
262
+ </template>
263
+ <div class="oip_list gap-8">
264
+ <p class="record_paper_title">{{ profile.title || (fr ? "Titre non disponible" : "Title not available") }}</p>
265
+ <p class="record_muted">
266
+ {{ profile.journal }}<template v-if="profile.year"> · {{ profile.year }}</template> · PMID {{ profile.pmid }}
267
+ </p>
268
+ <a v-if="pubmedUrl" class="record_link" :href="pubmedUrl" target="_blank" rel="noopener">
269
+ {{ fr ? "Lire sur PubMed" : "Read on PubMed" }}
270
+ <RmIcon name="ArrowSquareOut" :size="12" />
271
+ </a>
272
+
273
+ <div v-if="samePaper.length" class="oip_list gap-8 record_siblings">
274
+ <p class="record_subtitle">{{ fr ? "Autres personnes du même article" : "Other people in the same paper" }}</p>
275
+ <div class="oip_row gap-16 wrap">
276
+ <!-- Dans une page, le lien navigue ; dans le tiroir d'un projet, il empile la fiche (cf. useOipResourceNav). -->
277
+ <a
278
+ v-for="sibling in samePaper"
279
+ :key="sibling.record_id"
280
+ :href="nav.href('VaccinePatient', sibling.record_id)"
281
+ class="record_link"
282
+ @click="openSibling($event, sibling.record_id)"
283
+ >
284
+ {{ siblingLabel(sibling) }}
285
+ </a>
286
+ </div>
287
+ </div>
288
+ </div>
289
+ </RmSectionCard>
290
+ </div>
291
+ </div>
292
+ </div>
293
+ </template>
294
+
295
+ <script setup lang="ts">
296
+ import { SEX_LABELS, patientLabel, patientLabelOf } from "../../oip-patients/labels";
297
+ import {
298
+ CAUSALITY_LABELS,
299
+ EVENT_CATEGORY_LABELS,
300
+ EVENT_OUTCOME_LABELS,
301
+ ONSET_BAND_LABELS,
302
+ PATHOGEN_LABELS,
303
+ PLATFORM_LABELS,
304
+ PRODUCT_LABELS,
305
+ RECHALLENGE_LABELS,
306
+ SERIOUSNESS_LABELS,
307
+ SERIOUS_KEYS,
308
+ VACCINE_FLAG_LABELS,
309
+ vaccineLabelOf,
310
+ } from "../../oip-patients/vaccineLabels";
311
+ import { OIP_PATIENT_INDEXES } from "../../oip-patients/indexes";
312
+ import { VACCINE_KIND } from "../../oip-patients/kinds";
313
+ import { useCaseRecord } from "../../oip-patients/useCaseRecord";
314
+ import { useOipResourceHooks } from "../../composables/useOipResourceHooks";
315
+ import { useOipResourceNav } from "../../composables/useOipResourceNav";
316
+
317
+ type RmTagVariant = "gray" | "blue" | "green" | "orange" | "amber" | "rose" | "purple";
318
+ type Fact = { icon: string; title: string; value: string };
319
+
320
+ const props = withDefaults(
321
+ defineProps<{
322
+ /** `record_id` du cas, ou son `_id` (le resourceId d'un projet). */
323
+ id: string;
324
+ type?: string;
325
+ /** Onglet ouvert : « summary » (defaut) ou « publications ». */
326
+ tab?: string;
327
+ /** Retour de la page vers sa liste. Absent (tiroir) : pas de fleche. */
328
+ backTo?: string;
329
+ highlight?: string[];
330
+ lang?: "fr" | "en";
331
+ }>(),
332
+ { type: "VaccinePatient", tab: "", backTo: "", highlight: () => [], lang: "fr" },
333
+ );
334
+
335
+ const emit = defineEmits<{
336
+ "update:tab": [tab: string];
337
+ record: [profile: Record<string, any> | null];
338
+ }>();
339
+
340
+ const lang = computed(() => props.lang);
341
+ const fr = computed(() => lang.value === "fr");
342
+ const hooks = useOipResourceHooks();
343
+ const nav = useOipResourceNav();
344
+
345
+ /** L'onglet ouvert : celui de la page, sinon le dernier choisi. */
346
+ const activeTab = ref(props.tab === "publications" ? "publications" : "summary");
347
+ watch(
348
+ () => props.tab,
349
+ (tab) => (activeTab.value = tab === "publications" ? "publications" : "summary"),
350
+ );
351
+ function setTab(key: string) {
352
+ activeTab.value = key === "publications" ? "publications" : "summary";
353
+ emit("update:tab", activeTab.value);
354
+ }
355
+ const headerTabs = computed(() =>
356
+ [
357
+ { key: "summary", label: fr.value ? "Résumé" : "Summary" },
358
+ { key: "publications", label: "Publications" },
359
+ ].map((tab) => ({ ...tab, active: tab.key === activeTab.value })),
360
+ );
361
+
362
+ const recordId = computed(() => props.id);
363
+ const { profile, samePaper, loading, error, reload } = useCaseRecord(
364
+ OIP_PATIENT_INDEXES.VACCINE_PATIENTS,
365
+ recordId,
366
+ VACCINE_KIND.source!,
367
+ );
368
+ watch(profile, (value) => emit("record", value));
369
+
370
+ /** Ctrl/Cmd-clic garde le vrai lien, pour un nouvel onglet. */
371
+ function openSibling(event: MouseEvent, recordId: string) {
372
+ if (event.metaKey || event.ctrlKey || event.shiftKey) return;
373
+ event.preventDefault();
374
+ nav.open("VaccinePatient", recordId);
375
+ }
376
+
377
+ const t = (frText: string, enText: string) => (fr.value ? frText : enText);
378
+ const record = computed(() => profile.value?.record ?? {});
379
+ const block = computed(() => record.value.vaccine ?? {});
380
+ const exposures = computed<any[]>(() => block.value.exposures ?? []);
381
+ const event = computed(() => block.value.event ?? {});
382
+ const causality = computed(() => block.value.causality ?? {});
383
+ const host = computed(() => block.value.host ?? {});
384
+ const summary = computed(() => record.value.summary || profile.value?.summary || "");
385
+ const pubmedUrl = computed(() => (profile.value?.pmid ? `https://pubmed.ncbi.nlm.nih.gov/${profile.value.pmid}/` : undefined));
386
+
387
+ /** Qui, et quand : « 25-year-old · homme · Suède · 2026 », comme sur la carte. */
388
+ const subline = computed(() => {
389
+ const p = profile.value;
390
+ if (!p) return "";
391
+ const who = record.value.age_report?.text || patientLabel(p.label);
392
+ const parts = [who.replace(/^\p{Ll}/u, (c: string) => c.toUpperCase())];
393
+ if (p.sex && p.sex !== "unknown") parts.push(patientLabelOf(SEX_LABELS, p.sex, lang.value).toLowerCase());
394
+ if (p.country) parts.push(p.country);
395
+ if (p.year) parts.push(String(p.year));
396
+ return parts.join(" · ");
397
+ });
398
+
399
+ const headerTags = computed(() => {
400
+ const p = profile.value;
401
+ if (!p) return [];
402
+ const tags: { label: string; variant: RmTagVariant }[] = [];
403
+ const vaccine = p.vaccine_product_label || vaccineLabelOf(PRODUCT_LABELS, p.vaccine_product, lang.value);
404
+ if (vaccine) tags.push({ label: p.dose_number ? `${vaccine} · dose ${p.dose_number}` : vaccine, variant: "blue" });
405
+ if (p.vaccine_platform) tags.push({ label: vaccineLabelOf(PLATFORM_LABELS, p.vaccine_platform, lang.value), variant: "gray" });
406
+ const seriousness: string[] = p.seriousness ?? [];
407
+ if (seriousness.includes("death") || p.event_outcome === "fatal") tags.push({ label: t("Décès", "Died"), variant: "rose" });
408
+ else if (seriousness.some((s) => SERIOUS_KEYS.includes(s))) tags.push({ label: t("Grave", "Serious"), variant: "orange" });
409
+ if (p.event_outcome && !["unknown", "fatal"].includes(p.event_outcome)) {
410
+ tags.push({ label: vaccineLabelOf(EVENT_OUTCOME_LABELS, p.event_outcome, lang.value), variant: p.event_outcome === "recovered" ? "green" : "gray" });
411
+ }
412
+ if (p.event_category && p.event_category !== "adverse_event") {
413
+ tags.push({ label: vaccineLabelOf(EVENT_CATEGORY_LABELS, p.event_category, lang.value), variant: "purple" });
414
+ }
415
+ if (p.evidence_basis === "secondary") tags.push({ label: t("Cas déjà publié", "Re-report"), variant: "amber" });
416
+ return tags;
417
+ });
418
+
419
+ const flagNotes = computed<string[]>(() =>
420
+ (profile.value?.vaccine_flags ?? []).map((flag: string) => vaccineLabelOf(VACCINE_FLAG_LABELS, flag, lang.value)),
421
+ );
422
+
423
+ /** Ajoute un fait, sauf ce que l'article n'a pas dit. */
424
+ function collect(fill: (add: (icon: string, title: string, value?: string | number | null) => void) => void): Fact[] {
425
+ const rows: Fact[] = [];
426
+ fill((icon, title, value) => {
427
+ if (value !== null && value !== undefined && value !== "") rows.push({ icon, title, value: String(value) });
428
+ });
429
+ return rows;
430
+ }
431
+
432
+ const vaccineFacts = computed(() =>
433
+ collect((add) => {
434
+ const p = profile.value!;
435
+ add("ShieldCheck", t("Protège contre", "Protects against"), vaccineLabelOf(PATHOGEN_LABELS, p.vaccine_pathogen, lang.value));
436
+ add("Package", t("Produit", "Product"), vaccineLabelOf(PRODUCT_LABELS, p.vaccine_product, lang.value));
437
+ add("Flask", t("Type de vaccin", "Vaccine type"), vaccineLabelOf(PLATFORM_LABELS, p.vaccine_platform, lang.value));
438
+ add("NumberCircleOne", t("Dose", "Dose"), p.dose_number);
439
+ add("Barcode", t("Code ATC", "ATC code"), p.vaccine_atc);
440
+ }),
441
+ );
442
+
443
+ function exposureDetails(e: any): string {
444
+ return [
445
+ e.manufacturer,
446
+ e.dose_number ? `dose ${e.dose_number}` : "",
447
+ e.route,
448
+ e.lot ? `lot ${e.lot}` : "",
449
+ e.co_administered?.length ? `${t("avec", "given with")} ${e.co_administered.join(", ")}` : "",
450
+ ]
451
+ .filter(Boolean)
452
+ .join(" · ");
453
+ }
454
+
455
+ const eventFacts = computed(() =>
456
+ collect((add) => {
457
+ const p = profile.value!;
458
+ const band = p.time_to_onset_band && p.time_to_onset_band !== "unknown" ? vaccineLabelOf(ONSET_BAND_LABELS, p.time_to_onset_band, lang.value) : "";
459
+ add("Tag", t("Type de cas", "Kind of report"), vaccineLabelOf(EVENT_CATEGORY_LABELS, p.event_category, lang.value));
460
+ add("Timer", t("Délai après la dose", "Delay after the dose"), [event.value.time_to_onset_text, band ? `(${band.toLowerCase()})` : ""].filter(Boolean).join(" ") || t("Non précisé", "Not stated"));
461
+ add("Warning", t("Gravité", "Seriousness"), (p.seriousness ?? []).map((s: string) => vaccineLabelOf(SERIOUSNESS_LABELS, s, lang.value)).join(", "));
462
+ add("Pulse", t("Évolution", "Outcome"), vaccineLabelOf(EVENT_OUTCOME_LABELS, p.event_outcome || "unknown", lang.value));
463
+ add("MagnifyingGlass", t("Diagnostic", "How it was diagnosed"), event.value.diagnostic_certainty);
464
+ add("Pill", t("Traitements", "Treated with"), (event.value.treatment ?? []).join(", "));
465
+ }),
466
+ );
467
+
468
+ /** Plus le lien est affirme, plus la pastille est chaude ; « non discute » reste neutre. */
469
+ const causalityVariant = computed<RmTagVariant>(() => {
470
+ const v = profile.value?.causality_assessment;
471
+ if (v === "certain" || v === "probable") return "orange";
472
+ if (v === "possible") return "amber";
473
+ if (v === "unlikely") return "green";
474
+ return "gray";
475
+ });
476
+
477
+ /** Une recidive a la dose suivante est le signal fort ; une dose bien toleree rassure. */
478
+ const rechallengeVariant = computed<RmTagVariant>(() => {
479
+ const v = profile.value?.rechallenge;
480
+ if (v === "re_vaccinated_recurrence") return "rose";
481
+ if (v === "advised_against") return "orange";
482
+ if (v === "re_vaccinated_no_event") return "green";
483
+ return "gray";
484
+ });
485
+
486
+ const ruledOut = computed<{ name: string; evidence?: string }[]>(() => {
487
+ const fromRecord = (record.value.ruled_out ?? []).map((r: any) => ({ name: r.disease, evidence: r.evidence })).filter((r: any) => r.name);
488
+ const named = new Set(fromRecord.map((r: any) => r.name.toLowerCase()));
489
+ const fromBlock = (causality.value.alternatives_excluded ?? [])
490
+ .filter((name: string) => !named.has(name.toLowerCase()))
491
+ .map((name: string) => ({ name }));
492
+ return [...fromRecord, ...fromBlock];
493
+ });
494
+
495
+ const personFacts = computed(() =>
496
+ collect((add) => {
497
+ const p = profile.value!;
498
+ const h = host.value;
499
+ add(p.sex === "female" ? "GenderFemale" : "GenderMale", t("Sexe", "Sex"), p.sex && p.sex !== "unknown" ? patientLabelOf(SEX_LABELS, p.sex, lang.value) : null);
500
+ add("Clock", t("Âge à la description", "Age when reported"), record.value.age_report?.text);
501
+ add("GlobeHemisphereWest", t("Pays", "Country"), p.country);
502
+ add("User", t("Origine", "Background"), record.value.ethnicity);
503
+ if (h.immunosuppressed_at_vaccination != null) {
504
+ add("ShieldWarning", t("Immunité lors de la vaccination", "Immune system at vaccination"), h.immunosuppressed_at_vaccination ? t("Affaiblie", "Weakened") : t("Non affaiblie", "Not weakened"));
505
+ }
506
+ if (h.pregnant) add("Baby", t("Grossesse", "Pregnancy"), h.pregnancy_weeks ? t(`Oui, ${h.pregnancy_weeks} semaines`, `Yes, ${h.pregnancy_weeks} weeks`) : t("Oui", "Yes"));
507
+ add("Virus", t("Infection antérieure", "Earlier infection"), h.prior_infection);
508
+ add("ClipboardText", t("Antécédents", "Relevant history"), h.relevant_history);
509
+ add("Users", t("Antécédents familiaux", "Family history"), record.value.family_history);
510
+ }),
511
+ );
512
+
513
+ const presentSigns = computed<string[]>(() =>
514
+ (record.value.phenotypes ?? []).filter((s: any) => s.present !== false).map((s: any) => s.label || s.hpo_id).filter(Boolean),
515
+ );
516
+ const absentSigns = computed<string[]>(() =>
517
+ (record.value.phenotypes ?? []).filter((s: any) => s.present === false).map((s: any) => s.label || s.hpo_id).filter(Boolean),
518
+ );
519
+ const biomarkers = computed<any[]>(() => (record.value.biomarkers ?? []).filter((b: any) => b.name));
520
+
521
+ /** Le pipeline vaccin range le delai dans `offset_text` (« 2 weeks before collapse ») plutot qu'en age. */
522
+ const timeline = computed(() =>
523
+ (record.value.timeline ?? [])
524
+ .filter((step: any) => step.event)
525
+ .map((step: any) => ({ when: step.offset_text || step.age?.text || "?", event: step.event })),
526
+ );
527
+
528
+ function siblingLabel(sibling: Record<string, any>) {
529
+ const label = patientLabel(sibling.label) || sibling.record_id;
530
+ return sibling.event_term ? `${label} (${sibling.event_term})` : label;
531
+ }
532
+
533
+ /** Seules les sections rendues : un chapitre qui ne mene nulle part est un lien mort. */
534
+ const chapters = computed(() => {
535
+ const list: { label: string; value: string }[] = [];
536
+ if (summary.value) list.push({ label: t("En bref", "In short"), value: "in-short" });
537
+ list.push({ label: t("Vaccin", "Vaccine"), value: "vaccine" });
538
+ list.push({ label: t("Ce qui s'est passé", "What happened"), value: "event" });
539
+ list.push({ label: t("Avis des auteurs", "Authors' view"), value: "causality" });
540
+ list.push({ label: t("La personne", "The person"), value: "person" });
541
+ if (presentSigns.value.length || absentSigns.value.length) list.push({ label: t("Signes", "Signs"), value: "signs" });
542
+ if (biomarkers.value.length) list.push({ label: t("Examens", "Tests"), value: "tests" });
543
+ if (timeline.value.length) list.push({ label: t("Chronologie", "Timeline"), value: "timeline" });
544
+ list.push({ label: "Source", value: "source" });
545
+ return list;
546
+ });
547
+
548
+ </script>
549
+
550
+ <style scoped>
551
+ .record_layout {
552
+ align-items: flex-start;
553
+ width: 100%;
554
+ }
555
+
556
+ /* Sans min-width, l'item flex ne descend pas sous son enfant le plus large. */
557
+ .record_content {
558
+ flex: 1 1 auto;
559
+ min-width: 0;
560
+ }
561
+
562
+ .record_subline {
563
+ margin: 0;
564
+ color: var(--text-secondary);
565
+ font-size: 14px;
566
+ }
567
+
568
+ .record_title {
569
+ display: block;
570
+ }
571
+
572
+ .record_section_title {
573
+ margin: 0;
574
+ font-size: 16px;
575
+ font-weight: 700;
576
+ }
577
+
578
+ .record_text {
579
+ margin: 0;
580
+ font-size: 14px;
581
+ line-height: 1.55;
582
+ color: var(--text-primary);
583
+ }
584
+
585
+ .record_strong {
586
+ font-weight: 600;
587
+ }
588
+
589
+ .record_muted {
590
+ margin: 0;
591
+ font-size: 13px;
592
+ font-weight: 400;
593
+ color: var(--text-muted);
594
+ }
595
+
596
+ .record_subtitle {
597
+ margin: 0;
598
+ font-size: 14px;
599
+ font-weight: 600;
600
+ color: var(--text-primary);
601
+ }
602
+
603
+ .record_facts {
604
+ display: grid;
605
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
606
+ gap: 20px 24px;
607
+ margin: 0;
608
+ }
609
+
610
+ .record_fact_icon {
611
+ flex-shrink: 0;
612
+ margin-top: 2px;
613
+ color: var(--text-muted);
614
+ }
615
+
616
+ .record_fact_label {
617
+ font-size: 12px;
618
+ color: var(--text-muted);
619
+ }
620
+
621
+ .record_fact_value {
622
+ margin: 2px 0 0;
623
+ font-size: 14px;
624
+ color: var(--text-primary);
625
+ }
626
+
627
+ .record_quote_block {
628
+ display: flex;
629
+ flex-direction: column;
630
+ gap: 6px;
631
+ }
632
+
633
+ /* La phrase de l'article, telle quelle : un filet la distingue de ce que la fiche en tire. */
634
+ .record_quote {
635
+ margin: 0;
636
+ padding: 2px 0 2px 12px;
637
+ border-left: 3px solid var(--border-default);
638
+ font-size: 13px;
639
+ font-style: italic;
640
+ line-height: 1.5;
641
+ color: var(--text-secondary);
642
+ }
643
+
644
+ .record_verdicts {
645
+ display: flex;
646
+ flex-direction: column;
647
+ margin: 0;
648
+ }
649
+
650
+ .record_verdict {
651
+ display: grid;
652
+ grid-template-columns: 200px minmax(0, 1fr);
653
+ gap: 16px;
654
+ padding: 14px 0;
655
+ }
656
+
657
+ .record_verdict:first-child {
658
+ padding-top: 0;
659
+ }
660
+
661
+ .record_verdict:last-child {
662
+ padding-bottom: 0;
663
+ }
664
+
665
+ .record_verdict + .record_verdict {
666
+ border-top: 1px solid var(--border-default);
667
+ }
668
+
669
+ .record_verdict_label {
670
+ font-size: 13px;
671
+ font-weight: 600;
672
+ color: var(--text-secondary);
673
+ }
674
+
675
+ .record_verdict_value {
676
+ display: flex;
677
+ flex-direction: column;
678
+ gap: 8px;
679
+ min-width: 0;
680
+ margin: 0;
681
+ }
682
+
683
+ @media (max-width: 640px) {
684
+ .record_verdict {
685
+ grid-template-columns: 1fr;
686
+ gap: 8px;
687
+ }
688
+ }
689
+
690
+ .record_list {
691
+ display: flex;
692
+ flex-direction: column;
693
+ gap: 10px;
694
+ margin: 0;
695
+ padding: 0;
696
+ list-style: none;
697
+ }
698
+
699
+ .record_list .record_quote {
700
+ margin-top: 4px;
701
+ }
702
+
703
+ .record_mono {
704
+ font-family: var(--font-mono, ui-monospace, monospace);
705
+ font-size: 13px;
706
+ }
707
+
708
+ /* Un rail relie les points : l'ordre se lit comme une suite, pas comme une liste. */
709
+ .record_timeline {
710
+ list-style: none;
711
+ margin: 0;
712
+ padding: 0;
713
+ }
714
+
715
+ .record_timeline_step {
716
+ position: relative;
717
+ display: flex;
718
+ align-items: flex-start;
719
+ gap: 12px;
720
+ padding-bottom: 12px;
721
+ }
722
+
723
+ .record_timeline_step:not(:last-child)::before {
724
+ content: "";
725
+ position: absolute;
726
+ left: 4px;
727
+ top: 14px;
728
+ bottom: 0;
729
+ width: 1px;
730
+ background: var(--border-default);
731
+ }
732
+
733
+ .record_timeline_dot {
734
+ flex-shrink: 0;
735
+ width: 9px;
736
+ height: 9px;
737
+ margin-top: 5px;
738
+ border-radius: 50%;
739
+ background: var(--primary);
740
+ }
741
+
742
+ .record_timeline_when {
743
+ flex-shrink: 0;
744
+ min-width: 150px;
745
+ }
746
+
747
+ .record_paper_title {
748
+ margin: 0;
749
+ font-size: 14px;
750
+ font-weight: 600;
751
+ }
752
+
753
+ .record_link {
754
+ display: inline-flex;
755
+ align-items: center;
756
+ gap: 4px;
757
+ width: fit-content;
758
+ font-size: 13px;
759
+ color: var(--primary);
760
+ text-decoration: underline;
761
+ }
762
+
763
+ .record_siblings {
764
+ margin-top: 8px;
765
+ }
766
+ </style>