@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.
- package/dist/module.json +1 -1
- package/dist/runtime/assets/styles/tokens.css +1 -1
- package/layer/app/app.config.ts +90 -0
- package/layer/app/assets/tekkare_logo.svg +20 -0
- package/layer/app/components/OipDirectusSources.vue +192 -0
- package/layer/app/components/OipDocumentPreview.vue +158 -0
- package/layer/app/components/OipMetadataEditDialog.vue +350 -0
- package/layer/app/components/OipNoteEditDialog.vue +382 -0
- package/layer/app/components/OipShell.vue +711 -0
- package/layer/app/components/OipSourceEditDialog.vue +419 -0
- package/layer/app/components/OipSourceGroups.vue +107 -0
- package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
- package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
- package/layer/app/components/panels/OipPanelNotes.vue +99 -0
- package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
- package/layer/app/components/panels/OipPanelSources.vue +361 -0
- package/layer/app/components/panels/OipPanelTopics.vue +339 -0
- package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
- package/layer/app/composables/useOipCrumbLabel.ts +11 -0
- package/layer/app/composables/useOipEsIndices.ts +57 -0
- package/layer/app/composables/useOipFavorites.ts +69 -0
- package/layer/app/composables/useOipNotes.ts +90 -0
- package/layer/app/composables/useOipNotify.ts +21 -0
- package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
- package/layer/app/layouts/anonymous.vue +6 -0
- package/layer/app/layouts/error.vue +38 -0
- package/layer/app/notes/categories.ts +46 -0
- package/layer/app/notes/types.ts +100 -0
- package/layer/app/plugins/oip-es-indices.client.ts +18 -0
- package/layer/app/types/metadata.ts +38 -0
- package/layer/app/types/oip.ts +60 -0
- package/layer/app/types/tableGroups.ts +42 -0
- package/layer/app/utils/oipRail.ts +82 -0
- package/layer/nuxt.config.ts +32 -0
- package/layer/server/api/oip/asset/[id].get.ts +89 -0
- package/layer/server/api/oip/documents.get.ts +92 -0
- package/layer/server/api/oip/es-indices.get.ts +21 -0
- package/layer/server/api/oip/favorites.get.ts +107 -0
- package/layer/server/api/oip/metadata.get.ts +109 -0
- package/layer/server/api/oip/metadata.patch.ts +163 -0
- package/layer/server/api/oip/note-options.get.ts +89 -0
- package/layer/server/api/oip/note.get.ts +95 -0
- package/layer/server/api/oip/notes.get.ts +173 -0
- package/layer/server/api/oip/notes.patch.ts +198 -0
- package/layer/server/api/oip/sources.patch.ts +145 -0
- package/layer/server/api/oip/sources.post.ts +88 -0
- package/layer/server/api/oip/table-groups.get.ts +145 -0
- package/layer/server/api/oip/tutorial-video.get.ts +44 -0
- package/layer/server/api/romulus/version.get.ts +4 -0
- package/layer/server/utils/oipAuth.ts +69 -0
- package/layer/server/utils/oipEsIndices.ts +99 -0
- package/layer/server/utils/oipGuards.ts +90 -0
- package/layer/server/utils/oipNoteMarkup.ts +197 -0
- 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>
|