@tekkare/romulus 0.1.240 → 0.1.242

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 (54) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/assets/styles/tokens.css +1 -1
  3. package/layer/app/app.config.ts +90 -0
  4. package/layer/app/assets/tekkare_logo.svg +20 -0
  5. package/layer/app/components/OipDirectusSources.vue +192 -0
  6. package/layer/app/components/OipDocumentPreview.vue +158 -0
  7. package/layer/app/components/OipMetadataEditDialog.vue +350 -0
  8. package/layer/app/components/OipNoteEditDialog.vue +382 -0
  9. package/layer/app/components/OipShell.vue +711 -0
  10. package/layer/app/components/OipSourceEditDialog.vue +419 -0
  11. package/layer/app/components/OipSourceGroups.vue +107 -0
  12. package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
  13. package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
  14. package/layer/app/components/panels/OipPanelNotes.vue +99 -0
  15. package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
  16. package/layer/app/components/panels/OipPanelSources.vue +361 -0
  17. package/layer/app/components/panels/OipPanelTopics.vue +339 -0
  18. package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
  19. package/layer/app/composables/useOipCrumbLabel.ts +11 -0
  20. package/layer/app/composables/useOipEsIndices.ts +57 -0
  21. package/layer/app/composables/useOipFavorites.ts +69 -0
  22. package/layer/app/composables/useOipNotes.ts +90 -0
  23. package/layer/app/composables/useOipNotify.ts +21 -0
  24. package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
  25. package/layer/app/layouts/anonymous.vue +6 -0
  26. package/layer/app/layouts/error.vue +38 -0
  27. package/layer/app/notes/categories.ts +46 -0
  28. package/layer/app/notes/types.ts +100 -0
  29. package/layer/app/plugins/oip-es-indices.client.ts +18 -0
  30. package/layer/app/types/metadata.ts +38 -0
  31. package/layer/app/types/oip.ts +60 -0
  32. package/layer/app/types/tableGroups.ts +42 -0
  33. package/layer/app/utils/oipRail.ts +82 -0
  34. package/layer/nuxt.config.ts +32 -0
  35. package/layer/server/api/oip/asset/[id].get.ts +89 -0
  36. package/layer/server/api/oip/documents.get.ts +92 -0
  37. package/layer/server/api/oip/es-indices.get.ts +21 -0
  38. package/layer/server/api/oip/favorites.get.ts +107 -0
  39. package/layer/server/api/oip/metadata.get.ts +109 -0
  40. package/layer/server/api/oip/metadata.patch.ts +163 -0
  41. package/layer/server/api/oip/note-options.get.ts +89 -0
  42. package/layer/server/api/oip/note.get.ts +95 -0
  43. package/layer/server/api/oip/notes.get.ts +173 -0
  44. package/layer/server/api/oip/notes.patch.ts +198 -0
  45. package/layer/server/api/oip/sources.patch.ts +145 -0
  46. package/layer/server/api/oip/sources.post.ts +88 -0
  47. package/layer/server/api/oip/table-groups.get.ts +145 -0
  48. package/layer/server/api/oip/tutorial-video.get.ts +44 -0
  49. package/layer/server/api/romulus/version.get.ts +4 -0
  50. package/layer/server/utils/oipAuth.ts +69 -0
  51. package/layer/server/utils/oipEsIndices.ts +99 -0
  52. package/layer/server/utils/oipGuards.ts +90 -0
  53. package/layer/server/utils/oipNoteMarkup.ts +197 -0
  54. package/package.json +5 -3
