@tekkare/romulus 0.1.35 → 0.1.37

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.
@@ -0,0 +1,744 @@
1
+ <!--
2
+ RmExportPanel - le panneau d'export d'une page, en quatre etapes.
3
+
4
+ L'export est la sortie de chaque ecran OIP, et jusqu'ici chaque page avait
5
+ son bouton : un XLSX, une colonne fixe, un nom de fichier different. Ce
6
+ panneau remplace ce bouton par une question posee dans l'ordre : quel
7
+ format, quelles donnees, quelle presentation, on lance.
8
+
9
+ Le panneau ne connait pas les formats par leur nom. Chaque format porte
10
+ trois drapeaux (`tabular`, `visual`, `paged`) et chaque section une nature
11
+ (`table`, `visual`, `meta`) : c'est leur croisement qui decide de ce qui est
12
+ propose. Un PNG n'a donc ni colonnes ni mise en page sans qu'aucune ligne du
13
+ composant ne parle de PNG, et un format ajoute plus tard herite du bon
14
+ comportement en posant ses drapeaux.
15
+
16
+ Le composant ne genere rien : il emet une configuration. Les fichiers sont
17
+ rendus par `useRmExport`, et c'est l'app qui fournit le contenu — elle seule
18
+ sait ce que sa page affiche.
19
+ -->
20
+ <script setup lang="ts">
21
+ import { computed, reactive, ref, watch } from 'vue'
22
+ import type {
23
+ RmExportConfig,
24
+ RmExportFormat,
25
+ RmExportLayout,
26
+ RmExportOption,
27
+ RmExportRange,
28
+ RmExportRecent,
29
+ RmExportSection,
30
+ RmExportTemplate,
31
+ } from '../utils/export'
32
+ import {
33
+ RM_EXPORT_EXTENSIONS,
34
+ RM_EXPORT_FORMATS,
35
+ RM_EXPORT_LAYOUTS,
36
+ RM_EXPORT_OPTIONS,
37
+ RM_EXPORT_SECTIONS,
38
+ rmExportDuration,
39
+ rmExportSize,
40
+ rmExportSizeKb,
41
+ } from '../utils/export'
42
+
43
+ const props = withDefaults(
44
+ defineProps<{
45
+ /** Nom de fichier propose, sans extension. */
46
+ fileName: string
47
+ formats?: RmExportFormat[]
48
+ sections?: RmExportSection[]
49
+ ranges?: RmExportRange[]
50
+ /** Colonnes disponibles, dans l'ordre du tableau de la page. */
51
+ columns?: string[]
52
+ options?: RmExportOption[]
53
+ layouts?: RmExportLayout[]
54
+ /** Modeles pre-remplis. Vide : le panneau ouvre directement le wizard. */
55
+ templates?: RmExportTemplate[]
56
+ recent?: RmExportRecent[]
57
+ /**
58
+ * La programmation recurrente suppose un service qui execute l'export
59
+ * sans le navigateur. Tant qu'il n'existe pas, l'etape reste visible mais
60
+ * inerte : cacher la promesse serait pire que l'annoncer.
61
+ */
62
+ scheduling?: boolean
63
+ busy?: boolean
64
+ /** Message d'erreur du dernier export. */
65
+ error?: string
66
+ /** Message de confirmation (lien copie, fichier pret). */
67
+ notice?: string
68
+ lang?: 'fr' | 'en'
69
+ }>(),
70
+ {
71
+ formats: () => RM_EXPORT_FORMATS,
72
+ sections: () => RM_EXPORT_SECTIONS,
73
+ ranges: () => [],
74
+ columns: () => [],
75
+ options: () => RM_EXPORT_OPTIONS,
76
+ layouts: () => RM_EXPORT_LAYOUTS,
77
+ templates: () => [],
78
+ recent: () => [],
79
+ scheduling: false,
80
+ busy: false,
81
+ error: '',
82
+ notice: '',
83
+ lang: 'fr',
84
+ },
85
+ )
86
+
87
+ const emit = defineEmits<{ export: [config: RmExportConfig] }>()
88
+
89
+ const LABELS = {
90
+ fr: {
91
+ tabExport: 'Exporter',
92
+ tabMine: 'Mes exports',
93
+ lead: 'Démarrez avec un modèle pré-configuré, ou personnalisez chaque paramètre via le wizard 4 étapes.',
94
+ entryTitle: 'Comment voulez-vous commencer ?',
95
+ entryHint: 'Les modèles pré-remplissent toutes les étapes, vous arrivez directement à la revue finale et pouvez ajuster avant de lancer.',
96
+ custom: 'Export personnalisé',
97
+ customMeta: 'Configurer chaque paramètre via le wizard 4 étapes',
98
+ backToEntry: 'Choisir un autre modèle',
99
+ template: 'Modèle',
100
+ steps: ['Format', 'Données', 'Personnaliser', 'Exporter'],
101
+ step1: 'Choisissez le format de votre export. Le contenu disponible (graphiques, tableau, sections) varie selon le format.',
102
+ step2: 'Quelles données inclure dans l\'export ?',
103
+ step3: 'Nom du fichier, mise en page, options avancées.',
104
+ step4: 'Vérifiez votre configuration et lancez l\'export.',
105
+ range: 'Plage de données',
106
+ sectionsLabel: 'Sections à inclure',
107
+ columnsLabel: 'Colonnes du tableau',
108
+ fileNameLabel: 'Nom du fichier',
109
+ fileNameHint: 'Extension ajoutée automatiquement selon le format.',
110
+ layoutLabel: 'Mise en page',
111
+ optionsLabel: 'Options',
112
+ scheduleLabel: 'Type d\'export',
113
+ once: 'Une seule fois',
114
+ recurring: 'Récurrent',
115
+ schedulingSoon: 'La programmation récurrente et l\'envoi par email arrivent avec le service d\'exports. Pour l\'instant, un export part depuis ce navigateur.',
116
+ frequency: 'Fréquence',
117
+ daily: 'Quotidien',
118
+ weekly: 'Hebdomadaire',
119
+ monthly: 'Mensuel',
120
+ email: 'Envoyer par email à chaque export',
121
+ recap: 'Récapitulatif',
122
+ recapFormat: 'Format',
123
+ recapFile: 'Nom du fichier',
124
+ recapData: 'Données',
125
+ recapSections: 'Sections',
126
+ recapColumns: 'Colonnes',
127
+ recapLayout: 'Mise en page',
128
+ recapSchedule: 'Programmation',
129
+ recapSize: 'Taille estimée',
130
+ recapDuration: 'Durée estimée',
131
+ rows: 'lignes',
132
+ of: 'sur',
133
+ prev: 'Retour',
134
+ next: 'Suivant',
135
+ launch: 'Lancer l\'export',
136
+ running: 'Export en cours...',
137
+ progress: 'Étape',
138
+ recentTitle: 'Exports récents',
139
+ recentEmpty: 'Aucun export lancé depuis ce navigateur.',
140
+ recentHint: 'Historique local à ce navigateur : les exports ne sont pas conservés sur nos serveurs.',
141
+ permalinkHint: 'Le lien reprend la vue courante, filtres compris. Rien à configurer.',
142
+ nothing: 'Aucun contenu disponible pour ce format sur cette page.',
143
+ },
144
+ en: {
145
+ tabExport: 'Export',
146
+ tabMine: 'My exports',
147
+ lead: 'Start from a preset, or set every parameter through the 4-step wizard.',
148
+ entryTitle: 'How do you want to start?',
149
+ entryHint: 'Presets fill in every step, you land on the final review and can adjust before running.',
150
+ custom: 'Custom export',
151
+ customMeta: 'Set every parameter through the 4-step wizard',
152
+ backToEntry: 'Pick another preset',
153
+ template: 'Preset',
154
+ steps: ['Format', 'Data', 'Customize', 'Export'],
155
+ step1: 'Pick your export format. Available content (charts, table, sections) depends on the format.',
156
+ step2: 'Which data should the export contain?',
157
+ step3: 'File name, layout, advanced options.',
158
+ step4: 'Check your setup and run the export.',
159
+ range: 'Data range',
160
+ sectionsLabel: 'Sections to include',
161
+ columnsLabel: 'Table columns',
162
+ fileNameLabel: 'File name',
163
+ fileNameHint: 'Extension added automatically, based on the format.',
164
+ layoutLabel: 'Layout',
165
+ optionsLabel: 'Options',
166
+ scheduleLabel: 'Export type',
167
+ once: 'Once',
168
+ recurring: 'Recurring',
169
+ schedulingSoon: 'Recurring exports and email delivery ship with the export service. For now, an export runs from this browser.',
170
+ frequency: 'Frequency',
171
+ daily: 'Daily',
172
+ weekly: 'Weekly',
173
+ monthly: 'Monthly',
174
+ email: 'Email me every export',
175
+ recap: 'Summary',
176
+ recapFormat: 'Format',
177
+ recapFile: 'File name',
178
+ recapData: 'Data',
179
+ recapSections: 'Sections',
180
+ recapColumns: 'Columns',
181
+ recapLayout: 'Layout',
182
+ recapSchedule: 'Schedule',
183
+ recapSize: 'Estimated size',
184
+ recapDuration: 'Estimated time',
185
+ rows: 'rows',
186
+ of: 'of',
187
+ prev: 'Back',
188
+ next: 'Next',
189
+ launch: 'Run export',
190
+ running: 'Exporting...',
191
+ progress: 'Step',
192
+ recentTitle: 'Recent exports',
193
+ recentEmpty: 'No export run from this browser yet.',
194
+ recentHint: 'History is local to this browser: exports are not kept on our servers.',
195
+ permalinkHint: 'The link points at the current view, filters included. Nothing to configure.',
196
+ nothing: 'No content available for this format on this page.',
197
+ },
198
+ } as const
199
+
200
+ const t = computed(() => LABELS[props.lang] ?? LABELS.fr)
201
+
202
+ /** Premier format utilisable : un catalogue peut ouvrir sur un format grise. */
203
+ const firstFormat = computed(() => props.formats.find(format => !format.disabled) ?? props.formats[0])
204
+
205
+ const config = reactive<RmExportConfig>({
206
+ format: firstFormat.value?.key ?? 'xlsx',
207
+ range: props.ranges[0]?.key ?? 'filtered',
208
+ sections: props.sections.filter(section => section.on).map(section => section.key),
209
+ columns: [...props.columns],
210
+ options: props.options.filter(option => option.on).map(option => option.key),
211
+ fileName: props.fileName,
212
+ layout: props.layouts[0]?.key ?? 'a4-portrait',
213
+ schedule: 'once',
214
+ frequency: 'monthly',
215
+ email: false,
216
+ })
217
+
218
+ // Le nom de fichier propose depend de la page et du scope : il change sous le
219
+ // panneau quand l'utilisateur filtre. On suit la proposition tant qu'il n'a
220
+ // pas ecrit son propre nom.
221
+ const renamed = ref(false)
222
+ watch(
223
+ () => props.fileName,
224
+ (next) => {
225
+ if (!renamed.value) config.fileName = next
226
+ },
227
+ )
228
+
229
+ // Les colonnes ne sont connues qu'une fois la page chargee : la selection
230
+ // suit, sinon un export lance tot sort avec toutes les colonnes alors que le
231
+ // panneau en montre cinq.
232
+ watch(
233
+ () => props.columns,
234
+ (next) => {
235
+ config.columns = config.columns.filter(column => next.includes(column))
236
+ if (!config.columns.length) config.columns = [...next]
237
+ },
238
+ )
239
+
240
+ const step = ref(1)
241
+ const mode = ref<'entry' | 'wizard'>(props.templates.length ? 'entry' : 'wizard')
242
+ const templateKey = ref<string | null>(null)
243
+
244
+ const format = computed(
245
+ () => props.formats.find(item => item.key === config.format) ?? firstFormat.value,
246
+ )
247
+
248
+ /** Une section n'a de sens que si le format sait la rendre. */
249
+ function enabled(section: RmExportSection) {
250
+ if (!format.value) return false
251
+ if (section.kind === 'table') return !!format.value.tabular
252
+ if (section.kind === 'visual') return !!format.value.visual
253
+ return !!format.value.paged
254
+ }
255
+
256
+ const showColumns = computed(() => !!format.value?.tabular && props.columns.length > 0)
257
+ const showLayout = computed(() => !!format.value?.paged && props.layouts.length > 0)
258
+ const showRange = computed(() => !!format.value?.tabular && props.ranges.length > 0)
259
+ const isPermalink = computed(() => !format.value?.tabular && !format.value?.visual)
260
+
261
+ /** Sections cochees, une fois retirees celles que le format ne rend pas. */
262
+ const activeSections = computed(() =>
263
+ props.sections.filter(section => enabled(section) && config.sections.includes(section.key)),
264
+ )
265
+
266
+ const availableSections = computed(() => props.sections.filter(section => enabled(section)))
267
+
268
+ const range = computed(
269
+ () => props.ranges.find(item => item.key === config.range) ?? props.ranges[0],
270
+ )
271
+
272
+ const rows = computed(() => (showRange.value ? range.value?.rows ?? 0 : 0))
273
+
274
+ const sizeKb = computed(() =>
275
+ rmExportSizeKb({
276
+ format: config.format,
277
+ rows: rows.value,
278
+ columns: config.columns.length,
279
+ totalColumns: props.columns.length,
280
+ sections: activeSections.value.length,
281
+ includesTable: activeSections.value.some(section => section.kind === 'table'),
282
+ zip: config.options.includes('zip'),
283
+ }),
284
+ )
285
+
286
+ const fullFileName = computed(() => {
287
+ if (isPermalink.value) return '—'
288
+ const extension = RM_EXPORT_EXTENSIONS[config.format] ?? config.format
289
+ return `${config.fileName || 'export'}.${extension}`
290
+ })
291
+
292
+ const scheduleLabel = computed(() => {
293
+ if (config.schedule === 'once') return t.value.once
294
+ const names: Record<string, string> = {
295
+ daily: t.value.daily,
296
+ weekly: t.value.weekly,
297
+ monthly: t.value.monthly,
298
+ }
299
+ return `${t.value.recurring}, ${(names[config.frequency] ?? '').toLowerCase()}`
300
+ })
301
+
302
+ /** Le format ne rend rien de ce que la page declare : le lancer serait vide. */
303
+ const emptyForFormat = computed(
304
+ () => !isPermalink.value && availableSections.value.length === 0,
305
+ )
306
+
307
+ const formatMeta = computed(() => (format.value?.disabled ? format.value.disabledHint ?? '' : ''))
308
+
309
+ function pickFormat(item: RmExportFormat) {
310
+ if (item.disabled) return
311
+ config.format = item.key
312
+ // Une section que le nouveau format ne rend pas reste cochee dans l'etat
313
+ // mais ne compte plus : c'est ce qui permet de revenir a PDF sans avoir
314
+ // perdu ses choix.
315
+ props.sections.forEach((section) => {
316
+ if (enabled(section) && section.on && !config.sections.includes(section.key)) {
317
+ config.sections.push(section.key)
318
+ }
319
+ })
320
+ }
321
+
322
+ function toggle(list: string[], key: string) {
323
+ const index = list.indexOf(key)
324
+ if (index === -1) list.push(key)
325
+ else list.splice(index, 1)
326
+ }
327
+
328
+ function applyTemplate(template: RmExportTemplate | null) {
329
+ if (!template) {
330
+ templateKey.value = null
331
+ mode.value = 'wizard'
332
+ step.value = 1
333
+ return
334
+ }
335
+ Object.assign(config, template.config)
336
+ if (template.config.fileName) renamed.value = true
337
+ templateKey.value = template.key
338
+ mode.value = 'wizard'
339
+ step.value = 4
340
+ }
341
+
342
+ function goto(next: number) {
343
+ step.value = Math.min(4, Math.max(1, next))
344
+ }
345
+
346
+ function launch() {
347
+ emit('export', { ...config, sections: activeSections.value.map(section => section.key) })
348
+ }
349
+
350
+ const recentDate = (at: string | Date) =>
351
+ new Date(at).toLocaleDateString(props.lang === 'en' ? 'en-US' : 'fr-FR', {
352
+ day: 'numeric',
353
+ month: 'long',
354
+ })
355
+ </script>
356
+
357
+ <template>
358
+ <div class="rm-export">
359
+ <div class="rm-export__tabs" role="tablist">
360
+ <button class="rm-export__tab rm-export__tab--active" type="button" role="tab">
361
+ <RmIcon name="PlusCircle" :size="16" /> {{ t.tabExport }}
362
+ </button>
363
+ <button
364
+ class="rm-export__tab"
365
+ type="button"
366
+ role="tab"
367
+ disabled
368
+ :title="t.schedulingSoon"
369
+ >
370
+ <RmIcon name="ClockClockwise" :size="16" /> {{ t.tabMine }}
371
+ </button>
372
+ </div>
373
+
374
+ <p class="rm-export__lead">{{ t.lead }}</p>
375
+
376
+ <!-- Entree : modeles pre-remplis, ou le wizard a la main -->
377
+ <div v-if="mode === 'entry'" class="rm-export__entry">
378
+ <p class="rm-export__eyebrow">{{ t.entryTitle }}</p>
379
+ <div class="rm-export__templates">
380
+ <button
381
+ v-for="template in templates"
382
+ :key="template.key"
383
+ class="rm-export__template"
384
+ type="button"
385
+ @click="applyTemplate(template)"
386
+ >
387
+ <RmIcon :name="template.icon" :size="26" weight="fill" :color="template.color" />
388
+ <span class="rm-export__template-body">
389
+ <span class="rm-export__template-name">{{ template.name }}</span>
390
+ <span class="rm-export__template-meta">{{ template.meta }}</span>
391
+ </span>
392
+ </button>
393
+ <button
394
+ class="rm-export__template rm-export__template--custom"
395
+ type="button"
396
+ @click="applyTemplate(null)"
397
+ >
398
+ <RmIcon name="PlusCircle" :size="26" />
399
+ <span class="rm-export__template-body">
400
+ <span class="rm-export__template-name">{{ t.custom }}</span>
401
+ <span class="rm-export__template-meta">{{ t.customMeta }}</span>
402
+ </span>
403
+ </button>
404
+ </div>
405
+ <p class="rm-export__hint">{{ t.entryHint }}</p>
406
+ </div>
407
+
408
+ <!-- Wizard -->
409
+ <div v-else class="rm-export__wizard">
410
+ <div v-if="templates.length" class="rm-export__wizard-head">
411
+ <button class="rm-export__back" type="button" @click="mode = 'entry'">
412
+ <RmIcon name="ArrowLeft" :size="14" /> {{ t.backToEntry }}
413
+ </button>
414
+ <RmBadge v-if="templateKey" variant="info">
415
+ {{ t.template }} : {{ templates.find(item => item.key === templateKey)?.name }}
416
+ </RmBadge>
417
+ </div>
418
+
419
+ <div class="rm-export__stepper" role="tablist">
420
+ <template v-for="(label, index) in t.steps" :key="label">
421
+ <div v-if="index" class="rm-export__step-divider" />
422
+ <button
423
+ class="rm-export__step"
424
+ :class="{
425
+ 'rm-export__step--active': step === index + 1,
426
+ 'rm-export__step--done': step > index + 1,
427
+ }"
428
+ type="button"
429
+ role="tab"
430
+ :aria-selected="step === index + 1"
431
+ @click="goto(index + 1)"
432
+ >
433
+ <span class="rm-export__step-num">{{ index + 1 }}</span>
434
+ <span class="rm-export__step-label">{{ label }}</span>
435
+ </button>
436
+ </template>
437
+ </div>
438
+
439
+ <!-- Etape 1 : format -->
440
+ <div v-if="step === 1" class="rm-export__panel">
441
+ <p class="rm-export__desc">{{ t.step1 }}</p>
442
+ <div class="rm-export__formats" role="radiogroup">
443
+ <label
444
+ v-for="item in formats"
445
+ :key="item.key"
446
+ class="rm-export__format"
447
+ :class="{
448
+ 'rm-export__format--active': config.format === item.key,
449
+ 'rm-export__format--off': item.disabled,
450
+ }"
451
+ :title="item.disabled ? item.disabledHint : undefined"
452
+ >
453
+ <input
454
+ type="radio"
455
+ name="rm-export-format"
456
+ :value="item.key"
457
+ :checked="config.format === item.key"
458
+ :disabled="item.disabled"
459
+ @change="pickFormat(item)"
460
+ >
461
+ <RmIcon :name="item.icon" :size="24" weight="fill" :color="item.color" />
462
+ <span class="rm-export__format-text">
463
+ <span class="rm-export__format-name">{{ item.label }}</span>
464
+ <span class="rm-export__format-desc">{{ item.desc }}</span>
465
+ <span v-if="item.disabled && item.disabledHint" class="rm-export__format-meta">
466
+ {{ item.disabledHint }}
467
+ </span>
468
+ </span>
469
+ </label>
470
+ </div>
471
+ </div>
472
+
473
+ <!-- Etape 2 : donnees -->
474
+ <div v-else-if="step === 2" class="rm-export__panel">
475
+ <p class="rm-export__desc">{{ isPermalink ? t.permalinkHint : t.step2 }}</p>
476
+
477
+ <div v-if="showRange" class="rm-export__row">
478
+ <span class="rm-export__label">{{ t.range }}</span>
479
+ <RmSegmented
480
+ v-model="config.range"
481
+ block
482
+ size="sm"
483
+ :options="ranges.map(item => ({ value: item.key, label: item.label }))"
484
+ :aria-label="t.range"
485
+ />
486
+ </div>
487
+
488
+ <div v-if="availableSections.length" class="rm-export__row">
489
+ <span class="rm-export__label">{{ t.sectionsLabel }}</span>
490
+ <div class="rm-export__checks">
491
+ <label
492
+ v-for="section in sections"
493
+ :key="section.key"
494
+ class="rm-export__check"
495
+ :class="{ 'rm-export__check--off': !enabled(section) }"
496
+ >
497
+ <input
498
+ type="checkbox"
499
+ :checked="config.sections.includes(section.key) && enabled(section)"
500
+ :disabled="!enabled(section)"
501
+ @change="toggle(config.sections, section.key)"
502
+ >
503
+ {{ section.label }}
504
+ </label>
505
+ </div>
506
+ </div>
507
+
508
+ <div v-if="showColumns" class="rm-export__row">
509
+ <span class="rm-export__label">
510
+ {{ t.columnsLabel }} ({{ config.columns.length }} {{ t.of }} {{ columns.length }})
511
+ </span>
512
+ <div class="rm-export__chips">
513
+ <button
514
+ v-for="column in columns"
515
+ :key="column"
516
+ class="rm-export__chip"
517
+ :class="config.columns.includes(column) ? 'rm-export__chip--on' : 'rm-export__chip--off'"
518
+ type="button"
519
+ @click="toggle(config.columns, column)"
520
+ >
521
+ {{ config.columns.includes(column) ? column : `+ ${column}` }}
522
+ <RmIcon v-if="config.columns.includes(column)" name="X" :size="11" />
523
+ </button>
524
+ </div>
525
+ </div>
526
+
527
+ <p v-if="emptyForFormat" class="rm-export__hint">{{ t.nothing }}</p>
528
+ </div>
529
+
530
+ <!-- Etape 3 : personnaliser -->
531
+ <div v-else-if="step === 3" class="rm-export__panel">
532
+ <p class="rm-export__desc">{{ isPermalink ? t.permalinkHint : t.step3 }}</p>
533
+
534
+ <div v-if="!isPermalink" class="rm-export__row">
535
+ <label class="rm-export__label" for="rm-export-file">{{ t.fileNameLabel }}</label>
536
+ <input
537
+ id="rm-export-file"
538
+ v-model="config.fileName"
539
+ class="rm-export__input"
540
+ type="text"
541
+ @input="renamed = true"
542
+ >
543
+ <span class="rm-export__hint">{{ t.fileNameHint }}</span>
544
+ </div>
545
+
546
+ <div v-if="showLayout" class="rm-export__row">
547
+ <span class="rm-export__label">{{ t.layoutLabel }}</span>
548
+ <RmSegmented
549
+ v-model="config.layout"
550
+ block
551
+ size="sm"
552
+ :options="layouts.map(item => ({ value: item.key, label: item.label }))"
553
+ :aria-label="t.layoutLabel"
554
+ />
555
+ </div>
556
+
557
+ <div v-if="options.length" class="rm-export__row">
558
+ <span class="rm-export__label">{{ t.optionsLabel }}</span>
559
+ <div class="rm-export__checks">
560
+ <label
561
+ v-for="option in options"
562
+ :key="option.key"
563
+ class="rm-export__check"
564
+ :class="{ 'rm-export__check--off': option.disabled }"
565
+ >
566
+ <input
567
+ type="checkbox"
568
+ :checked="config.options.includes(option.key)"
569
+ :disabled="option.disabled"
570
+ @change="toggle(config.options, option.key)"
571
+ >
572
+ {{ option.label }}
573
+ </label>
574
+ </div>
575
+ </div>
576
+ </div>
577
+
578
+ <!-- Etape 4 : exporter -->
579
+ <div v-else class="rm-export__panel">
580
+ <p class="rm-export__desc">{{ t.step4 }}</p>
581
+
582
+ <div class="rm-export__row">
583
+ <span class="rm-export__label">{{ t.scheduleLabel }}</span>
584
+ <RmSegmented
585
+ v-model="config.schedule"
586
+ block
587
+ size="sm"
588
+ :options="[
589
+ { value: 'once', label: t.once, icon: 'download-simple' },
590
+ { value: 'recurring', label: t.recurring, icon: 'clock-clockwise', disabled: !scheduling },
591
+ ]"
592
+ :aria-label="t.scheduleLabel"
593
+ />
594
+ <span v-if="!scheduling" class="rm-export__hint">{{ t.schedulingSoon }}</span>
595
+
596
+ <div v-if="scheduling && config.schedule === 'recurring'" class="rm-export__sub">
597
+ <span class="rm-export__label">{{ t.frequency }}</span>
598
+ <RmSegmented
599
+ v-model="config.frequency"
600
+ block
601
+ size="sm"
602
+ :options="[
603
+ { value: 'daily', label: t.daily },
604
+ { value: 'weekly', label: t.weekly },
605
+ { value: 'monthly', label: t.monthly },
606
+ ]"
607
+ :aria-label="t.frequency"
608
+ />
609
+ <label class="rm-export__check">
610
+ <input v-model="config.email" type="checkbox"> {{ t.email }}
611
+ </label>
612
+ </div>
613
+ </div>
614
+
615
+ <div class="rm-export__recap">
616
+ <p class="rm-export__eyebrow">{{ t.recap }}</p>
617
+ <div class="rm-export__recap-row">
618
+ <RmIcon name="FileText" :size="16" />
619
+ <div>
620
+ <div class="rm-export__recap-k">{{ t.recapFormat }}</div>
621
+ <div class="rm-export__recap-v">{{ format?.label }}</div>
622
+ </div>
623
+ </div>
624
+ <div class="rm-export__recap-row">
625
+ <RmIcon name="Textbox" :size="16" />
626
+ <div>
627
+ <div class="rm-export__recap-k">{{ t.recapFile }}</div>
628
+ <div class="rm-export__recap-v">{{ fullFileName }}</div>
629
+ </div>
630
+ </div>
631
+ <div v-if="showRange" class="rm-export__recap-row">
632
+ <RmIcon name="Database" :size="16" />
633
+ <div>
634
+ <div class="rm-export__recap-k">{{ t.recapData }}</div>
635
+ <div class="rm-export__recap-v">
636
+ {{ rows.toLocaleString(lang === 'en' ? 'en-US' : 'fr-FR') }} {{ t.rows }}, {{ range?.label }}
637
+ </div>
638
+ </div>
639
+ </div>
640
+ <div v-if="availableSections.length" class="rm-export__recap-row">
641
+ <RmIcon name="CardsThree" :size="16" />
642
+ <div>
643
+ <div class="rm-export__recap-k">{{ t.recapSections }}</div>
644
+ <div class="rm-export__recap-v">
645
+ {{ activeSections.length }} {{ t.of }} {{ availableSections.length }}
646
+ </div>
647
+ </div>
648
+ </div>
649
+ <div v-if="showColumns" class="rm-export__recap-row">
650
+ <RmIcon name="Columns" :size="16" />
651
+ <div>
652
+ <div class="rm-export__recap-k">{{ t.recapColumns }}</div>
653
+ <div class="rm-export__recap-v">
654
+ {{ config.columns.length }} {{ t.of }} {{ columns.length }}
655
+ </div>
656
+ </div>
657
+ </div>
658
+ <div v-if="showLayout" class="rm-export__recap-row">
659
+ <RmIcon name="Layout" :size="16" />
660
+ <div>
661
+ <div class="rm-export__recap-k">{{ t.recapLayout }}</div>
662
+ <div class="rm-export__recap-v">
663
+ {{ layouts.find(item => item.key === config.layout)?.label }}
664
+ </div>
665
+ </div>
666
+ </div>
667
+ <div class="rm-export__recap-row">
668
+ <RmIcon name="Clock" :size="16" />
669
+ <div>
670
+ <div class="rm-export__recap-k">{{ t.recapSchedule }}</div>
671
+ <div class="rm-export__recap-v">{{ scheduleLabel }}</div>
672
+ </div>
673
+ </div>
674
+ <div v-if="!isPermalink" class="rm-export__recap-row">
675
+ <RmIcon name="HardDrives" :size="16" />
676
+ <div>
677
+ <div class="rm-export__recap-k">{{ t.recapSize }}</div>
678
+ <div class="rm-export__recap-v">{{ rmExportSize(sizeKb) }}</div>
679
+ </div>
680
+ </div>
681
+ <div v-if="!isPermalink" class="rm-export__recap-row">
682
+ <RmIcon name="Timer" :size="16" />
683
+ <div>
684
+ <div class="rm-export__recap-k">{{ t.recapDuration }}</div>
685
+ <div class="rm-export__recap-v">{{ rmExportDuration(sizeKb, rows) }}</div>
686
+ </div>
687
+ </div>
688
+ </div>
689
+
690
+ <p v-if="formatMeta" class="rm-export__hint">{{ formatMeta }}</p>
691
+ <p v-if="notice" class="rm-export__notice">{{ notice }}</p>
692
+ <p v-if="error" class="rm-export__error">{{ error }}</p>
693
+ </div>
694
+
695
+ <div class="rm-export__footer">
696
+ <RmButton variant="secondary" size="sm" :disabled="step === 1" prefix-icon="ArrowLeft" @click="goto(step - 1)">
697
+ {{ t.prev }}
698
+ </RmButton>
699
+ <span class="rm-export__progress">{{ t.progress }} <b>{{ step }}</b> {{ t.of }} 4</span>
700
+ <RmButton
701
+ v-if="step < 4"
702
+ variant="primary-fill"
703
+ size="sm"
704
+ suffix-icon="ArrowRight"
705
+ @click="goto(step + 1)"
706
+ >
707
+ {{ t.next }}
708
+ </RmButton>
709
+ <RmButton
710
+ v-else
711
+ variant="primary-fill"
712
+ size="sm"
713
+ prefix-icon="DownloadSimple"
714
+ :disabled="busy || emptyForFormat"
715
+ @click="launch"
716
+ >
717
+ {{ busy ? t.running : t.launch }}
718
+ </RmButton>
719
+ </div>
720
+ </div>
721
+
722
+ <!-- Exports recents -->
723
+ <div class="rm-export__history">
724
+ <p class="rm-export__eyebrow">
725
+ <RmIcon name="DownloadSimple" :size="14" /> {{ t.recentTitle }}
726
+ </p>
727
+ <p v-if="!recent.length" class="rm-export__hint">{{ t.recentEmpty }}</p>
728
+ <div v-for="(item, index) in recent" :key="`${item.name}-${index}`" class="rm-export__history-row">
729
+ <RmIcon name="File" :size="16" />
730
+ <span class="rm-export__history-name">{{ item.name }}</span>
731
+ <span class="rm-export__history-meta">
732
+ <template v-if="item.rows">{{ item.rows.toLocaleString(lang === 'en' ? 'en-US' : 'fr-FR') }} {{ t.rows }} · </template>
733
+ <template v-if="item.size">{{ item.size }} · </template>
734
+ {{ recentDate(item.at) }}
735
+ </span>
736
+ </div>
737
+ <p v-if="recent.length" class="rm-export__hint">{{ t.recentHint }}</p>
738
+ </div>
739
+ </div>
740
+ </template>
741
+
742
+ <style scoped>
743
+ .rm-export{display:flex;flex-direction:column}.rm-export__tabs{border-bottom:1px solid var(--border-default);display:flex;margin-bottom:var(--space-16)}.rm-export__tab{align-items:center;background:none;border:none;border-bottom:3px solid transparent;color:var(--text-muted);cursor:pointer;display:inline-flex;font-family:inherit;font-size:var(--font-14);font-weight:600;gap:var(--space-6);padding:var(--space-12) var(--space-16)}.rm-export__tab--active{border-bottom-color:var(--color-primary);color:var(--color-secondary)}.rm-export__tab:disabled{cursor:not-allowed;opacity:.5}.rm-export__desc,.rm-export__lead{color:var(--text-secondary);font-size:12.5px;line-height:1.55;margin:0 0 var(--space-16)}.rm-export__eyebrow{align-items:center;color:var(--text-muted);display:flex;font-size:10.5px;font-weight:700;gap:var(--space-6);letter-spacing:.1em;margin:0 0 var(--space-8);text-transform:uppercase}.rm-export__hint{color:var(--text-muted);display:block;font-size:11.5px;line-height:1.45;margin:var(--space-8) 0 0}.rm-export__notice{color:var(--color-success)}.rm-export__error,.rm-export__notice{font-size:12px;margin:var(--space-8) 0 0}.rm-export__error{color:var(--color-danger)}.rm-export__templates{display:grid;gap:var(--space-8)}.rm-export__template{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);cursor:pointer;display:flex;font-family:inherit;gap:var(--space-12);padding:var(--space-12) var(--space-16);text-align:left;transition:border-color var(--duration-fast),background var(--duration-fast),transform var(--duration-fast)}.rm-export__template:hover{background:var(--bg-hover);border-color:var(--color-secondary);transform:translateY(-1px)}.rm-export__template--custom{background:transparent;border-style:dashed}.rm-export__template-body{display:flex;flex-direction:column;min-width:0}.rm-export__template-name{color:var(--text-primary);font-size:13.5px;font-weight:700;line-height:1.2}.rm-export__template-meta{color:var(--text-muted);font-size:11px;line-height:1.35;margin-top:3px}.rm-export__wizard{background:var(--bg-surface-2);border:1px solid var(--border-default);border-radius:var(--radius-2xl);padding:var(--space-16) var(--space-20)}.rm-export__wizard-head{align-items:center;border-bottom:1px solid var(--border-soft);display:flex;gap:var(--space-12);margin-bottom:var(--space-16);padding-bottom:var(--space-12)}.rm-export__back{align-items:center;background:none;border:none;color:var(--color-secondary);cursor:pointer;display:inline-flex;font-family:inherit;font-size:12px;font-weight:600;gap:var(--space-4);padding:0}.rm-export__back:hover{text-decoration:underline;text-underline-offset:3px}.rm-export__stepper{align-items:center;display:flex;gap:var(--space-4);margin-bottom:var(--space-20)}.rm-export__step{background:none;border:none;border-radius:var(--radius-pill);cursor:pointer;font-family:inherit;gap:var(--space-6);padding:var(--space-4) var(--space-8)}.rm-export__step,.rm-export__step-num{align-items:center;display:inline-flex}.rm-export__step-num{background:var(--bg-surface-3);border:1.5px solid var(--border-default);border-radius:50%;color:var(--text-muted);font-size:12px;font-weight:700;height:22px;justify-content:center;transition:background var(--duration-fast),color var(--duration-fast),border-color var(--duration-fast);width:22px}.rm-export__step-label{color:var(--text-muted);font-size:12px;font-weight:600}.rm-export__step:hover .rm-export__step-label{color:var(--text-primary)}.rm-export__step--active .rm-export__step-num{background:var(--color-primary);border-color:var(--color-primary);box-shadow:var(--shadow-focus);color:#fff}.rm-export__step--active .rm-export__step-label{color:var(--color-secondary)}.rm-export__step--done .rm-export__step-num{background:var(--color-secondary);border-color:var(--color-secondary);color:#fff}.rm-export__step--done .rm-export__step-label{color:var(--text-secondary)}.rm-export__step-divider{background:var(--border-default);border-radius:var(--radius-pill);flex:1;height:1.5px}.rm-export__panel{animation:rm-export-in var(--duration-base) var(--ease-out)}@keyframes rm-export-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.rm-export__formats{display:grid;gap:var(--space-8)}.rm-export__format{align-items:flex-start;background:var(--bg-surface);border:1.5px solid var(--border-default);border-radius:var(--radius-xl);cursor:pointer;display:flex;gap:var(--space-12);padding:var(--space-12) var(--space-16);transition:border-color var(--duration-fast),background var(--duration-fast),transform var(--duration-fast)}.rm-export__format:hover{border-color:var(--color-secondary);transform:translateY(-1px)}.rm-export__format--active{background:var(--bg-hover);border-color:var(--color-primary);box-shadow:var(--shadow-focus)}.rm-export__format--active .rm-export__format-name{color:var(--color-primary)}.rm-export__format--off{cursor:not-allowed;opacity:.55}.rm-export__format--off:hover{border-color:var(--border-default);transform:none}.rm-export__format input[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:var(--bg-surface);border:1.5px solid var(--border-default);border-radius:50%;cursor:inherit;flex-shrink:0;height:18px;margin:1px 0 0;position:relative;width:18px}.rm-export__format input[type=radio]:checked{border-color:var(--color-primary)}.rm-export__format input[type=radio]:checked:after{background:var(--color-primary);border-radius:50%;content:"";inset:3px;position:absolute}.rm-export__format-text{display:flex;flex-direction:column;min-width:0}.rm-export__format-name{color:var(--text-primary);font-size:14px;font-weight:700;line-height:1.2}.rm-export__format-desc{color:var(--text-muted);font-size:11.5px;line-height:1.45;margin-top:3px}.rm-export__format-meta{border-top:1px solid var(--border-soft);color:var(--text-muted);font-size:10px;font-weight:700;letter-spacing:.08em;margin-top:var(--space-8);padding-top:var(--space-8);text-transform:uppercase}.rm-export__row{margin-bottom:var(--space-20)}.rm-export__row:last-child{margin-bottom:0}.rm-export__label{color:var(--text-secondary);display:block;font-size:11px;font-weight:700;letter-spacing:.06em;margin-bottom:var(--space-8);text-transform:uppercase}.rm-export__sub{margin-top:var(--space-12)}.rm-export__input{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);color:var(--text-primary);font-family:inherit;font-size:13px;padding:var(--space-8) var(--space-12);width:100%}.rm-export__input:focus{border-color:var(--color-primary);box-shadow:var(--shadow-focus);outline:none}.rm-export__checks{display:grid;gap:var(--space-6)}.rm-export__check{align-items:center;color:var(--text-primary);cursor:pointer;display:flex;font-size:12.5px;gap:var(--space-8)}.rm-export__check--off{cursor:not-allowed;opacity:.5}.rm-export__check input[type=checkbox]{accent-color:var(--color-primary);cursor:inherit;height:15px;width:15px}.rm-export__chips{display:flex;flex-wrap:wrap;gap:var(--space-6)}.rm-export__chip{align-items:center;border:1px solid var(--border-default);border-radius:var(--radius-pill);cursor:pointer;display:inline-flex;font-family:inherit;font-size:11.5px;font-weight:600;gap:var(--space-4);padding:3px var(--space-8)}.rm-export__chip--on{background:var(--bg-hover);border-color:var(--color-primary);color:var(--color-primary)}.rm-export__chip--off{background:var(--bg-surface);color:var(--text-muted)}.rm-export__chip--off:hover{border-color:var(--color-secondary);color:var(--color-secondary)}.rm-export__recap{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);margin-top:var(--space-16);padding:var(--space-16)}.rm-export__recap-row{align-items:flex-start;border-bottom:1px solid var(--border-soft);color:var(--color-secondary);display:flex;gap:var(--space-12);padding:var(--space-8) 0}.rm-export__recap-row:last-child{border-bottom:none}.rm-export__recap-row>div{flex:1;min-width:0}.rm-export__recap-k{color:var(--text-muted);font-size:10.5px;font-weight:700;letter-spacing:.08em;margin-bottom:2px;text-transform:uppercase}.rm-export__recap-v{color:var(--text-primary);font-size:13px;font-weight:600;word-break:break-word}.rm-export__footer{align-items:center;border-top:1px solid var(--border-default);display:flex;gap:var(--space-8);margin-top:var(--space-20);padding-top:var(--space-16)}.rm-export__progress{color:var(--text-muted);flex:1;font-size:12px;text-align:center}.rm-export__progress b{color:var(--text-primary)}.rm-export__history{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl);margin-top:var(--space-24);padding:var(--space-16)}.rm-export__history-row{align-items:center;color:var(--color-primary);display:flex;gap:var(--space-8);padding:var(--space-6) 0}.rm-export__history-name{color:var(--text-primary);flex:1;font-size:12.5px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-export__history-meta{color:var(--text-muted);flex-shrink:0;font-size:11px}
744
+ </style>