@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,295 @@
1
+ <!--
2
+ RmSavedViewCard - une vue sauvegardee : un jeu de filtres, de tri et
3
+ d'affichage qu'on a mis de cote pour le rejouer.
4
+
5
+ La carte entiere active la vue, comme un element de liste : le bouton
6
+ « Activer » n'est qu'un point d'entree visible, pas le seul. Les commandes
7
+ internes (favori, menu, confirmation) arretent la propagation, sinon un clic
8
+ sur le menu activerait la vue au passage.
9
+
10
+ Le titre se renomme sur place. Un champ pose la ou vit le titre coute un
11
+ aller-retour de moins qu'une fenetre modale pour changer trois mots, et
12
+ garde la carte sous les yeux pendant l'edition.
13
+
14
+ La suppression demande confirmation dans la carte, sur une barre posee en
15
+ bas. Une `confirm()` du navigateur sort du cadre de l'app et ne dit pas
16
+ quelle vue est visee ; la barre, elle, nomme la vue et reste a cote d'elle.
17
+ -->
18
+ <script setup lang="ts">
19
+ import { computed, nextTick, ref } from 'vue'
20
+
21
+ export interface RmSavedViewPill {
22
+ /** Cle du critere (« Aire », « SMR »). */
23
+ k: string
24
+ /** Valeur retenue, deja mise en forme par l'appelant. */
25
+ v: string
26
+ }
27
+
28
+ export interface RmSavedViewConfigRow {
29
+ /** Icone Phosphor de la ligne. */
30
+ icon?: string
31
+ /** Intitule, en capitales dans la carte (« Display », « Tri », « Filtres »). */
32
+ key: string
33
+ /** Valeur simple. Ignoree si `pills` est fourni. */
34
+ value?: string
35
+ /** Valeurs en pastilles, pour une liste de criteres. */
36
+ pills?: RmSavedViewPill[]
37
+ }
38
+
39
+ export interface RmSavedViewMeta {
40
+ icon?: string
41
+ label: string
42
+ }
43
+
44
+ export interface RmSavedView {
45
+ id: string
46
+ title: string
47
+ description?: string
48
+ /** La vue est celle qui est appliquee a l'ecran. */
49
+ active?: boolean
50
+ /** La vue s'applique a l'ouverture de la page. */
51
+ isDefault?: boolean
52
+ starred?: boolean
53
+ config?: RmSavedViewConfigRow[]
54
+ /** Pied de carte : auteur, date, compteurs. */
55
+ meta?: RmSavedViewMeta[]
56
+ /** Horodatage, pour le filtre « Recentes » de `RmSavedViews`. */
57
+ updatedAt?: string | number
58
+ }
59
+
60
+ export interface RmSavedViewMenuItem {
61
+ key: string
62
+ label: string
63
+ icon?: string
64
+ /** Rouge dans le menu : action destructrice. */
65
+ danger?: boolean
66
+ /** Demande confirmation dans la carte avant d'emettre l'action. */
67
+ confirm?: boolean
68
+ }
69
+
70
+ const props = withDefaults(
71
+ defineProps<{
72
+ view: RmSavedView
73
+ /** Entrees du menu. Vide : pas de menu. */
74
+ menu?: RmSavedViewMenuItem[]
75
+ /** Le titre se renomme sur place. */
76
+ renamable?: boolean
77
+ /** L'etoile de favori est posee devant le titre. */
78
+ starrable?: boolean
79
+ lang?: 'fr' | 'en'
80
+ }>(),
81
+ { menu: () => [], renamable: false, starrable: false, lang: 'fr' },
82
+ )
83
+
84
+ const emit = defineEmits<{
85
+ activate: [id: string]
86
+ star: [id: string]
87
+ rename: [id: string, title: string]
88
+ action: [key: string, id: string]
89
+ }>()
90
+
91
+ const en = computed(() => props.lang === 'en')
92
+
93
+ const T = computed(() => ({
94
+ activate: en.value ? 'Activate' : 'Activer',
95
+ active: en.value ? 'Active' : 'Active',
96
+ star: en.value ? 'Add to favorites' : 'Ajouter aux favoris',
97
+ starred: en.value ? 'Favorite' : 'Favori',
98
+ more: en.value ? 'More actions' : "Plus d'actions",
99
+ isDefault: en.value
100
+ ? 'Default view, applied on opening'
101
+ : "Vue par défaut, s'active à l'ouverture",
102
+ cancel: en.value ? 'Cancel' : 'Annuler',
103
+ confirmPrefix: en.value ? 'Permanently delete' : 'Supprimer définitivement',
104
+ }))
105
+
106
+ const menuOpen = ref(false)
107
+ /** Entree du menu en attente de confirmation, ou null. */
108
+ const pending = ref<RmSavedViewMenuItem | null>(null)
109
+
110
+ const renaming = ref(false)
111
+ const draft = ref('')
112
+ const input = ref<HTMLInputElement | null>(null)
113
+
114
+ function toggleMenu() {
115
+ menuOpen.value = !menuOpen.value
116
+ if (menuOpen.value) pending.value = null
117
+ }
118
+
119
+ function pick(item: RmSavedViewMenuItem) {
120
+ menuOpen.value = false
121
+ if (item.confirm) {
122
+ pending.value = item
123
+ return
124
+ }
125
+ if (item.key === 'rename' && props.renamable) {
126
+ startRename()
127
+ return
128
+ }
129
+ emit('action', item.key, props.view.id)
130
+ }
131
+
132
+ function confirmPending() {
133
+ const item = pending.value
134
+ pending.value = null
135
+ if (item) emit('action', item.key, props.view.id)
136
+ }
137
+
138
+ async function startRename() {
139
+ draft.value = props.view.title
140
+ renaming.value = true
141
+ await nextTick()
142
+ input.value?.focus()
143
+ input.value?.select()
144
+ }
145
+
146
+ function commitRename() {
147
+ if (!renaming.value) return
148
+ renaming.value = false
149
+ const next = draft.value.trim()
150
+ if (next && next !== props.view.title) emit('rename', props.view.id, next)
151
+ }
152
+
153
+ /** Le clic sur la carte active la vue ; la vue active, elle, est inerte. */
154
+ function onCardClick() {
155
+ if (props.view.active || renaming.value || pending.value) return
156
+ emit('activate', props.view.id)
157
+ }
158
+ </script>
159
+
160
+ <template>
161
+ <div
162
+ class="rm-view"
163
+ :class="{ 'rm-view--active': view.active, 'rm-view--default': view.isDefault }"
164
+ @click="onCardClick"
165
+ >
166
+ <div v-if="view.isDefault" class="rm-view__strip">
167
+ <RmIcon name="PushPin" :size="14" weight="fill" />
168
+ {{ T.isDefault }}
169
+ </div>
170
+
171
+ <div class="rm-view__head">
172
+ <button
173
+ v-if="starrable"
174
+ type="button"
175
+ class="rm-view__star"
176
+ :class="{ 'rm-view__star--on': view.starred }"
177
+ :title="view.starred ? T.starred : T.star"
178
+ :aria-label="view.starred ? T.starred : T.star"
179
+ :aria-pressed="!!view.starred"
180
+ @click.stop="emit('star', view.id)"
181
+ >
182
+ <RmIcon name="Star" :size="16" :weight="view.starred ? 'fill' : 'regular'" />
183
+ </button>
184
+
185
+ <input
186
+ v-if="renaming"
187
+ ref="input"
188
+ v-model="draft"
189
+ class="rm-view__title-edit"
190
+ type="text"
191
+ @click.stop
192
+ @keydown.enter.prevent="commitRename"
193
+ @keydown.esc.prevent="renaming = false"
194
+ @blur="commitRename"
195
+ >
196
+ <span v-else class="rm-view__title" @dblclick.stop="renamable && startRename()">
197
+ {{ view.title }}
198
+ </span>
199
+
200
+ <div class="rm-view__actions" @click.stop>
201
+ <span v-if="view.active" class="rm-view__badge">
202
+ <RmIcon name="CheckCircle" :size="12" weight="fill" />
203
+ {{ T.active }}
204
+ </span>
205
+ <RmButton
206
+ v-else
207
+ variant="primary-fill"
208
+ size="sm"
209
+ @click="emit('activate', view.id)"
210
+ >
211
+ {{ T.activate }}
212
+ </RmButton>
213
+
214
+ <div v-if="menu.length" class="rm-view__menu-wrap">
215
+ <button
216
+ type="button"
217
+ class="rm-view__more"
218
+ :title="T.more"
219
+ :aria-label="T.more"
220
+ :aria-expanded="menuOpen"
221
+ @click="toggleMenu"
222
+ >
223
+ <RmIcon name="List" :size="18" />
224
+ </button>
225
+ <!-- Le menu se referme au clic dehors : la couche transparente evite
226
+ d'ecouter le document entier, et empeche au passage un clic
227
+ d'atteindre la carte (qui activerait la vue). -->
228
+ <template v-if="menuOpen">
229
+ <div class="rm-view__menu-shade" @click.stop="menuOpen = false" />
230
+ <div class="rm-view__menu" role="menu">
231
+ <button
232
+ v-for="item in menu"
233
+ :key="item.key"
234
+ type="button"
235
+ class="rm-view__menu-item"
236
+ :class="{ 'rm-view__menu-item--danger': item.danger }"
237
+ role="menuitem"
238
+ @click.stop="pick(item)"
239
+ >
240
+ <RmIcon v-if="item.icon" :name="item.icon" :size="16" />
241
+ {{ item.label }}
242
+ </button>
243
+ </div>
244
+ </template>
245
+ </div>
246
+ </div>
247
+ </div>
248
+
249
+ <p v-if="view.description" class="rm-view__desc">{{ view.description }}</p>
250
+
251
+ <div v-if="view.config?.length" class="rm-view__config">
252
+ <div v-for="(row, index) in view.config" :key="index" class="rm-view__row">
253
+ <span class="rm-view__row-key">
254
+ <RmIcon v-if="row.icon" :name="row.icon" :size="13" />
255
+ {{ row.key }}
256
+ </span>
257
+ <span class="rm-view__row-val">
258
+ <template v-if="row.pills?.length">
259
+ <span v-for="(pill, pillIndex) in row.pills" :key="pillIndex" class="rm-view__pill">
260
+ <span class="rm-view__pill-k">{{ pill.k }}</span>
261
+ <span class="rm-view__pill-v">{{ pill.v }}</span>
262
+ </span>
263
+ </template>
264
+ <template v-else>{{ row.value }}</template>
265
+ </span>
266
+ </div>
267
+ </div>
268
+
269
+ <div v-if="view.meta?.length" class="rm-view__foot">
270
+ <span v-for="(item, index) in view.meta" :key="index">
271
+ <RmIcon v-if="item.icon" :name="item.icon" :size="12" />
272
+ {{ item.label }}
273
+ </span>
274
+ </div>
275
+
276
+ <div v-if="pending" class="rm-view__confirm" @click.stop>
277
+ <span>
278
+ <RmIcon name="WarningCircle" :size="16" />
279
+ {{ T.confirmPrefix }} « {{ view.title }} » ?
280
+ </span>
281
+ <div class="rm-view__confirm-actions">
282
+ <RmButton variant="secondary" size="sm" @click="pending = null">
283
+ {{ T.cancel }}
284
+ </RmButton>
285
+ <RmButton variant="danger" size="sm" prefix-icon="Trash" @click="confirmPending">
286
+ {{ pending.label }}
287
+ </RmButton>
288
+ </div>
289
+ </div>
290
+ </div>
291
+ </template>
292
+
293
+ <style scoped>
294
+ .rm-view{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);color:var(--text-primary);cursor:pointer;font-family:var(--font-sans);padding:14px 18px;transition:border-color var(--duration-fast) var(--ease-out),box-shadow var(--duration-fast) var(--ease-out),background var(--duration-fast) var(--ease-out)}.rm-view:not(.rm-view--active):hover{background:var(--bg-hover);border-color:var(--color-accent-soft)}.rm-view--active{border-color:var(--color-secondary);box-shadow:0 0 0 3px rgba(var(--brand-rgb),.08);cursor:default}.rm-view__strip{background:var(--bg-hover);border-bottom:1px solid var(--border-soft);border-radius:7px 7px 0 0;color:var(--color-secondary);font-size:var(--font-11);font-weight:var(--weight-bold);gap:var(--space-8);letter-spacing:.06em;margin:-14px -18px var(--space-12);padding:var(--space-8) var(--space-16);text-transform:uppercase}.rm-view__head,.rm-view__strip{align-items:center;display:flex}.rm-view__head{gap:10px}.rm-view__star{align-items:center;background:transparent;border:1px solid transparent;border-radius:var(--radius-md);color:var(--text-muted);cursor:pointer;display:inline-flex;flex:0 0 auto;height:28px;justify-content:center;padding:0;width:28px}.rm-view__star:hover{background:var(--bg-hover)}.rm-view__star--on,.rm-view__star:hover{color:var(--color-warning)}.rm-view__title,.rm-view__title-edit{flex:1;font-size:var(--font-16);font-weight:var(--weight-bold);min-width:0}.rm-view__title-edit{background:var(--bg-surface);border:1px solid var(--color-secondary);border-radius:var(--radius-md);color:var(--text-primary);font-family:inherit;outline:none;padding:2px var(--space-8)}.rm-view__title-edit:focus{box-shadow:0 0 0 3px rgba(var(--brand-rgb),.12)}.rm-view__actions{align-items:center;display:flex;gap:var(--space-8)}.rm-view__badge{align-items:center;background:var(--color-success-bg);border-radius:var(--radius-pill);color:var(--color-success);display:inline-flex;font-size:var(--font-10);font-weight:var(--weight-bold);gap:var(--space-4);letter-spacing:.06em;padding:3px var(--space-8);text-transform:uppercase}.rm-view__menu-wrap{position:relative}.rm-view__more{align-items:center;background:transparent;border:none;border-radius:var(--radius-md);color:var(--text-muted);cursor:pointer;display:inline-flex;height:28px;justify-content:center;padding:0;width:28px}.rm-view__more:hover{background:var(--bg-hover);color:var(--text-primary)}.rm-view__menu-shade{inset:0;position:fixed;z-index:60}.rm-view__menu{background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);display:flex;flex-direction:column;min-width:200px;padding:var(--space-4);position:absolute;right:0;top:calc(100% + 6px);z-index:61}.rm-view__menu-item{align-items:center;background:transparent;border:none;border-radius:var(--radius-md);color:var(--text-primary);cursor:pointer;display:flex;font-family:inherit;font-size:var(--font-13);gap:var(--space-8);padding:var(--space-8) var(--space-12);text-align:left;width:100%}.rm-view__menu-item:hover{background:var(--bg-hover);color:var(--color-secondary)}.rm-view__menu-item--danger{color:var(--color-danger)}.rm-view__menu-item--danger:hover{background:var(--color-danger-bg);color:var(--color-danger)}.rm-view__desc{color:var(--text-secondary);font-size:var(--font-13);margin:var(--space-8) 0 var(--space-12)}.rm-view__config{background:var(--bg-surface-2);border:1px solid var(--border-soft);border-radius:var(--radius-md);display:flex;flex-direction:column;gap:var(--space-6);margin:10px 0 var(--space-12);padding:10px 14px}.rm-view__row{align-items:flex-start;display:flex;flex-wrap:wrap;font-size:var(--font-12);gap:14px}.rm-view__row-key{color:var(--text-muted);display:inline-flex;flex-shrink:0;font-size:var(--font-10);font-weight:var(--weight-semibold);letter-spacing:.04em;min-width:96px;padding-top:4px;text-transform:uppercase}.rm-view__row-key,.rm-view__row-val{align-items:center;gap:var(--space-6)}.rm-view__row-val{color:var(--text-primary);display:flex;flex:1 1 200px;flex-wrap:wrap;padding:2px 0}.rm-view__pill{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);display:inline-flex;font-size:var(--font-12);gap:var(--space-4);padding:5px 10px}.rm-view__pill-k{color:var(--color-primary);font-weight:var(--weight-semibold)}.rm-view__pill-v{color:var(--text-primary);font-weight:var(--weight-medium)}.rm-view__foot{border-top:1px solid var(--border-soft);color:var(--text-muted);display:flex;flex-wrap:wrap;font-size:var(--font-11);gap:var(--space-16);padding-top:10px}.rm-view__foot span{align-items:center;display:inline-flex;gap:var(--space-4)}.rm-view__confirm{align-items:center;background:var(--color-danger-bg);border:1px solid var(--color-danger);border-radius:var(--radius-md);color:var(--color-danger);display:flex;flex-wrap:wrap;font-size:var(--font-13);gap:var(--space-12);margin-top:var(--space-12);padding:var(--space-8) var(--space-12)}.rm-view__confirm>span{align-items:center;display:inline-flex;flex:1 1 220px;gap:var(--space-8)}.rm-view__confirm-actions{display:flex;flex-shrink:0;gap:var(--space-8)}
295
+ </style>
@@ -0,0 +1,282 @@
1
+ <!--
2
+ RmSavedViews - le panneau des vues sauvegardees.
3
+
4
+ Une vue capture les filtres, le tri et le mode d'affichage d'une analyse.
5
+ Ce panneau les liste, en active une, et sauvegarde la configuration courante.
6
+ Il ne connait pas la nature des criteres : l'appelant lui donne des vues
7
+ deja mises en forme (cf. `RmSavedView`), lui rend les actions.
8
+
9
+ L'ordre des elements suit la lecture : ce qu'on peut faire tout de suite
10
+ (sauvegarder), a qui appartiennent les vues (onglets), comment retrouver la
11
+ bonne (recherche puis filtres), ce qui demande une decision (la banniere de
12
+ changements non enregistres), et enfin la liste.
13
+
14
+ Recherche et filtres travaillent sur `views` sans rien demander a
15
+ l'appelant : filtrer une liste deja en memoire n'a pas besoin d'un
16
+ aller-retour, et une recherche qui remonte au serveur clignote.
17
+ -->
18
+ <script setup lang="ts">
19
+ import { computed, ref } from 'vue'
20
+ import type { RmSavedView, RmSavedViewMenuItem } from './SavedViewCard.vue'
21
+
22
+ export interface RmSavedViewsTab {
23
+ key: string
24
+ label: string
25
+ icon?: string
26
+ count?: number
27
+ }
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ views: RmSavedView[]
32
+ /** Titre du panneau. Vide quand le cadre le porte deja (rail, tiroir). */
33
+ title?: string
34
+ /** Phrase d'introduction, ce qu'une vue capture. */
35
+ description?: string
36
+ /** Libelle du bouton de sauvegarde. Vide : pas de bouton. */
37
+ saveLabel?: string
38
+ /** Rien a sauvegarder (aucun filtre pose, par exemple). */
39
+ saveDisabled?: boolean
40
+ /** Onglets de portee. Masques s'il y en a moins de deux. */
41
+ tabs?: RmSavedViewsTab[]
42
+ /** Onglet courant (v-model). */
43
+ tab?: string
44
+ /** Menu d'une carte, passe tel quel a `RmSavedViewCard`. */
45
+ menu?: RmSavedViewMenuItem[]
46
+ renamable?: boolean
47
+ starrable?: boolean
48
+ /** Chargement : la liste pose son squelette. */
49
+ loading?: boolean
50
+ /** Nom de la vue active dont la configuration a change a l'ecran. */
51
+ dirtyTitle?: string
52
+ /** Textes de l'etat vide. */
53
+ emptyTitle?: string
54
+ emptyText?: string
55
+ emptyActionLabel?: string
56
+ lang?: 'fr' | 'en'
57
+ }>(),
58
+ {
59
+ title: '',
60
+ description: '',
61
+ saveLabel: '',
62
+ saveDisabled: false,
63
+ tabs: () => [],
64
+ tab: '',
65
+ menu: () => [],
66
+ renamable: false,
67
+ starrable: false,
68
+ loading: false,
69
+ dirtyTitle: '',
70
+ emptyTitle: '',
71
+ emptyText: '',
72
+ emptyActionLabel: '',
73
+ lang: 'fr',
74
+ },
75
+ )
76
+
77
+ const emit = defineEmits<{
78
+ 'update:tab': [key: string]
79
+ 'save-current': []
80
+ activate: [id: string]
81
+ star: [id: string]
82
+ rename: [id: string, title: string]
83
+ action: [key: string, id: string]
84
+ /** Banniere : abandonner les changements, les sauvegarder, en faire une vue. */
85
+ discard: []
86
+ save: []
87
+ 'save-as': []
88
+ }>()
89
+
90
+ const en = computed(() => props.lang === 'en')
91
+
92
+ const T = computed(() => ({
93
+ search: en.value ? 'Search a view by name or author...' : 'Rechercher une vue par nom ou auteur...',
94
+ all: en.value ? 'All' : 'Toutes',
95
+ starred: en.value ? 'Favorites' : 'Favoris',
96
+ recent: en.value ? 'Recent' : 'Récentes',
97
+ isDefault: en.value ? 'Default' : 'Par défaut',
98
+ dirty: en.value ? 'has unsaved changes.' : 'a des changements non enregistrés.',
99
+ discard: en.value ? 'Discard' : 'Annuler',
100
+ saveAs: en.value ? 'Save as new' : 'Sauver comme nouvelle',
101
+ save: en.value ? 'Save' : 'Sauver',
102
+ noMatch: en.value ? 'No view matches your search' : 'Aucune vue ne correspond à votre recherche',
103
+ noMatchText: en.value
104
+ ? 'Change the name you are looking for, or clear the active filter.'
105
+ : 'Modifiez le nom recherché, ou retirez le filtre actif.',
106
+ emptyTitle: en.value ? 'No saved view' : 'Aucune vue sauvegardée',
107
+ emptyText: en.value
108
+ ? 'Capture your filters, sort and display in a reusable view. You can then find it instantly or set it as default.'
109
+ : "Capturez vos filtres, votre tri et votre affichage dans une vue réutilisable. Vous pourrez ensuite la retrouver instantanément, ou la définir par défaut.",
110
+ defaultTip: en.value
111
+ ? 'Mark a view as default from its menu, it will apply when you open this page.'
112
+ : "Astuce : marquez une vue par défaut depuis son menu, elle s'activera à l'ouverture de cette page.",
113
+ }))
114
+
115
+ const search = ref('')
116
+
117
+ type Chip = 'all' | 'starred' | 'recent' | 'default'
118
+ const chip = ref<Chip>('all')
119
+
120
+ /** « Recentes » : modifiees dans les trente derniers jours. */
121
+ const RECENT_DAYS = 30
122
+
123
+ function isRecent(view: RmSavedView) {
124
+ if (!view.updatedAt) return false
125
+ const time = new Date(view.updatedAt).getTime()
126
+ if (Number.isNaN(time)) return false
127
+ return Date.now() - time <= RECENT_DAYS * 24 * 3600 * 1000
128
+ }
129
+
130
+ const chips = computed(() => [
131
+ { key: 'all' as Chip, label: T.value.all, icon: 'List', count: props.views.length },
132
+ { key: 'starred' as Chip, label: T.value.starred, icon: 'Star' },
133
+ { key: 'recent' as Chip, label: T.value.recent, icon: 'Clock' },
134
+ { key: 'default' as Chip, label: T.value.isDefault, icon: 'PushPin' },
135
+ ])
136
+
137
+ const shown = computed(() => {
138
+ const needle = search.value.trim().toLowerCase()
139
+ return props.views.filter((view) => {
140
+ if (chip.value === 'starred' && !view.starred) return false
141
+ if (chip.value === 'default' && !view.isDefault) return false
142
+ if (chip.value === 'recent' && !isRecent(view)) return false
143
+ if (!needle) return true
144
+ const hay = `${view.title} ${view.description ?? ''} ${view.meta?.map((m) => m.label).join(' ') ?? ''}`
145
+ return hay.toLowerCase().includes(needle)
146
+ })
147
+ })
148
+
149
+ /** Liste vide faute de vue, ou vide faute de correspondance : deux messages. */
150
+ const filtered = computed(() => props.views.length > 0 && shown.value.length === 0)
151
+
152
+ /** L'astuce ne sort que s'il y a des vues et qu'aucune ne s'applique seule. */
153
+ const showDefaultTip = computed(
154
+ () => props.views.length > 0 && !props.views.some((view) => view.isDefault),
155
+ )
156
+
157
+ const visibleTabs = computed(() => (props.tabs.length > 1 ? props.tabs : []))
158
+ </script>
159
+
160
+ <template>
161
+ <div class="rm-views">
162
+ <div v-if="title || saveLabel" class="rm-views__head">
163
+ <h2 v-if="title" class="rm-views__title">{{ title }}</h2>
164
+ <RmButton
165
+ v-if="saveLabel"
166
+ variant="primary-fill"
167
+ size="sm"
168
+ prefix-icon="BookmarkSimple"
169
+ :disabled="saveDisabled"
170
+ :block="!title"
171
+ @click="emit('save-current')"
172
+ >
173
+ {{ saveLabel }}
174
+ </RmButton>
175
+ </div>
176
+
177
+ <p v-if="description" class="rm-views__desc">{{ description }}</p>
178
+
179
+ <div v-if="visibleTabs.length" class="rm-views__tabs" role="tablist">
180
+ <button
181
+ v-for="item in visibleTabs"
182
+ :key="item.key"
183
+ type="button"
184
+ class="rm-views__tab"
185
+ :class="{ 'rm-views__tab--active': item.key === tab }"
186
+ role="tab"
187
+ :aria-selected="item.key === tab"
188
+ @click="emit('update:tab', item.key)"
189
+ >
190
+ <RmIcon v-if="item.icon" :name="item.icon" :size="16" />
191
+ {{ item.label }}
192
+ <span v-if="item.count !== undefined" class="rm-views__tab-count">{{ item.count }}</span>
193
+ </button>
194
+ </div>
195
+
196
+ <template v-if="views.length > 1">
197
+ <label class="rm-views__search">
198
+ <RmIcon name="MagnifyingGlass" :size="14" />
199
+ <input v-model="search" type="text" :placeholder="T.search">
200
+ </label>
201
+
202
+ <div class="rm-views__chips" role="group">
203
+ <button
204
+ v-for="item in chips"
205
+ :key="item.key"
206
+ type="button"
207
+ class="rm-views__chip"
208
+ :class="{ 'rm-views__chip--on': chip === item.key }"
209
+ :aria-pressed="chip === item.key"
210
+ @click="chip = item.key"
211
+ >
212
+ <RmIcon :name="item.icon" :size="12" :weight="chip === item.key ? 'fill' : 'regular'" />
213
+ {{ item.label }}
214
+ <span v-if="item.count !== undefined" class="rm-views__chip-count">{{ item.count }}</span>
215
+ </button>
216
+ </div>
217
+ </template>
218
+
219
+ <RmCallout v-if="dirtyTitle" tone="warning" class="rm-views__banner">
220
+ <b>« {{ dirtyTitle }} »</b> {{ T.dirty }}
221
+ <template #actions>
222
+ <RmButton variant="secondary" size="sm" @click="emit('discard')">
223
+ {{ T.discard }}
224
+ </RmButton>
225
+ <RmButton variant="secondary" size="sm" @click="emit('save-as')">
226
+ {{ T.saveAs }}
227
+ </RmButton>
228
+ <RmButton variant="primary-fill" size="sm" @click="emit('save')">
229
+ {{ T.save }}
230
+ </RmButton>
231
+ </template>
232
+ </RmCallout>
233
+
234
+ <slot name="before-list" />
235
+
236
+ <div v-if="loading" class="rm-views__list">
237
+ <RmSkeleton v-for="index in 3" :key="index" width="100%" :height="132" radius="var(--radius-lg)" />
238
+ </div>
239
+
240
+ <RmEmptyState
241
+ v-else-if="!views.length"
242
+ framed
243
+ tone="primary"
244
+ icon="BookmarkSimple"
245
+ :title="emptyTitle || T.emptyTitle"
246
+ :text="emptyText || T.emptyText"
247
+ :action-label="emptyActionLabel"
248
+ action-icon="BookmarkSimple"
249
+ @action="emit('save-current')"
250
+ />
251
+
252
+ <RmEmptyState
253
+ v-else-if="filtered"
254
+ framed
255
+ icon="MagnifyingGlass"
256
+ :title="T.noMatch"
257
+ :text="T.noMatchText"
258
+ />
259
+
260
+ <div v-else class="rm-views__list">
261
+ <RmSavedViewCard
262
+ v-for="view in shown"
263
+ :key="view.id"
264
+ :view="view"
265
+ :menu="menu"
266
+ :renamable="renamable"
267
+ :starrable="starrable"
268
+ :lang="lang"
269
+ @activate="emit('activate', $event)"
270
+ @star="emit('star', $event)"
271
+ @rename="(id, value) => emit('rename', id, value)"
272
+ @action="(key, id) => emit('action', key, id)"
273
+ />
274
+
275
+ <RmCallout v-if="showDefaultTip" tone="info" :text="T.defaultTip" />
276
+ </div>
277
+ </div>
278
+ </template>
279
+
280
+ <style scoped>
281
+ .rm-views{flex-direction:column;font-family:var(--font-sans)}.rm-views,.rm-views__head{display:flex;gap:var(--space-12)}.rm-views__head{align-items:center;justify-content:space-between}.rm-views__title{color:var(--text-primary);font-family:var(--font-serif);font-size:var(--font-22);font-weight:var(--weight-bold);margin:0}.rm-views__desc{color:var(--text-secondary);font-size:var(--font-13);line-height:1.5;margin:0}.rm-views__tabs{align-items:stretch;border-bottom:1px solid var(--border-default);display:flex}.rm-views__tab{align-items:center;background:none;border:none;border-bottom:3px solid transparent;color:var(--text-secondary);cursor:pointer;display:inline-flex;font-family:inherit;font-size:var(--font-14);font-weight:var(--weight-semibold);gap:var(--space-6);padding:var(--space-12) var(--space-16);transition:color var(--duration-fast) var(--ease-out)}.rm-views__tab:hover{color:var(--text-primary)}.rm-views__tab--active{background:linear-gradient(180deg,transparent 30%,rgba(var(--brand-rgb),.06) 100%);border-bottom-color:var(--color-primary);border-radius:6px 6px 0 0;color:var(--color-primary);font-weight:var(--weight-bold)}.rm-views__tab-count{background:var(--bg-surface-3);border-radius:var(--radius-pill);color:var(--text-muted);font-size:var(--font-11);font-variant-numeric:tabular-nums;font-weight:var(--weight-bold);min-width:18px;padding:0 var(--space-6);text-align:center}.rm-views__tab--active .rm-views__tab-count{background:var(--bg-hover);color:var(--color-secondary)}.rm-views__search{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-md);color:var(--text-muted);display:flex;gap:var(--space-8);padding:var(--space-8) var(--space-12)}.rm-views__search:focus-within{border-color:var(--color-secondary);box-shadow:0 0 0 3px rgba(var(--brand-rgb),.1)}.rm-views__search input{background:none;border:none;color:var(--text-primary);flex:1;font-family:inherit;font-size:var(--font-13);min-width:0;outline:none}.rm-views__search input::-moz-placeholder{color:var(--text-muted)}.rm-views__search input::placeholder{color:var(--text-muted)}.rm-views__chips{display:flex;flex-wrap:wrap;gap:var(--space-6)}.rm-views__chip{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-pill);color:var(--text-secondary);cursor:pointer;display:inline-flex;font-family:inherit;font-size:var(--font-12);gap:var(--space-4);padding:5px 10px}.rm-views__chip:hover{background:var(--bg-surface-3)}.rm-views__chip--on{background:var(--bg-hover);border-color:var(--color-violet-pale);color:var(--color-primary);font-weight:var(--weight-semibold)}.rm-views__chip-count{font-variant-numeric:tabular-nums;opacity:.8}.rm-views__list{display:flex;flex-direction:column;gap:var(--space-12)}
282
+ </style>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * useRmExport - les generateurs de fichiers derriere `RmExportPanel`.
3
+ *
4
+ * Le wizard produit une configuration, la page produit un contenu
5
+ * (`RmExportPayload`) ; ce composable croise les deux et rend un fichier. Il
6
+ * vit dans le paquet et pas dans les apps parce que l'en-tete « OIP Healthcare
7
+ * par Tekkare », la page de garde du classeur et la mise en page A4 doivent
8
+ * etre les memes partout : un export de Hub-R et un export d'une app pays ne
9
+ * peuvent pas se presenter differemment devant un client.
10
+ *
11
+ * Toutes les bibliotheques lourdes (write-excel-file, jsPDF) sont importees a
12
+ * l'appel, pas au chargement de la page : un ecran qui n'exporte rien ne paie
13
+ * pas leur poids.
14
+ */
15
+ import type { RmExportConfig, RmExportPayload } from '../utils/export.js';
16
+ export interface RmExportResult {
17
+ /** Nom du fichier rendu, extension comprise. Vide pour un permalink. */
18
+ fileName: string;
19
+ /** Nombre de lignes reellement ecrites. */
20
+ rows: number;
21
+ /** Taille du fichier rendu, en octets. */
22
+ bytes: number;
23
+ /** URL, pour le format « Permalink ». */
24
+ url?: string;
25
+ }
26
+ export declare function useRmExport(): {
27
+ run: (config: RmExportConfig, payload: RmExportPayload, lang?: "fr" | "en") => Promise<RmExportResult>;
28
+ extensions: Record<string, string>;
29
+ };