@tekkare/romulus 0.1.99 → 0.1.101

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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.97",
7
+ "version": "0.1.100",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,278 @@
1
+ <!--
2
+ RmAlertCard - une alerte posee sur une vue sauvegardee.
3
+
4
+ Une alerte surveille le perimetre d'une vue et rapporte ce qui y apparait,
5
+ y change ou en sort. La carte dit trois choses dans cet ordre : ce qu'elle
6
+ surveille, comment elle previent, et si elle fonctionne.
7
+
8
+ Ce dernier point n'est pas decoratif. Une alerte a des etats ou elle ne
9
+ previendra de rien — reference en cours de capture, vue modifiee sans que
10
+ sa requete suive, dernier passage refuse — et une alerte muette dont
11
+ personne ne sait qu'elle est muette est pire que pas d'alerte du tout. Ces
12
+ etats prennent donc un bandeau en haut de la carte, pas une nuance de
13
+ couleur sur la pastille.
14
+ -->
15
+ <script setup lang="ts">
16
+ import { computed, ref } from 'vue'
17
+ import type { RmAlert, RmAlertMenuItem } from '../utils/alerts'
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ alert: RmAlert
22
+ menu?: RmAlertMenuItem[]
23
+ lang?: 'fr' | 'en'
24
+ /**
25
+ * La page affichee est celle que l'alerte surveille. Sans elle, une
26
+ * alerte perimee ne peut pas etre rearmee : seule sa page sait
27
+ * reconstruire sa requete Elasticsearch.
28
+ */
29
+ onWatchedPage?: boolean
30
+ }>(),
31
+ { menu: () => [], lang: 'fr', onWatchedPage: false },
32
+ )
33
+
34
+ const emit = defineEmits<{
35
+ open: [id: string]
36
+ toggle: [id: string]
37
+ rearm: [id: string]
38
+ menu: [payload: { key: string; id: string }]
39
+ }>()
40
+
41
+ const FR = {
42
+ view: 'Vue',
43
+ trigger: 'Déclencheur',
44
+ watched: 'Champs suivis',
45
+ channel: 'Canaux',
46
+ cadence: 'Cadence',
47
+ tracked: 'éléments suivis',
48
+ unread: 'non lus',
49
+ more: 'Actions',
50
+ pause: 'Mettre en pause',
51
+ resume: 'Réactiver',
52
+ rearm: 'Réarmer depuis cette page',
53
+ shared: "Partagée avec l'espace",
54
+ appeared: 'Nouvel élément',
55
+ changed: 'Changement de valeur',
56
+ disappeared: 'Sortie du périmètre',
57
+ in_app: 'Dans l’app',
58
+ email: 'Email',
59
+ daily: 'Une fois par jour au plus',
60
+ weekly: 'Une fois par semaine au plus',
61
+ // Court a dessein : la pastille tient sur une ligne a cote du titre, et
62
+ // l'explication complete vit dans le bandeau juste en dessous.
63
+ status: {
64
+ seeding: 'En préparation',
65
+ active: 'Active',
66
+ paused: 'En pause',
67
+ stale: 'À réarmer',
68
+ failing: 'En échec',
69
+ },
70
+ notice: {
71
+ seeding:
72
+ "La référence est en cours de capture. Rien ne sera signalé tant qu'elle n'est pas posée.",
73
+ paused: 'En pause : les changements continuent d’être détectés, mais rien ne vous est signalé.',
74
+ stale:
75
+ "La vue a changé sans que la requête de l'alerte suive. Ouvrez la page surveillée pour la réarmer.",
76
+ failing: 'Le dernier passage a été refusé, la référence précédente est conservée.',
77
+ },
78
+ reseeded: 'La vue a changé, l’alerte est repartie d’une nouvelle référence le',
79
+ }
80
+
81
+ const EN = {
82
+ view: 'View',
83
+ trigger: 'Trigger',
84
+ watched: 'Watched fields',
85
+ channel: 'Channels',
86
+ cadence: 'Cadence',
87
+ tracked: 'items tracked',
88
+ unread: 'unread',
89
+ more: 'Actions',
90
+ pause: 'Pause',
91
+ resume: 'Resume',
92
+ rearm: 'Re-arm from this page',
93
+ shared: 'Shared with the workspace',
94
+ appeared: 'New item',
95
+ changed: 'Value change',
96
+ disappeared: 'Left the scope',
97
+ in_app: 'In app',
98
+ email: 'Email',
99
+ daily: 'At most once a day',
100
+ weekly: 'At most once a week',
101
+ status: {
102
+ seeding: 'Preparing',
103
+ active: 'Active',
104
+ paused: 'Paused',
105
+ stale: 'Needs re-arming',
106
+ failing: 'Failing',
107
+ },
108
+ notice: {
109
+ seeding: 'The baseline is being captured. Nothing is reported until it is in place.',
110
+ paused: 'Paused: changes are still detected, but nothing is reported to you.',
111
+ stale:
112
+ "The view changed and the alert's query did not follow. Open the watched page to re-arm it.",
113
+ failing: 'The last run was refused; the previous baseline is kept.',
114
+ },
115
+ reseeded: 'The view changed, the alert restarted from a new baseline on',
116
+ }
117
+
118
+ const T = computed(() => (props.lang === 'en' ? EN : FR))
119
+
120
+ /** Les etats qui demandent une explication, pas seulement une pastille. */
121
+ const notice = computed(() => {
122
+ const status = props.alert.status
123
+ if (status === 'active') return null
124
+ return {
125
+ tone: status === 'stale' || status === 'failing' ? 'warn' : 'info',
126
+ text: T.value.notice[status],
127
+ detail: status === 'failing' ? props.alert.lastError : null,
128
+ }
129
+ })
130
+
131
+ const triggerLabels = computed(() =>
132
+ props.alert.triggers.map((trigger) => T.value[trigger]),
133
+ )
134
+
135
+ const channelLabels = computed(() =>
136
+ props.alert.channels.map((channel) => (T.value as any)[channel] ?? channel),
137
+ )
138
+
139
+ const cadenceLabel = computed(() => {
140
+ const cadence = props.alert.cadence
141
+ if (!cadence) return ''
142
+ return (T.value as any)[cadence] ?? cadence
143
+ })
144
+
145
+ /** Le pied garde sa bordure meme vide : il ne se dessine que s'il dit quelque chose. */
146
+ const hasMeta = computed(
147
+ () =>
148
+ !!props.alert.owner ||
149
+ !!props.alert.trackedCount ||
150
+ !!props.alert.createdLabel ||
151
+ !!props.alert.lastEventLabel,
152
+ )
153
+
154
+ const menuOpen = ref(false)
155
+
156
+ function pick(item: RmAlertMenuItem) {
157
+ menuOpen.value = false
158
+ emit('menu', { key: item.key, id: props.alert.id })
159
+ }
160
+ </script>
161
+
162
+ <template>
163
+ <div class="rm-alert" :class="`rm-alert--${alert.status}`">
164
+ <div class="rm-alert__head">
165
+ <button type="button" class="rm-alert__title" @click="emit('open', alert.id)">
166
+ {{ alert.title }}
167
+ <span v-if="alert.unreadCount" class="rm-alert__unread">
168
+ {{ alert.unreadCount }}
169
+ </span>
170
+ </button>
171
+
172
+ <div class="rm-alert__head-actions">
173
+ <span class="rm-alert__status" :class="`rm-alert__status--${alert.status}`">
174
+ <span class="rm-alert__dot" />
175
+ {{ T.status[alert.status] }}
176
+ </span>
177
+
178
+ <div v-if="menu.length" class="rm-alert__menu-wrap">
179
+ <button
180
+ type="button"
181
+ class="rm-alert__more"
182
+ :title="T.more"
183
+ :aria-label="T.more"
184
+ :aria-expanded="menuOpen"
185
+ @click="menuOpen = !menuOpen"
186
+ >
187
+ <RmIcon name="List" :size="18" />
188
+ </button>
189
+ <template v-if="menuOpen">
190
+ <div class="rm-alert__menu-shade" @click.stop="menuOpen = false" />
191
+ <div class="rm-alert__menu" role="menu">
192
+ <button
193
+ v-for="item in menu"
194
+ :key="item.key"
195
+ type="button"
196
+ class="rm-alert__menu-item"
197
+ :class="{ 'rm-alert__menu-item--danger': item.danger }"
198
+ role="menuitem"
199
+ @click.stop="pick(item)"
200
+ >
201
+ <RmIcon v-if="item.icon" :name="item.icon" :size="16" />
202
+ {{ item.label }}
203
+ </button>
204
+ </div>
205
+ </template>
206
+ </div>
207
+ </div>
208
+ </div>
209
+
210
+ <p v-if="alert.viewTitle" class="rm-alert__view">
211
+ <RmIcon name="BookmarkSimple" :size="13" />
212
+ {{ T.view }} : {{ alert.viewTitle }}
213
+ <span v-if="alert.shared" class="rm-alert__shared">{{ T.shared }}</span>
214
+ </p>
215
+
216
+ <div v-if="notice" class="rm-alert__notice" :class="`rm-alert__notice--${notice.tone}`">
217
+ <RmIcon :name="notice.tone === 'warn' ? 'Warning' : 'Info'" :size="14" />
218
+ <span>
219
+ {{ notice.text }}
220
+ <em v-if="notice.detail" class="rm-alert__notice-detail">{{ notice.detail }}</em>
221
+ </span>
222
+ <RmButton
223
+ v-if="alert.status === 'stale' && onWatchedPage"
224
+ variant="secondary"
225
+ size="sm"
226
+ @click="emit('rearm', alert.id)"
227
+ >
228
+ {{ T.rearm }}
229
+ </RmButton>
230
+ </div>
231
+
232
+ <dl class="rm-alert__spec">
233
+ <div class="rm-alert__row">
234
+ <dt>{{ T.trigger }}</dt>
235
+ <dd>{{ triggerLabels.join(', ') }}</dd>
236
+ </div>
237
+ <div v-if="alert.watchedLabels?.length" class="rm-alert__row">
238
+ <dt>{{ T.watched }}</dt>
239
+ <dd>{{ alert.watchedLabels.join(', ') }}</dd>
240
+ </div>
241
+ <div class="rm-alert__row">
242
+ <dt>{{ T.channel }}</dt>
243
+ <dd>
244
+ <span v-for="label in channelLabels" :key="label" class="rm-alert__chip">
245
+ {{ label }}
246
+ </span>
247
+ </dd>
248
+ </div>
249
+ <div v-if="cadenceLabel" class="rm-alert__row">
250
+ <dt>{{ T.cadence }}</dt>
251
+ <dd>{{ cadenceLabel }}</dd>
252
+ </div>
253
+ </dl>
254
+
255
+ <p v-if="alert.reseededAt" class="rm-alert__reseed">
256
+ {{ T.reseeded }} {{ alert.reseededAt }}.
257
+ </p>
258
+
259
+ <div v-if="hasMeta" class="rm-alert__meta">
260
+ <span v-if="alert.owner">
261
+ <RmIcon name="User" :size="13" />{{ alert.owner }}
262
+ </span>
263
+ <span v-if="alert.trackedCount">
264
+ <RmIcon name="Target" :size="13" />{{ alert.trackedCount }} {{ T.tracked }}
265
+ </span>
266
+ <span v-if="alert.createdLabel">
267
+ <RmIcon name="Clock" :size="13" />{{ alert.createdLabel }}
268
+ </span>
269
+ <span v-if="alert.lastEventLabel">
270
+ <RmIcon name="Fire" :size="13" />{{ alert.lastEventLabel }}
271
+ </span>
272
+ </div>
273
+ </div>
274
+ </template>
275
+
276
+ <style scoped>
277
+ .rm-alert{background:var(--rm-surface,#fff);border:1px solid var(--rm-border,#e5e7eb);border-radius:10px;display:flex;flex-direction:column;gap:10px;padding:14px 16px}.rm-alert--paused,.rm-alert--seeding{background:var(--rm-surface-muted,#fafafa)}.rm-alert__head{align-items:flex-start;display:flex;gap:10px;justify-content:space-between}.rm-alert__title{all:unset;align-items:center;color:var(--rm-text,#111827);cursor:pointer;display:inline-flex;font-size:15px;font-weight:600;gap:8px;line-height:1.35}.rm-alert__title:hover{color:var(--rm-primary,#4f46e5)}.rm-alert__unread{align-items:center;background:var(--rm-primary,#4f46e5);border-radius:999px;color:#fff;display:inline-flex;font-size:11px;font-weight:700;height:18px;justify-content:center;min-width:18px;padding:0 5px}.rm-alert__head-actions{align-items:center;display:flex;flex-shrink:0;gap:6px}.rm-alert__status{align-items:center;background:var(--rm-surface-muted,#f3f4f6);border-radius:999px;color:var(--rm-text-soft,#6b7280);display:inline-flex;font-size:11px;font-weight:600;gap:5px;letter-spacing:.03em;padding:3px 8px;text-transform:uppercase;white-space:nowrap}.rm-alert__dot{background:currentColor;border-radius:999px;height:6px;width:6px}.rm-alert__status--active{background:var(--rm-success-soft,#ecfdf5);color:var(--rm-success,#047857)}.rm-alert__status--failing,.rm-alert__status--stale{background:var(--rm-warn-soft,#fffbeb);color:var(--rm-warn,#b45309)}.rm-alert__view{align-items:center;color:var(--rm-text-soft,#6b7280);display:flex;flex-wrap:wrap;font-size:12px;gap:5px;margin:0}.rm-alert__shared{border-radius:999px;font-size:11px;padding:1px 6px}.rm-alert__notice,.rm-alert__shared{background:var(--rm-surface-muted,#f3f4f6)}.rm-alert__notice{align-items:flex-start;border-radius:8px;color:var(--rm-text-soft,#4b5563);display:flex;font-size:12.5px;gap:8px;line-height:1.45;padding:9px 11px}.rm-alert__notice--warn{background:var(--rm-warn-soft,#fffbeb);color:var(--rm-warn,#92400e)}.rm-alert__notice-detail{display:block;font-size:11.5px;font-style:normal;margin-top:3px;opacity:.8}.rm-alert__spec{background:var(--rm-surface-muted,#f9fafb);border-radius:8px;display:flex;flex-direction:column;gap:6px;margin:0;padding:10px 12px}.rm-alert__row{align-items:baseline;display:grid;gap:10px;grid-template-columns:110px 1fr}.rm-alert__row dt{color:var(--rm-text-soft,#9ca3af);font-size:11px;font-weight:600;letter-spacing:.03em;text-transform:uppercase}.rm-alert__row dd{color:var(--rm-text,#111827);display:flex;flex-wrap:wrap;font-size:13px;gap:5px;margin:0}.rm-alert__chip{background:var(--rm-surface,#fff);border:1px solid var(--rm-border,#e5e7eb);border-radius:6px;font-size:12px;padding:1px 7px}.rm-alert__reseed{margin:0}.rm-alert__meta,.rm-alert__reseed{color:var(--rm-text-soft,#9ca3af);font-size:11.5px}.rm-alert__meta{border-top:1px solid var(--rm-border-soft,#f3f4f6);display:flex;flex-wrap:wrap;gap:12px;padding-top:9px}.rm-alert__meta span{align-items:center;display:inline-flex;gap:4px}.rm-alert__menu-wrap{position:relative}.rm-alert__more{all:unset;border-radius:6px;color:var(--rm-text-soft,#9ca3af);cursor:pointer;display:inline-flex;padding:3px}.rm-alert__more:hover{background:var(--rm-surface-muted,#f3f4f6);color:var(--rm-text,#111827)}.rm-alert__menu-shade{inset:0;position:fixed;z-index:20}.rm-alert__menu{background:var(--rm-surface,#fff);border:1px solid var(--rm-border,#e5e7eb);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.1);display:flex;flex-direction:column;min-width:190px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:21}.rm-alert__menu-item{all:unset;align-items:center;border-radius:6px;color:var(--rm-text,#111827);cursor:pointer;display:flex;font-size:13px;gap:8px;padding:7px 9px}.rm-alert__menu-item:hover{background:var(--rm-surface-muted,#f3f4f6)}.rm-alert__menu-item--danger{color:var(--rm-danger,#b91c1c)}
278
+ </style>
@@ -0,0 +1,262 @@
1
+ <!--
2
+ RmAlertForm - creer une alerte sur la vue affichee.
3
+
4
+ Le formulaire ne connait ni l'API ni la requete : l'appelant lui donne la
5
+ vue surveillee et le catalogue de champs de sa page, il lui rend un
6
+ descripteur.
7
+
8
+ Trois choix de conception que l'ecran ne montre pas :
9
+
10
+ - les declencheurs sont des cases, pas des boutons radio. Les trois sortent
11
+ du meme passage de diff, donc les rendre exclusifs n'economise rien et
12
+ force a creer deux alertes pour « nouveau produit ET changement de prix » ;
13
+ - les champs suivis sont multiples dans UNE alerte. Une alerte par champ
14
+ voudrait dire autant de requetes et autant de references sur le meme jeu
15
+ de documents, et plusieurs messages le meme matin pour le meme produit ;
16
+ - la cadence appartient au mail. La pastille de l'app encaisse au fil de
17
+ l'eau, elle n'a pas de digest.
18
+ -->
19
+ <script setup lang="ts">
20
+ import { computed, ref, watch } from 'vue'
21
+ import type { RmAlertDraft, RmAlertField, RmAlertTrigger } from '../utils/alerts'
22
+
23
+ const props = withDefaults(
24
+ defineProps<{
25
+ /** Nom de la vue surveillee, affiche et repris comme titre par defaut. */
26
+ viewTitle: string
27
+ /** Champs surveillables de la page. Vide : seule l'apparition compte. */
28
+ fields?: RmAlertField[]
29
+ /** Nombre d'elements que la vue tient a l'ecran, s'il est connu. */
30
+ scopeCount?: number | null
31
+ submitting?: boolean
32
+ lang?: 'fr' | 'en'
33
+ }>(),
34
+ { fields: () => [], scopeCount: null, submitting: false, lang: 'fr' },
35
+ )
36
+
37
+ const emit = defineEmits<{
38
+ submit: [draft: RmAlertDraft]
39
+ cancel: []
40
+ }>()
41
+
42
+ const FR = {
43
+ name: 'Nom de l’alerte',
44
+ view: 'Vue surveillée',
45
+ viewHint: 'L’alerte se déclenche sur le périmètre de cette vue, tel qu’il est à l’écran.',
46
+ triggers: 'Déclencheurs',
47
+ triggersHint: 'Les trois sortent de la même comparaison, en cocher plusieurs ne coûte rien.',
48
+ appeared: 'Nouvel élément',
49
+ appearedHint: 'Un résultat entre dans la vue',
50
+ changed: 'Changement de valeur',
51
+ changedHint: 'Un champ suivi bouge sur un élément déjà présent',
52
+ disappeared: 'Sortie du périmètre',
53
+ disappearedHint: 'Un élément quitte la vue (retrait, déremboursement)',
54
+ fields: 'Champs suivis',
55
+ fieldsHint: 'Un seul message par élément, quel que soit le nombre de champs cochés.',
56
+ fieldsRequired: 'Cochez au moins un champ pour surveiller les changements.',
57
+ noFields: 'Cette page ne propose aucun champ surveillable pour le moment.',
58
+ channels: 'Canaux',
59
+ inApp: 'Dans l’app (pastille sur la tuile Alertes)',
60
+ email: 'Email',
61
+ cadence: 'Fréquence d’envoi du mail',
62
+ daily: 'Quotidienne (un envoi par jour au plus)',
63
+ weekly: 'Hebdomadaire (un envoi par semaine au plus)',
64
+ cadenceHint:
65
+ 'Un plafond, pas un rythme : rien ne part quand rien n’a bougé, et la détection suit celle des données.',
66
+ visibility: 'Partage',
67
+ private: 'Pour moi seul',
68
+ workspace: 'Visible par mon espace de travail',
69
+ submit: 'Créer l’alerte',
70
+ cancel: 'Annuler',
71
+ baseline:
72
+ 'À la création, la référence est capturée sans rien signaler : vous ne serez prévenu que de ce qui bouge ensuite.',
73
+ scope: 'éléments dans la vue actuellement',
74
+ }
75
+
76
+ const EN = {
77
+ name: 'Alert name',
78
+ view: 'Watched view',
79
+ viewHint: 'The alert fires on this view’s scope, as it stands on screen.',
80
+ triggers: 'Triggers',
81
+ triggersHint: 'All three come from the same comparison; ticking several costs nothing.',
82
+ appeared: 'New item',
83
+ appearedHint: 'A result enters the view',
84
+ changed: 'Value change',
85
+ changedHint: 'A watched field moves on an item already there',
86
+ disappeared: 'Left the scope',
87
+ disappearedHint: 'An item leaves the view (withdrawal, delisting)',
88
+ fields: 'Watched fields',
89
+ fieldsHint: 'One message per item, however many fields are ticked.',
90
+ fieldsRequired: 'Tick at least one field to watch for changes.',
91
+ noFields: 'This page offers no watchable field yet.',
92
+ channels: 'Channels',
93
+ inApp: 'In app (badge on the Alerts tile)',
94
+ email: 'Email',
95
+ cadence: 'Mail frequency',
96
+ daily: 'Daily (at most one a day)',
97
+ weekly: 'Weekly (at most one a week)',
98
+ cadenceHint:
99
+ 'A cap, not a rhythm: nothing goes out when nothing moved, and detection follows the data.',
100
+ visibility: 'Sharing',
101
+ private: 'Just me',
102
+ workspace: 'Visible to my workspace',
103
+ submit: 'Create the alert',
104
+ cancel: 'Cancel',
105
+ baseline:
106
+ 'On creation the baseline is captured silently: you are only told about what moves afterwards.',
107
+ scope: 'items in the view right now',
108
+ }
109
+
110
+ const T = computed(() => (props.lang === 'en' ? EN : FR))
111
+
112
+ const title = ref(props.viewTitle)
113
+ const triggers = ref<RmAlertTrigger[]>(['appeared'])
114
+ const watchedFields = ref<string[]>([])
115
+ const emailOn = ref(false)
116
+ const cadence = ref<'daily' | 'weekly'>('weekly')
117
+ const shared = ref(false)
118
+
119
+ watch(
120
+ () => props.viewTitle,
121
+ (value) => {
122
+ title.value = value
123
+ },
124
+ )
125
+
126
+ /**
127
+ * Deux bascules plutot qu'une generique : dans le template, un `ref` est
128
+ * deballe, donc lui passer la reference elle-meme ne marche pas.
129
+ */
130
+ function toggleTrigger(key: RmAlertTrigger) {
131
+ triggers.value = triggers.value.includes(key)
132
+ ? triggers.value.filter((entry) => entry !== key)
133
+ : [...triggers.value, key]
134
+ }
135
+
136
+ function toggleField(value: string) {
137
+ watchedFields.value = watchedFields.value.includes(value)
138
+ ? watchedFields.value.filter((entry) => entry !== value)
139
+ : [...watchedFields.value, value]
140
+ }
141
+
142
+ const watchesChanges = computed(() => triggers.value.includes('changed'))
143
+
144
+ /**
145
+ * Surveiller un changement sans dire de quel champ n'a pas de sens : le
146
+ * moteur comparerait un hachage vide et ne trouverait jamais rien.
147
+ */
148
+ const missingFields = computed(
149
+ () => watchesChanges.value && watchedFields.value.length === 0 && props.fields.length > 0,
150
+ )
151
+
152
+ const valid = computed(
153
+ () => title.value.trim().length > 0 && triggers.value.length > 0 && !missingFields.value,
154
+ )
155
+
156
+ function submit() {
157
+ if (!valid.value) return
158
+ emit('submit', {
159
+ title: title.value.trim(),
160
+ triggers: triggers.value,
161
+ // Les champs ne servent qu'au declencheur « changement » : les garder
162
+ // sans lui ferait porter a l'alerte une promesse qu'elle ne tient pas.
163
+ watchedFields: watchesChanges.value ? watchedFields.value : [],
164
+ channels: emailOn.value ? ['in_app', 'email'] : ['in_app'],
165
+ cadence: cadence.value,
166
+ visibility: shared.value ? 'workspace' : 'private',
167
+ })
168
+ }
169
+ </script>
170
+
171
+ <template>
172
+ <form class="rm-alert-form" @submit.prevent="submit">
173
+ <RmInput v-model="title" :label="T.name" required />
174
+
175
+ <div class="rm-alert-form__view">
176
+ <p class="rm-alert-form__label">{{ T.view }}</p>
177
+ <p class="rm-alert-form__view-name">
178
+ <RmIcon name="BookmarkSimple" :size="14" />
179
+ {{ viewTitle }}
180
+ <span v-if="scopeCount" class="rm-alert-form__count">
181
+ {{ scopeCount }} {{ T.scope }}
182
+ </span>
183
+ </p>
184
+ <p class="rm-alert-form__hint">{{ T.viewHint }}</p>
185
+ </div>
186
+
187
+ <fieldset class="rm-alert-form__group">
188
+ <legend class="rm-alert-form__label">{{ T.triggers }}</legend>
189
+ <RmCheckbox
190
+ v-for="entry in [
191
+ { key: 'appeared', label: T.appeared, hint: T.appearedHint },
192
+ { key: 'changed', label: T.changed, hint: T.changedHint },
193
+ { key: 'disappeared', label: T.disappeared, hint: T.disappearedHint },
194
+ ]"
195
+ :key="entry.key"
196
+ :model-value="triggers.includes(entry.key as any)"
197
+ :label="entry.label"
198
+ :tooltip="entry.hint"
199
+ @update:model-value="toggleTrigger(entry.key as RmAlertTrigger)"
200
+ />
201
+ <p class="rm-alert-form__hint">{{ T.triggersHint }}</p>
202
+ </fieldset>
203
+
204
+ <fieldset v-if="watchesChanges" class="rm-alert-form__group">
205
+ <legend class="rm-alert-form__label">{{ T.fields }}</legend>
206
+ <template v-if="fields.length">
207
+ <RmCheckbox
208
+ v-for="field in fields"
209
+ :key="field.value"
210
+ :model-value="watchedFields.includes(field.value)"
211
+ :label="field.label"
212
+ @update:model-value="toggleField(field.value)"
213
+ />
214
+ <p class="rm-alert-form__hint">{{ T.fieldsHint }}</p>
215
+ <p v-if="missingFields" class="rm-alert-form__error">{{ T.fieldsRequired }}</p>
216
+ </template>
217
+ <p v-else class="rm-alert-form__hint">{{ T.noFields }}</p>
218
+ </fieldset>
219
+
220
+ <fieldset class="rm-alert-form__group">
221
+ <legend class="rm-alert-form__label">{{ T.channels }}</legend>
222
+ <RmCheckbox :model-value="true" :label="T.inApp" disabled />
223
+ <RmCheckbox v-model="emailOn" :label="T.email" />
224
+
225
+ <div v-if="emailOn" class="rm-alert-form__nested">
226
+ <RmDropdownSelect
227
+ :label="T.cadence"
228
+ :options="[T.weekly, T.daily]"
229
+ :model-value="cadence === 'weekly' ? T.weekly : T.daily"
230
+ @update:model-value="cadence = $event === T.daily ? 'daily' : 'weekly'"
231
+ />
232
+ <p class="rm-alert-form__hint">{{ T.cadenceHint }}</p>
233
+ </div>
234
+ </fieldset>
235
+
236
+ <fieldset class="rm-alert-form__group">
237
+ <legend class="rm-alert-form__label">{{ T.visibility }}</legend>
238
+ <RmCheckbox v-model="shared" :label="T.workspace" />
239
+ </fieldset>
240
+
241
+ <p class="rm-alert-form__baseline">
242
+ <RmIcon name="Info" :size="14" />
243
+ {{ T.baseline }}
244
+ </p>
245
+
246
+ <div class="rm-alert-form__actions">
247
+ <RmButton variant="secondary" @click="emit('cancel')">{{ T.cancel }}</RmButton>
248
+ <RmButton
249
+ variant="primary"
250
+ type="submit"
251
+ :disabled="!valid || submitting"
252
+ :loading="submitting"
253
+ >
254
+ {{ T.submit }}
255
+ </RmButton>
256
+ </div>
257
+ </form>
258
+ </template>
259
+
260
+ <style scoped>
261
+ .rm-alert-form{display:flex;flex-direction:column;gap:18px}.rm-alert-form__label{color:var(--rm-text,#111827);font-size:13px;font-weight:600;margin:0 0 6px;padding:0}.rm-alert-form__group{border:0;display:flex;flex-direction:column;gap:8px;margin:0;padding:0}.rm-alert-form__view-name{align-items:center;color:var(--rm-text,#111827);display:flex;flex-wrap:wrap;font-size:14px;gap:6px;margin:0}.rm-alert-form__count{color:var(--rm-text-soft,#9ca3af);font-size:11.5px}.rm-alert-form__hint{color:var(--rm-text-soft,#9ca3af);font-size:12px;line-height:1.45;margin:0}.rm-alert-form__error{color:var(--rm-danger,#b91c1c);font-size:12px;margin:0}.rm-alert-form__nested{display:flex;flex-direction:column;gap:6px;padding-left:26px}.rm-alert-form__baseline{align-items:flex-start;background:var(--rm-surface-muted,#f9fafb);border-radius:8px;color:var(--rm-text-soft,#4b5563);display:flex;font-size:12.5px;gap:8px;line-height:1.45;margin:0;padding:10px 12px}.rm-alert-form__actions{display:flex;gap:8px;justify-content:flex-end}
262
+ </style>
@@ -0,0 +1,222 @@
1
+ <!--
2
+ RmAlerts - le panneau des alertes du rail.
3
+
4
+ Il ne connait ni l'API ni la page : l'appelant lui passe des alertes deja
5
+ mises en forme (cf. `RmAlert`), il lui rend les gestes.
6
+
7
+ Les onglets separent trois questions differentes : ce qui veille, ce qui
8
+ s'est tu, et ce qui a bougé. Le troisieme est le journal, la seule vue qui
9
+ se lit dans le temps plutot que par alerte.
10
+
11
+ Le bouton de creation reste visible meme quand il ne peut rien faire, avec
12
+ la raison sous lui : une alerte se pose sur une vue enregistree, et un
13
+ bouton qui disparait n'apprend pas pourquoi.
14
+ -->
15
+ <script setup lang="ts">
16
+ import { computed } from 'vue'
17
+ import type { RmAlert, RmAlertEvent, RmAlertMenuItem } from '../utils/alerts'
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ alerts: RmAlert[]
22
+ events?: RmAlertEvent[]
23
+ /** Onglet courant (v-model). */
24
+ tab?: 'active' | 'paused' | 'events'
25
+ menu?: RmAlertMenuItem[]
26
+ loading?: boolean
27
+ /** L'utilisateur peut poser une alerte sur cet ecran. */
28
+ canCreate?: boolean
29
+ /** Pourquoi il ne peut pas, quand il ne peut pas. */
30
+ createHint?: string
31
+ /** La page affichee est celle que les alertes de la liste surveillent. */
32
+ onWatchedPage?: boolean
33
+ lang?: 'fr' | 'en'
34
+ }>(),
35
+ {
36
+ events: () => [],
37
+ tab: 'active',
38
+ menu: () => [],
39
+ loading: false,
40
+ canCreate: false,
41
+ createHint: '',
42
+ onWatchedPage: false,
43
+ lang: 'fr',
44
+ },
45
+ )
46
+
47
+ const emit = defineEmits<{
48
+ 'update:tab': [value: 'active' | 'paused' | 'events']
49
+ create: []
50
+ open: [id: string]
51
+ rearm: [id: string]
52
+ menu: [payload: { key: string; id: string }]
53
+ 'read-all': []
54
+ }>()
55
+
56
+ const FR = {
57
+ intro:
58
+ 'Une alerte surveille le périmètre d’une vue sauvegardée et vous signale ce qui y apparaît, y change ou en sort.',
59
+ create: 'Nouvelle alerte',
60
+ active: 'Actives',
61
+ paused: 'En pause',
62
+ events: 'Activité',
63
+ readAll: 'Tout marquer comme lu',
64
+ emptyActive: 'Aucune alerte active',
65
+ emptyActiveText:
66
+ 'Posez une alerte sur une vue pour être prévenu sans avoir à rouvrir la page.',
67
+ emptyPaused: 'Aucune alerte en pause',
68
+ emptyEvents: 'Rien de neuf',
69
+ emptyEventsText:
70
+ 'Les changements détectés sur vos périmètres apparaîtront ici, du plus récent au plus ancien.',
71
+ appeared: 'Nouveau',
72
+ changed: 'Modifié',
73
+ disappeared: 'Sorti du périmètre',
74
+ becomes: 'devient',
75
+ empty: '(vide)',
76
+ }
77
+
78
+ const EN = {
79
+ intro:
80
+ 'An alert watches a saved view’s scope and reports what appears, changes or leaves it.',
81
+ create: 'New alert',
82
+ active: 'Active',
83
+ paused: 'Paused',
84
+ events: 'Activity',
85
+ readAll: 'Mark all as read',
86
+ emptyActive: 'No active alert',
87
+ emptyActiveText: 'Set an alert on a view to hear about it without reopening the page.',
88
+ emptyPaused: 'No paused alert',
89
+ emptyEvents: 'Nothing new',
90
+ emptyEventsText:
91
+ 'Changes detected on your scopes will show up here, most recent first.',
92
+ appeared: 'New',
93
+ changed: 'Changed',
94
+ disappeared: 'Left the scope',
95
+ becomes: 'becomes',
96
+ empty: '(empty)',
97
+ }
98
+
99
+ const T = computed(() => (props.lang === 'en' ? EN : FR))
100
+
101
+ /** En pause au sens de l'utilisateur : rien ne lui sera signalé. */
102
+ const paused = computed(() => props.alerts.filter((alert) => alert.status === 'paused'))
103
+
104
+ /**
105
+ * Tout le reste veille, y compris ce qui va mal. Une alerte périmée ou en
106
+ * échec appartient à cette liste : la ranger ailleurs la ferait disparaître
107
+ * de l'écran, alors que c'est précisément celle qu'il faut voir.
108
+ */
109
+ const active = computed(() => props.alerts.filter((alert) => alert.status !== 'paused'))
110
+
111
+ const unreadTotal = computed(() =>
112
+ props.alerts.reduce((total, alert) => total + (alert.unreadCount ?? 0), 0),
113
+ )
114
+
115
+ function setTab(value: 'active' | 'paused' | 'events') {
116
+ emit('update:tab', value)
117
+ }
118
+ </script>
119
+
120
+ <template>
121
+ <div class="rm-alerts">
122
+ <p class="rm-alerts__intro">{{ T.intro }}</p>
123
+
124
+ <div class="rm-alerts__create">
125
+ <RmButton variant="primary" size="sm" :disabled="!canCreate" @click="emit('create')">
126
+ <RmIcon name="Plus" :size="15" />
127
+ {{ T.create }}
128
+ </RmButton>
129
+ <p v-if="!canCreate && createHint" class="rm-alerts__hint">{{ createHint }}</p>
130
+ </div>
131
+
132
+ <div class="rm-alerts__tabs" role="tablist">
133
+ <button
134
+ v-for="entry in [
135
+ { key: 'active', label: T.active, count: active.length },
136
+ { key: 'paused', label: T.paused, count: paused.length },
137
+ { key: 'events', label: T.events, count: unreadTotal },
138
+ ]"
139
+ :key="entry.key"
140
+ type="button"
141
+ role="tab"
142
+ class="rm-alerts__tab"
143
+ :class="{ 'rm-alerts__tab--on': tab === entry.key }"
144
+ :aria-selected="tab === entry.key"
145
+ @click="setTab(entry.key as any)"
146
+ >
147
+ {{ entry.label }}
148
+ <span v-if="entry.count" class="rm-alerts__tab-count">{{ entry.count }}</span>
149
+ </button>
150
+ </div>
151
+
152
+ <RmLoadingState v-if="loading" />
153
+
154
+ <template v-else-if="tab === 'events'">
155
+ <div v-if="events.length" class="rm-alerts__events">
156
+ <button
157
+ v-if="unreadTotal"
158
+ type="button"
159
+ class="rm-alerts__read-all"
160
+ @click="emit('read-all')"
161
+ >
162
+ {{ T.readAll }}
163
+ </button>
164
+
165
+ <article
166
+ v-for="event in events"
167
+ :key="event.id"
168
+ class="rm-alerts__event"
169
+ :class="{ 'rm-alerts__event--unread': !event.read }"
170
+ >
171
+ <header class="rm-alerts__event-head">
172
+ <span class="rm-alerts__kind" :class="`rm-alerts__kind--${event.kind}`">
173
+ {{ T[event.kind] }}
174
+ </span>
175
+ <time class="rm-alerts__event-date">{{ event.dateLabel }}</time>
176
+ </header>
177
+ <p class="rm-alerts__event-title">{{ event.title }}</p>
178
+ <p v-if="event.subtitle" class="rm-alerts__event-sub">{{ event.subtitle }}</p>
179
+ <p
180
+ v-for="(change, index) in event.changes ?? []"
181
+ :key="index"
182
+ class="rm-alerts__change"
183
+ >
184
+ {{ change.label || change.field }} :
185
+ <s>{{ change.before || T.empty }}</s>
186
+ {{ T.becomes }}
187
+ <strong>{{ change.after || T.empty }}</strong>
188
+ </p>
189
+ <p v-if="event.alertTitle" class="rm-alerts__event-from">{{ event.alertTitle }}</p>
190
+ </article>
191
+ </div>
192
+
193
+ <RmEmptyState v-else :title="T.emptyEvents" :text="T.emptyEventsText" />
194
+ </template>
195
+
196
+ <template v-else>
197
+ <div v-if="(tab === 'paused' ? paused : active).length" class="rm-alerts__list">
198
+ <RmAlertCard
199
+ v-for="alert in tab === 'paused' ? paused : active"
200
+ :key="alert.id"
201
+ :alert="alert"
202
+ :menu="menu"
203
+ :lang="lang"
204
+ :on-watched-page="onWatchedPage"
205
+ @open="emit('open', $event)"
206
+ @rearm="emit('rearm', $event)"
207
+ @menu="emit('menu', $event)"
208
+ />
209
+ </div>
210
+
211
+ <RmEmptyState
212
+ v-else
213
+ :title="tab === 'paused' ? T.emptyPaused : T.emptyActive"
214
+ :text="tab === 'paused' ? '' : T.emptyActiveText"
215
+ />
216
+ </template>
217
+ </div>
218
+ </template>
219
+
220
+ <style scoped>
221
+ .rm-alerts{display:flex;flex-direction:column;gap:14px}.rm-alerts__intro{color:var(--rm-text-soft,#6b7280);font-size:13px;line-height:1.5;margin:0}.rm-alerts__create{align-items:flex-start;display:flex;flex-direction:column;gap:6px}.rm-alerts__hint{color:var(--rm-text-soft,#9ca3af);font-size:12px;line-height:1.45;margin:0}.rm-alerts__tabs{border-bottom:1px solid var(--rm-border,#e5e7eb);display:flex;gap:4px}.rm-alerts__tab{all:unset;align-items:center;border-bottom:2px solid transparent;color:var(--rm-text-soft,#6b7280);cursor:pointer;display:inline-flex;font-size:13px;font-weight:500;gap:6px;padding:8px 10px}.rm-alerts__tab--on{border-bottom-color:var(--rm-primary,#4f46e5);color:var(--rm-primary,#4f46e5)}.rm-alerts__tab-count{background:var(--rm-surface-muted,#f3f4f6);border-radius:999px;font-size:11px;font-weight:700;padding:0 6px}.rm-alerts__tab--on .rm-alerts__tab-count{background:var(--rm-primary,#4f46e5);color:#fff}.rm-alerts__events,.rm-alerts__list{display:flex;flex-direction:column;gap:10px}.rm-alerts__read-all{all:unset;align-self:flex-end;color:var(--rm-primary,#4f46e5);cursor:pointer;font-size:12px}.rm-alerts__event{border:1px solid var(--rm-border,#e5e7eb);border-radius:9px;display:flex;flex-direction:column;gap:3px;padding:10px 12px}.rm-alerts__event--unread{border-left:3px solid var(--rm-primary,#4f46e5)}.rm-alerts__event-head{align-items:center;display:flex;gap:8px;justify-content:space-between}.rm-alerts__kind{color:var(--rm-text-soft,#6b7280);font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}.rm-alerts__kind--appeared{color:var(--rm-success,#047857)}.rm-alerts__kind--changed{color:var(--rm-info,#1d4ed8)}.rm-alerts__kind--disappeared{color:var(--rm-warn,#b45309)}.rm-alerts__event-date{color:var(--rm-text-soft,#9ca3af);font-size:11px}.rm-alerts__event-title{color:var(--rm-text,#111827);font-size:13.5px;line-height:1.4;margin:0}.rm-alerts__event-from,.rm-alerts__event-sub{color:var(--rm-text-soft,#9ca3af);font-size:11.5px;margin:0}.rm-alerts__change{color:var(--rm-text-soft,#4b5563);font-size:12.5px;margin:2px 0 0}.rm-alerts__change s{opacity:.65}
222
+ </style>
@@ -0,0 +1,226 @@
1
+ <!--
2
+ RmExportButton - exporter le contenu d'une carte, en un clic.
3
+
4
+ Le pendant court de RmExportPanel. Le panneau pose quatre questions depuis
5
+ le rail parce qu'il exporte un ecran entier ; ce bouton n'en pose aucune
6
+ parce qu'il exporte une carte, dont le lecteur voit deja ce qu'elle
7
+ contient. Poser un assistant sur un resultat de liste demanderait de choisir
8
+ ce qui n'a qu'une reponse.
9
+
10
+ Il ne genere rien lui-meme : le classeur sort de `useRmExport`, le meme que
11
+ le panneau. Page de garde « OIP Healthcare par Tekkare », en-tetes, plafond
12
+ de 32 767 caracteres par cellule : un export de carte et un export de page
13
+ se presentent donc a l'identique devant un client.
14
+
15
+ Le garde-fou du plafond est garde d'Auriga : au-dela de `limit` lignes, une
16
+ modale dit combien la selection contient et combien partiront, plutot que de
17
+ livrer un fichier tronque en silence. Elle ne parait que si la page passe
18
+ `total`, le seul a connaitre le compte reel : `sheets` ne porte que les
19
+ lignes deja chargees.
20
+
21
+ ORIGINE AURIGA
22
+ --------------
23
+ Remplace `<ArgExportXLSX>` (`argExportXLSX.vue`), et le wrapper applicatif
24
+ `AppExportXLSX` qui lui greffait le suivi Posthog.
25
+
26
+ | Auriga | Romulus | Note |
27
+ |--------------------|--------------|----------------------------------------|
28
+ | `name` | `label` | libelle du bouton, pas le nom du fichier |
29
+ | `export-name` | `file-name` | sans extension ni date, ajoutees ici |
30
+ | `subtitle` | `subtitle` | va sur la page de garde |
31
+ | `tabs: Tab[]` | `sheets: RmExportSheet[]` | meme forme : `{ name, schema, data }`, et `schema` garde `{ column, value }` |
32
+ | `kpi` | `kpi` | inchange |
33
+ | `sources` | `sources` | `{ name, date }` |
34
+ | `limit` | `limit` | inchange |
35
+ | `actualAmount` | `total` | `null` devient absent |
36
+ | `style` | `variant` | les noms de RmButton |
37
+ | `beforeExport` | `@before` | evenement plutot que prop fonction ; attendu s'il rend une promesse |
38
+ | `lang` | `lang` | `es` n'est pas repris, personne ne l'appelait |
39
+ | `posthog-key` / `posthog-host` / `posthog-properties` | - | le suivi ne regarde pas un composant de rendu : la page l'accroche a `@export` |
40
+ | `cut` | - | jamais passe par aucun appelant |
41
+ -->
42
+ <script setup lang="ts">
43
+ import { computed, ref } from 'vue'
44
+ import { useRmExport } from '../composables/useRmExport'
45
+ import type { RmExportKpi, RmExportSheet, RmExportSource } from '../utils/export'
46
+
47
+ const props = withDefaults(
48
+ defineProps<{
49
+ /** Nom du fichier, sans extension : la date et `.xlsx` sont ajoutees ici. */
50
+ fileName?: string
51
+ /** Libelle du bouton. */
52
+ label?: string
53
+ /** Titre du document, sur la page de garde. Absent : le libelle sert. */
54
+ title?: string
55
+ subtitle?: string
56
+ /** Les onglets du classeur. Un seul dans le cas courant. */
57
+ sheets?: RmExportSheet[]
58
+ kpi?: RmExportKpi[]
59
+ sources?: RmExportSource[]
60
+ /** Lignes ecrites au plus, par onglet. */
61
+ limit?: number
62
+ /**
63
+ * Lignes que la selection contient vraiment, quand elle depasse ce qui est
64
+ * charge. Au-dela de `limit`, elle declenche la confirmation. Absente,
65
+ * aucune confirmation : le composant ne peut pas deviner qu'il tronque.
66
+ */
67
+ total?: number
68
+ variant?: string
69
+ size?: 'sm' | 'md' | 'lg'
70
+ lang?: 'fr' | 'en'
71
+ }>(),
72
+ {
73
+ fileName: 'export-tekkare',
74
+ label: '',
75
+ title: '',
76
+ subtitle: '',
77
+ sheets: () => [],
78
+ kpi: () => [],
79
+ sources: () => [],
80
+ limit: 1000,
81
+ total: undefined,
82
+ variant: 'secondary',
83
+ size: 'sm',
84
+ lang: 'fr',
85
+ },
86
+ )
87
+
88
+ const emit = defineEmits<{
89
+ /**
90
+ * Emis avant de construire le classeur, pour la page qui doit d'abord aller
91
+ * chercher les lignes. Rendre une promesse par `until` la fait attendre.
92
+ */
93
+ before: [hold: (promise: Promise<unknown>) => void]
94
+ /** Emis apres l'ecriture, avec le nom du fichier et le nombre de lignes. */
95
+ export: [result: { fileName: string, rows: number }]
96
+ /** Emis quand l'ecriture echoue. */
97
+ error: [error: unknown]
98
+ }>()
99
+
100
+ const WORDS = {
101
+ fr: {
102
+ label: 'Exporter',
103
+ limitTitle: 'Limite de téléchargement atteinte',
104
+ cancel: 'Annuler',
105
+ confirm: 'Continuer le téléchargement',
106
+ body: (total: number, limit: number) =>
107
+ `Votre sélection contient ${total} éléments. Pour des raisons d’optimisation, seuls les ${limit} premiers seront téléchargés.`,
108
+ },
109
+ en: {
110
+ label: 'Export',
111
+ limitTitle: 'Download limit reached',
112
+ cancel: 'Cancel',
113
+ confirm: 'Continue the download',
114
+ body: (total: number, limit: number) =>
115
+ `Your selection contains ${total} items. For optimisation reasons, only the first ${limit} will be downloaded.`,
116
+ },
117
+ } as const
118
+
119
+ const t = computed(() => WORDS[props.lang] ?? WORDS.fr)
120
+ const buttonLabel = computed(() => props.label || t.value.label)
121
+
122
+ const busy = ref(false)
123
+ const confirming = ref(false)
124
+
125
+ /** Le fichier porte sa date : deux exports du meme ecran ne s'ecrasent pas. */
126
+ const stampedName = computed(() => {
127
+ const today = new Date().toISOString().slice(0, 10)
128
+ return `${props.fileName}-from_${today}`
129
+ })
130
+
131
+ const overLimit = computed(
132
+ () => props.total !== undefined && props.total > props.limit,
133
+ )
134
+
135
+ async function start() {
136
+ if (overLimit.value) {
137
+ confirming.value = true
138
+ return
139
+ }
140
+ await write()
141
+ }
142
+
143
+ async function write() {
144
+ busy.value = true
145
+ try {
146
+ // La page peut avoir a charger ses lignes : elle retient le bouton en
147
+ // passant sa promesse, plutot que le composant ne prenne une prop fonction
148
+ // que la moitie des appelants laissent vide.
149
+ const held: Promise<unknown>[] = []
150
+ emit('before', promise => held.push(promise))
151
+ if (held.length) await Promise.all(held)
152
+
153
+ const { run } = useRmExport()
154
+ const result = await run(
155
+ {
156
+ format: 'xlsx',
157
+ range: 'all',
158
+ sections: [],
159
+ columns: [],
160
+ options: [],
161
+ fileName: stampedName.value,
162
+ layout: '',
163
+ schedule: 'once',
164
+ frequency: '',
165
+ email: false,
166
+ },
167
+ {
168
+ title: props.title || buttonLabel.value,
169
+ subtitle: props.subtitle,
170
+ kpi: props.kpi,
171
+ sources: props.sources,
172
+ sheets: props.sheets,
173
+ limit: props.limit,
174
+ },
175
+ props.lang,
176
+ )
177
+ emit('export', { fileName: result.fileName, rows: result.rows })
178
+ }
179
+ catch (error) {
180
+ emit('error', error)
181
+ }
182
+ finally {
183
+ busy.value = false
184
+ confirming.value = false
185
+ }
186
+ }
187
+ </script>
188
+
189
+ <template>
190
+ <RmButton
191
+ :variant="variant"
192
+ :size="size"
193
+ prefix-icon="FileXls"
194
+ :loading="busy"
195
+ @click="start"
196
+ >
197
+ {{ buttonLabel }}
198
+ </RmButton>
199
+
200
+ <RmModal
201
+ v-model="confirming"
202
+ size="sm"
203
+ mode="free"
204
+ :label="t.limitTitle"
205
+ :close-label="t.cancel"
206
+ >
207
+ <template #header>
208
+ <h2 class="rm-export-button__title">{{ t.limitTitle }}</h2>
209
+ </template>
210
+
211
+ <p class="rm-export-button__body">{{ t.body(total ?? 0, limit) }}</p>
212
+
213
+ <template #footer>
214
+ <RmButton variant="secondary" @click="confirming = false">
215
+ {{ t.cancel }}
216
+ </RmButton>
217
+ <RmButton variant="primary" :loading="busy" @click="write">
218
+ {{ t.confirm }}
219
+ </RmButton>
220
+ </template>
221
+ </RmModal>
222
+ </template>
223
+
224
+ <style scoped>
225
+ .rm-export-button__title{color:var(--text-primary);font-size:var(--font-16);font-weight:700;margin:0}.rm-export-button__body{color:var(--text-secondary);font-size:var(--font-14);line-height:1.6;margin:0}
226
+ </style>
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Types des alertes posees sur les vues sauvegardees.
3
+ *
4
+ * Ils vivent ici plutot que dans les composants parce que le paquet n'expose
5
+ * que `utils/*` : une app qui construit ces objets doit pouvoir les typer sans
6
+ * importer un `.vue`. Meme raison que pour `utils/export.ts`.
7
+ */
8
+ export type RmAlertStatus = 'seeding' | 'active' | 'paused' | 'stale' | 'failing';
9
+ /** Ce qu'une alerte sait detecter. Les trois sortent du meme passage de diff. */
10
+ export type RmAlertTrigger = 'appeared' | 'changed' | 'disappeared';
11
+ export interface RmAlertMenuItem {
12
+ key: string;
13
+ label: string;
14
+ icon?: string;
15
+ danger?: boolean;
16
+ }
17
+ export interface RmAlert {
18
+ id: string;
19
+ title: string;
20
+ /** Nom de la vue surveillee. */
21
+ viewTitle?: string | null;
22
+ status: RmAlertStatus;
23
+ triggers: RmAlertTrigger[];
24
+ /** Libelles des champs suivis, deja traduits. */
25
+ watchedLabels?: string[];
26
+ channels: string[];
27
+ cadence?: string;
28
+ /** Documents dans la reference : la preuve que la requete tourne. */
29
+ trackedCount?: number;
30
+ /** Evenements pas encore lus. */
31
+ unreadCount?: number;
32
+ /** Message du dernier passage refuse. */
33
+ lastError?: string | null;
34
+ lastRunAt?: string | null;
35
+ /** Date de la derniere remise a zero de la reference, s'il y en a eu une. */
36
+ reseededAt?: string | null;
37
+ owner?: string | null;
38
+ createdLabel?: string | null;
39
+ lastEventLabel?: string | null;
40
+ eventsThisMonth?: number;
41
+ /** Partagee avec l'espace de travail. */
42
+ shared?: boolean;
43
+ /** L'utilisateur courant en est proprietaire. */
44
+ isOwner?: boolean;
45
+ }
46
+ export interface RmAlertChange {
47
+ field: string;
48
+ /** Libelle du champ tel que la page le nomme. */
49
+ label?: string | null;
50
+ before: string;
51
+ after: string;
52
+ }
53
+ export interface RmAlertEvent {
54
+ id: string;
55
+ kind: RmAlertTrigger;
56
+ title: string;
57
+ subtitle?: string | null;
58
+ changes?: RmAlertChange[] | null;
59
+ /** Date deja mise en forme par l'appelant. */
60
+ dateLabel: string;
61
+ alertTitle?: string | null;
62
+ read?: boolean;
63
+ }
64
+ /** Un champ que la page propose de surveiller. */
65
+ export interface RmAlertField {
66
+ value: string;
67
+ label: string;
68
+ }
69
+ /** Ce que le formulaire de creation rend a l'appelant. */
70
+ export interface RmAlertDraft {
71
+ title: string;
72
+ triggers: RmAlertTrigger[];
73
+ watchedFields: string[];
74
+ channels: string[];
75
+ cadence: 'daily' | 'weekly';
76
+ visibility: 'private' | 'workspace';
77
+ }
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.99",
3
+ "version": "0.1.101",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",