@tekkare/romulus 0.1.242 → 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/AppUpdateToast.vue +157 -0
- package/layer/app/components/OipShell.vue +2 -0
- package/layer/app/components/auth/SplitLayout.vue +261 -0
- package/layer/app/components/layout/alertBrandSync.vue +143 -0
- package/layer/app/components/layout/firstVisitVideo.vue +165 -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/components/panels/OipPanelProjects.vue +94 -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/useViewEditing.ts +11 -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/countries.ts +30 -0
- package/layer/app/utils/exportPage.ts +418 -0
- package/layer/app/utils/headerTabs.ts +15 -0
- package/layer/app/utils/hybridRows.ts +22 -0
- package/layer/app/utils/oipScopeStore.ts +112 -0
- package/layer/app/utils/oipScopes.ts +376 -0
- package/layer/app/utils/postLoginRedirect.ts +58 -0
- package/layer/app/utils/valueTable.ts +126 -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 +4 -2
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Panneau « Mes vues » du rail.
|
|
3
|
+
|
|
4
|
+
Une vue est un jeu de filtres mis de cote pour etre rejoue. C'est ce que
|
|
5
|
+
l'app appelait « scope » : le mot change a l'ecran, l'API ne change pas (les
|
|
6
|
+
pulses), donc les vues deja enregistrees restent en place.
|
|
7
|
+
|
|
8
|
+
Le panneau ne fait que traduire : les pulses de la page courante deviennent
|
|
9
|
+
des `RmSavedView`, les actions du panneau retombent sur les appels existants
|
|
10
|
+
(creer, mettre a jour, supprimer, appliquer). Le favori et la vue par defaut
|
|
11
|
+
sont les marques du LECTEUR (`starred`, `isDefault`), ecrites par l'API sur
|
|
12
|
+
le couple (vue, lecteur) : elles valent donc aussi sur une vue qu'on a recue.
|
|
13
|
+
|
|
14
|
+
Une vue peut venir de quelqu'un d'autre. Elle est alors en lecture seule :
|
|
15
|
+
on l'active, on la marque, on la duplique, mais on ne la renomme pas, on ne
|
|
16
|
+
l'ecrase pas et on ne la supprime pas. C'est `isOwner` qui le dit, jamais la
|
|
17
|
+
comparaison des identifiants d'utilisateur.
|
|
18
|
+
|
|
19
|
+
Le panneau est celui de tout le hub : il ne connait pas l'app affichee. Le
|
|
20
|
+
magasin de scopes lui vient de la page qui a declare sa vue (`useViews`),
|
|
21
|
+
parce que chaque app tient le sien.
|
|
22
|
+
-->
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
import { VIEW_BRAND_FILTERS } from "#oip-app/views";
|
|
25
|
+
import { sameViewPage } from "../../../utils/viewPages";
|
|
26
|
+
|
|
27
|
+
const { locale } = useI18n();
|
|
28
|
+
const route = useRoute();
|
|
29
|
+
const views = useViews();
|
|
30
|
+
const brandSync = useAlertBrandSync();
|
|
31
|
+
const alerts = useAlerts();
|
|
32
|
+
|
|
33
|
+
const source = computed(() => views.current.value);
|
|
34
|
+
const filterLabel = useViewFilterLabels(() => source.value?.filterNamespace);
|
|
35
|
+
|
|
36
|
+
/** Page cote API : les vues sont rangees par page, comme avant. */
|
|
37
|
+
const page = computed(() => source.value?.page ?? route.path);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Le magasin de scopes de la page affichee. Un appel par acces : ces
|
|
41
|
+
* composables creent leurs refs a chaque fois, donc rien ne se garde ici.
|
|
42
|
+
*/
|
|
43
|
+
const store = () => views.scopes.value();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Le magasin cree ses refs a chaque appel : son `scopeLoading` ne dit rien de
|
|
47
|
+
* ce que ce panneau a lance. On tient donc l'etat de notre propre chargement.
|
|
48
|
+
*/
|
|
49
|
+
const loading = ref(false);
|
|
50
|
+
|
|
51
|
+
// Les alertes des vues, pour le compte affiche sur chaque carte. La liste est
|
|
52
|
+
// deja chargee par le layout (pastille de la tuile) : pas de relecture forcee.
|
|
53
|
+
onMounted(() => alerts.load());
|
|
54
|
+
|
|
55
|
+
onMounted(async () => {
|
|
56
|
+
if (store().scopeList.value.length > 0) return;
|
|
57
|
+
loading.value = true;
|
|
58
|
+
try {
|
|
59
|
+
await store().getScopes();
|
|
60
|
+
} finally {
|
|
61
|
+
loading.value = false;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Les pulses de cette page, et de celles qui partagent ses vues (cf. viewPages). */
|
|
66
|
+
const scopes = computed(() =>
|
|
67
|
+
store().scopeList.value.filter((scope: any) => sameViewPage(scope.page, page.value)),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// La vue active de cette page, pas la selection de l'app : une vue = une page
|
|
71
|
+
// (cf. `active` dans useViews).
|
|
72
|
+
const selected = computed(() => views.active.value);
|
|
73
|
+
|
|
74
|
+
// Lecture des criteres et signature : les memes que celles de la pastille de
|
|
75
|
+
// vue, cf. useViews. Deux definitions donneraient deux verdicts sur « la vue
|
|
76
|
+
// est-elle a jour ».
|
|
77
|
+
const parseCriteria = parseViewCriteria;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Valeurs reellement posees sur un filtre.
|
|
81
|
+
*
|
|
82
|
+
* Le magasin garde des filtres vides : une periode sans borne vaut
|
|
83
|
+
* `[null, null]`, ce qui a bien deux entrees mais ne filtre rien. Les compter
|
|
84
|
+
* ferait afficher un critere vide sur la carte, et surtout ferait croire a un
|
|
85
|
+
* ecart entre l'ecran et la vue (l'API, elle, ne garde pas ces entrees).
|
|
86
|
+
*/
|
|
87
|
+
/**
|
|
88
|
+
* Libelles des options d'affichage, tous ecrans confondus.
|
|
89
|
+
*
|
|
90
|
+
* Une vue garde les preferences de carte sous leur cle (`oncoIcd10`,
|
|
91
|
+
* `genes`...). La carte de vue montre le libelle. Les cles ne se recoupent pas
|
|
92
|
+
* d'un ecran a l'autre, une table unique suffit.
|
|
93
|
+
*/
|
|
94
|
+
const prefLabels = computed<Record<string, string>>(() =>
|
|
95
|
+
Object.fromEntries(
|
|
96
|
+
Object.values(CARD_PREF_GROUPS)
|
|
97
|
+
.flat()
|
|
98
|
+
.flatMap((group) => group.options)
|
|
99
|
+
.map((option) => [
|
|
100
|
+
option.key,
|
|
101
|
+
locale.value === "fr" ? option.fr : option.en,
|
|
102
|
+
]),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
function filledValues(item: any): string[] {
|
|
107
|
+
if (item?.filter === VIEW_PREFS_FILTER && Array.isArray(item.values)) {
|
|
108
|
+
return item.values.map((key: string) => prefLabels.value[key] ?? key);
|
|
109
|
+
}
|
|
110
|
+
return viewFilledValues(item);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Criteres retenus : pagination exclue, filtres vides exclus. */
|
|
114
|
+
function usefulCriteria(scope: any) {
|
|
115
|
+
return parseCriteria(scope).filter((item: any) => filledValues(item).length > 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Les criteres qui portent des produits (vues du portail) : cites en premier. */
|
|
119
|
+
const PRODUCT_FILTERS = VIEW_BRAND_FILTERS;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Le bloc « contenu » d'une carte : ses criteres, valeurs comprises, les
|
|
123
|
+
* produits d'abord. Meme lecture que les templates de l'onglet voisin : un
|
|
124
|
+
* nombre de produits ne disait pas lesquels.
|
|
125
|
+
*/
|
|
126
|
+
function cardConfig(criteria: any[]) {
|
|
127
|
+
const filters = [
|
|
128
|
+
...criteria.filter((item: any) => PRODUCT_FILTERS.includes(item?.filter)),
|
|
129
|
+
...criteria.filter((item: any) => !PRODUCT_FILTERS.includes(item?.filter)),
|
|
130
|
+
];
|
|
131
|
+
if (!filters.length) return [];
|
|
132
|
+
return [
|
|
133
|
+
{
|
|
134
|
+
icon: "Funnel",
|
|
135
|
+
key: `${locale.value === "en" ? "Filters" : "Filtres"} (${
|
|
136
|
+
filters.filter((item: any) => item.filter !== VIEW_PREFS_FILTER).length
|
|
137
|
+
})`,
|
|
138
|
+
pills: filters.map((item: any) => ({
|
|
139
|
+
k: filterLabel(item.filter),
|
|
140
|
+
v: filledValues(item).join(", "),
|
|
141
|
+
})),
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const dateFormat = computed(
|
|
147
|
+
() => new Intl.DateTimeFormat(locale.value === "en" ? "en-GB" : "fr-FR", { dateStyle: "medium" }),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
function metaOf(scope: any) {
|
|
151
|
+
const meta = [
|
|
152
|
+
{
|
|
153
|
+
icon: "User",
|
|
154
|
+
label: scope.isOwner === false
|
|
155
|
+
? (scope.ownerName ?? (locale.value === "en" ? "Shared" : "Partagée"))
|
|
156
|
+
: locale.value === "en"
|
|
157
|
+
? "You"
|
|
158
|
+
: "Vous",
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
// Une vue deja surveillee le dit sur sa carte : sans ce repere, le
|
|
162
|
+
// raccourci « Créer une alerte » proposait un doublon a l'aveugle.
|
|
163
|
+
const watched = alerts.alerts.value.filter(
|
|
164
|
+
(alert) => alert.kind === "view" && alert.pulseId === scope.id,
|
|
165
|
+
).length;
|
|
166
|
+
if (watched) {
|
|
167
|
+
meta.push({
|
|
168
|
+
icon: "Bell",
|
|
169
|
+
label:
|
|
170
|
+
locale.value === "en"
|
|
171
|
+
? `${watched} alert${watched > 1 ? "s" : ""}`
|
|
172
|
+
: `${watched} alerte${watched > 1 ? "s" : ""}`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (scope.createdAt) {
|
|
176
|
+
const date = new Date(scope.createdAt);
|
|
177
|
+
if (!Number.isNaN(date.getTime())) {
|
|
178
|
+
meta.push({
|
|
179
|
+
icon: "Clock",
|
|
180
|
+
label:
|
|
181
|
+
(locale.value === "en" ? "Created on " : "Créée le ") + dateFormat.value.format(date),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return meta;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/*
|
|
189
|
+
* Menu des cartes, partage compris : commun avec l'etat vide des pages.
|
|
190
|
+
*
|
|
191
|
+
* Appele avant `cardsOf` qui lit `menuOf` : un `watch` sur la liste des vues
|
|
192
|
+
* recues evalue sa source des le `setup`, et le bundle jetait « Cannot access
|
|
193
|
+
* ... before initialization » chez le destinataire d'un partage quand le menu
|
|
194
|
+
* etait declare plus bas. `apply` est une declaration de fonction, hissee.
|
|
195
|
+
*/
|
|
196
|
+
const {
|
|
197
|
+
menuOf,
|
|
198
|
+
isShared,
|
|
199
|
+
onAction: onCardAction,
|
|
200
|
+
rename,
|
|
201
|
+
star,
|
|
202
|
+
sharing,
|
|
203
|
+
shareTarget,
|
|
204
|
+
shareMembers,
|
|
205
|
+
isSuperAdmin,
|
|
206
|
+
workspaces,
|
|
207
|
+
templateNote,
|
|
208
|
+
onShareSearch,
|
|
209
|
+
confirmShare,
|
|
210
|
+
} = useViewCardActions(apply);
|
|
211
|
+
|
|
212
|
+
function cardsOf(items: any[]) {
|
|
213
|
+
return items.map((scope: any) => {
|
|
214
|
+
const criteria = usefulCriteria(scope);
|
|
215
|
+
const mine = scope.isOwner !== false;
|
|
216
|
+
return {
|
|
217
|
+
id: scope.id,
|
|
218
|
+
title: scope.title,
|
|
219
|
+
description: scope.description,
|
|
220
|
+
active: selected.value?.id === scope.id,
|
|
221
|
+
starred: scope.starred === true,
|
|
222
|
+
isDefault: scope.isDefault === true,
|
|
223
|
+
// Le bandeau de la carte dit d'ou vient la vue : recue de quelqu'un, ou
|
|
224
|
+
// a moi mais ouverte a d'autres.
|
|
225
|
+
sharedBy: mine ? undefined : (scope.ownerName ?? undefined),
|
|
226
|
+
shared: mine && isShared(scope),
|
|
227
|
+
// Un template reste prive au sens du partage : sans sa propre marque,
|
|
228
|
+
// sa carte serait celle d'une vue que personne ne voit.
|
|
229
|
+
isTemplate: mine && scope.isTemplate === true,
|
|
230
|
+
templateTargets: scope.targetWorkspaces?.length ?? 0,
|
|
231
|
+
// Un menu par carte : le lecteur d'une vue recue ne peut ni la renommer,
|
|
232
|
+
// ni la partager, ni la supprimer.
|
|
233
|
+
menu: menuOf(scope),
|
|
234
|
+
renamable: mine,
|
|
235
|
+
updatedAt: scope.createdAt,
|
|
236
|
+
config: cardConfig(criteria),
|
|
237
|
+
meta: metaOf(scope),
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Mes vues de cette page. Les templates que j'ai publies n'y sont pas : ils se
|
|
244
|
+
* gerent dans l'onglet « Admin templates » du groupe, et melanges ici ils
|
|
245
|
+
* noyaient les vues de travail de l'administrateur.
|
|
246
|
+
*/
|
|
247
|
+
const list = computed(() =>
|
|
248
|
+
cardsOf(
|
|
249
|
+
scopes.value.filter((scope: any) => scope.isOwner !== false && scope.isTemplate !== true),
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
/** Les vues que d'autres m'ont ouvertes, sur cette page. */
|
|
254
|
+
const sharedList = computed(() =>
|
|
255
|
+
cardsOf(scopes.value.filter((scope: any) => scope.isOwner === false)),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// ------------------------------------------------------- templates Tekkare
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Trois onglets : mes vues, celles qu'on m'a partagees, les templates fournis.
|
|
262
|
+
* L'onglet des vues recues sort meme vide : c'est la seule place ou l'on va
|
|
263
|
+
* voir si quelqu'un a partage quelque chose, et le cacher quand il n'y a rien
|
|
264
|
+
* laissait croire que le partage n'existait pas.
|
|
265
|
+
*/
|
|
266
|
+
const tab = ref("mine");
|
|
267
|
+
|
|
268
|
+
// Mise en carte et demarrage vivent dans `useViewTemplates` : l'etat vide des
|
|
269
|
+
// pages a selection de produits propose les memes templates, de la meme facon.
|
|
270
|
+
const { cards: templateList, start, save: saveTemplate } = useViewTemplates(() => page.value);
|
|
271
|
+
|
|
272
|
+
const tabs = computed(() => [
|
|
273
|
+
{
|
|
274
|
+
key: "mine",
|
|
275
|
+
label: locale.value === "en" ? "My views" : "Mes vues",
|
|
276
|
+
icon: "BookmarkSimple",
|
|
277
|
+
count: list.value.length,
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
key: "shared",
|
|
281
|
+
label: locale.value === "en" ? "Team" : "Équipe",
|
|
282
|
+
icon: "UsersThree",
|
|
283
|
+
count: sharedList.value.length,
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
key: "templates",
|
|
287
|
+
label: locale.value === "en" ? "Tekkare templates" : "Templates Tekkare",
|
|
288
|
+
icon: "MagicWand",
|
|
289
|
+
count: templateList.value.length,
|
|
290
|
+
},
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
const onTemplates = computed(() => tab.value === "templates");
|
|
294
|
+
const onShared = computed(() => tab.value === "shared");
|
|
295
|
+
|
|
296
|
+
const signature = viewSignature;
|
|
297
|
+
|
|
298
|
+
// Filtres a l'ecran, plus l'etat du « Personnaliser » : les deux partent dans
|
|
299
|
+
// la vue, et les deux comptent pour dire si la vue active est depassee.
|
|
300
|
+
const current = computed(() => views.criteriaOf());
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* La vue active ne correspond plus a l'ecran : l'utilisateur a touche aux
|
|
304
|
+
* filtres depuis qu'il l'a appliquee. On le lui dit plutot que de laisser
|
|
305
|
+
* croire que la vue porte ce qu'il voit.
|
|
306
|
+
*
|
|
307
|
+
* Une vue appartient a une page : si la vue active vient d'ailleurs (elle a
|
|
308
|
+
* ete appliquee sur un autre ecran, et son application a pu faire naviguer
|
|
309
|
+
* jusqu'ici), il n'y a rien a rattraper. Proposer de l'ecraser avec les
|
|
310
|
+
* filtres d'une autre page detruirait la vue de l'utilisateur.
|
|
311
|
+
*/
|
|
312
|
+
const dirtyTitle = computed(() => {
|
|
313
|
+
const scope = selected.value;
|
|
314
|
+
if (!scope?.id || !sameViewPage(scope.page, page.value)) return "";
|
|
315
|
+
// Une vue recue ne s'ecrase pas : la banniere n'offrirait qu'un bouton
|
|
316
|
+
// interdit. Ce qu'on peut faire de cet ecran, c'est l'enregistrer comme
|
|
317
|
+
// vue a soi, ce que le bouton du panneau propose deja.
|
|
318
|
+
if (scope.isOwner === false) return "";
|
|
319
|
+
if (signature(current.value) === signature(parseCriteria(scope))) return "";
|
|
320
|
+
return scope.title;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------- sauvegarde
|
|
324
|
+
|
|
325
|
+
/** Formulaire de sauvegarde, ouvert par le bouton ou par « comme nouvelle ». */
|
|
326
|
+
const saving = ref(false);
|
|
327
|
+
const draftTitle = ref("");
|
|
328
|
+
const draftDescription = ref("");
|
|
329
|
+
const titleMissing = ref(false);
|
|
330
|
+
|
|
331
|
+
function openSave(prefill = "") {
|
|
332
|
+
draftTitle.value = prefill;
|
|
333
|
+
draftDescription.value = "";
|
|
334
|
+
titleMissing.value = false;
|
|
335
|
+
saving.value = true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function confirmSave() {
|
|
339
|
+
const title = draftTitle.value.trim();
|
|
340
|
+
if (!title) {
|
|
341
|
+
titleMissing.value = true;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
saving.value = false;
|
|
345
|
+
// Les identifiants d'avant : la creation ne rend pas la vue, elle recharge
|
|
346
|
+
// la liste. La chercher par titre appliquait une homonyme plus ancienne.
|
|
347
|
+
const before = new Set(store().scopeList.value.map((scope: any) => scope.id));
|
|
348
|
+
await store().createScope({
|
|
349
|
+
title,
|
|
350
|
+
description: draftDescription.value.trim(),
|
|
351
|
+
criteria: current.value,
|
|
352
|
+
currentPage: page.value,
|
|
353
|
+
});
|
|
354
|
+
// La vue tout juste creee devient la vue active : c'est ce qui est a l'ecran.
|
|
355
|
+
const created = store()
|
|
356
|
+
.scopeList.value.filter((scope: any) => sameViewPage(scope.page, page.value))
|
|
357
|
+
.find((scope: any) => !before.has(scope.id));
|
|
358
|
+
if (created) apply(created.id);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ------------------------------------------------------------------- actions
|
|
362
|
+
|
|
363
|
+
function scopeOf(id: string) {
|
|
364
|
+
return scopes.value.find((scope: any) => scope.id === id) ?? null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function apply(id: string) {
|
|
368
|
+
const scope = scopeOf(id);
|
|
369
|
+
if (!scope) return;
|
|
370
|
+
// Copie volontaire : les filtres se reappliquent sur un changement de
|
|
371
|
+
// reference de `selectedScope`. Rejouer le meme objet ne reveillerait aucun
|
|
372
|
+
// watcher, donc « Annuler » ne remettrait rien en place.
|
|
373
|
+
store().setSelectedScope({ ...scope });
|
|
374
|
+
views.applyPrefs(scope, source.value?.prefsScope);
|
|
375
|
+
source.value?.onApply?.(scope);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* « Nouvelle vue » : meme geste que celui de la pastille. L'ecran repart
|
|
381
|
+
* vierge (cf. `resetAll`), puis la page ouvre son tiroir de selection quand
|
|
382
|
+
* elle en a un, ou la vue se compose.
|
|
383
|
+
*/
|
|
384
|
+
function startNew() {
|
|
385
|
+
const page = source.value;
|
|
386
|
+
views.resetAll();
|
|
387
|
+
page?.onEdit?.();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function onAction(key: string, id: string) {
|
|
391
|
+
if (key === "save-template") void saveTemplate(id);
|
|
392
|
+
else onCardAction(key, id);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Banniere : remettre l'ecran dans l'etat de la vue active. */
|
|
396
|
+
function discard() {
|
|
397
|
+
if (selected.value?.id) apply(selected.value.id);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Banniere : ecrire l'ecran courant dans la vue active. */
|
|
401
|
+
async function save() {
|
|
402
|
+
const scope = selected.value;
|
|
403
|
+
if (!scope?.id) return;
|
|
404
|
+
await store().updateScope({
|
|
405
|
+
id: scope.id,
|
|
406
|
+
title: scope.title,
|
|
407
|
+
description: scope.description,
|
|
408
|
+
criteria: current.value,
|
|
409
|
+
currentPage: scope.page,
|
|
410
|
+
});
|
|
411
|
+
apply(scope.id);
|
|
412
|
+
// Une alerte de marque posee sur cette vue ne suit pas ses produits : elle
|
|
413
|
+
// porte sa propre liste. On propose le report plutot que de le faire.
|
|
414
|
+
brandSync.ask(scope.id, viewBrandsFromCriteria(current.value));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ------------------------------------------------------- mode modification
|
|
418
|
+
|
|
419
|
+
/*
|
|
420
|
+
* Ouvert par « Modifier ma vue » de la pastille. Le panneau prend alors toute
|
|
421
|
+
* la place : nom, description, contenu. Le contenu se change par l'action de
|
|
422
|
+
* la page quand elle en a une (le tiroir de produits du portail, qui ecrit
|
|
423
|
+
* lui-meme dans la vue), sinon par les filtres de la page, repris ici.
|
|
424
|
+
*/
|
|
425
|
+
const editing = useViewEditing();
|
|
426
|
+
const editTitle = ref("");
|
|
427
|
+
const editDescription = ref("");
|
|
428
|
+
const editTitleMissing = ref(false);
|
|
429
|
+
const editBusy = ref(false);
|
|
430
|
+
/** Ecrire aussi les filtres affiches, quand ils different de la vue. */
|
|
431
|
+
const editTakeScreen = ref(true);
|
|
432
|
+
|
|
433
|
+
/** La vue modifiee, lue dans la liste : elle suit le tiroir de produits. */
|
|
434
|
+
const editScope = computed(() => (editing.value?.id ? scopeOf(editing.value.id) : null));
|
|
435
|
+
const editCreating = computed(() => editing.value !== null && !editScope.value);
|
|
436
|
+
|
|
437
|
+
/** L'ecran ne correspond plus a la vue modifiee. */
|
|
438
|
+
const editScreenDiffers = computed(
|
|
439
|
+
() =>
|
|
440
|
+
!!editScope.value &&
|
|
441
|
+
selected.value?.id === editScope.value.id &&
|
|
442
|
+
signature(current.value) !== signature(parseCriteria(editScope.value)),
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
/** Ce que la vue emportera a l'enregistrement. */
|
|
446
|
+
const editCriteria = computed(() =>
|
|
447
|
+
!editScope.value || (editScreenDiffers.value && editTakeScreen.value)
|
|
448
|
+
? current.value
|
|
449
|
+
: parseCriteria(editScope.value),
|
|
450
|
+
);
|
|
451
|
+
const editContent = computed(() => cardConfig(usefulCriteria({ criteria: editCriteria.value }))[0]?.pills ?? []);
|
|
452
|
+
|
|
453
|
+
// Rejoue aussi quand la vue arrive dans la liste : le panneau charge les vues
|
|
454
|
+
// a son montage, apres que la pastille a demande le mode.
|
|
455
|
+
watch(
|
|
456
|
+
() => [editing.value, editScope.value?.id] as const,
|
|
457
|
+
([value], previous) => {
|
|
458
|
+
if (!value || (previous?.[0] === value && previous?.[1])) return;
|
|
459
|
+
const scope = value.id ? scopeOf(value.id) : null;
|
|
460
|
+
// Une vue recue se reprend sous son nom : la copie est a soi.
|
|
461
|
+
editTitle.value = scope?.title ?? (selected.value?.id ? selected.value.title : "");
|
|
462
|
+
editDescription.value = scope?.description ?? selected.value?.description ?? "";
|
|
463
|
+
editTitleMissing.value = false;
|
|
464
|
+
editTakeScreen.value = true;
|
|
465
|
+
},
|
|
466
|
+
{ immediate: true },
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// Fermer le rail quitte le mode : rouvert plus tard, le panneau montre ses listes.
|
|
470
|
+
onBeforeUnmount(() => {
|
|
471
|
+
editing.value = null;
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
async function confirmEdit() {
|
|
475
|
+
const title = editTitle.value.trim();
|
|
476
|
+
if (!title) {
|
|
477
|
+
editTitleMissing.value = true;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const scope = editScope.value;
|
|
481
|
+
const criteria = editCriteria.value;
|
|
482
|
+
const description = editDescription.value.trim();
|
|
483
|
+
editBusy.value = true;
|
|
484
|
+
try {
|
|
485
|
+
if (scope) {
|
|
486
|
+
const contentChanged = signature(criteria) !== signature(parseCriteria(scope));
|
|
487
|
+
await store().updateScope({
|
|
488
|
+
id: scope.id,
|
|
489
|
+
title,
|
|
490
|
+
description,
|
|
491
|
+
criteria,
|
|
492
|
+
currentPage: scope.page,
|
|
493
|
+
refreshAlerts: contentChanged,
|
|
494
|
+
});
|
|
495
|
+
// La vue active porte une copie : la rejouer met le nouveau nom sur la
|
|
496
|
+
// pastille (et remet l'ecran sur la vue si on n'a pas repris ses filtres).
|
|
497
|
+
if (selected.value?.id === scope.id) apply(scope.id);
|
|
498
|
+
if (contentChanged) brandSync.ask(scope.id, viewBrandsFromCriteria(criteria));
|
|
499
|
+
} else {
|
|
500
|
+
const before = new Set(store().scopeList.value.map((item: any) => item.id));
|
|
501
|
+
await store().createScope({ title, description, criteria, currentPage: page.value });
|
|
502
|
+
const created = store()
|
|
503
|
+
.scopeList.value.filter((item: any) => sameViewPage(item.page, page.value))
|
|
504
|
+
.find((item: any) => !before.has(item.id));
|
|
505
|
+
if (created) apply(created.id);
|
|
506
|
+
}
|
|
507
|
+
editing.value = null;
|
|
508
|
+
} finally {
|
|
509
|
+
editBusy.value = false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
</script>
|
|
514
|
+
|
|
515
|
+
<template>
|
|
516
|
+
<div class="full_width">
|
|
517
|
+
<form v-if="editing" class="vedit" @submit.prevent="confirmEdit">
|
|
518
|
+
<button type="button" class="vedit_back" @click="editing = null">
|
|
519
|
+
<RmIcon name="ArrowLeft" :size="14" />
|
|
520
|
+
{{ locale === "en" ? "All views" : "Toutes les vues" }}
|
|
521
|
+
</button>
|
|
522
|
+
<p class="vedit_title">
|
|
523
|
+
<RmIcon name="PencilSimple" :size="18" class="vedit_title_icon" />
|
|
524
|
+
{{
|
|
525
|
+
editCreating
|
|
526
|
+
? locale === "en" ? "Save the view" : "Sauvegarder la vue"
|
|
527
|
+
: locale === "en" ? "Edit the view" : "Modifier la vue"
|
|
528
|
+
}}
|
|
529
|
+
</p>
|
|
530
|
+
<p v-if="editCreating" class="vedit_hint">
|
|
531
|
+
{{
|
|
532
|
+
locale === "en"
|
|
533
|
+
? "This view is not saved yet. Name it to find it again."
|
|
534
|
+
: "Cette vue n'est pas encore enregistrée. Nommez-la pour la retrouver."
|
|
535
|
+
}}
|
|
536
|
+
</p>
|
|
537
|
+
|
|
538
|
+
<label class="vsave_field">
|
|
539
|
+
<span class="vsave_label">{{ locale === "en" ? "View name" : "Nom de la vue" }}</span>
|
|
540
|
+
<input
|
|
541
|
+
v-model="editTitle"
|
|
542
|
+
class="vsave_input"
|
|
543
|
+
:class="{ vsave_input_error: editTitleMissing }"
|
|
544
|
+
type="text"
|
|
545
|
+
maxlength="60"
|
|
546
|
+
:placeholder="locale === 'en' ? 'e.g. Oncology monitoring' : 'ex. Veille oncologie'"
|
|
547
|
+
autofocus
|
|
548
|
+
>
|
|
549
|
+
<span v-if="editTitleMissing" class="vsave_error">
|
|
550
|
+
{{ locale === "en" ? "A name is required." : "Un nom est nécessaire." }}
|
|
551
|
+
</span>
|
|
552
|
+
</label>
|
|
553
|
+
<label class="vsave_field">
|
|
554
|
+
<span class="vsave_label">
|
|
555
|
+
{{ locale === "en" ? "Description (optional)" : "Description (optionnelle)" }}
|
|
556
|
+
</span>
|
|
557
|
+
<textarea v-model="editDescription" class="vsave_input" rows="3" maxlength="240" />
|
|
558
|
+
</label>
|
|
559
|
+
|
|
560
|
+
<div class="vsave_field">
|
|
561
|
+
<!-- Le geste qui change le contenu se pose sur son titre : il dit de quoi
|
|
562
|
+
il parle sans descendre sous la liste des filtres. -->
|
|
563
|
+
<div class="vedit_content_head">
|
|
564
|
+
<span class="vsave_label">{{ locale === "en" ? "Content" : "Contenu de la vue" }}</span>
|
|
565
|
+
<RmButton
|
|
566
|
+
v-if="source?.onEdit"
|
|
567
|
+
variant="primary"
|
|
568
|
+
size="sm"
|
|
569
|
+
prefix-icon="Package"
|
|
570
|
+
@click="source.onEdit()"
|
|
571
|
+
>
|
|
572
|
+
{{ source.editLabel ?? (locale === "en" ? "Edit the view content" : "Modifier le contenu de la vue") }}
|
|
573
|
+
</RmButton>
|
|
574
|
+
</div>
|
|
575
|
+
<div class="vedit_content">
|
|
576
|
+
<span v-for="pill in editContent" :key="pill.k" class="vedit_chip" :title="pill.v">
|
|
577
|
+
<span class="vedit_chip_k">{{ pill.k }}</span>
|
|
578
|
+
<span class="vedit_chip_v">{{ pill.v }}</span>
|
|
579
|
+
</span>
|
|
580
|
+
<span v-if="editContent.length === 0" class="vedit_empty">
|
|
581
|
+
{{ locale === "en" ? "No filter set" : "Aucun filtre posé" }}
|
|
582
|
+
</span>
|
|
583
|
+
</div>
|
|
584
|
+
<label v-if="editScreenDiffers" class="vedit_check">
|
|
585
|
+
<input v-model="editTakeScreen" type="checkbox">
|
|
586
|
+
<span>
|
|
587
|
+
{{
|
|
588
|
+
locale === "en"
|
|
589
|
+
? "Save the filters shown on the page"
|
|
590
|
+
: "Enregistrer les filtres affichés sur la page"
|
|
591
|
+
}}
|
|
592
|
+
<span class="vedit_check_hint">
|
|
593
|
+
{{
|
|
594
|
+
locale === "en"
|
|
595
|
+
? "(otherwise the page returns to the saved view)"
|
|
596
|
+
: "(sinon la page revient à la vue enregistrée)"
|
|
597
|
+
}}
|
|
598
|
+
</span>
|
|
599
|
+
</span>
|
|
600
|
+
</label>
|
|
601
|
+
<p v-if="!source?.onEdit && !editScreenDiffers" class="vedit_hint">
|
|
602
|
+
{{
|
|
603
|
+
locale === "en"
|
|
604
|
+
? "To change the content, adjust the page filters: they can then be saved here."
|
|
605
|
+
: "Pour changer le contenu, modifiez les filtres de la page : vous pourrez ensuite les enregistrer ici."
|
|
606
|
+
}}
|
|
607
|
+
</p>
|
|
608
|
+
</div>
|
|
609
|
+
|
|
610
|
+
<div class="vsave_actions vedit_actions">
|
|
611
|
+
<RmButton variant="primary" size="sm" type="submit" prefix-icon="BookmarkSimple" :loading="editBusy">
|
|
612
|
+
{{ locale === "en" ? "Save" : "Enregistrer" }}
|
|
613
|
+
</RmButton>
|
|
614
|
+
<RmButton variant="secondary" size="sm" :disabled="editBusy" @click="editing = null">
|
|
615
|
+
{{ locale === "en" ? "Cancel" : "Annuler" }}
|
|
616
|
+
</RmButton>
|
|
617
|
+
</div>
|
|
618
|
+
</form>
|
|
619
|
+
|
|
620
|
+
<RmSavedViews
|
|
621
|
+
v-else
|
|
622
|
+
v-model:tab="tab"
|
|
623
|
+
class="full_width"
|
|
624
|
+
:views="onTemplates ? templateList : onShared ? sharedList : list"
|
|
625
|
+
:tabs="tabs"
|
|
626
|
+
:description="
|
|
627
|
+
onTemplates
|
|
628
|
+
? locale === 'en'
|
|
629
|
+
? 'Ready-made views built by Tekkare. Start an analysis, then adjust it and save it in your own views.'
|
|
630
|
+
: `Vues prêtes à l'emploi créées par Tekkare. Démarrez une analyse, vous pouvez ensuite la personnaliser et l'enregistrer dans vos vues.`
|
|
631
|
+
: locale === 'en'
|
|
632
|
+
? 'A view captures the filters of an analysis. Save your setup to find it again instantly.'
|
|
633
|
+
: `Une vue capture les filtres d'une analyse. Sauvegardez votre configuration pour la retrouver instantanément.`
|
|
634
|
+
"
|
|
635
|
+
:save-label="
|
|
636
|
+
saving || onTemplates || onShared
|
|
637
|
+
? ''
|
|
638
|
+
: locale === 'en'
|
|
639
|
+
? 'Save the current view'
|
|
640
|
+
: 'Sauvegarder la vue actuelle'
|
|
641
|
+
"
|
|
642
|
+
:save-disabled="current.length === 0"
|
|
643
|
+
:reset-label="
|
|
644
|
+
saving || onTemplates || onShared ? '' : locale === 'en' ? 'New view' : 'Nouvelle vue'
|
|
645
|
+
"
|
|
646
|
+
:menu="[]"
|
|
647
|
+
:renamable="!onTemplates"
|
|
648
|
+
:starrable="!onTemplates"
|
|
649
|
+
:loading="onTemplates ? false : loading"
|
|
650
|
+
:hide-tip="onTemplates"
|
|
651
|
+
:dirty-title="onTemplates || onShared ? '' : dirtyTitle"
|
|
652
|
+
:empty-title="
|
|
653
|
+
onTemplates
|
|
654
|
+
? locale === 'en'
|
|
655
|
+
? 'No template yet'
|
|
656
|
+
: 'Aucun template pour cette page'
|
|
657
|
+
: onShared
|
|
658
|
+
? locale === 'en'
|
|
659
|
+
? 'No view shared with you'
|
|
660
|
+
: 'Aucune vue partagée avec vous'
|
|
661
|
+
: ''
|
|
662
|
+
"
|
|
663
|
+
:empty-text="
|
|
664
|
+
onTemplates
|
|
665
|
+
? locale === 'en'
|
|
666
|
+
? 'Tekkare has not published a ready-made view for this analysis yet.'
|
|
667
|
+
: `Tekkare n'a pas encore publié de vue prête à l'emploi pour cette analyse.`
|
|
668
|
+
: onShared
|
|
669
|
+
? locale === 'en'
|
|
670
|
+
? 'Views your colleagues share with you show up here.'
|
|
671
|
+
: 'Les vues que vos collègues vous partagent apparaissent ici.'
|
|
672
|
+
: ''
|
|
673
|
+
"
|
|
674
|
+
:empty-action-label="
|
|
675
|
+
onTemplates || onShared || current.length === 0
|
|
676
|
+
? ''
|
|
677
|
+
: locale === 'en'
|
|
678
|
+
? 'Save your first view'
|
|
679
|
+
: 'Sauvegarder votre première vue'
|
|
680
|
+
"
|
|
681
|
+
:lang="locale === 'en' ? 'en' : 'fr'"
|
|
682
|
+
@save-current="openSave()"
|
|
683
|
+
@reset="startNew"
|
|
684
|
+
@activate="onTemplates ? start($event) : apply($event)"
|
|
685
|
+
@star="star"
|
|
686
|
+
@rename="rename"
|
|
687
|
+
@action="onAction"
|
|
688
|
+
@discard="discard"
|
|
689
|
+
@save="save"
|
|
690
|
+
@save-as="openSave(selected?.title ?? '')"
|
|
691
|
+
>
|
|
692
|
+
<template #before-list>
|
|
693
|
+
<!-- Le formulaire prend la place du bouton, dans le panneau : nommer une
|
|
694
|
+
vue ne merite pas une fenetre par-dessus la page qu'on est en train
|
|
695
|
+
de regarder. -->
|
|
696
|
+
<form v-if="saving && !onTemplates" class="vsave" @submit.prevent="confirmSave">
|
|
697
|
+
<label class="vsave_field">
|
|
698
|
+
<span class="vsave_label">{{ locale === "en" ? "View name" : "Nom de la vue" }}</span>
|
|
699
|
+
<input
|
|
700
|
+
v-model="draftTitle"
|
|
701
|
+
class="vsave_input"
|
|
702
|
+
:class="{ vsave_input_error: titleMissing }"
|
|
703
|
+
type="text"
|
|
704
|
+
:placeholder="
|
|
705
|
+
locale === 'en' ? 'e.g. Oncology monitoring' : 'ex. Veille oncologie'
|
|
706
|
+
"
|
|
707
|
+
autofocus
|
|
708
|
+
>
|
|
709
|
+
</label>
|
|
710
|
+
<label class="vsave_field">
|
|
711
|
+
<span class="vsave_label">
|
|
712
|
+
{{ locale === "en" ? "Description (optional)" : "Description (optionnelle)" }}
|
|
713
|
+
</span>
|
|
714
|
+
<textarea v-model="draftDescription" class="vsave_input" rows="2" />
|
|
715
|
+
</label>
|
|
716
|
+
<p v-if="titleMissing" class="vsave_error">
|
|
717
|
+
{{ locale === "en" ? "A name is required." : "Un nom est nécessaire." }}
|
|
718
|
+
</p>
|
|
719
|
+
<div class="vsave_actions">
|
|
720
|
+
<RmButton variant="secondary" size="sm" @click="saving = false">
|
|
721
|
+
{{ locale === "en" ? "Cancel" : "Annuler" }}
|
|
722
|
+
</RmButton>
|
|
723
|
+
<RmButton variant="primary" size="sm" type="submit" prefix-icon="BookmarkSimple">
|
|
724
|
+
{{ locale === "en" ? "Save" : "Sauvegarder" }}
|
|
725
|
+
</RmButton>
|
|
726
|
+
</div>
|
|
727
|
+
</form>
|
|
728
|
+
</template>
|
|
729
|
+
</RmSavedViews>
|
|
730
|
+
|
|
731
|
+
<RmShareDialog
|
|
732
|
+
v-model="sharing"
|
|
733
|
+
:title="shareTarget?.title ?? ''"
|
|
734
|
+
:members="shareMembers"
|
|
735
|
+
:visibility="shareTarget?.visibility ?? 'private'"
|
|
736
|
+
:shared-with="shareTarget?.sharedWith ?? []"
|
|
737
|
+
:can-template="isSuperAdmin"
|
|
738
|
+
:is-template="shareTarget?.isTemplate ?? false"
|
|
739
|
+
:target-workspaces="shareTarget?.targetWorkspaces ?? []"
|
|
740
|
+
:workspaces="workspaces"
|
|
741
|
+
:template-note="templateNote"
|
|
742
|
+
:lang="locale === 'en' ? 'en' : 'fr'"
|
|
743
|
+
@search="onShareSearch"
|
|
744
|
+
@save="confirmShare"
|
|
745
|
+
/>
|
|
746
|
+
</div>
|
|
747
|
+
</template>
|
|
748
|
+
|
|
749
|
+
<style scoped>
|
|
750
|
+
.full_width {
|
|
751
|
+
align-self: stretch;
|
|
752
|
+
width: 100%;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
.vsave {
|
|
756
|
+
display: flex;
|
|
757
|
+
flex-direction: column;
|
|
758
|
+
gap: 10px;
|
|
759
|
+
padding: 14px 16px;
|
|
760
|
+
border: 1px solid var(--border-default);
|
|
761
|
+
border-radius: var(--radius-lg);
|
|
762
|
+
background: var(--bg-surface-2);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
.vsave_field {
|
|
766
|
+
display: flex;
|
|
767
|
+
flex-direction: column;
|
|
768
|
+
gap: 4px;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.vsave_label {
|
|
772
|
+
color: var(--text-muted);
|
|
773
|
+
font-size: var(--font-11);
|
|
774
|
+
font-weight: var(--weight-semibold);
|
|
775
|
+
letter-spacing: 0.04em;
|
|
776
|
+
text-transform: uppercase;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
.vsave_input {
|
|
780
|
+
padding: 8px 10px;
|
|
781
|
+
border: 1px solid var(--border-default);
|
|
782
|
+
border-radius: var(--radius-md);
|
|
783
|
+
background: var(--bg-surface);
|
|
784
|
+
color: var(--text-primary);
|
|
785
|
+
font-family: var(--font-sans);
|
|
786
|
+
font-size: var(--font-13);
|
|
787
|
+
outline: none;
|
|
788
|
+
resize: vertical;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
.vsave_input:focus {
|
|
792
|
+
border-color: var(--color-secondary);
|
|
793
|
+
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.1);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
.vsave_input_error,
|
|
797
|
+
.vsave_input_error:focus {
|
|
798
|
+
border-color: var(--color-danger);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
.vsave_error {
|
|
802
|
+
margin: 0;
|
|
803
|
+
color: var(--color-danger);
|
|
804
|
+
font-size: var(--font-12);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
.vsave_actions {
|
|
808
|
+
display: flex;
|
|
809
|
+
justify-content: flex-end;
|
|
810
|
+
gap: 8px;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
.vedit {
|
|
814
|
+
display: flex;
|
|
815
|
+
flex-direction: column;
|
|
816
|
+
gap: 16px;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
.vedit_back {
|
|
820
|
+
align-self: flex-start;
|
|
821
|
+
display: inline-flex;
|
|
822
|
+
align-items: center;
|
|
823
|
+
gap: 6px;
|
|
824
|
+
padding: 0;
|
|
825
|
+
border: 0;
|
|
826
|
+
background: none;
|
|
827
|
+
color: var(--text-muted);
|
|
828
|
+
font-family: var(--font-sans);
|
|
829
|
+
font-size: var(--font-12);
|
|
830
|
+
cursor: pointer;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
.vedit_back:hover {
|
|
834
|
+
color: var(--color-secondary);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
.vedit_title {
|
|
838
|
+
display: flex;
|
|
839
|
+
align-items: center;
|
|
840
|
+
gap: 8px;
|
|
841
|
+
margin: 0;
|
|
842
|
+
color: var(--text-primary);
|
|
843
|
+
font-size: var(--font-16);
|
|
844
|
+
font-weight: var(--weight-semibold);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.vedit_title_icon {
|
|
848
|
+
color: var(--color-secondary);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
.vedit_hint {
|
|
852
|
+
margin: 0;
|
|
853
|
+
color: var(--text-muted);
|
|
854
|
+
font-size: var(--font-12);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
.vedit_content {
|
|
858
|
+
display: flex;
|
|
859
|
+
flex-wrap: wrap;
|
|
860
|
+
gap: 6px;
|
|
861
|
+
padding: 10px 12px;
|
|
862
|
+
border: 1px solid var(--border-soft);
|
|
863
|
+
border-radius: var(--radius-md);
|
|
864
|
+
background: var(--bg-surface-2);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
.vedit_chip {
|
|
868
|
+
display: inline-flex;
|
|
869
|
+
gap: 6px;
|
|
870
|
+
max-width: 100%;
|
|
871
|
+
padding: 3px 8px;
|
|
872
|
+
border: 1px solid var(--border-default);
|
|
873
|
+
border-radius: var(--radius-pill);
|
|
874
|
+
background: var(--bg-surface);
|
|
875
|
+
font-size: var(--font-12);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
.vedit_chip_k {
|
|
879
|
+
flex-shrink: 0;
|
|
880
|
+
color: var(--text-muted);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
.vedit_chip_v {
|
|
884
|
+
overflow: hidden;
|
|
885
|
+
color: var(--text-primary);
|
|
886
|
+
font-weight: var(--weight-semibold);
|
|
887
|
+
text-overflow: ellipsis;
|
|
888
|
+
white-space: nowrap;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
.vedit_empty {
|
|
892
|
+
color: var(--text-muted);
|
|
893
|
+
font-size: var(--font-12);
|
|
894
|
+
font-style: italic;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
.vedit_check {
|
|
898
|
+
display: flex;
|
|
899
|
+
align-items: flex-start;
|
|
900
|
+
gap: 8px;
|
|
901
|
+
color: var(--text-secondary);
|
|
902
|
+
font-size: var(--font-13);
|
|
903
|
+
cursor: pointer;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
.vedit_check input {
|
|
907
|
+
margin-top: 2px;
|
|
908
|
+
accent-color: var(--color-secondary);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
.vedit_check_hint {
|
|
912
|
+
color: var(--text-muted);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
.vedit_content_head {
|
|
916
|
+
display: flex;
|
|
917
|
+
align-items: center;
|
|
918
|
+
gap: 12px;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/* Le formulaire se lit de haut en bas, a gauche : ses actions aussi, bien
|
|
922
|
+
detachees du contenu. Collees au bas du panneau quand il defile : elles ne
|
|
923
|
+
sortent jamais du champ. */
|
|
924
|
+
.vedit_actions {
|
|
925
|
+
position: sticky;
|
|
926
|
+
bottom: 0;
|
|
927
|
+
z-index: 1;
|
|
928
|
+
justify-content: flex-start;
|
|
929
|
+
margin-top: 24px;
|
|
930
|
+
padding: 12px 0;
|
|
931
|
+
background: var(--bg-surface);
|
|
932
|
+
}
|
|
933
|
+
</style>
|