@tekkare/romulus 0.1.243 → 0.1.244
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/layer/app/components/layout/alertBrandSync.vue +143 -0
- package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
- package/layer/app/components/layout/tool/alerts.vue +969 -0
- package/layer/app/components/layout/tool/export.vue +590 -0
- package/layer/app/components/layout/tool/views.vue +933 -0
- package/layer/app/components/layout/viewEmptyState.vue +278 -0
- package/layer/app/components/layout/viewPill.vue +550 -0
- package/layer/app/composables/useAdminAccess.ts +86 -0
- package/layer/app/composables/useAlertBrandSync.ts +85 -0
- package/layer/app/composables/useAlertSource.ts +210 -0
- package/layer/app/composables/useAlerts.ts +345 -0
- package/layer/app/composables/useExportSource.ts +236 -0
- package/layer/app/composables/useHubrEmbedded.ts +13 -0
- package/layer/app/composables/usePermalink.ts +108 -0
- package/layer/app/composables/useSavedExports.ts +286 -0
- package/layer/app/composables/useViewCardActions.ts +500 -0
- package/layer/app/composables/useViewTemplates.ts +369 -0
- package/layer/app/composables/useViews.ts +574 -0
- package/layer/app/oip-defaults/alerts.ts +34 -0
- package/layer/app/oip-defaults/api.ts +18 -0
- package/layer/app/oip-defaults/export.ts +23 -0
- package/layer/app/oip-defaults/ui.ts +21 -0
- package/layer/app/oip-defaults/views.ts +59 -0
- package/layer/app/utils/alertFieldLabels.ts +191 -0
- package/layer/app/utils/oipScopeStore.ts +112 -0
- package/layer/app/utils/oipScopes.ts +376 -0
- package/layer/app/utils/viewPages.ts +30 -0
- package/layer/app/utils/viewTemplateCriteria.ts +215 -0
- package/layer/app/utils/viewTemplates.ts +116 -0
- package/layer/nuxt.config.ts +9 -0
- package/layer/server/api/oip/notes.get.ts +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Pastille de vue du cadre, posee dans la barre de filtres de la page.
|
|
3
|
+
|
|
4
|
+
Elle ne sort que si la page a declare sa vue (cf. useViews) : ailleurs, il n'y
|
|
5
|
+
aurait rien a nommer. Elle vit dans le cadre et non dans les pages parce que
|
|
6
|
+
toutes les pages la portent : la barre de filtres reserve un emplacement, on
|
|
7
|
+
y teleporte la pastille.
|
|
8
|
+
|
|
9
|
+
Elle donne les gestes courants sans ouvrir le rail : rattraper des
|
|
10
|
+
modifications, basculer sur une autre vue de la page, et « Modifier ma vue »,
|
|
11
|
+
qui ouvre le panneau « Mes vues » en mode modification, et « Nouvelle vue »,
|
|
12
|
+
qui repart d'un ecran vierge. Le panneau garde la
|
|
13
|
+
gestion complete, ce qui evite deux ecrans qui font la meme chose.
|
|
14
|
+
-->
|
|
15
|
+
<template>
|
|
16
|
+
<Teleport v-if="mounted && has" to="#rm_fbar_views" defer>
|
|
17
|
+
<RmViewPill
|
|
18
|
+
:view-name="viewName"
|
|
19
|
+
:views="views"
|
|
20
|
+
:views-label="viewsLabel"
|
|
21
|
+
:dirty="dirty"
|
|
22
|
+
:temporary="!namedActive?.id"
|
|
23
|
+
:actions="actions"
|
|
24
|
+
:lang="locale === 'en' ? 'en' : 'fr'"
|
|
25
|
+
@select="apply"
|
|
26
|
+
@save="saveCurrent"
|
|
27
|
+
@action="run"
|
|
28
|
+
/>
|
|
29
|
+
</Teleport>
|
|
30
|
+
|
|
31
|
+
<!-- Nommer une vue est une decision : elle s'arrete sur une modale, avec le
|
|
32
|
+
recapitulatif de ce qu'on enregistre. Le panneau du rail garde son
|
|
33
|
+
formulaire en place, pour la gestion. -->
|
|
34
|
+
<RmModal
|
|
35
|
+
v-model="saving"
|
|
36
|
+
size="md"
|
|
37
|
+
mode="free"
|
|
38
|
+
labelled-by="oip_save_view_title"
|
|
39
|
+
:close-label="fr ? 'Fermer' : 'Close'"
|
|
40
|
+
>
|
|
41
|
+
<template #header>
|
|
42
|
+
<p id="oip_save_view_title" class="sv_title">
|
|
43
|
+
<RmIcon name="BookmarkSimple" :size="20" class="sv_title_icon" />
|
|
44
|
+
{{ fr ? "Sauvegarder la vue" : "Save the view" }}
|
|
45
|
+
</p>
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<form class="sv_form" @submit.prevent="confirmSave">
|
|
49
|
+
<label class="sv_field">
|
|
50
|
+
<span class="sv_label">{{ fr ? "Nom de la vue" : "View name" }}</span>
|
|
51
|
+
<input
|
|
52
|
+
ref="nameInput"
|
|
53
|
+
v-model="draftTitle"
|
|
54
|
+
class="sv_input"
|
|
55
|
+
:class="{ sv_input_error: titleMissing }"
|
|
56
|
+
type="text"
|
|
57
|
+
maxlength="60"
|
|
58
|
+
:placeholder="fr ? 'Ex. Suivi mensuel oncologie' : 'e.g. Monthly oncology monitoring'"
|
|
59
|
+
>
|
|
60
|
+
<span v-if="titleMissing" class="sv_error">
|
|
61
|
+
{{ fr ? "Un nom est nécessaire." : "A name is required." }}
|
|
62
|
+
</span>
|
|
63
|
+
<span v-else class="sv_hint">
|
|
64
|
+
{{
|
|
65
|
+
fr
|
|
66
|
+
? "Choisissez un nom descriptif pour la retrouver facilement."
|
|
67
|
+
: "Pick a descriptive name so you can find it again."
|
|
68
|
+
}}
|
|
69
|
+
</span>
|
|
70
|
+
</label>
|
|
71
|
+
|
|
72
|
+
<label class="sv_field">
|
|
73
|
+
<span class="sv_label">
|
|
74
|
+
{{ fr ? "Description" : "Description" }}
|
|
75
|
+
<span class="sv_optional">{{ fr ? "(optionnel)" : "(optional)" }}</span>
|
|
76
|
+
</span>
|
|
77
|
+
<textarea
|
|
78
|
+
v-model="draftDescription"
|
|
79
|
+
class="sv_input"
|
|
80
|
+
rows="3"
|
|
81
|
+
maxlength="240"
|
|
82
|
+
:placeholder="fr ? 'Pourquoi cette vue ? Quels filtres ?' : 'Why this view? Which filters?'"
|
|
83
|
+
/>
|
|
84
|
+
</label>
|
|
85
|
+
|
|
86
|
+
<!-- Ce que la vue emporte, nomme : sans ce rappel, on enregistre a
|
|
87
|
+
l'aveugle et on decouvre le contenu en la rejouant. -->
|
|
88
|
+
<div class="sv_recap">
|
|
89
|
+
<span class="sv_recap_label">{{ fr ? "Configuration capturée :" : "Captured setup:" }}</span>
|
|
90
|
+
<span v-for="chip in recap" :key="chip.k" class="sv_chip">
|
|
91
|
+
<span class="sv_chip_k">{{ chip.k }}</span>
|
|
92
|
+
<span class="sv_chip_v">{{ chip.v }}</span>
|
|
93
|
+
</span>
|
|
94
|
+
<span v-if="recap.length === 0" class="sv_recap_empty">
|
|
95
|
+
{{ fr ? "aucun filtre posé" : "no filter set" }}
|
|
96
|
+
</span>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<label class="sv_check">
|
|
100
|
+
<input v-model="draftDefault" type="checkbox">
|
|
101
|
+
<span>
|
|
102
|
+
<RmIcon name="PushPin" weight="fill" :size="14" class="sv_check_icon" />
|
|
103
|
+
{{ fr ? "Définir comme vue par défaut" : "Set as default view" }}
|
|
104
|
+
<span class="sv_check_hint">
|
|
105
|
+
{{ fr ? "(s'activera à chaque ouverture)" : "(applied on every visit)" }}
|
|
106
|
+
</span>
|
|
107
|
+
</span>
|
|
108
|
+
</label>
|
|
109
|
+
</form>
|
|
110
|
+
|
|
111
|
+
<template #footer>
|
|
112
|
+
<div class="sv_actions">
|
|
113
|
+
<RmButton variant="secondary" :disabled="savingBusy" @click="saving = false">
|
|
114
|
+
{{ fr ? "Annuler" : "Cancel" }}
|
|
115
|
+
</RmButton>
|
|
116
|
+
<RmButton
|
|
117
|
+
variant="primary"
|
|
118
|
+
prefix-icon="BookmarkSimple"
|
|
119
|
+
:loading="savingBusy"
|
|
120
|
+
@click="confirmSave"
|
|
121
|
+
>
|
|
122
|
+
{{ fr ? "Sauvegarder" : "Save" }}
|
|
123
|
+
</RmButton>
|
|
124
|
+
</div>
|
|
125
|
+
</template>
|
|
126
|
+
</RmModal>
|
|
127
|
+
</template>
|
|
128
|
+
|
|
129
|
+
<script setup lang="ts">
|
|
130
|
+
import { sameViewPage } from "../../utils/viewPages";
|
|
131
|
+
const emit = defineEmits<{
|
|
132
|
+
/** Ouvrir la tuile « Mes vues » du rail. */
|
|
133
|
+
openViews: [];
|
|
134
|
+
}>();
|
|
135
|
+
|
|
136
|
+
const { locale } = useI18n();
|
|
137
|
+
const route = useRoute();
|
|
138
|
+
const viewsApi = useViews();
|
|
139
|
+
const brandSync = useAlertBrandSync();
|
|
140
|
+
const fr = computed(() => locale.value === "fr");
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* L'emplacement de la pastille est un noeud de la barre de filtres, donc du
|
|
144
|
+
* rendu de la page : on ne peut y teleporter qu'apres le premier montage, et
|
|
145
|
+
* seulement quand la page en a un.
|
|
146
|
+
*/
|
|
147
|
+
const mounted = ref(false);
|
|
148
|
+
const hasTarget = ref(false);
|
|
149
|
+
|
|
150
|
+
function findTarget() {
|
|
151
|
+
hasTarget.value =
|
|
152
|
+
typeof document !== "undefined" && document.getElementById("rm_fbar_views") !== null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* La barre de filtres ne parait qu'avec la donnee de la page : son emplacement
|
|
157
|
+
* n'existe pas encore au montage du cadre. On le cherche a l'arrivee, puis on
|
|
158
|
+
* suit le contenu de la page plutot que de deviner un delai.
|
|
159
|
+
*/
|
|
160
|
+
let observer: MutationObserver | null = null;
|
|
161
|
+
|
|
162
|
+
onMounted(() => {
|
|
163
|
+
mounted.value = true;
|
|
164
|
+
nextTick(findTarget);
|
|
165
|
+
if (typeof MutationObserver === "undefined") return;
|
|
166
|
+
observer = new MutationObserver(findTarget);
|
|
167
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
onBeforeUnmount(() => {
|
|
171
|
+
observer?.disconnect();
|
|
172
|
+
observer = null;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
watch(() => route.fullPath, () => nextTick(findTarget));
|
|
176
|
+
|
|
177
|
+
const source = computed(() => viewsApi.current.value);
|
|
178
|
+
const has = computed(() => viewsApi.has.value && hasTarget.value);
|
|
179
|
+
const store = () => viewsApi.scopes.value();
|
|
180
|
+
|
|
181
|
+
// La vue active de cette page, pas la selection de l'app : une vue = une page
|
|
182
|
+
// (cf. `active` dans useViews).
|
|
183
|
+
const active = computed(() => viewsApi.active.value);
|
|
184
|
+
const dirty = computed(() => viewsApi.dirty.value);
|
|
185
|
+
const templates = useViewTemplates(() => source.value?.page ?? route.path);
|
|
186
|
+
|
|
187
|
+
/** Les vues de cette page et des pages qui partagent son périmètre. */
|
|
188
|
+
const pageViews = computed(() =>
|
|
189
|
+
store()
|
|
190
|
+
.scopeList.value.filter((scope: any) => sameViewPage(scope.page, source.value?.page ?? route.path)),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Quand une navigation recrée le scope sélectionné sans son id, retrouver la
|
|
195
|
+
* vue sauvegardée par ses critères permet de conserver son nom dans la pill.
|
|
196
|
+
*/
|
|
197
|
+
const namedActive = computed(() => {
|
|
198
|
+
const scope = active.value;
|
|
199
|
+
if (!scope) return null;
|
|
200
|
+
if (!isTemporaryView(scope)) return scope;
|
|
201
|
+
|
|
202
|
+
const signature = viewSignature(parseViewCriteria(scope));
|
|
203
|
+
if (!signature) return null;
|
|
204
|
+
return (
|
|
205
|
+
pageViews.value.find(
|
|
206
|
+
(saved: any) => viewSignature(parseViewCriteria(saved)) === signature,
|
|
207
|
+
) ?? null
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Nom affiche. Une vue qui n'est pas enregistree n'a pas de nom a porter (cf.
|
|
213
|
+
* `isTemporaryView`) : elle se dit temporaire plutot que de laisser la
|
|
214
|
+
* pastille vide ou de montrer le marqueur « Custom » de l'API.
|
|
215
|
+
*/
|
|
216
|
+
const viewName = computed(() => {
|
|
217
|
+
const scope = active.value;
|
|
218
|
+
if (!scope) return null;
|
|
219
|
+
if (namedActive.value?.title && namedActive.value.title !== "Custom") {
|
|
220
|
+
return String(namedActive.value.title);
|
|
221
|
+
}
|
|
222
|
+
// Une vue temporaire venue d'un template Tekkare porte son nom : « Vue
|
|
223
|
+
// temporaire » ne disait pas d'ou sortait la selection affichee.
|
|
224
|
+
const templateTitle = scope.template ? templates.titleOf(scope.template) : null;
|
|
225
|
+
if (templateTitle) {
|
|
226
|
+
return fr.value ? `Template Tekkare : ${templateTitle}` : `Tekkare template: ${templateTitle}`;
|
|
227
|
+
}
|
|
228
|
+
return fr.value ? "Vue temporaire" : "Temporary view";
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Les autres vues de la page, favorites d'abord : la pastille sert a basculer.
|
|
233
|
+
* La vue affichee n'y figure pas, son nom est deja sur la pastille.
|
|
234
|
+
*/
|
|
235
|
+
const views = computed(() =>
|
|
236
|
+
pageViews.value
|
|
237
|
+
.filter((scope: any) => scope.id !== namedActive.value?.id)
|
|
238
|
+
.sort((a: any, b: any) => Number(b.starred === true) - Number(a.starred === true))
|
|
239
|
+
.slice(0, 10)
|
|
240
|
+
.map((scope: any) => ({ id: String(scope.id), title: String(scope.title) })),
|
|
241
|
+
);
|
|
242
|
+
const viewsLabel = computed(() => (fr.value ? "Autres vues" : "Other views"));
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Deux gestes sous la liste. « Nouvelle vue » repart d'un ecran vierge. Tout
|
|
246
|
+
* ce qu'on fait de la vue affichee (renommer, decrire, changer son contenu) se
|
|
247
|
+
* fait dans le mode modification du panneau « Mes vues ». Une vue temporaire
|
|
248
|
+
* ou recue y ouvre une creation.
|
|
249
|
+
*/
|
|
250
|
+
const actions = computed(() => [
|
|
251
|
+
{
|
|
252
|
+
key: "new",
|
|
253
|
+
label: fr.value ? "Nouvelle vue" : "New view",
|
|
254
|
+
icon: "Plus",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
key: "edit",
|
|
258
|
+
label: fr.value ? "Modifier ma vue" : "Edit my view",
|
|
259
|
+
icon: "PencilSimple",
|
|
260
|
+
},
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
function apply(id: string) {
|
|
264
|
+
const scope = pageViews.value.find((item: any) => String(item.id) === id);
|
|
265
|
+
if (!scope) return;
|
|
266
|
+
// Copie volontaire : les filtres se reappliquent sur un changement de
|
|
267
|
+
// reference de `selectedScope`, cf. le panneau du rail.
|
|
268
|
+
store().setSelectedScope({ ...scope });
|
|
269
|
+
viewsApi.applyPrefs(scope, source.value?.prefsScope);
|
|
270
|
+
source.value?.onApply?.(scope);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Ecrit l'ecran courant dans la vue active.
|
|
275
|
+
*
|
|
276
|
+
* Sans vue active il n'y a rien a ecrire, mais il y a bien quelque chose a
|
|
277
|
+
* enregistrer : la pastille pulse desormais aussi dans ce cas (cf. `dirty`
|
|
278
|
+
* dans useViews), et son bouton ouvrait alors une action sans effet. Il ouvre
|
|
279
|
+
* la modale de creation, qui est le geste que la situation demande.
|
|
280
|
+
*/
|
|
281
|
+
async function saveCurrent() {
|
|
282
|
+
const scope = active.value;
|
|
283
|
+
// Une vue partagee par quelqu'un d'autre est en lecture seule : ecrire
|
|
284
|
+
// l'ecran dedans reecrirait la vue d'un collegue. Le geste devient donc une
|
|
285
|
+
// creation, comme quand aucune vue n'est active.
|
|
286
|
+
if (!scope?.id || scope.isOwner === false) {
|
|
287
|
+
openSave();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const criteria = viewsApi.criteriaOf();
|
|
291
|
+
await store().updateScope({
|
|
292
|
+
id: scope.id,
|
|
293
|
+
title: scope.title,
|
|
294
|
+
description: scope.description,
|
|
295
|
+
criteria,
|
|
296
|
+
currentPage: scope.page,
|
|
297
|
+
});
|
|
298
|
+
apply(String(scope.id));
|
|
299
|
+
// Meme demande que depuis le panneau « Mes vues » : l'alerte de marque de
|
|
300
|
+
// cette vue ne suit pas ses produits toute seule.
|
|
301
|
+
brandSync.ask(String(scope.id), viewBrandsFromCriteria(criteria));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const editing = useViewEditing();
|
|
305
|
+
|
|
306
|
+
function run(key: string) {
|
|
307
|
+
if (key === "new") {
|
|
308
|
+
startNew();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (key !== "edit") return;
|
|
312
|
+
const scope = namedActive.value;
|
|
313
|
+
editing.value = { id: scope?.id && scope.isOwner !== false ? String(scope.id) : null };
|
|
314
|
+
emit("openViews");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Repart d'un ecran vierge : plus de vue, filtres et perimetre a leur defaut
|
|
319
|
+
* (cf. `resetAll`). Sur une page a selection de produits, la vue se compose
|
|
320
|
+
* dans son tiroir : on l'ouvre, sinon l'ecran reste vide sans dire quoi faire.
|
|
321
|
+
*/
|
|
322
|
+
function startNew() {
|
|
323
|
+
const page = source.value;
|
|
324
|
+
viewsApi.resetAll();
|
|
325
|
+
page?.onEdit?.();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ------------------------------------------------------ nouvelle vue (modale)
|
|
329
|
+
|
|
330
|
+
const saving = ref(false);
|
|
331
|
+
const savingBusy = ref(false);
|
|
332
|
+
const draftTitle = ref("");
|
|
333
|
+
const draftDescription = ref("");
|
|
334
|
+
const draftDefault = ref(false);
|
|
335
|
+
const titleMissing = ref(false);
|
|
336
|
+
const nameInput = ref<HTMLInputElement | null>(null);
|
|
337
|
+
|
|
338
|
+
/** Nom propose : date du jour, deja selectionne pour etre remplace d'un mot. */
|
|
339
|
+
const suggestedTitle = computed(() => {
|
|
340
|
+
const date = new Intl.DateTimeFormat(fr.value ? "fr-FR" : "en-GB", {
|
|
341
|
+
day: "numeric",
|
|
342
|
+
month: "short",
|
|
343
|
+
}).format(new Date());
|
|
344
|
+
return fr.value ? `Vue du ${date}` : `View of ${date}`;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const filterLabel = useViewFilterLabels(() => source.value?.filterNamespace);
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Recapitulatif de ce que la vue emporte : un jeton par filtre pose, avec le
|
|
351
|
+
* nombre de valeurs. Les filtres vides n'y figurent pas, comme ils ne partent
|
|
352
|
+
* pas dans la vue.
|
|
353
|
+
*/
|
|
354
|
+
const recap = computed(() =>
|
|
355
|
+
viewsApi
|
|
356
|
+
.criteriaOf()
|
|
357
|
+
.map((item: any) => ({ k: filterLabel(item.filter), v: viewFilledValues(item).length }))
|
|
358
|
+
.filter((chip: any) => chip.v > 0)
|
|
359
|
+
.map((chip: any) => ({ k: chip.k, v: String(chip.v) })),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
function openSave() {
|
|
363
|
+
draftTitle.value = suggestedTitle.value;
|
|
364
|
+
draftDescription.value = "";
|
|
365
|
+
draftDefault.value = false;
|
|
366
|
+
titleMissing.value = false;
|
|
367
|
+
saving.value = true;
|
|
368
|
+
nextTick(() => nameInput.value?.select());
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function confirmSave() {
|
|
372
|
+
const title = draftTitle.value.trim();
|
|
373
|
+
if (!title) {
|
|
374
|
+
titleMissing.value = true;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const page = source.value?.page ?? route.path;
|
|
378
|
+
savingBusy.value = true;
|
|
379
|
+
// Les identifiants d'avant : la creation ne rend pas la vue, elle recharge
|
|
380
|
+
// la liste. La chercher par titre appliquait une homonyme plus ancienne.
|
|
381
|
+
const before = new Set(store().scopeList.value.map((scope: any) => scope.id));
|
|
382
|
+
try {
|
|
383
|
+
await store().createScope({
|
|
384
|
+
title,
|
|
385
|
+
description: draftDescription.value.trim(),
|
|
386
|
+
criteria: viewsApi.criteriaOf(),
|
|
387
|
+
currentPage: page,
|
|
388
|
+
});
|
|
389
|
+
} finally {
|
|
390
|
+
savingBusy.value = false;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// La vue tout juste creee devient la vue active : c'est ce qui est a l'ecran.
|
|
394
|
+
const created = store()
|
|
395
|
+
.scopeList.value.filter((scope: any) => sameViewPage(scope.page, page))
|
|
396
|
+
.find((scope: any) => !before.has(scope.id));
|
|
397
|
+
|
|
398
|
+
saving.value = false;
|
|
399
|
+
if (!created) return;
|
|
400
|
+
if (draftDefault.value) await store().setDefault(created.id, true);
|
|
401
|
+
apply(String(created.id));
|
|
402
|
+
}
|
|
403
|
+
</script>
|
|
404
|
+
|
|
405
|
+
<style scoped>
|
|
406
|
+
.sv_title {
|
|
407
|
+
display: flex;
|
|
408
|
+
align-items: center;
|
|
409
|
+
gap: 8px;
|
|
410
|
+
margin: 0;
|
|
411
|
+
font-size: 18px;
|
|
412
|
+
font-weight: 700;
|
|
413
|
+
color: var(--text-primary);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.sv_title_icon {
|
|
417
|
+
color: var(--color-secondary);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.sv_form {
|
|
421
|
+
display: flex;
|
|
422
|
+
flex-direction: column;
|
|
423
|
+
gap: 16px;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.sv_field {
|
|
427
|
+
display: flex;
|
|
428
|
+
flex-direction: column;
|
|
429
|
+
gap: 6px;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.sv_label {
|
|
433
|
+
font-size: 13px;
|
|
434
|
+
font-weight: 600;
|
|
435
|
+
color: var(--text-secondary);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.sv_optional {
|
|
439
|
+
font-weight: 400;
|
|
440
|
+
color: var(--text-muted);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.sv_input {
|
|
444
|
+
box-sizing: border-box;
|
|
445
|
+
width: 100%;
|
|
446
|
+
padding: 9px 12px;
|
|
447
|
+
border: 1px solid var(--border-default);
|
|
448
|
+
border-radius: 6px;
|
|
449
|
+
background: var(--bg-surface);
|
|
450
|
+
color: var(--text-primary);
|
|
451
|
+
font-family: var(--font-sans);
|
|
452
|
+
font-size: 14px;
|
|
453
|
+
resize: vertical;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.sv_input:focus {
|
|
457
|
+
outline: none;
|
|
458
|
+
border-color: var(--color-healthcare);
|
|
459
|
+
box-shadow: 0 0 0 3px var(--bg-hover);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.sv_input_error {
|
|
463
|
+
border-color: var(--color-danger);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.sv_hint,
|
|
467
|
+
.sv_error {
|
|
468
|
+
font-size: 12px;
|
|
469
|
+
color: var(--text-muted);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.sv_error {
|
|
473
|
+
color: var(--color-danger);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* Recapitulatif : des jetons en lecture seule, pas des filtres cliquables. */
|
|
477
|
+
.sv_recap {
|
|
478
|
+
display: flex;
|
|
479
|
+
flex-wrap: wrap;
|
|
480
|
+
align-items: center;
|
|
481
|
+
gap: 8px;
|
|
482
|
+
padding: 10px 12px;
|
|
483
|
+
border: 1px solid var(--border-soft);
|
|
484
|
+
border-radius: 8px;
|
|
485
|
+
background: var(--bg-surface-2);
|
|
486
|
+
font-size: 12px;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.sv_recap_label {
|
|
490
|
+
color: var(--text-muted);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.sv_recap_empty {
|
|
494
|
+
color: var(--text-muted);
|
|
495
|
+
font-style: italic;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.sv_chip {
|
|
499
|
+
display: inline-flex;
|
|
500
|
+
align-items: center;
|
|
501
|
+
gap: 6px;
|
|
502
|
+
padding: 4px 8px;
|
|
503
|
+
border: 1px solid var(--border-default);
|
|
504
|
+
border-radius: var(--radius-pill);
|
|
505
|
+
background: var(--bg-surface);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.sv_chip_k {
|
|
509
|
+
color: var(--text-muted);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.sv_chip_v {
|
|
513
|
+
font-weight: 600;
|
|
514
|
+
color: var(--color-secondary);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
.sv_check {
|
|
518
|
+
display: flex;
|
|
519
|
+
align-items: center;
|
|
520
|
+
gap: 10px;
|
|
521
|
+
padding: 10px 12px;
|
|
522
|
+
border-radius: 8px;
|
|
523
|
+
background: var(--bg-hover);
|
|
524
|
+
font-size: 13px;
|
|
525
|
+
color: var(--text-secondary);
|
|
526
|
+
cursor: pointer;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
.sv_check input {
|
|
530
|
+
width: 14px;
|
|
531
|
+
height: 14px;
|
|
532
|
+
accent-color: var(--color-secondary);
|
|
533
|
+
cursor: pointer;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.sv_check_icon {
|
|
537
|
+
color: var(--color-secondary);
|
|
538
|
+
vertical-align: -2px;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.sv_check_hint {
|
|
542
|
+
color: var(--text-muted);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.sv_actions {
|
|
546
|
+
display: flex;
|
|
547
|
+
justify-content: flex-end;
|
|
548
|
+
gap: 8px;
|
|
549
|
+
}
|
|
550
|
+
</style>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useOipApi } from "#oip-app/api";
|
|
2
|
+
/**
|
|
3
|
+
* useAdminAccess - a-t-on le droit de publier un template, et pour qui.
|
|
4
|
+
*
|
|
5
|
+
* Deux choses distinctes, souvent confondues :
|
|
6
|
+
* - `isPlatformAdmin`, porte par `/me/summary`, vaut pour toute adresse
|
|
7
|
+
* @tekkare.com : il ouvre les produits, pas l'administration ;
|
|
8
|
+
* - `isSuperAdmin`, rendu par `/superadmin/me`, est attribue a la main. C'est
|
|
9
|
+
* lui qui garde la console d'OIP 7, donc aussi `/superadmin/workspaces`.
|
|
10
|
+
*
|
|
11
|
+
* Publier un template met une vue devant des espaces clients : c'est le
|
|
12
|
+
* second portillon, le meme que la console. Rien n'est demande au chargement
|
|
13
|
+
* de la page : `ensureLoaded()` n'est appele qu'a l'ouverture du dialogue de
|
|
14
|
+
* partage, sans quoi tout le monde paierait un aller-retour pour une option
|
|
15
|
+
* que presque personne ne voit.
|
|
16
|
+
*
|
|
17
|
+
* Meme forme que le composable homonyme d'OIP 7 : un `useState` partage, une
|
|
18
|
+
* seule requete par session.
|
|
19
|
+
*/
|
|
20
|
+
export interface AdminWorkspace {
|
|
21
|
+
workspaceId: string;
|
|
22
|
+
name: string;
|
|
23
|
+
tier: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const useAdminAccess = () => {
|
|
27
|
+
const isSuperAdmin = useState<boolean>("admin:isSuperAdmin", () => false);
|
|
28
|
+
const loaded = useState<boolean>("admin:loaded", () => false);
|
|
29
|
+
const workspaces = useState<AdminWorkspace[]>("admin:workspaces", () => []);
|
|
30
|
+
const workspacesLoaded = useState<boolean>("admin:workspacesLoaded", () => false);
|
|
31
|
+
const elysiaApi = useOipApi();
|
|
32
|
+
const { status } = useAuth();
|
|
33
|
+
|
|
34
|
+
async function ensureLoaded(): Promise<boolean> {
|
|
35
|
+
if (loaded.value) return isSuperAdmin.value;
|
|
36
|
+
if (status.value !== "authenticated") return false;
|
|
37
|
+
try {
|
|
38
|
+
const me = await elysiaApi.admin.me();
|
|
39
|
+
isSuperAdmin.value = !!me?.isSuperAdmin;
|
|
40
|
+
} catch {
|
|
41
|
+
isSuperAdmin.value = false;
|
|
42
|
+
} finally {
|
|
43
|
+
loaded.value = true;
|
|
44
|
+
}
|
|
45
|
+
return isSuperAdmin.value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tous les espaces, pour le selecteur de cibles d'un template.
|
|
50
|
+
*
|
|
51
|
+
* La route est paginee et rend bien plus que des noms (licences, membres,
|
|
52
|
+
* comptes) : on la deroule une fois, on ne garde que le nom, et on ecarte
|
|
53
|
+
* les espaces personnels — offrir un template a l'espace prive de
|
|
54
|
+
* quelqu'un n'a pas de sens. La recherche se fait ensuite dans le
|
|
55
|
+
* selecteur, sur la liste en memoire, comme pour les membres.
|
|
56
|
+
*/
|
|
57
|
+
async function ensureWorkspaces(): Promise<AdminWorkspace[]> {
|
|
58
|
+
if (workspacesLoaded.value) return workspaces.value;
|
|
59
|
+
if (!(await ensureLoaded())) return [];
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const first = await elysiaApi.admin.workspaces({ page: 1, limit: 100 });
|
|
63
|
+
const pages = first?.pagination?.totalPages ?? 1;
|
|
64
|
+
const rest = await Promise.all(
|
|
65
|
+
Array.from({ length: Math.max(0, pages - 1) }, (_, i) =>
|
|
66
|
+
elysiaApi.admin.workspaces({ page: i + 2, limit: 100 }),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
workspaces.value = [first, ...rest]
|
|
71
|
+
.flatMap((res) => res?.workspaces ?? [])
|
|
72
|
+
.filter((ws) => !ws.isPersonal)
|
|
73
|
+
.map((ws) => ({ workspaceId: ws.workspaceId, name: ws.name, tier: ws.tier }))
|
|
74
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error("[useAdminAccess] Impossible de lister les espaces:", err);
|
|
77
|
+
workspaces.value = [];
|
|
78
|
+
} finally {
|
|
79
|
+
workspacesLoaded.value = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return workspaces.value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { isSuperAdmin, workspaces, ensureLoaded, ensureWorkspaces };
|
|
86
|
+
};
|