@@ -0,0 +1,350 @@
1
+ <template>
2
+ <RmModal v-model="open" size="lg" mode="free" :label="title">
3
+ <template #header>
4
+ <h2 class="meta_edit_title">{{ title }}</h2>
5
+ </template>
6
+
7
+ <template #tabs>
8
+ <div class="meta_edit_tabs">
9
+ <RmTabBar
10
+ v-model="tabLang"
11
+ :tabs="LANGUAGES"
12
+ :aria-label="isFr ? 'Langue de la fiche' : 'Card language'"
13
+ />
14
+ </div>
15
+ </template>
16
+
17
+ <div class="meta_edit_body">
18
+ <section class="meta_edit_section">
19
+ <p class="meta_edit_eyebrow">{{ isFr ? "Texte" : "Text" }}</p>
20
+ <p v-if="!activeTranslation?.id" class="meta_edit_hint">
21
+ {{ isFr
22
+ ? "Rien dans cette langue. Elle sera créée à l'enregistrement."
23
+ : "Nothing in this language. It will be created on save." }}
24
+ </p>
25
+ <RmInput v-model="form.name" :label="isFr ? 'Nom' : 'Name'" />
26
+ <RmRichText
27
+ v-model="form.description"
28
+ :label="isFr ? 'Description' : 'Description'"
29
+ :rows="6"
30
+ :lang="isFr ? 'fr' : 'en'"
31
+ />
32
+ </section>
33
+
34
+ <section class="meta_edit_section">
35
+ <p class="meta_edit_eyebrow">{{ isFr ? "Fiche" : "Record" }}</p>
36
+ <RmDropdownSelect
37
+ v-model="statusLabel"
38
+ :label="isFr ? 'Statut' : 'Status'"
39
+ :options="statusLabels"
40
+ :search="false"
41
+ :lang="isFr ? 'fr' : 'en'"
42
+ />
43
+
44
+ <template v-if="kind === 'application'">
45
+ <RmInput v-model="app.prod_url" label="URL de production" placeholder="https://" />
46
+ <RmInput v-model="app.staging_url" label="URL de staging" placeholder="https://" />
47
+ <RmInput v-model="app.github_front_url" label="GitHub (front)" placeholder="https://" />
48
+ <RmComplexSelect
49
+ v-model="app.categories"
50
+ :label="isFr ? 'Catégories' : 'Categories'"
51
+ :options="CATEGORY_OPTIONS"
52
+ :lang="isFr ? 'fr' : 'en'"
53
+ />
54
+ </template>
55
+
56
+ <template v-else>
57
+ <RmInput
58
+ v-model="subject.relative_path"
59
+ :label="isFr ? 'Chemin de l\'écran' : 'Screen path'"
60
+ placeholder="/hubr/..."
61
+ />
62
+ <RmTextarea
63
+ :model-value="subject.keywords"
64
+ :label="isFr ? 'Mots-clés (séparés par des virgules)' : 'Keywords (comma separated)'"
65
+ :rows="4"
66
+ @update:model-value="subject.keywords = $event"
67
+ />
68
+ <RmSwitch
69
+ v-model="subject.is_specialized_report"
70
+ :label="isFr ? 'Rapport spécialisé' : 'Specialized report'"
71
+ :active-text="isFr ? 'Oui' : 'Yes'"
72
+ :inactive-text="isFr ? 'Non' : 'No'"
73
+ />
74
+ </template>
75
+ </section>
76
+
77
+ <p v-if="error" class="meta_edit_error">{{ error }}</p>
78
+ </div>
79
+
80
+ <template #footer>
81
+ <RmButton variant="tertiary" @click="open = false">
82
+ {{ isFr ? "Annuler" : "Cancel" }}
83
+ </RmButton>
84
+ <RmButton variant="primary" :disabled="saving" @click="save">
85
+ {{ saving ? (isFr ? "Enregistrement..." : "Saving...") : (isFr ? "Enregistrer" : "Save") }}
86
+ </RmButton>
87
+ </template>
88
+ </RmModal>
89
+ </template>
90
+
91
+ <script setup lang="ts">
92
+ /**
93
+ * Correction d'une metadonnee de l'app, reservee aux superadmins : la fiche
94
+ * `OipApplication` ou l'un de ses `OipSubject`.
95
+ *
96
+ * Meme geste que pour une source ou une note : un crayon sur la fiche, un
97
+ * formulaire, une route serveur qui revalide le droit. Les deux fiches
98
+ * partagent leur texte traduit (nom, description, une langue par onglet) et
99
+ * leur statut ; le reste depend de ce qu'on corrige.
100
+ *
101
+ * Les traductions se gardent par langue en brouillon et ne partent que si
102
+ * elles ont change : une langue ouverte puis laissee telle quelle ne
103
+ * reecrit rien dans le CMS.
104
+ */
105
+ import type {
106
+ MetadataApplication,
107
+ MetadataSubject,
108
+ MetadataTranslation,
109
+ } from "../types/metadata";
110
+
111
+ const props = defineProps<{
112
+ kind: "application" | "subject";
113
+ item: MetadataApplication | MetadataSubject;
114
+ lang: string;
115
+ }>();
116
+ const emit = defineEmits<{ saved: [] }>();
117
+ const open = defineModel<boolean>({ default: false });
118
+
119
+ const isFr = computed(() => props.lang === "fr");
120
+ const kind = computed(() => props.kind);
121
+
122
+ const title = computed(() =>
123
+ props.kind === "application"
124
+ ? isFr.value ? "Modifier l'application" : "Edit application"
125
+ : isFr.value ? "Modifier le sujet" : "Edit subject"
126
+ );
127
+
128
+ const LANGUAGES = [
129
+ { value: "fr-FR", name: "Français" },
130
+ { value: "en-US", name: "English" },
131
+ ];
132
+
133
+ const STATUSES = [
134
+ { value: "published", fr: "Publié", en: "Published" },
135
+ { value: "draft", fr: "Brouillon", en: "Draft" },
136
+ { value: "archived", fr: "Archivé", en: "Archived" },
137
+ { value: "ai_generated", fr: "Généré par IA", en: "AI generated" },
138
+ ];
139
+
140
+ /** Les choix du champ `categories` de Directus, tels que le CMS les liste. */
141
+ const CATEGORY_OPTIONS = [
142
+ "Census",
143
+ "Clinical Trials",
144
+ "Drugs Reimbursement",
145
+ "Epidemio",
146
+ "Global Customer Knowledge",
147
+ "Hospital Activity",
148
+ "Market Authorization",
149
+ "Medical Device",
150
+ "Oncology",
151
+ "Price Tracker",
152
+ "Rare Disease Knowledge",
153
+ "Sunshine Act",
154
+ "Vaccination",
155
+ ].map((value) => ({ label: value, value }));
156
+
157
+ const activeLang = ref("fr-FR");
158
+ /**
159
+ * L'onglet de langue : changer d'onglet garde le brouillon de la langue
160
+ * qu'on quitte et charge celui de la langue qu'on ouvre. `reset` pose la
161
+ * langue directement, sans passer par ici : au moment ou il la pose, le
162
+ * formulaire porte encore la fiche precedente et ne doit rien garder.
163
+ */
164
+ const tabLang = computed({
165
+ get: () => activeLang.value,
166
+ set: (next: string) => {
167
+ if (next === activeLang.value) return;
168
+ keepDraft(activeLang.value);
169
+ activeLang.value = next;
170
+ loadDraft(next);
171
+ },
172
+ });
173
+ const form = reactive({ name: "", description: "" });
174
+ const drafts = reactive<Record<string, { name: string; description: string }>>({});
175
+ const loaded = reactive<Record<string, string>>({});
176
+ const statusLabel = ref<string | undefined>(undefined);
177
+ const app = reactive({ prod_url: "", staging_url: "", github_front_url: "", categories: [] as string[] });
178
+ const subject = reactive({ relative_path: "", keywords: "", is_specialized_report: false });
179
+
180
+ const saving = ref(false);
181
+ const error = ref("");
182
+
183
+ const statusLabels = computed(() => STATUSES.map((s) => (isFr.value ? s.fr : s.en)));
184
+
185
+ const activeTranslation = computed<MetadataTranslation | undefined>(() =>
186
+ props.item.translations.find((t) => t.languages_code === activeLang.value)
187
+ );
188
+
189
+ const fingerprint = (draft: { name: string; description: string }) =>
190
+ JSON.stringify([draft.name, draft.description]);
191
+
192
+ function loadDraft(code: string) {
193
+ form.name = drafts[code]?.name ?? "";
194
+ form.description = drafts[code]?.description ?? "";
195
+ }
196
+
197
+ function keepDraft(code: string) {
198
+ drafts[code] = { name: form.name, description: form.description };
199
+ }
200
+
201
+ function reset() {
202
+ for (const code of Object.keys(drafts)) delete drafts[code];
203
+ for (const code of Object.keys(loaded)) delete loaded[code];
204
+ for (const t of props.item.translations) {
205
+ const draft = { name: t.name ?? "", description: t.description ?? "" };
206
+ drafts[t.languages_code] = draft;
207
+ loaded[t.languages_code] = fingerprint(draft);
208
+ }
209
+ activeLang.value = isFr.value ? "fr-FR" : "en-US";
210
+ loadDraft(activeLang.value);
211
+
212
+ const status = STATUSES.find((s) => s.value === props.item.status);
213
+ statusLabel.value = status ? (isFr.value ? status.fr : status.en) : undefined;
214
+
215
+ if (props.kind === "application") {
216
+ const item = props.item as MetadataApplication;
217
+ app.prod_url = item.prod_url ?? "";
218
+ app.staging_url = item.staging_url ?? "";
219
+ app.github_front_url = item.github_front_url ?? "";
220
+ app.categories = [...(item.categories ?? [])];
221
+ } else {
222
+ const item = props.item as MetadataSubject;
223
+ subject.relative_path = item.relative_path ?? "";
224
+ subject.keywords = item.keywords ?? "";
225
+ subject.is_specialized_report = !!item.is_specialized_report;
226
+ }
227
+ error.value = "";
228
+ }
229
+
230
+ watch(open, (value) => value && reset());
231
+
232
+ const notify = useOipNotify();
233
+ const endpoint = useAppConfig().oip.panels.Metadata.endpoint;
234
+
235
+ async function save() {
236
+ keepDraft(activeLang.value);
237
+ saving.value = true;
238
+ error.value = "";
239
+
240
+ // Une langue vide et jamais ecrite n'est pas creee : une ligne sans nom ni
241
+ // description ferait une fiche muette dans le referentiel.
242
+ const translations = LANGUAGES.map((l) => l.value)
243
+ .filter((code) => {
244
+ const draft = drafts[code];
245
+ if (!draft) return false;
246
+ if (loaded[code] !== undefined) return fingerprint(draft) !== loaded[code];
247
+ return !!(draft.name || draft.description);
248
+ })
249
+ .map((code) => {
250
+ const existing = props.item.translations.find((t) => t.languages_code === code);
251
+ return {
252
+ ...(existing?.id ? { id: existing.id } : {}),
253
+ languages_code: code,
254
+ name: drafts[code]!.name,
255
+ description: drafts[code]!.description,
256
+ };
257
+ });
258
+
259
+ const status = STATUSES.find((s) => (isFr.value ? s.fr : s.en) === statusLabel.value);
260
+ const base = {
261
+ ...(status ? { status: status.value } : {}),
262
+ ...(translations.length ? { translations } : {}),
263
+ };
264
+
265
+ const body =
266
+ props.kind === "application"
267
+ ? {
268
+ application: {
269
+ ...base,
270
+ // L'URL de production designe la fiche cote serveur : vide, on
271
+ // ne l'envoie pas, sinon l'app ne se retrouverait plus.
272
+ ...(app.prod_url ? { prod_url: app.prod_url } : {}),
273
+ staging_url: app.staging_url || null,
274
+ github_front_url: app.github_front_url || null,
275
+ categories: app.categories,
276
+ },
277
+ }
278
+ : {
279
+ subject: {
280
+ ...base,
281
+ id: props.item.id,
282
+ relative_path: subject.relative_path,
283
+ keywords: subject.keywords || null,
284
+ is_specialized_report: subject.is_specialized_report,
285
+ },
286
+ };
287
+
288
+ try {
289
+ await $fetch(endpoint, { method: "PATCH", body });
290
+ notify(isFr.value ? "Métadonnées enregistrées" : "Metadata saved");
291
+ open.value = false;
292
+ emit("saved");
293
+ } catch (err: any) {
294
+ error.value = err?.data?.message || err?.message || (isFr.value ? "Enregistrement impossible" : "Could not save");
295
+ } finally {
296
+ saving.value = false;
297
+ }
298
+ }
299
+ </script>
300
+
301
+ <style scoped>
302
+ .meta_edit_tabs {
303
+ padding: 0 24px;
304
+ }
305
+
306
+ .meta_edit_tabs :deep(.rm-tabbar) {
307
+ max-width: 100%;
308
+ margin-bottom: 0;
309
+ }
310
+
311
+ .meta_edit_title {
312
+ font-size: 16px;
313
+ font-weight: 600;
314
+ margin: 0;
315
+ }
316
+
317
+ .meta_edit_body {
318
+ display: flex;
319
+ flex-direction: column;
320
+ gap: 24px;
321
+ padding: 8px 24px 16px;
322
+ }
323
+
324
+ .meta_edit_section {
325
+ display: flex;
326
+ flex-direction: column;
327
+ gap: 12px;
328
+ }
329
+
330
+ .meta_edit_eyebrow {
331
+ color: var(--text-secondary);
332
+ font-size: 12px;
333
+ font-weight: 600;
334
+ letter-spacing: 0.04em;
335
+ margin: 0;
336
+ text-transform: uppercase;
337
+ }
338
+
339
+ .meta_edit_hint {
340
+ color: var(--text-secondary);
341
+ font-size: 13px;
342
+ margin: 0;
343
+ }
344
+
345
+ .meta_edit_error {
346
+ color: var(--color-danger, #d64545);
347
+ font-size: 13px;
348
+ margin: 0;
349
+ }
350
+ </style>