@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.
Files changed (33) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/components/layout/alertBrandSync.vue +143 -0
  3. package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
  4. package/layer/app/components/layout/tool/alerts.vue +969 -0
  5. package/layer/app/components/layout/tool/export.vue +590 -0
  6. package/layer/app/components/layout/tool/views.vue +933 -0
  7. package/layer/app/components/layout/viewEmptyState.vue +278 -0
  8. package/layer/app/components/layout/viewPill.vue +550 -0
  9. package/layer/app/composables/useAdminAccess.ts +86 -0
  10. package/layer/app/composables/useAlertBrandSync.ts +85 -0
  11. package/layer/app/composables/useAlertSource.ts +210 -0
  12. package/layer/app/composables/useAlerts.ts +345 -0
  13. package/layer/app/composables/useExportSource.ts +236 -0
  14. package/layer/app/composables/useHubrEmbedded.ts +13 -0
  15. package/layer/app/composables/usePermalink.ts +108 -0
  16. package/layer/app/composables/useSavedExports.ts +286 -0
  17. package/layer/app/composables/useViewCardActions.ts +500 -0
  18. package/layer/app/composables/useViewTemplates.ts +369 -0
  19. package/layer/app/composables/useViews.ts +574 -0
  20. package/layer/app/oip-defaults/alerts.ts +34 -0
  21. package/layer/app/oip-defaults/api.ts +18 -0
  22. package/layer/app/oip-defaults/export.ts +23 -0
  23. package/layer/app/oip-defaults/ui.ts +21 -0
  24. package/layer/app/oip-defaults/views.ts +59 -0
  25. package/layer/app/utils/alertFieldLabels.ts +191 -0
  26. package/layer/app/utils/oipScopeStore.ts +112 -0
  27. package/layer/app/utils/oipScopes.ts +376 -0
  28. package/layer/app/utils/viewPages.ts +30 -0
  29. package/layer/app/utils/viewTemplateCriteria.ts +215 -0
  30. package/layer/app/utils/viewTemplates.ts +116 -0
  31. package/layer/nuxt.config.ts +9 -0
  32. package/layer/server/api/oip/notes.get.ts +1 -1
  33. package/package.json +3 -2
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.242",
7
+ "version": "0.1.243",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,143 @@
1
+ <!--
2
+ Demande posee quand une vue surveillee par une alerte de marque change de
3
+ produits.
4
+
5
+ Le composant ne rend rien tant que `useAlertBrandSync` n'a rien a demander :
6
+ il est monte dans les layouts a cote de la snack bar, parce que la question
7
+ nait de deux endroits (le panneau « Mes vues » et la pastille de vue de la
8
+ barre de filtres) et qu'il n'y a pas de raison d'en avoir deux copies.
9
+ -->
10
+ <script setup lang="ts">
11
+ const { locale } = useI18n();
12
+ const sync = useAlertBrandSync();
13
+
14
+ const fr = computed(() => locale.value !== "en");
15
+
16
+ /**
17
+ * Les marques ajoutees et retirees, plutot que les deux listes entieres.
18
+ *
19
+ * C'est la difference qui decide, et une vue de trente produits rendait la
20
+ * question illisible des qu'on montrait les listes cote a cote.
21
+ */
22
+ const added = computed(() => {
23
+ const current = new Set(sync.pending.value?.current ?? []);
24
+ return (sync.pending.value?.brands ?? []).filter((brand) => !current.has(brand));
25
+ });
26
+
27
+ const removed = computed(() => {
28
+ const next = new Set(sync.pending.value?.brands ?? []);
29
+ return (sync.pending.value?.current ?? []).filter((brand) => !next.has(brand));
30
+ });
31
+
32
+ /** Au-dela, la liste ne s'enumere plus : elle se compte. */
33
+ const NAMES = 5;
34
+
35
+ function summarize(list: string[]): string {
36
+ if (list.length <= NAMES) return list.join(", ");
37
+ const rest = list.length - NAMES;
38
+ return `${list.slice(0, NAMES).join(", ")} ${fr.value ? `et ${rest} autre${rest > 1 ? "s" : ""}` : `and ${rest} more`}`;
39
+ }
40
+
41
+ const title = computed(() =>
42
+ fr.value ? "Mettre l’alerte à jour ?" : "Update the alert?",
43
+ );
44
+ </script>
45
+
46
+ <template>
47
+ <RmModal
48
+ :model-value="!!sync.pending.value"
49
+ mode="free"
50
+ size="sm"
51
+ :label="title"
52
+ @update:model-value="sync.dismiss()"
53
+ >
54
+ <template #header>{{ title }}</template>
55
+
56
+ <div v-if="sync.pending.value" class="abs_body">
57
+ <p class="abs_lead">
58
+ {{
59
+ fr
60
+ ? `Cette vue est surveillée par « ${sync.pending.value.alert.title} », qui suit encore les produits d’avant.`
61
+ : `This view is watched by “${sync.pending.value.alert.title}”, still following the previous products.`
62
+ }}
63
+ </p>
64
+
65
+ <ul class="abs_changes">
66
+ <li v-if="added.length">
67
+ <strong>{{ fr ? "Ajoutées" : "Added" }}</strong> : {{ summarize(added) }}
68
+ </li>
69
+ <li v-if="removed.length">
70
+ <strong>{{ fr ? "Retirées" : "Removed" }}</strong> : {{ summarize(removed) }}
71
+ </li>
72
+ </ul>
73
+
74
+ <!-- Dit d'avance ce que « Mettre à jour » coûte : l'alerte repart d'une
75
+ référence neuve, donc elle ne rapportera rien de ce qui a bougé
76
+ entre-temps. Le découvrir après coup passerait pour une panne. -->
77
+ <p class="abs_note">
78
+ {{
79
+ fr
80
+ ? "L’alerte repart d’une nouvelle référence : elle ne signalera que ce qui bouge à partir de maintenant."
81
+ : "The alert starts from a fresh baseline: it will only report what moves from now on."
82
+ }}
83
+ </p>
84
+
85
+ <div class="abs_actions">
86
+ <RmButton variant="ghost" size="sm" @click="sync.dismiss()">
87
+ {{ fr ? "Laisser telle quelle" : "Leave it as is" }}
88
+ </RmButton>
89
+ <RmButton
90
+ variant="primary"
91
+ size="sm"
92
+ :disabled="sync.syncing.value"
93
+ @click="sync.confirm()"
94
+ >
95
+ {{ fr ? "Mettre à jour" : "Update" }}
96
+ </RmButton>
97
+ </div>
98
+ </div>
99
+ </RmModal>
100
+ </template>
101
+
102
+ <style scoped>
103
+ .abs_body {
104
+ display: flex;
105
+ flex-direction: column;
106
+ gap: 12px;
107
+ }
108
+
109
+ .abs_lead {
110
+ margin: 0;
111
+ color: var(--text-primary);
112
+ font-size: 13px;
113
+ line-height: 1.5;
114
+ }
115
+
116
+ .abs_changes {
117
+ margin: 0;
118
+ padding: 10px 12px;
119
+ border-radius: 8px;
120
+ background: var(--bg-surface-3);
121
+ list-style: none;
122
+ }
123
+
124
+ .abs_changes li {
125
+ color: var(--text-secondary);
126
+ font-size: 12px;
127
+ line-height: 1.6;
128
+ }
129
+
130
+ .abs_note {
131
+ margin: 0;
132
+ color: var(--text-secondary);
133
+ font-size: 12px;
134
+ line-height: 1.5;
135
+ }
136
+
137
+ .abs_actions {
138
+ display: flex;
139
+ justify-content: flex-end;
140
+ gap: 8px;
141
+ margin-top: 4px;
142
+ }
143
+ </style>
@@ -0,0 +1,261 @@
1
+ <!--
2
+ Onglet « Admin templates » du groupe Vues et alertes (superadmins seuls).
3
+
4
+ L'inventaire des templates publies sur la page, quel que soit l'espace
5
+ vise : qui l'a publie, a qui il est offert, quand il a bouge. Ils vivaient
6
+ dans « Mes vues » de l'administrateur qui les avait publies, melanges a ses
7
+ vues de travail, et ceux de ses collegues n'apparaissaient nulle part.
8
+
9
+ Tout template se gere ici, quel que soit qui l'a publie : changer les
10
+ espaces qui le recoivent, ou le retirer. Retirer ne supprime rien : la vue
11
+ revient dans « Mes vues » de son proprietaire. L'acces d'un template ne
12
+ passe que par ses espaces cibles (l'API ignore le partage nominatif sur un
13
+ template), d'ou un dialogue sans liste de personnes.
14
+ -->
15
+ <script setup lang="ts">
16
+ import { useOipApi } from "#oip-app/api";
17
+ import { oipSnackbar } from "#oip-app/ui";
18
+ import { toTemplate } from "../../../composables/useViewTemplates";
19
+ import { sameViewPage } from "../../../utils/viewPages";
20
+
21
+ const { locale } = useI18n();
22
+ const route = useRoute();
23
+ const views = useViews();
24
+ const elysia = useOipApi();
25
+ const { workspaces, ensureWorkspaces } = useAdminAccess();
26
+
27
+ const page = computed(() => views.current.value?.page ?? route.path);
28
+ const { cardOf, start } = useViewTemplates(() => page.value);
29
+
30
+ type Row = Awaited<ReturnType<typeof elysia.admin.viewTemplates>>["data"][number];
31
+
32
+ const rows = ref<Row[]>([]);
33
+ const loading = ref(false);
34
+
35
+ /**
36
+ * Tout l'inventaire d'un coup, trie par page ici : une vue est rangee sous
37
+ * `route.path`, donc sous `/en/...` pour qui a publie en anglais (cf.
38
+ * `sameViewPage`). Recharge a chaque ouverture : un partage modifie depuis
39
+ * « Mes vues » doit s'y lire au retour.
40
+ */
41
+ async function load() {
42
+ loading.value = true;
43
+ try {
44
+ const [res] = await Promise.all([elysia.admin.viewTemplates(), ensureWorkspaces()]);
45
+ rows.value = res?.data ?? [];
46
+ } catch (error) {
47
+ console.error("[adminTemplates] Inventaire indisponible:", error);
48
+ rows.value = [];
49
+ } finally {
50
+ loading.value = false;
51
+ }
52
+ }
53
+
54
+ onMounted(load);
55
+
56
+ const pageRows = computed(() => rows.value.filter((row) => sameViewPage(row.page, page.value)));
57
+
58
+ const en = computed(() => locale.value === "en");
59
+
60
+ const dateFormat = computed(
61
+ () => new Intl.DateTimeFormat(en.value ? "en-GB" : "fr-FR", { dateStyle: "medium" }),
62
+ );
63
+
64
+ function workspaceName(id: string) {
65
+ return workspaces.value.find((ws) => ws.workspaceId === id)?.name ?? id;
66
+ }
67
+
68
+ /** Qui a acces : tous les espaces quand aucune cible n'est posee. */
69
+ function accessRow(row: Row) {
70
+ const targets = row.targetWorkspaces ?? [];
71
+ if (!targets.length) {
72
+ return {
73
+ icon: "Globe",
74
+ key: en.value ? "Access" : "Accès",
75
+ value: en.value ? "Every workspace" : "Tous les espaces",
76
+ };
77
+ }
78
+ return {
79
+ icon: "Buildings",
80
+ key: `${en.value ? "Access" : "Accès"} (${targets.length})`,
81
+ pills: targets.map((id) => ({ k: en.value ? "Workspace" : "Espace", v: workspaceName(id) })),
82
+ };
83
+ }
84
+
85
+ function metaOf(row: Row) {
86
+ const meta = [
87
+ {
88
+ icon: "User",
89
+ label: row.isOwner
90
+ ? en.value
91
+ ? "Published by you"
92
+ : "Publié par vous"
93
+ : `${en.value ? "Published by" : "Publié par"} ${row.ownerName ?? "?"}`,
94
+ },
95
+ ];
96
+ const date = new Date(row.updatedAt);
97
+ if (!Number.isNaN(date.getTime())) {
98
+ meta.push({
99
+ icon: "Clock",
100
+ label: `${en.value ? "Updated on" : "Modifié le"} ${dateFormat.value.format(date)}`,
101
+ });
102
+ }
103
+ return meta;
104
+ }
105
+
106
+ function menuOf(row: Row) {
107
+ return [
108
+ { key: "share", label: en.value ? "Edit access" : "Modifier l'accès", icon: "Buildings" },
109
+ {
110
+ key: "withdraw",
111
+ label: en.value ? "Remove from templates" : "Retirer des templates",
112
+ icon: "XCircle",
113
+ danger: true,
114
+ confirm: true,
115
+ confirmText: en.value
116
+ ? `« ${row.title} » will no longer be offered to any workspace. The view stays in its owner's views.`
117
+ : `« ${row.title} » ne sera plus proposé à aucun espace. La vue reste dans les vues de son propriétaire.`,
118
+ },
119
+ ];
120
+ }
121
+
122
+ const cards = computed(() =>
123
+ pageRows.value.map((row) => {
124
+ const card = cardOf(toTemplate(row));
125
+ return {
126
+ ...card,
127
+ id: row.id,
128
+ menu: menuOf(row),
129
+ config: [accessRow(row), ...card.config],
130
+ meta: metaOf(row),
131
+ };
132
+ }),
133
+ );
134
+
135
+ function activate(id: string) {
136
+ const row = rows.value.find((item) => item.id === id);
137
+ if (row) start(toTemplate(row));
138
+ }
139
+
140
+ /** Un mot dans la barre du bas, comme le panneau « Mes vues ». */
141
+ function notify(text: string) {
142
+ oipSnackbar([
143
+ { attribute: "isVisible", value: true },
144
+ { attribute: "text", value: text },
145
+ { attribute: "loader", value: false },
146
+ { attribute: "hasTimeOut", value: true },
147
+ ]);
148
+ }
149
+
150
+ async function update(id: string, body: { isTemplate?: boolean; targetWorkspaces?: string[] }) {
151
+ try {
152
+ await elysia.admin.updateViewTemplate(id, body);
153
+ return true;
154
+ } catch (error) {
155
+ console.error("[adminTemplates] Mise a jour refusee:", error);
156
+ notify(en.value ? "The template could not be updated." : "Le template n'a pas pu être modifié.");
157
+ return false;
158
+ } finally {
159
+ await load();
160
+ }
161
+ }
162
+
163
+ /** Template dont on regle l'acces, ou null. */
164
+ const editingId = ref<string | null>(null);
165
+ const editing = computed(() => rows.value.find((row) => row.id === editingId.value) ?? null);
166
+ const dialogOpen = computed({
167
+ get: () => editing.value !== null,
168
+ set: (open: boolean) => {
169
+ if (!open) editingId.value = null;
170
+ },
171
+ });
172
+
173
+ /**
174
+ * Le dialogue rend toute la portee ; seuls le drapeau et les espaces comptent
175
+ * ici. Choisir autre chose que « template » revient a le retirer : la
176
+ * visibilite et les destinataires restent l'affaire du proprietaire.
177
+ */
178
+ async function confirmAccess(share: { isTemplate?: boolean; targetWorkspaces?: string[] }) {
179
+ const row = editing.value;
180
+ editingId.value = null;
181
+ if (!row) return;
182
+ if (share.isTemplate === false) {
183
+ if (await update(row.id, { isTemplate: false })) {
184
+ notify(en.value ? "Template removed." : "Template retiré.");
185
+ }
186
+ return;
187
+ }
188
+ const targets = share.targetWorkspaces ?? [];
189
+ if (await update(row.id, { targetWorkspaces: targets })) {
190
+ notify(
191
+ targets.length
192
+ ? en.value
193
+ ? `Template offered to ${targets.length} workspace${targets.length > 1 ? "s" : ""}.`
194
+ : `Template proposé à ${targets.length} espace${targets.length > 1 ? "s" : ""}.`
195
+ : en.value
196
+ ? "Template offered to every workspace."
197
+ : "Template proposé à tous les espaces.",
198
+ );
199
+ }
200
+ }
201
+
202
+ async function onAction(key: string, id: string) {
203
+ if (key === "share") editingId.value = id;
204
+ else if (key === "withdraw") {
205
+ if (await update(id, { isTemplate: false })) {
206
+ notify(en.value ? "Template removed." : "Template retiré.");
207
+ }
208
+ }
209
+ }
210
+ </script>
211
+
212
+ <template>
213
+ <div class="full_width">
214
+ <RmSavedViews
215
+ class="full_width"
216
+ :views="cards"
217
+ :menu="[]"
218
+ :renamable="false"
219
+ :starrable="false"
220
+ :loading="loading"
221
+ hide-tip
222
+ save-label=""
223
+ reset-label=""
224
+ dirty-title=""
225
+ :description="
226
+ en
227
+ ? 'Every template published on this page, whatever workspace it is offered to: who published it and who can see it.'
228
+ : 'Tous les templates publiés sur cette page, quel que soit l\'espace visé : qui l\'a publié et qui y a accès.'
229
+ "
230
+ :empty-title="en ? 'No template on this page' : 'Aucun template sur cette page'"
231
+ :empty-text="
232
+ en
233
+ ? 'Publish a view as a template from its Share menu in My views.'
234
+ : 'Publiez une vue comme template depuis son menu Partager, dans Mes vues.'
235
+ "
236
+ empty-action-label=""
237
+ :lang="en ? 'en' : 'fr'"
238
+ @activate="activate"
239
+ @action="onAction"
240
+ />
241
+
242
+ <RmShareDialog
243
+ v-model="dialogOpen"
244
+ :title="editing?.title ?? ''"
245
+ :members="[]"
246
+ can-template
247
+ is-template
248
+ :target-workspaces="editing?.targetWorkspaces ?? []"
249
+ :workspaces="workspaces"
250
+ :lang="en ? 'en' : 'fr'"
251
+ @save="confirmAccess"
252
+ />
253
+ </div>
254
+ </template>
255
+
256
+ <style scoped>
257
+ .full_width {
258
+ align-self: stretch;
259
+ width: 100%;
260
+ }
261
+ </style>