@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,220 @@
1
+ <!--
2
+ Panneau « Documents » du rail, servi par le layer OIP (tuile Documents).
3
+
4
+ Les documents de reference du portail OIP 7 (licences de reutilisation,
5
+ rapports CEPS et Assurance Maladie, nomenclatures PMSI…), restreints a ceux
6
+ qui parlent des donnees de cette app. La selection, l'ordre et les libelles
7
+ des categories viennent de app.config.oip.panels.Documents ; la route
8
+ (`endpoint`) fait le tri, le panneau ne fait que rendre.
9
+
10
+ Une trentaine de lignes ne se parcourt pas d'un coup d'oeil : elles sont
11
+ regroupees par theme dans des sections repliables, chacune avec le nombre de
12
+ documents qu'elle porte. La premiere section est ouverte, les autres non.
13
+
14
+ Un PDF s'ouvre en apercu dans un tiroir, sans quitter la page : c'est fait
15
+ pour jeter un oeil, pas pour lire. Les autres formats (tableur, CSV) n'ont
16
+ pas d'apercu dans un navigateur et partent dans un onglet.
17
+
18
+ Lecture seule : les documents s'ajoutent dans Directus, pas ici.
19
+ -->
20
+ <script setup lang="ts">
21
+ interface DocumentRow {
22
+ id: number;
23
+ category: string;
24
+ title: string;
25
+ src: string;
26
+ downloadSrc: string;
27
+ mime: string | null;
28
+ }
29
+
30
+ const props = defineProps<{
31
+ /** Partie de l'app qui ouvre le panneau, passee par OipShell (`appKey`). */
32
+ appKey?: string;
33
+ superAdmin?: boolean;
34
+ /** Remplace `appKey` pour le filtre des documents. Vide : toute la liste. */
35
+ app?: string;
36
+ }>();
37
+
38
+ const options = useAppConfig().oip.panels.Documents;
39
+ const app = computed(() => props.app ?? props.appKey ?? "");
40
+
41
+ const { locale } = useI18n();
42
+
43
+ // Une seule requete pour toute la session : la liste ne bouge pas d'une
44
+ // ouverture du panneau a l'autre. `lazy` evite un `await` de premier niveau,
45
+ // qui rendrait ce composant asynchrone et ferait resuspendre la page a chaque
46
+ // ouverture de la tuile.
47
+ const { data, pending, error } = useAsyncData<DocumentRow[]>(
48
+ `oip-documents-${app.value || "all"}`,
49
+ () => $fetch<DocumentRow[]>(options.endpoint, { query: { app: app.value || undefined } }),
50
+ { server: false, lazy: true },
51
+ );
52
+
53
+ /** Libelles des categories (app.config), dans l'ordre ou le serveur les renvoie. */
54
+ const CATEGORIES = options.categories as Record<string, { fr: string; en: string }>;
55
+
56
+ const search = ref("");
57
+
58
+ const shown = computed(() => {
59
+ const query = search.value.trim().toLowerCase();
60
+ const rows = data.value ?? [];
61
+ if (!query) return rows;
62
+ return rows.filter((row) => row.title.toLowerCase().includes(query));
63
+ });
64
+
65
+ /** Les documents groupes par categorie, l'ordre du serveur conserve. */
66
+ const groups = computed(() => {
67
+ const out: { key: string; label: string; rows: DocumentRow[] }[] = [];
68
+ for (const row of shown.value) {
69
+ const label = CATEGORIES[row.category]?.[locale.value === "en" ? "en" : "fr"] ?? row.category;
70
+ const last = out[out.length - 1];
71
+ if (last?.key === row.category) last.rows.push(row);
72
+ else out.push({ key: row.category, label, rows: [row] });
73
+ }
74
+ return out;
75
+ });
76
+
77
+ /**
78
+ * Sections ouvertes. La premiere l'est au chargement, et une recherche ouvre
79
+ * tout : replier ce qui vient de repondre a la recherche n'aurait pas de sens.
80
+ */
81
+ const opened = ref<Record<string, boolean>>({});
82
+ const isOpen = (key: string, index: number) =>
83
+ opened.value[key] ?? (!!search.value.trim() || index === 0);
84
+ const setOpen = (key: string, value: boolean) => {
85
+ opened.value[key] = value;
86
+ };
87
+
88
+ /** Seuls les PDF s'affichent dans un cadre ; le reste se telechargerait. */
89
+ const previewable = (row: DocumentRow) => !!row.mime?.includes("pdf");
90
+
91
+ const preview = ref<DocumentRow | null>(null);
92
+ const previewOpen = computed({
93
+ get: () => !!preview.value,
94
+ set: (value: boolean) => {
95
+ if (!value) preview.value = null;
96
+ },
97
+ });
98
+
99
+ function openDocument(row: DocumentRow) {
100
+ if (previewable(row)) preview.value = row;
101
+ else window.open(row.src, "_blank", "noopener");
102
+ }
103
+ </script>
104
+
105
+ <template>
106
+ <div class="documents">
107
+ <p class="documents_intro">
108
+ {{
109
+ locale === "en"
110
+ ? "Reference documents behind the data of this app: licences, reports and coding manuals."
111
+ : "Les documents de référence derrière les données de cette app : licences, rapports et nomenclatures."
112
+ }}
113
+ </p>
114
+
115
+ <input
116
+ v-model="search"
117
+ class="documents_search"
118
+ type="search"
119
+ :placeholder="locale === 'en' ? 'Search a document' : 'Rechercher un document'"
120
+ >
121
+
122
+ <p v-if="pending && !data?.length" class="documents_note">
123
+ {{ locale === "en" ? "Loading…" : "Chargement…" }}
124
+ </p>
125
+
126
+ <p v-else-if="error" class="documents_note">
127
+ {{
128
+ locale === "en"
129
+ ? "Documents could not be loaded."
130
+ : "Les documents n'ont pas pu être chargés."
131
+ }}
132
+ </p>
133
+
134
+ <template v-else-if="groups.length">
135
+ <RmCollapse
136
+ v-for="(group, index) in groups"
137
+ :key="group.key"
138
+ :title="group.label"
139
+ :count="group.rows.length"
140
+ :model-value="isOpen(group.key, index)"
141
+ @update:model-value="setOpen(group.key, $event)"
142
+ >
143
+ <ul class="documents_list">
144
+ <li v-for="row in group.rows" :key="row.id">
145
+ <RmFileRow
146
+ :name="row.title"
147
+ :format="row.mime ?? ''"
148
+ :action="previewable(row) ? 'preview' : 'open'"
149
+ @select="openDocument(row)"
150
+ />
151
+ </li>
152
+ </ul>
153
+ </RmCollapse>
154
+ </template>
155
+
156
+ <p v-else-if="search.trim()" class="documents_note">
157
+ {{ locale === "en" ? "No document matches that search." : "Aucun document ne correspond." }}
158
+ </p>
159
+
160
+ <p v-else class="documents_note">
161
+ {{ locale === "en" ? "No document available." : "Aucun document disponible." }}
162
+ </p>
163
+
164
+ <!-- L'apercu : le PDF tel quel, dans la visionneuse du navigateur. Le
165
+ tiroir garde les deux sorties (onglet, telechargement) sous la main,
166
+ parce qu'un document qu'on lit vraiment se lit en plein ecran. Le
167
+ dessin vit dans `OipDocumentPreview`, partage avec les ecrans qui ouvrent
168
+ leurs propres PDF. -->
169
+ <OipDocumentPreview
170
+ v-model="previewOpen"
171
+ :src="preview?.src ?? null"
172
+ :title="preview?.title"
173
+ :download-src="preview?.downloadSrc ?? null"
174
+ />
175
+ </div>
176
+ </template>
177
+
178
+ <style scoped>
179
+ .documents {
180
+ display: flex;
181
+ flex-direction: column;
182
+ gap: 12px;
183
+ align-self: stretch;
184
+ width: 100%;
185
+ }
186
+
187
+ .documents_intro,
188
+ .documents_note {
189
+ margin: 0;
190
+ color: var(--text-muted);
191
+ font-size: var(--font-12);
192
+ line-height: 1.5;
193
+ }
194
+
195
+ .documents_search {
196
+ padding: 8px 10px;
197
+ border: 1px solid var(--border-default);
198
+ border-radius: var(--radius-md);
199
+ background: var(--bg-surface);
200
+ color: var(--text-primary);
201
+ font-family: var(--font-sans);
202
+ font-size: var(--font-13);
203
+ outline: none;
204
+ }
205
+
206
+ .documents_search:focus {
207
+ border-color: var(--color-secondary);
208
+ box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.1);
209
+ }
210
+
211
+ .documents_list {
212
+ display: flex;
213
+ flex-direction: column;
214
+ gap: 2px;
215
+ margin: 0;
216
+ padding: 0;
217
+ list-style: none;
218
+ }
219
+
220
+ </style>
@@ -0,0 +1,388 @@
1
+ <!--
2
+ Le volet « Métadonnées » du rail : ce que Directus sait de cette app.
3
+
4
+ La fiche `OipApplication` (nom, description, statut, URLs, catégories) et
5
+ ses `OipSubject`, un par écran du portail. C'est le référentiel que lisent
6
+ les notes, les favoris et le portail OIP 7 : le relire depuis l'app évite
7
+ d'ouvrir le CMS pour vérifier qu'un écran y est bien déclaré.
8
+
9
+ Réservé aux superadmins, comme la correction : la tuile n'est posée par le
10
+ cadre que pour eux, et la route serveur revalide le droit.
11
+
12
+ Seul le sujet de l'écran ouvert est affiché : on ouvre le volet pour
13
+ vérifier la page qu'on a sous les yeux.
14
+ -->
15
+ <template>
16
+ <div class="toolbar_tab_container meta_panel">
17
+ <div v-if="pending" class="meta_loading">
18
+ <RmSkeleton v-for="i in 3" :key="i" width="100%" :height="72" radius="8px" />
19
+ </div>
20
+
21
+ <p v-else-if="error" class="meta_error">
22
+ {{ isFr ? "Métadonnées illisibles : " : "Could not read metadata: " }}{{ errorMessage }}
23
+ </p>
24
+
25
+ <template v-else-if="data">
26
+ <section class="meta_section">
27
+ <p class="meta_section_title">Application</p>
28
+
29
+ <article v-if="data.application" class="meta_card meta_card--app">
30
+ <div class="meta_card_head">
31
+ <div class="meta_card_titles">
32
+ <h3 class="meta_card_title">{{ text(data.application).name || data.application.prod_url }}</h3>
33
+ <RmBadge :variant="statusVariant(data.application.status)">
34
+ {{ statusLabel(data.application.status) }}
35
+ </RmBadge>
36
+ </div>
37
+ <button
38
+ type="button"
39
+ class="meta_edit_btn"
40
+ :aria-label="isFr ? 'Modifier' : 'Edit'"
41
+ :title="isFr ? 'Modifier' : 'Edit'"
42
+ @click="edit('application', data.application)"
43
+ >
44
+ <RmIcon name="PencilSimple" :size="14" />
45
+ </button>
46
+ </div>
47
+
48
+ <div
49
+ v-if="text(data.application).description"
50
+ class="meta_card_description"
51
+ v-html="text(data.application).description"
52
+ />
53
+
54
+ <dl class="meta_facts">
55
+ <dt>Prod</dt>
56
+ <dd><a :href="data.application.prod_url" target="_blank" rel="noopener">{{ host(data.application.prod_url) }}</a></dd>
57
+ <dt>Staging</dt>
58
+ <dd>
59
+ <a v-if="data.application.staging_url" :href="data.application.staging_url" target="_blank" rel="noopener">{{ host(data.application.staging_url) }}</a>
60
+ <span v-else class="meta_empty">{{ isFr ? "non renseigné" : "not set" }}</span>
61
+ </dd>
62
+ <dt>GitHub</dt>
63
+ <dd>
64
+ <a v-if="data.application.github_front_url" :href="data.application.github_front_url" target="_blank" rel="noopener">{{ repo(data.application.github_front_url) }}</a>
65
+ <span v-else class="meta_empty">{{ isFr ? "non renseigné" : "not set" }}</span>
66
+ </dd>
67
+ <dt>{{ isFr ? "Catégories" : "Categories" }}</dt>
68
+ <dd>
69
+ <span v-if="data.application.categories.length" class="meta_tags">
70
+ <RmTag v-for="c in data.application.categories" :key="c">{{ c }}</RmTag>
71
+ </span>
72
+ <span v-else class="meta_empty">{{ isFr ? "aucune" : "none" }}</span>
73
+ </dd>
74
+ <dt>{{ isFr ? "Langues" : "Languages" }}</dt>
75
+ <dd>{{ langs(data.application) }}</dd>
76
+ <dt>{{ isFr ? "Mis à jour" : "Updated" }}</dt>
77
+ <dd>{{ day(data.application.date_updated) }}</dd>
78
+ </dl>
79
+ </article>
80
+
81
+ <p v-else class="meta_empty">
82
+ {{ isFr ? "Aucune fiche OipApplication ne porte l'URL de cette app." : "No OipApplication record carries this app's URL." }}
83
+ </p>
84
+ </section>
85
+
86
+ <section class="meta_section">
87
+ <p class="meta_section_title">{{ isFr ? "Écran ouvert" : "Current screen" }}</p>
88
+
89
+ <p v-if="!currentSubject" class="meta_empty">
90
+ {{ isFr ? "Aucun sujet déclaré pour cet écran." : "No subject declared for this screen." }}
91
+ </p>
92
+
93
+ <article v-for="s in currentSubject ? [currentSubject] : []" :key="s.id" class="meta_card">
94
+ <div class="meta_card_head">
95
+ <div class="meta_card_titles">
96
+ <h3 class="meta_card_title">{{ text(s).name || s.relative_path }}</h3>
97
+ <code class="meta_path">{{ s.relative_path }}</code>
98
+ <div class="meta_card_badges">
99
+ <RmBadge :variant="statusVariant(s.status)">{{ statusLabel(s.status) }}</RmBadge>
100
+ <RmBadge v-if="s.is_specialized_report" variant="info">
101
+ {{ isFr ? "Rapport spécialisé" : "Specialized report" }}
102
+ </RmBadge>
103
+ <span class="meta_langs">{{ langs(s) }}</span>
104
+ </div>
105
+ </div>
106
+ <button
107
+ type="button"
108
+ class="meta_edit_btn"
109
+ :aria-label="isFr ? 'Modifier' : 'Edit'"
110
+ :title="isFr ? 'Modifier' : 'Edit'"
111
+ @click="edit('subject', s)"
112
+ >
113
+ <RmIcon name="PencilSimple" :size="14" />
114
+ </button>
115
+ </div>
116
+
117
+ <RmCollapse v-if="text(s).description || s.keywords" :title="isFr ? 'Détails' : 'Details'">
118
+ <div v-if="text(s).description" class="meta_card_description" v-html="text(s).description" />
119
+ <p v-if="s.keywords" class="meta_keywords">
120
+ <span class="meta_keywords_label">{{ isFr ? "Mots-clés : " : "Keywords: " }}</span>{{ s.keywords }}
121
+ </p>
122
+ </RmCollapse>
123
+ </article>
124
+ </section>
125
+
126
+ </template>
127
+
128
+ <OipMetadataEditDialog
129
+ v-if="editing"
130
+ v-model="isEditorOpen"
131
+ :kind="editing.kind"
132
+ :item="editing.item"
133
+ :lang="isFr ? 'fr' : 'en'"
134
+ @saved="refresh()"
135
+ />
136
+ </div>
137
+ </template>
138
+
139
+ <script setup lang="ts">
140
+ import type { Metadata, MetadataApplication, MetadataSubject } from "../../types/metadata";
141
+
142
+ // Passee par OipShell a tous les panneaux ; la fiche est celle de l'app.
143
+ defineProps<{ appKey?: string; superAdmin?: boolean }>();
144
+
145
+ const { locale } = useI18n();
146
+ const route = useRoute();
147
+ const isFr = computed(() => locale.value !== "en");
148
+
149
+ const { data, pending, error, refresh } = useFetch<Metadata>(useAppConfig().oip.panels.Metadata.endpoint, {
150
+ key: "oip-rail-metadata",
151
+ });
152
+
153
+ const errorMessage = computed(
154
+ () => (error.value as any)?.data?.message || error.value?.message || ""
155
+ );
156
+
157
+ /** Le texte dans la langue du rail, le francais ou l'anglais a defaut. */
158
+ function text(item: MetadataApplication | MetadataSubject) {
159
+ const wanted = isFr.value ? "fr-FR" : "en-US";
160
+ const rows = item.translations;
161
+ return (
162
+ rows.find((t) => t.languages_code === wanted) ??
163
+ rows.find((t) => t.languages_code === "fr-FR") ??
164
+ rows[0] ?? { name: "", description: "" }
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Le sujet de l'ecran ouvert : le chemin le plus long qui prefixe celui de
170
+ * la page, meme peigne que les notes (`/fr/hubr/x/` et `/hubr/x` se valent).
171
+ */
172
+ function normalize(path: string) {
173
+ return (path.split("?")[0] || "").replace(/^\/(fr|en)(?=\/|$)/, "").replace(/\/+$/, "") || "/";
174
+ }
175
+
176
+ const currentSubject = computed(() => {
177
+ const path = normalize(route.path);
178
+ let best: MetadataSubject | null = null;
179
+ for (const s of data.value?.subjects ?? []) {
180
+ const target = normalize(s.relative_path);
181
+ if (target === "/") continue;
182
+ if (path === target || path.startsWith(target + "/")) {
183
+ if (!best || target.length > normalize(best.relative_path).length) best = s;
184
+ }
185
+ }
186
+ return best;
187
+ });
188
+
189
+ const STATUS: Record<string, { fr: string; en: string; variant: "active" | "pending" | "error" | "info" | "grey" }> = {
190
+ published: { fr: "Publié", en: "Published", variant: "active" },
191
+ draft: { fr: "Brouillon", en: "Draft", variant: "pending" },
192
+ archived: { fr: "Archivé", en: "Archived", variant: "error" },
193
+ ai_generated: { fr: "Généré par IA", en: "AI generated", variant: "info" },
194
+ };
195
+ const statusLabel = (s: string) => (STATUS[s] ? (isFr.value ? STATUS[s].fr : STATUS[s].en) : s || "?");
196
+ const statusVariant = (s: string) => STATUS[s]?.variant ?? "grey";
197
+
198
+ const langs = (item: MetadataApplication | MetadataSubject) =>
199
+ item.translations.map((t) => t.languages_code.slice(0, 2)).join(", ") || (isFr.value ? "aucune" : "none");
200
+
201
+ const host = (url: string) => url.replace(/^https?:\/\//, "").replace(/\/$/, "");
202
+ const repo = (url: string) => url.replace(/^https?:\/\/github\.com\//, "").replace(/\/$/, "");
203
+ const day = (iso: string | null) => (iso ? iso.slice(0, 10) : isFr.value ? "jamais" : "never");
204
+
205
+ const isEditorOpen = ref(false);
206
+ const editing = ref<
207
+ | { kind: "application"; item: MetadataApplication }
208
+ | { kind: "subject"; item: MetadataSubject }
209
+ | null
210
+ >(null);
211
+
212
+ function edit(kind: "application", item: MetadataApplication): void;
213
+ function edit(kind: "subject", item: MetadataSubject): void;
214
+ function edit(kind: "application" | "subject", item: any) {
215
+ editing.value = { kind, item };
216
+ isEditorOpen.value = true;
217
+ }
218
+ </script>
219
+
220
+ <style scoped>
221
+ .meta_panel {
222
+ align-self: stretch;
223
+ width: 100%;
224
+ }
225
+
226
+ .meta_loading {
227
+ display: flex;
228
+ flex-direction: column;
229
+ gap: 12px;
230
+ width: 100%;
231
+ }
232
+
233
+ .meta_section {
234
+ display: flex;
235
+ flex-direction: column;
236
+ gap: 10px;
237
+ width: 100%;
238
+ }
239
+
240
+ .meta_section_title {
241
+ align-items: center;
242
+ color: var(--text-secondary);
243
+ display: flex;
244
+ font-size: 12px;
245
+ font-weight: 600;
246
+ gap: 8px;
247
+ letter-spacing: 0.04em;
248
+ margin: 0;
249
+ text-transform: uppercase;
250
+ }
251
+
252
+ .meta_card {
253
+ background: var(--bg-surface);
254
+ border: 1px solid var(--border-default);
255
+ border-radius: var(--radius-xl, 12px);
256
+ display: flex;
257
+ flex-direction: column;
258
+ gap: 10px;
259
+ padding: 12px 14px;
260
+ }
261
+
262
+ .meta_card_head {
263
+ align-items: flex-start;
264
+ display: flex;
265
+ gap: 8px;
266
+ justify-content: space-between;
267
+ }
268
+
269
+ .meta_card_titles {
270
+ display: flex;
271
+ flex-direction: column;
272
+ gap: 4px;
273
+ min-width: 0;
274
+ }
275
+
276
+ .meta_card_title {
277
+ color: var(--text-primary);
278
+ font-size: 14px;
279
+ font-weight: 600;
280
+ line-height: 1.3;
281
+ margin: 0;
282
+ }
283
+
284
+ .meta_path {
285
+ color: var(--text-secondary);
286
+ font-size: 12px;
287
+ word-break: break-all;
288
+ }
289
+
290
+ .meta_card_badges {
291
+ align-items: center;
292
+ display: flex;
293
+ flex-wrap: wrap;
294
+ gap: 6px;
295
+ margin-top: 2px;
296
+ }
297
+
298
+ .meta_langs {
299
+ color: var(--text-secondary);
300
+ font-size: 12px;
301
+ }
302
+
303
+ .meta_edit_btn {
304
+ align-items: center;
305
+ background: transparent;
306
+ border: 1px solid var(--border-default);
307
+ border-radius: 8px;
308
+ color: var(--text-secondary);
309
+ cursor: pointer;
310
+ display: inline-flex;
311
+ flex: none;
312
+ height: 28px;
313
+ justify-content: center;
314
+ width: 28px;
315
+ }
316
+
317
+ .meta_edit_btn:hover {
318
+ border-color: var(--color-primary);
319
+ color: var(--color-primary);
320
+ }
321
+
322
+ .meta_card_description {
323
+ color: var(--text-primary);
324
+ font-size: 13px;
325
+ line-height: 1.45;
326
+ }
327
+
328
+ .meta_card_description :deep(p) {
329
+ margin: 0 0 6px;
330
+ }
331
+
332
+ .meta_facts {
333
+ display: grid;
334
+ font-size: 13px;
335
+ gap: 4px 10px;
336
+ grid-template-columns: max-content 1fr;
337
+ margin: 0;
338
+ }
339
+
340
+ .meta_facts dt {
341
+ color: var(--text-secondary);
342
+ }
343
+
344
+ .meta_facts dd {
345
+ margin: 0;
346
+ min-width: 0;
347
+ overflow-wrap: anywhere;
348
+ }
349
+
350
+ .meta_facts a {
351
+ color: var(--color-primary);
352
+ text-decoration: none;
353
+ }
354
+
355
+ .meta_facts a:hover {
356
+ text-decoration: underline;
357
+ }
358
+
359
+ .meta_tags {
360
+ display: flex;
361
+ flex-wrap: wrap;
362
+ gap: 4px;
363
+ }
364
+
365
+ .meta_empty {
366
+ color: var(--text-secondary);
367
+ font-size: 13px;
368
+ font-style: italic;
369
+ margin: 0;
370
+ }
371
+
372
+ .meta_error {
373
+ color: var(--color-danger, #d64545);
374
+ font-size: 13px;
375
+ margin: 0;
376
+ }
377
+
378
+ .meta_keywords {
379
+ color: var(--text-secondary);
380
+ font-size: 12px;
381
+ line-height: 1.45;
382
+ margin: 6px 0 0;
383
+ }
384
+
385
+ .meta_keywords_label {
386
+ font-weight: 600;
387
+ }
388
+ </style>
@@ -0,0 +1,99 @@
1
+ <!--
2
+ Servi par le layer OIP (tuile Notes).
3
+
4
+ Le volet « Notes » du rail : la documentation de l'ecran ouvert.
5
+
6
+ Il remplace les anciens volets Infos, Metrics, Tips et Methodo, qui
7
+ decoupaient la meme documentation en quatre tuiles selon son genre. Le genre
8
+ est devenu une categorie qu'on filtre dans la liste.
9
+
10
+ Le titre reste vide : le cadre du panneau (RmRailPanel) porte deja
11
+ « Notes » dans son en-tete, l'afficher deux fois ferait doublon.
12
+
13
+ Une note sort de Directus par son sujet (le chemin de l'ecran) ou par une
14
+ source citee par la page. L'equipe Tekkare voit lequel des deux, en
15
+ etiquette : c'est le seul moyen de relire un rattachement sans ouvrir le
16
+ CMS. Un lecteur d'un espace client ne voit rien de tout ca.
17
+ -->
18
+ <template>
19
+ <RmNotes
20
+ class="full_width"
21
+ :notes="shownNotes"
22
+ :categories="categories"
23
+ title=""
24
+ :description="
25
+ locale === 'en'
26
+ ? 'Official documentation written by Tekkare: calculation methodologies, source coverage limits, reading conventions. Updated regularly.'
27
+ : `Documentation officielle rédigée par Tekkare : méthodologies de calcul, limites de couverture des sources, conventions d'interprétation. Mise à jour régulièrement.`
28
+ "
29
+ badge="Tekkare"
30
+ :lang="locale === 'en' ? 'en' : 'fr'"
31
+ :editable="superAdmin"
32
+ @navigate="go"
33
+ @edit="openEditor"
34
+ />
35
+
36
+ <OipNoteEditDialog
37
+ v-if="editingId"
38
+ v-model="isEditorOpen"
39
+ :note-id="editingId"
40
+ :lang="locale === 'en' ? 'en' : 'fr'"
41
+ @saved="refresh()"
42
+ />
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ const props = withDefaults(
47
+ defineProps<{
48
+ appKey?: string;
49
+ /** `source_uuid` des jeux affiches par l'ecran, fournis par l'app. */
50
+ sourceUuids?: string[];
51
+ /** Superadmin : crayon de correction. Passe par OipShell. */
52
+ superAdmin?: boolean;
53
+ /**
54
+ * Montre d'ou vient chaque note (sujet ou source) : un outil de relecture
55
+ * pour l'equipe. Par defaut, les superadmins seulement ; l'app peut
56
+ * l'ouvrir plus largement (toute adresse @tekkare.com).
57
+ */
58
+ showOrigin?: boolean;
59
+ }>(),
60
+ { sourceUuids: () => [], superAdmin: false, showOrigin: undefined },
61
+ );
62
+
63
+ const { locale } = useI18n();
64
+ const router = useRouter();
65
+ const { notes, categories, refresh } = useOipNotes(() => props.sourceUuids);
66
+
67
+ const showOrigin = computed(() => props.showOrigin ?? props.superAdmin);
68
+
69
+ const ORIGIN = {
70
+ fr: { subject: "sujet", source: "source" },
71
+ en: { subject: "subject", source: "source" },
72
+ } as const;
73
+
74
+ const shownNotes = computed(() => {
75
+ if (!showOrigin.value) return notes.value;
76
+ const words = ORIGIN[locale.value === "en" ? "en" : "fr"];
77
+ return notes.value.map((note) => ({ ...note, tag: words[note.match] }));
78
+ });
79
+
80
+ const isEditorOpen = ref(false);
81
+ const editingId = ref<string | null>(null);
82
+
83
+ function openEditor(id: string) {
84
+ editingId.value = id;
85
+ isEditorOpen.value = true;
86
+ }
87
+
88
+ /** Les renvois d'une note pointent vers une page de l'app : navigation interne. */
89
+ function go(to: string) {
90
+ router.push(to);
91
+ }
92
+ </script>
93
+
94
+ <style scoped>
95
+ .full_width {
96
+ align-self: stretch;
97
+ width: 100%;
98
+ }
99
+ </style>