codevdesign 2.0.5 → 2.0.7

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.
@@ -1,847 +1,847 @@
1
- <template>
2
- <div>
3
- <v-row v-if="barreHautAfficher">
4
- <slot name="ligne" />
5
- <!-- Affichage de la boite de recherche-->
6
- <v-col>
7
- <slot name="recherche"></slot>
8
- <Recherche
9
- :afficher="rechercheAfficher"
10
- :recherche-texte="rechercheTexte"
11
- :chargement="chargementListe"
12
- :recherche="recherche"
13
- :recherche-avancee="rechercheAvancee"
14
- :recherche-avancee-texte="rechercheAvanceeTexte"
15
- :recherche-avancee-largeur="rechercheAvanceeLargeur"
16
- :recherche-avancee-style="rechercheAvanceeStyle"
17
- class="flex-grow-1"
18
- v-bind="$attrs"
19
- @update:recherche="chargerRecherche"
20
- @panneau:etat="onPanelChange"
21
- >
22
- <template #milieu>
23
- <slot name="milieu" />
24
- </template>
25
-
26
- <template #droite>
27
- <slot name="droite" />
28
- <v-btn
29
- v-if="btnAjouter"
30
- color="primary"
31
- class="ml-1 float-right"
32
- @click.stop="ajouter"
33
- >
34
- {{ props.btnAjouterTexte ? props.btnAjouterTexte : $t('csqc.bouton.ajouter') }}
35
- </v-btn>
36
-
37
- <exportExcelComponent
38
- v-if="excel"
39
- :liste="filteredItems"
40
- :chargement-liste="chargementListe"
41
- :nom-fichier="excelNomFichier"
42
- :colonnes="colonnesAffichees"
43
- class="mt-1 ml-1 float-right"
44
- />
45
- <v-icon
46
- v-if="permettreChoixColonnes"
47
- color="grisMoyen"
48
- class="mt-1 ml-1 float-right"
49
- @click.stop="ouvrirChoixColonnes"
50
- >
51
- mdi-table-edit
52
- </v-icon>
53
- </template>
54
- <template #rechercheAvanceeTitre>
55
- <slot name="rechercheAvanceeTitre" />
56
- </template>
57
- <template #rechercheAvanceeApresTitre>
58
- <slot name="rechercheAvanceeApresTitre" />
59
- </template>
60
- <template #rechercheAvancee>
61
- <slot name="rechercheAvancee" />
62
- </template>
63
- </Recherche>
64
- </v-col>
65
- </v-row>
66
-
67
- <!-- datatable-->
68
- <v-row>
69
- <v-col
70
- cols="12"
71
- class="d-flex ControlesDatatable flex-wrap"
72
- >
73
- <v-data-table
74
- ref="datatable"
75
- :headers="colonnesAffichees"
76
- :item-key="itemKey"
77
- :items="listeEffective"
78
- :search="recherche"
79
- :loading="chargementListe"
80
- :hover="hover"
81
- :density="densite"
82
- :items-per-page="itemsParPageLocal"
83
- :item-per-page-options="itemsParPageOptions"
84
- :sort-by="triDepart"
85
- :multi-sort="estMultiTriActif"
86
- v-model:page="pageActuelle"
87
- :striped="props.styleEntete !== 'simplifie' ? 'even' : undefined"
88
- :class="classeEntete"
89
- v-bind="$attrs"
90
- @click:row="cliqueLigne"
91
- >
92
- <!-- utilisation des slots via le component parent-->
93
- <!-- eslint-disable-next-line -->
94
- <template
95
- v-for="(_, slot) of $slots"
96
- :key="slot"
97
- #[slot]="scope"
98
- >
99
- <slot
100
- :name="slot"
101
- v-bind="scope"
102
- />
103
- </template>
104
-
105
- <!-- Icône ancre (drag & drop) -->
106
- <template
107
- v-if="estEnModeModificateurOrdre"
108
- #item.ancre
109
- >
110
- <v-icon class="ancre-drag">mdi-drag-vertical</v-icon>
111
- </template>
112
-
113
- <!-- Footer : pagination design Québec -->
114
- <template #bottom>
115
- <div class="piv-pagination">
116
- <div class="piv-par-page">
117
- <span class="text-caption text-medium-emphasis">{{ $t('csqc.pagination.elementsParPage') }}</span>
118
- <v-select
119
- v-model="itemsParPageLocal"
120
- :items="itemsParPageSelectOptions"
121
- density="compact"
122
- variant="outlined"
123
- hide-details
124
- style="max-width: 90px"
125
- />
126
- </div>
127
- <nav aria-label="Pagination">
128
- <button
129
- variant="text"
130
- v-if="pageActuelle > 1"
131
- class="piv-page-btn"
132
- aria-label="Page précédente"
133
- @click="pageActuelle--"
134
- >
135
- <v-icon size="24">mdi-chevron-left</v-icon>
136
- </button>
137
-
138
- <template
139
- v-for="item in obtenirPages(pageCount)"
140
- :key="`page-${item}`"
141
- >
142
- <span
143
- v-if="item === '...'"
144
- class="piv-ellipsis"
145
- aria-hidden="true"
146
- >…</span
147
- >
148
- <button
149
- v-else
150
- variant="text"
151
- class="piv-page-btn"
152
- :class="{ 'piv-page-active': item === pageActuelle }"
153
- :aria-label="`Page ${item}`"
154
- :aria-current="item === pageActuelle ? 'page' : undefined"
155
- @click="pageActuelle = item as number"
156
- >
157
- {{ item }}
158
- </button>
159
- </template>
160
-
161
- <button
162
- v-if="pageActuelle < pageCount"
163
- class="piv-page-btn"
164
- aria-label="Page suivante"
165
- @click="pageActuelle++"
166
- >
167
- <v-icon size="24">mdi-chevron-right</v-icon>
168
- </button>
169
- </nav>
170
- <span class="piv-total text-caption text-medium-emphasis">
171
- {{ filteredItems.length }} {{ $t('csqc.pagination.total') }}
172
- </span>
173
- </div>
174
- </template>
175
-
176
- <!-- boutons supprimer et modifier -->
177
- <!-- eslint-disable-next-line -->
178
- <template v-slot:item.action="{ item }">
179
- <slot name="actionsCustom"></slot>
180
- <v-icon
181
- v-if="props.btnSupprimer"
182
- size="large"
183
- class="iconeSupprimer float-right"
184
- @click.stop.prevent="ouvrirModaleSupprimer(item)"
185
- >
186
- mdi-delete
187
- </v-icon>
188
-
189
- <v-icon
190
- v-if="props.btnModifier"
191
- size="large"
192
- class="iconeEditer float-right"
193
- @click.stop.prevent="modifier(item)"
194
- >
195
- mdi-pencil
196
- </v-icon>
197
- </template>
198
- </v-data-table>
199
-
200
- <!-- Fenêtre de suppression -->
201
- <confirmation
202
- v-if="props.btnSupprimer"
203
- ref="modaleSupprimer"
204
- :texte="supprimerTexte"
205
- :titre="supprimerTitreTexte"
206
- :largeur="modaleSupprimerLargeur"
207
- @confirmer="supprimer"
208
- />
209
- <modale-choix
210
- v-if="permettreChoixColonnes"
211
- ref="modaleChoix"
212
- :urlbase="urlbase"
213
- :formulaire-id="formulaireId"
214
- :identifiant="identifiant"
215
- :colonnes="colonnesPourChoix"
216
- :choix-origine="choixOrigineNormalise"
217
- @selection="selectionChoix"
218
- @sauvegarde="sauvegardeChoix"
219
- />
220
- </v-col>
221
- </v-row>
222
- </div>
223
- </template>
224
- <script setup lang="ts">
225
- /* eslint-disable @typescript-eslint/no-explicit-any */
226
- import { computed, onBeforeUnmount, nextTick, onMounted, ref, watch, type PropType, type Slots } from 'vue'
227
- import { useI18n } from 'vue-i18n'
228
- import type { SortItem } from 'vuetify/lib/components/VDataTable/composables/sort.mjs'
229
-
230
- import Recherche from '../csqcRecherche.vue'
231
- import confirmation from '../csqcConfirmation.vue'
232
- import exportExcelComponent from './csqcTableExportExcel.vue'
233
- import ModaleChoix from './csqcTableModaleChoixColonnes.vue'
234
- import axios from '../../outils/appAxios'
235
- import Colonne from '../../modeles/composants/datatableColonne'
236
- import Sortable from 'sortablejs'
237
-
238
- type VueColonnes = {
239
- nomVue: string
240
- colonnes: string[]
241
- defaut?: boolean
242
- }
243
-
244
- const slots = defineSlots<Slots>()
245
- const emit = defineEmits([
246
- 'ajouter',
247
- 'cliqueLigne',
248
- 'supprimer',
249
- 'modifier',
250
- 'donneesExportees',
251
- 'panneau:etat',
252
- 'ordreRangeeModifie',
253
- ])
254
-
255
- // ============================================================================
256
- // Props
257
- // ============================================================================
258
- const props = defineProps({
259
- barreHautAfficher: { type: Boolean, default: true },
260
-
261
- btnAjouter: { type: Boolean, default: true },
262
- btnAjouterTexte: { type: String, default: '' },
263
- btnModifier: { type: Boolean, default: true },
264
- btnSupprimer: { type: Boolean, default: true },
265
-
266
- chargementListe: { type: Boolean, default: false },
267
- operationEnCours: { type: Boolean, default: false },
268
-
269
- // Headers Vuetify
270
- colonnes: {
271
- type: Array as PropType<Colonne[]>,
272
- default: (): Colonne[] => [],
273
- },
274
-
275
- densite: { type: String as PropType<'default' | 'comfortable' | 'compact'>, default: 'default' },
276
-
277
- excel: { type: Boolean, default: false },
278
- excelNomFichier: { type: String, default: 'csqc' },
279
-
280
- // Choix colonnes
281
- formulaireId: { type: Number, default: -1 },
282
- identifiant: { type: String, default: '' },
283
- urlbase: { type: String, default: '' },
284
- permettreChoixColonnes: { type: Boolean, default: false },
285
-
286
- // Table
287
- itemKey: { type: String, default: 'id' },
288
- itemsParPage: { type: Number, default: 10 },
289
- itemsParPageOptions: { type: Array, default: () => [10, 25, 30, -1] },
290
- liste: {
291
- type: Array as PropType<Record<string, any>[]>,
292
- default: (): Record<string, any>[] => [],
293
- },
294
- hover: { type: Boolean, default: false },
295
-
296
- // Recherche
297
- rechercheTexte: { type: String, default: '' },
298
- rechercheAfficher: { type: Boolean, default: true },
299
- rechercheAvancee: { type: Boolean, default: false },
300
- rechercheAvanceeTexte: { type: String, default: '' },
301
- rechercheAvanceeLargeur: { type: Number, default: 12 },
302
- rechercheAvanceeStyle: { type: String, default: '' },
303
-
304
- // Style en-tête tableau (design Québec)
305
- styleEntete: {
306
- type: String as PropType<'standard' | 'standardGris' | 'simplifie'>,
307
- default: 'standard',
308
- },
309
-
310
- // Modale suppression
311
- modaleSupprimerChamp: { type: String, default: '' },
312
- modaleSupprimerTexte: { type: String, default: '' },
313
- modaleSupprimerTitre: { type: String, default: '' },
314
- modaleSupprimerLargeur: { type: String, default: '525px' },
315
-
316
- // Tri
317
- triDescDepart: { type: Boolean, default: false },
318
- triParDepart: {
319
- type: [Array, String] as PropType<string | string[] | SortItem[] | undefined>,
320
- default: undefined,
321
- },
322
-
323
- // Ordre manuel (drag & drop)
324
- modificateurOrdreChamp: { type: String, default: undefined },
325
- })
326
-
327
- const { t } = useI18n({ useScope: 'global' })
328
- const recherche = ref<string>('')
329
- const pageActuelle = ref(1)
330
- const itemsParPageLocal = ref(props.itemsParPage)
331
-
332
- watch(
333
- () => props.itemsParPage,
334
- val => {
335
- itemsParPageLocal.value = val
336
- },
337
- )
338
-
339
- watch(itemsParPageLocal, () => {
340
- pageActuelle.value = 1
341
- })
342
-
343
- const classeEntete = computed(() => ({
344
- 'entete-standard': props.styleEntete === 'standard',
345
- 'entete-standard-gris': props.styleEntete === 'standardGris',
346
- 'entete-simplifie': props.styleEntete === 'simplifie',
347
- }))
348
-
349
- const itemsParPageSelectOptions = computed(() =>
350
- (props.itemsParPageOptions as number[]).map(val =>
351
- val === -1 ? { title: t('csqc.pagination.tous'), value: -1 } : { title: String(val), value: val },
352
- ),
353
- )
354
-
355
- const pageCount = computed(() => {
356
- const total = filteredItems.value.length
357
- if (!itemsParPageLocal.value || itemsParPageLocal.value <= 0) return 1
358
- return Math.max(1, Math.ceil(total / itemsParPageLocal.value))
359
- })
360
-
361
- function obtenirPages(totalPages: number): (number | '...')[] {
362
- if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1)
363
- const current = pageActuelle.value
364
- const pages: (number | '...')[] = []
365
- const left = Math.max(2, current - 1)
366
- const right = Math.min(totalPages - 1, current + 1)
367
- pages.push(1)
368
- if (left > 2) pages.push('...')
369
- for (let i = left; i <= right; i++) pages.push(i)
370
- if (right < totalPages - 1) pages.push('...')
371
- pages.push(totalPages)
372
- return pages
373
- }
374
-
375
- // Modale suppression (on garde l’item sélectionné)
376
- const itemSelectionne = ref<any>(null)
377
-
378
- // ============================================================================
379
- // State - Choix de colonnes (vues sauvegardées) + sélection courante
380
- // ============================================================================
381
- const datatable = ref<any>(null)
382
- const listeInterne = ref<Record<string, any>[]>([])
383
- const modaleChoix = ref<InstanceType<typeof ModaleChoix> | null>(null)
384
- const openChoixColonnes = ref(false)
385
- const choix = ref<VueColonnes[]>([]) // la liste des vues disponibles
386
- const vueActive = ref<VueColonnes | null>(null) // optionnel: la vue "appliquée"
387
- const valeurSelectionChoixColonnes = ref<string | null>(null) // nomVue sélectionné dans la modale / UI
388
-
389
- // ============================================================================
390
- // Charger le choix de colonnes sauvegardé s'il y a lieu
391
- // ============================================================================
392
- onMounted(async () => {
393
- if (!props.permettreChoixColonnes) return
394
-
395
- try {
396
- const res: any = await axios
397
- .getAxios()
398
- .get(`${props.urlbase}/api/ComposantUI/Colonnes/${props.formulaireId}/Identifiant/${props.identifiant}`)
399
-
400
- const payload = res?.data ?? res
401
-
402
- if (!payload) {
403
- choix.value = []
404
- return
405
- }
406
-
407
- choix.value = typeof payload === 'string' ? JSON.parse(payload) : payload
408
- } catch (e) {
409
- choix.value = []
410
- // console.debug(e)
411
- }
412
- })
413
-
414
- // ============================================================================
415
- // Computed - Normaliser pour la modale Choix colonne
416
- // ============================================================================
417
- const choixOrigineNormalise = computed(() =>
418
- (choix.value ?? []).map(c => ({
419
- _id: '',
420
- nomVue: c.nomVue,
421
- colonnes: c.colonnes ?? [],
422
- defaut: c.defaut ?? false,
423
- })),
424
- )
425
-
426
- // ============================================================================
427
- // Actions - Modale choix colonnes
428
- // ============================================================================
429
- function ouvrirChoixColonnes() {
430
- modaleChoix.value?.ouvrir()
431
- }
432
-
433
- function appliquerVue(vue: VueColonnes) {
434
- vueActive.value = vue
435
- openChoixColonnes.value = false
436
- }
437
-
438
- function selectionChoix(vue: VueColonnes) {
439
- // on garde une trace du nom sélectionné (pour retrouver la vue plus tard)
440
- valeurSelectionChoixColonnes.value = vue.nomVue
441
- appliquerVue(vue)
442
- }
443
-
444
- function sauvegardeChoix(vues: VueColonnes[]) {
445
- if (!props.permettreChoixColonnes) return
446
-
447
- // si la sélection courante n’existe plus, on reset
448
- if (
449
- valeurSelectionChoixColonnes.value != null &&
450
- vues.find(x => x.nomVue === valeurSelectionChoixColonnes.value) == null
451
- ) {
452
- valeurSelectionChoixColonnes.value = null
453
- }
454
-
455
- choix.value = vues
456
-
457
- // optionnel: appliquer automatiquement la vue par défaut
458
- const defaut = vues.find(v => v.defaut)
459
- if (defaut) appliquerVue(defaut)
460
- }
461
-
462
- // ============================================================================
463
- // Computed - Mode ordre manuel
464
- // ============================================================================
465
- const estEnModeModificateurOrdre = computed(() => {
466
- if (!props.modificateurOrdreChamp) return false
467
-
468
- if (props.liste == null) return false
469
-
470
- // Vérifie que le champ existe dans les données (pas nécessairement une colonne visible)
471
- if (!props.liste.length) return false // liste vide, on attend sans avertissement
472
-
473
- const premierItem = props.liste[0]
474
-
475
- if (!(props.modificateurOrdreChamp in premierItem)) {
476
- console.log(
477
- `[csqcTable] modificateurOrdreChamp="${props.modificateurOrdreChamp}" n'existe pas dans les données. Le mode d'ordonnancement est désactivé.`,
478
- )
479
- return false
480
- }
481
-
482
- return true
483
- })
484
-
485
- // ============================================================================
486
- // Computed - Colonnes à envoyer à la modale (liste ordonnée + libellé)
487
- // ============================================================================
488
- const colonnesPourChoix = computed(() =>
489
- props.colonnes
490
- // on exclut l'action (souvent toujours visible) + colonnes "cachées"
491
- .filter(c => c.key && c.key !== 'action' && (c as any).align !== 'd-none')
492
- .map((c, idx) => ({
493
- title: (c as any).title ?? (c as any).text ?? String((c as any).key ?? (c as any).value),
494
- value: String((c as any).key ?? (c as any).value), // ← on force string
495
- ordre: idx + 1,
496
- })),
497
- )
498
-
499
- // ============================================================================
500
- // Computed - Headers réellement affichés par la table (selon le choix actif)
501
- // ============================================================================
502
- const colonnesAffichees = computed(() => {
503
- // 1) colonnes "disponibles" (on enlève celles explicitement cachées)
504
- const colonnesFiltre = props.colonnes.filter(c => (c as any).align !== 'd-none')
505
-
506
- // 2) résoudre la liste selon les vues sauvegardées (choix colonnes)
507
- let colonnesBase: Colonne[]
508
- if (!props.permettreChoixColonnes || !choix.value?.length) {
509
- colonnesBase = colonnesFiltre
510
- } else {
511
- // retrouver la vue sélectionnée (sinon la défaut)
512
- const choixSelection = choix.value.find(
513
- x => valeurSelectionChoixColonnes.value === x.nomVue || (!valeurSelectionChoixColonnes.value && x.defaut),
514
- )
515
- colonnesBase = choixSelection
516
- ? (choixSelection.colonnes.map(k => colonnesFiltre.find(c => String(c.key) === k)).filter(Boolean) as Colonne[])
517
- : colonnesFiltre
518
- }
519
-
520
- // 3) ordre manuel actif : colonne ancre en tête + toutes les colonnes non triables
521
- if (estEnModeModificateurOrdre.value) {
522
- const colonneAncre = { key: 'ancre', title: '', sortable: false, width: '40px' } as Colonne
523
- return [colonneAncre, ...colonnesBase.map(c => ({ ...c, sortable: false }))]
524
- }
525
-
526
- return colonnesBase
527
- })
528
-
529
- // ============================================================================
530
- // Computed - Tri initial (Vuetify sort-by)
531
- // ============================================================================
532
- const triDepart = computed<SortItem[] | undefined>(() => {
533
- // ordre manuel : tri forcé sur le champ ordre, croissant
534
- if (estEnModeModificateurOrdre.value) return [{ key: props.modificateurOrdreChamp!, order: 'asc' }]
535
-
536
- if (props.triParDepart == null) return undefined
537
-
538
- const ordre = props.triDescDepart ? 'desc' : 'asc'
539
- if (typeof props.triParDepart === 'string') return [{ key: props.triParDepart, order: ordre }]
540
-
541
- const retour: SortItem[] = []
542
- for (let i = 0; i < props.triParDepart.length; i += 1) {
543
- const tri = props.triParDepart[i]!
544
- if (typeof tri === 'string') retour.push({ key: tri, order: ordre })
545
- else retour.push(tri)
546
- }
547
- return retour
548
- })
549
-
550
- const estMultiTriActif = computed(() => {
551
- if (estEnModeModificateurOrdre.value) return false
552
- if (props.triParDepart == null) return false
553
- if (typeof props.triParDepart === 'string') return false
554
- return true
555
- })
556
-
557
- // ============================================================================
558
- // Computed - Export (filtrage local basé sur recherche)
559
- // ============================================================================
560
- const filteredItems = computed(() => {
561
- if (!recherche.value) return props.liste
562
- const q = recherche.value.toLowerCase()
563
- return props.liste.filter(item => Object.values(item).some(val => String(val).toLowerCase().includes(q)))
564
- })
565
-
566
- const listeEffective = computed(() => (estEnModeModificateurOrdre.value ? listeInterne.value : props.liste))
567
-
568
- // ============================================================================
569
- // Drag & drop - Ordre manuel (Sortable.js)
570
- // ============================================================================
571
- let sortableInstance: Sortable | null = null
572
-
573
- // Synchronise listeInterne quand le parent met à jour props.liste
574
- watch(
575
- () => props.liste,
576
- val => {
577
- listeInterne.value = [...val]
578
- },
579
- { immediate: true, deep: true },
580
- )
581
-
582
- // Init/destroy Sortable si la prop change en cours de vie du composant
583
- watch(estEnModeModificateurOrdre, actif => {
584
- if (actif) nextTick(initSortable)
585
- else destroySortable()
586
- })
587
-
588
- onMounted(() => {
589
- if (estEnModeModificateurOrdre.value) nextTick(initSortable)
590
- })
591
-
592
- onBeforeUnmount(() => {
593
- destroySortable()
594
- })
595
-
596
- function initSortable() {
597
- const tableEl = datatable.value?.$el as HTMLElement | undefined
598
- if (!tableEl) return
599
- const tbody = tableEl.querySelector('tbody')
600
- if (!tbody) return
601
-
602
- destroySortable() // garantit une seule instance active
603
-
604
- sortableInstance = Sortable.create(tbody, {
605
- animation: 150,
606
- handle: '.ancre-drag',
607
- onEnd(event) {
608
- const oldIndex = event.oldIndex
609
- const newIndex = event.newIndex
610
- if (oldIndex == null || newIndex == null || oldIndex === newIndex) return
611
-
612
- const items = [...listeInterne.value]
613
- const moved = items.splice(oldIndex, 1)[0]
614
- items.splice(newIndex, 0, moved)
615
-
616
- // Recalcule la valeur du champ ordre pour maintenir la cohérence
617
- const champ = props.modificateurOrdreChamp!
618
- items.forEach((item, idx) => {
619
- item[champ] = idx + 1
620
- })
621
-
622
- listeInterne.value = items
623
- emit('ordreRangeeModifie', [...items])
624
- },
625
- })
626
- }
627
-
628
- function destroySortable() {
629
- sortableInstance?.destroy()
630
- sortableInstance = null
631
- }
632
-
633
- // ============================================================================
634
- // UI Actions - Recherche / Table events
635
- // ============================================================================
636
- function chargerRecherche(val: string) {
637
- recherche.value = val
638
- pageActuelle.value = 1
639
- }
640
-
641
- function ajouter() {
642
- emit('ajouter')
643
- }
644
-
645
- function cliqueLigne(_e: Event, { item }: { item: any }) {
646
- emit('cliqueLigne', item)
647
- }
648
-
649
- function modifier(item: any) {
650
- emit('modifier', item)
651
- }
652
-
653
- // ============================================================================
654
- // Suppression - Texte + actions
655
- // ============================================================================
656
- const supprimerTexte = computed(() => {
657
- if (itemSelectionne.value == null) return ''
658
-
659
- if (props.modaleSupprimerTexte) return props.modaleSupprimerTexte
660
-
661
- return t('csqc.message.supprimerMessage', {
662
- nom: itemSelectionne?.value?.[props.modaleSupprimerChamp] ?? '',
663
- })
664
- })
665
-
666
- const supprimerTitreTexte = computed(() => props.modaleSupprimerTitre || t('csqc.message.supprimerTitre'))
667
-
668
- function ouvrirModaleSupprimer(item: any) {
669
- itemSelectionne.value = item
670
- emit('supprimer', itemSelectionne.value) // tu sembles gérer la modale ailleurs
671
- }
672
-
673
- function supprimer() {
674
- emit('supprimer', itemSelectionne.value)
675
- }
676
-
677
- // ============================================================================
678
- // UI - Panneau recherche avancée
679
- // ============================================================================
680
- function onPanelChange(val: boolean) {
681
- emit('panneau:etat', val)
682
- }
683
- </script>
684
-
685
- <style scoped lang="css">
686
- .ControlesDatatable {
687
- gap: 4px;
688
- }
689
-
690
- /* datatable contour */
691
- .v-data-table {
692
- border: 1px solid #d3d3d3;
693
- border-radius: 5px;
694
- overflow: hidden;
695
- }
696
-
697
- /* hover */
698
- .v-data-table:hover {
699
- cursor: pointer !important;
700
- }
701
-
702
- /* datatable row */
703
- .v-data-table .v-table__wrapper > table > thead > tr > td,
704
- .v-data-table .v-table__wrapper > table > thead > tr th,
705
- .v-data-table .v-table__wrapper > table tbody > tr > td,
706
- .v-data-table .v-table__wrapper > table tbody > tr th {
707
- background-color: #d3d3d375 !important;
708
- }
709
-
710
- /* datatable header contour */
711
- .v-data-table .v-table__wrapper > table > thead > tr > th,
712
- .v-data-table .v-table__wrapper > table tbody > tr > th {
713
- background-color: white !important;
714
- border-bottom: 4px #223654 solid !important;
715
- }
716
-
717
- /* datatable header commun */
718
- :deep(.v-data-table-header__content) {
719
- font-size: 15px;
720
- font-weight: 600;
721
- height: 46px;
722
- }
723
-
724
- /* standard : en-tête bleu moyen */
725
- :deep(.entete-standard .v-table__wrapper > table > thead > tr > th),
726
- :deep(.entete-standard .v-data-table-header__content) {
727
- background-color: rgb(var(--v-theme-bleuMoyen)) !important;
728
- color: rgb(var(--v-theme-blanc)) !important;
729
- }
730
-
731
- /* standardGris : en-tête gris pâle */
732
- :deep(.entete-standard-gris .v-table__wrapper > table > thead > tr > th),
733
- :deep(.entete-standard-gris .v-data-table-header__content) {
734
- background-color: rgb(var(--v-theme-grisPale)) !important;
735
- color: rgb(var(--v-theme-bleuFonce)) !important;
736
- }
737
-
738
- /* simplifié : en-tête blanc + filet bleuFonce */
739
- :deep(.entete-simplifie .v-table__wrapper > table > thead > tr > th) {
740
- background-color: rgb(var(--v-theme-blanc)) !important;
741
- border-bottom: 4px rgb(var(--v-theme-bleuFonce)) solid !important;
742
- }
743
- :deep(.entete-simplifie .v-data-table-header__content) {
744
- background-color: rgb(var(--v-theme-blanc)) !important;
745
- color: rgb(var(--v-theme-bleuFonce)) !important;
746
- }
747
-
748
- /* datatable footer - pagination design Québec */
749
- .piv-pagination {
750
- display: grid;
751
- grid-template-columns: 1fr auto 1fr;
752
- align-items: center;
753
- padding: 12px 16px;
754
- background-color: rgb(var(--v-theme-blanc));
755
- border-top: 4px rgb(var(--v-theme-bleuFonce)) solid;
756
- }
757
-
758
- .piv-par-page {
759
- display: flex;
760
- align-items: center;
761
- gap: 8px;
762
- }
763
-
764
- .piv-total {
765
- text-align: right;
766
- }
767
-
768
- .piv-pagination nav {
769
- display: flex;
770
- align-items: center;
771
- gap: 4px;
772
- margin: 0;
773
- padding: 0;
774
- }
775
-
776
- .piv-page-btn {
777
- min-width: 40px;
778
- height: 40px;
779
- padding: 0 10px;
780
- border: 0px solid rgb(var(--v-theme-grisMoyen));
781
- background: rgb(var(--v-theme-blanc));
782
- color: rgb(var(--v-theme-bleuPiv));
783
- font-size: 16px;
784
- font-weight: 600;
785
- cursor: pointer;
786
- border-radius: 4px;
787
- display: inline-flex;
788
- align-items: center;
789
- justify-content: center;
790
- transition:
791
- background-color 0.2s,
792
- color 0.2s;
793
- }
794
-
795
- .piv-page-btn:hover:not(.piv-page-active) {
796
- background-color: rgb(var(--v-theme-bleuPale));
797
- }
798
-
799
- .piv-page-btn:focus-visible {
800
- outline: 3px solid rgb(var(--v-theme-bleuPiv));
801
- outline-offset: 2px;
802
- }
803
-
804
- .piv-page-active {
805
- color: rgb(var(--v-theme-noir));
806
- font-weight: bolder;
807
- }
808
-
809
- .piv-ellipsis {
810
- padding: 0 6px;
811
- color: rgb(var(--v-theme-bleuFonce));
812
- font-size: 16px;
813
- font-weight: 600;
814
- display: inline-flex;
815
- align-items: center;
816
- height: 40px;
817
- }
818
-
819
- /* datatable row hover */
820
- :deep(.v-table__wrapper > table tbody > tr:hover > td) {
821
- background-color: rgb(var(--v-theme-bleuPale)) !important;
822
- }
823
-
824
- .iconeSupprimer:hover {
825
- color: red;
826
- }
827
- .v-icon.v-icon.v-icon--link.iconeSupprimer:hover {
828
- color: red !important;
829
- }
830
- .iconeEditer:hover {
831
- color: #095797;
832
- }
833
-
834
- /* hover icone à droite du ajouter*/
835
- .text-grisMoyen:hover {
836
- color: #095797 !important;
837
- }
838
-
839
- /* icône d'ancre drag & drop */
840
- .ancre-drag {
841
- color: #808a9d;
842
- cursor: grab;
843
- }
844
- .ancre-drag:active {
845
- cursor: grabbing;
846
- }
847
- </style>
1
+ <template>
2
+ <div>
3
+ <v-row v-if="barreHautAfficher">
4
+ <slot name="ligne" />
5
+ <!-- Affichage de la boite de recherche-->
6
+ <v-col>
7
+ <slot name="recherche"></slot>
8
+ <Recherche
9
+ :afficher="rechercheAfficher"
10
+ :recherche-texte="rechercheTexte"
11
+ :chargement="chargementListe"
12
+ :recherche="recherche"
13
+ :recherche-avancee="rechercheAvancee"
14
+ :recherche-avancee-texte="rechercheAvanceeTexte"
15
+ :recherche-avancee-largeur="rechercheAvanceeLargeur"
16
+ :recherche-avancee-style="rechercheAvanceeStyle"
17
+ class="flex-grow-1"
18
+ v-bind="$attrs"
19
+ @update:recherche="chargerRecherche"
20
+ @panneau:etat="onPanelChange"
21
+ >
22
+ <template #milieu>
23
+ <slot name="milieu" />
24
+ </template>
25
+
26
+ <template #droite>
27
+ <slot name="droite" />
28
+ <v-btn
29
+ v-if="btnAjouter"
30
+ color="primary"
31
+ class="ml-1 float-right"
32
+ @click.stop="ajouter"
33
+ >
34
+ {{ props.btnAjouterTexte ? props.btnAjouterTexte : $t('csqc.bouton.ajouter') }}
35
+ </v-btn>
36
+
37
+ <exportExcelComponent
38
+ v-if="excel"
39
+ :liste="filteredItems"
40
+ :chargement-liste="chargementListe"
41
+ :nom-fichier="excelNomFichier"
42
+ :colonnes="colonnesAffichees"
43
+ class="mt-1 ml-1 float-right"
44
+ />
45
+ <v-icon
46
+ v-if="permettreChoixColonnes"
47
+ color="grisMoyen"
48
+ class="mt-1 ml-1 float-right"
49
+ @click.stop="ouvrirChoixColonnes"
50
+ >
51
+ mdi-table-edit
52
+ </v-icon>
53
+ </template>
54
+ <template #rechercheAvanceeTitre>
55
+ <slot name="rechercheAvanceeTitre" />
56
+ </template>
57
+ <template #rechercheAvanceeApresTitre>
58
+ <slot name="rechercheAvanceeApresTitre" />
59
+ </template>
60
+ <template #rechercheAvancee>
61
+ <slot name="rechercheAvancee" />
62
+ </template>
63
+ </Recherche>
64
+ </v-col>
65
+ </v-row>
66
+
67
+ <!-- datatable-->
68
+ <v-row>
69
+ <v-col
70
+ cols="12"
71
+ class="d-flex ControlesDatatable flex-wrap"
72
+ >
73
+ <v-data-table
74
+ ref="datatable"
75
+ :headers="colonnesAffichees"
76
+ :item-key="itemKey"
77
+ :items="listeEffective"
78
+ :search="recherche"
79
+ :loading="chargementListe"
80
+ :hover="hover"
81
+ :density="densite"
82
+ :items-per-page="itemsParPageLocal"
83
+ :item-per-page-options="itemsParPageOptions"
84
+ :sort-by="triDepart"
85
+ :multi-sort="estMultiTriActif"
86
+ v-model:page="pageActuelle"
87
+ :striped="props.styleEntete !== 'simplifie' ? 'even' : undefined"
88
+ :class="classeEntete"
89
+ v-bind="$attrs"
90
+ @click:row="cliqueLigne"
91
+ >
92
+ <!-- utilisation des slots via le component parent-->
93
+ <!-- eslint-disable-next-line -->
94
+ <template
95
+ v-for="(_, slot) of $slots"
96
+ :key="slot"
97
+ #[slot]="scope"
98
+ >
99
+ <slot
100
+ :name="slot"
101
+ v-bind="scope"
102
+ />
103
+ </template>
104
+
105
+ <!-- Icône ancre (drag & drop) -->
106
+ <template
107
+ v-if="estEnModeModificateurOrdre"
108
+ #item.ancre
109
+ >
110
+ <v-icon class="ancre-drag">mdi-drag-vertical</v-icon>
111
+ </template>
112
+
113
+ <!-- Footer : pagination design Québec -->
114
+ <template #bottom>
115
+ <div class="piv-pagination">
116
+ <div class="piv-par-page">
117
+ <span class="text-caption text-medium-emphasis">{{ $t('csqc.pagination.elementsParPage') }}</span>
118
+ <v-select
119
+ v-model="itemsParPageLocal"
120
+ :items="itemsParPageSelectOptions"
121
+ density="compact"
122
+ variant="outlined"
123
+ hide-details
124
+ style="max-width: 90px"
125
+ />
126
+ </div>
127
+ <nav aria-label="Pagination">
128
+ <button
129
+ variant="text"
130
+ v-if="pageActuelle > 1"
131
+ class="piv-page-btn"
132
+ aria-label="Page précédente"
133
+ @click="pageActuelle--"
134
+ >
135
+ <v-icon size="24">mdi-chevron-left</v-icon>
136
+ </button>
137
+
138
+ <template
139
+ v-for="item in obtenirPages(pageCount)"
140
+ :key="`page-${item}`"
141
+ >
142
+ <span
143
+ v-if="item === '...'"
144
+ class="piv-ellipsis"
145
+ aria-hidden="true"
146
+ >…</span
147
+ >
148
+ <button
149
+ v-else
150
+ variant="text"
151
+ class="piv-page-btn"
152
+ :class="{ 'piv-page-active': item === pageActuelle }"
153
+ :aria-label="`Page ${item}`"
154
+ :aria-current="item === pageActuelle ? 'page' : undefined"
155
+ @click="pageActuelle = item as number"
156
+ >
157
+ {{ item }}
158
+ </button>
159
+ </template>
160
+
161
+ <button
162
+ v-if="pageActuelle < pageCount"
163
+ class="piv-page-btn"
164
+ aria-label="Page suivante"
165
+ @click="pageActuelle++"
166
+ >
167
+ <v-icon size="24">mdi-chevron-right</v-icon>
168
+ </button>
169
+ </nav>
170
+ <span class="piv-total text-caption text-medium-emphasis">
171
+ {{ filteredItems.length }} {{ $t('csqc.pagination.total') }}
172
+ </span>
173
+ </div>
174
+ </template>
175
+
176
+ <!-- boutons supprimer et modifier -->
177
+ <!-- eslint-disable-next-line -->
178
+ <template v-slot:item.action="{ item }">
179
+ <slot name="actionsCustom"></slot>
180
+ <v-icon
181
+ v-if="props.btnSupprimer"
182
+ size="large"
183
+ class="iconeSupprimer float-right"
184
+ @click.stop.prevent="ouvrirModaleSupprimer(item)"
185
+ >
186
+ mdi-delete
187
+ </v-icon>
188
+
189
+ <v-icon
190
+ v-if="props.btnModifier"
191
+ size="large"
192
+ class="iconeEditer float-right"
193
+ @click.stop.prevent="modifier(item)"
194
+ >
195
+ mdi-pencil
196
+ </v-icon>
197
+ </template>
198
+ </v-data-table>
199
+
200
+ <!-- Fenêtre de suppression -->
201
+ <confirmation
202
+ v-if="props.btnSupprimer"
203
+ ref="modaleSupprimer"
204
+ :texte="supprimerTexte"
205
+ :titre="supprimerTitreTexte"
206
+ :largeur="modaleSupprimerLargeur"
207
+ @confirmer="supprimer"
208
+ />
209
+ <modale-choix
210
+ v-if="permettreChoixColonnes"
211
+ ref="modaleChoix"
212
+ :urlbase="urlbase"
213
+ :formulaire-id="formulaireId"
214
+ :identifiant="identifiant"
215
+ :colonnes="colonnesPourChoix"
216
+ :choix-origine="choixOrigineNormalise"
217
+ @selection="selectionChoix"
218
+ @sauvegarde="sauvegardeChoix"
219
+ />
220
+ </v-col>
221
+ </v-row>
222
+ </div>
223
+ </template>
224
+ <script setup lang="ts">
225
+ /* eslint-disable @typescript-eslint/no-explicit-any */
226
+ import { computed, onBeforeUnmount, nextTick, onMounted, ref, watch, type PropType, type Slots } from 'vue'
227
+ import { useI18n } from 'vue-i18n'
228
+ import type { SortItem } from 'vuetify/lib/components/VDataTable/composables/sort.mjs'
229
+
230
+ import Recherche from '../csqcRecherche.vue'
231
+ import confirmation from '../csqcConfirmation.vue'
232
+ import exportExcelComponent from './csqcTableExportExcel.vue'
233
+ import ModaleChoix from './csqcTableModaleChoixColonnes.vue'
234
+ import axios from '../../outils/appAxios'
235
+ import Colonne from '../../modeles/composants/datatableColonne'
236
+ import Sortable from 'sortablejs'
237
+
238
+ type VueColonnes = {
239
+ nomVue: string
240
+ colonnes: string[]
241
+ defaut?: boolean
242
+ }
243
+
244
+ const slots = defineSlots<Slots>()
245
+ const emit = defineEmits([
246
+ 'ajouter',
247
+ 'cliqueLigne',
248
+ 'supprimer',
249
+ 'modifier',
250
+ 'donneesExportees',
251
+ 'panneau:etat',
252
+ 'ordreRangeeModifie',
253
+ ])
254
+
255
+ // ============================================================================
256
+ // Props
257
+ // ============================================================================
258
+ const props = defineProps({
259
+ barreHautAfficher: { type: Boolean, default: true },
260
+
261
+ btnAjouter: { type: Boolean, default: true },
262
+ btnAjouterTexte: { type: String, default: '' },
263
+ btnModifier: { type: Boolean, default: true },
264
+ btnSupprimer: { type: Boolean, default: true },
265
+
266
+ chargementListe: { type: Boolean, default: false },
267
+ operationEnCours: { type: Boolean, default: false },
268
+
269
+ // Headers Vuetify
270
+ colonnes: {
271
+ type: Array as PropType<Colonne[]>,
272
+ default: (): Colonne[] => [],
273
+ },
274
+
275
+ densite: { type: String as PropType<'default' | 'comfortable' | 'compact'>, default: 'default' },
276
+
277
+ excel: { type: Boolean, default: false },
278
+ excelNomFichier: { type: String, default: 'csqc' },
279
+
280
+ // Choix colonnes
281
+ formulaireId: { type: Number, default: -1 },
282
+ identifiant: { type: String, default: '' },
283
+ urlbase: { type: String, default: '' },
284
+ permettreChoixColonnes: { type: Boolean, default: false },
285
+
286
+ // Table
287
+ itemKey: { type: String, default: 'id' },
288
+ itemsParPage: { type: Number, default: 25 },
289
+ itemsParPageOptions: { type: Array, default: () => [10, 25, 50, 100, -1] },
290
+ liste: {
291
+ type: Array as PropType<Record<string, any>[]>,
292
+ default: (): Record<string, any>[] => [],
293
+ },
294
+ hover: { type: Boolean, default: false },
295
+
296
+ // Recherche
297
+ rechercheTexte: { type: String, default: '' },
298
+ rechercheAfficher: { type: Boolean, default: true },
299
+ rechercheAvancee: { type: Boolean, default: false },
300
+ rechercheAvanceeTexte: { type: String, default: '' },
301
+ rechercheAvanceeLargeur: { type: Number, default: 12 },
302
+ rechercheAvanceeStyle: { type: String, default: '' },
303
+
304
+ // Style en-tête tableau (design Québec)
305
+ styleEntete: {
306
+ type: String as PropType<'standard' | 'standardGris' | 'simplifie'>,
307
+ default: 'standard',
308
+ },
309
+
310
+ // Modale suppression
311
+ modaleSupprimerChamp: { type: String, default: '' },
312
+ modaleSupprimerTexte: { type: String, default: '' },
313
+ modaleSupprimerTitre: { type: String, default: '' },
314
+ modaleSupprimerLargeur: { type: String, default: '525px' },
315
+
316
+ // Tri
317
+ triDescDepart: { type: Boolean, default: false },
318
+ triParDepart: {
319
+ type: [Array, String] as PropType<string | string[] | SortItem[] | undefined>,
320
+ default: undefined,
321
+ },
322
+
323
+ // Ordre manuel (drag & drop)
324
+ modificateurOrdreChamp: { type: String, default: undefined },
325
+ })
326
+
327
+ const { t } = useI18n({ useScope: 'global' })
328
+ const recherche = ref<string>('')
329
+ const pageActuelle = ref(1)
330
+ const itemsParPageLocal = ref(props.itemsParPage)
331
+
332
+ watch(
333
+ () => props.itemsParPage,
334
+ val => {
335
+ itemsParPageLocal.value = val
336
+ },
337
+ )
338
+
339
+ watch(itemsParPageLocal, () => {
340
+ pageActuelle.value = 1
341
+ })
342
+
343
+ const classeEntete = computed(() => ({
344
+ 'entete-standard': props.styleEntete === 'standard',
345
+ 'entete-standard-gris': props.styleEntete === 'standardGris',
346
+ 'entete-simplifie': props.styleEntete === 'simplifie',
347
+ }))
348
+
349
+ const itemsParPageSelectOptions = computed(() =>
350
+ (props.itemsParPageOptions as number[]).map(val =>
351
+ val === -1 ? { title: t('csqc.pagination.tous'), value: -1 } : { title: String(val), value: val },
352
+ ),
353
+ )
354
+
355
+ const pageCount = computed(() => {
356
+ const total = filteredItems.value.length
357
+ if (!itemsParPageLocal.value || itemsParPageLocal.value <= 0) return 1
358
+ return Math.max(1, Math.ceil(total / itemsParPageLocal.value))
359
+ })
360
+
361
+ function obtenirPages(totalPages: number): (number | '...')[] {
362
+ if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1)
363
+ const current = pageActuelle.value
364
+ const pages: (number | '...')[] = []
365
+ const left = Math.max(2, current - 1)
366
+ const right = Math.min(totalPages - 1, current + 1)
367
+ pages.push(1)
368
+ if (left > 2) pages.push('...')
369
+ for (let i = left; i <= right; i++) pages.push(i)
370
+ if (right < totalPages - 1) pages.push('...')
371
+ pages.push(totalPages)
372
+ return pages
373
+ }
374
+
375
+ // Modale suppression (on garde l’item sélectionné)
376
+ const itemSelectionne = ref<any>(null)
377
+
378
+ // ============================================================================
379
+ // State - Choix de colonnes (vues sauvegardées) + sélection courante
380
+ // ============================================================================
381
+ const datatable = ref<any>(null)
382
+ const listeInterne = ref<Record<string, any>[]>([])
383
+ const modaleChoix = ref<InstanceType<typeof ModaleChoix> | null>(null)
384
+ const openChoixColonnes = ref(false)
385
+ const choix = ref<VueColonnes[]>([]) // la liste des vues disponibles
386
+ const vueActive = ref<VueColonnes | null>(null) // optionnel: la vue "appliquée"
387
+ const valeurSelectionChoixColonnes = ref<string | null>(null) // nomVue sélectionné dans la modale / UI
388
+
389
+ // ============================================================================
390
+ // Charger le choix de colonnes sauvegardé s'il y a lieu
391
+ // ============================================================================
392
+ onMounted(async () => {
393
+ if (!props.permettreChoixColonnes) return
394
+
395
+ try {
396
+ const res: any = await axios
397
+ .getAxios()
398
+ .get(`${props.urlbase}/api/ComposantUI/Colonnes/${props.formulaireId}/Identifiant/${props.identifiant}`)
399
+
400
+ const payload = res?.data ?? res
401
+
402
+ if (!payload) {
403
+ choix.value = []
404
+ return
405
+ }
406
+
407
+ choix.value = typeof payload === 'string' ? JSON.parse(payload) : payload
408
+ } catch (e) {
409
+ choix.value = []
410
+ // console.debug(e)
411
+ }
412
+ })
413
+
414
+ // ============================================================================
415
+ // Computed - Normaliser pour la modale Choix colonne
416
+ // ============================================================================
417
+ const choixOrigineNormalise = computed(() =>
418
+ (choix.value ?? []).map(c => ({
419
+ _id: '',
420
+ nomVue: c.nomVue,
421
+ colonnes: c.colonnes ?? [],
422
+ defaut: c.defaut ?? false,
423
+ })),
424
+ )
425
+
426
+ // ============================================================================
427
+ // Actions - Modale choix colonnes
428
+ // ============================================================================
429
+ function ouvrirChoixColonnes() {
430
+ modaleChoix.value?.ouvrir()
431
+ }
432
+
433
+ function appliquerVue(vue: VueColonnes) {
434
+ vueActive.value = vue
435
+ openChoixColonnes.value = false
436
+ }
437
+
438
+ function selectionChoix(vue: VueColonnes) {
439
+ // on garde une trace du nom sélectionné (pour retrouver la vue plus tard)
440
+ valeurSelectionChoixColonnes.value = vue.nomVue
441
+ appliquerVue(vue)
442
+ }
443
+
444
+ function sauvegardeChoix(vues: VueColonnes[]) {
445
+ if (!props.permettreChoixColonnes) return
446
+
447
+ // si la sélection courante n’existe plus, on reset
448
+ if (
449
+ valeurSelectionChoixColonnes.value != null &&
450
+ vues.find(x => x.nomVue === valeurSelectionChoixColonnes.value) == null
451
+ ) {
452
+ valeurSelectionChoixColonnes.value = null
453
+ }
454
+
455
+ choix.value = vues
456
+
457
+ // optionnel: appliquer automatiquement la vue par défaut
458
+ const defaut = vues.find(v => v.defaut)
459
+ if (defaut) appliquerVue(defaut)
460
+ }
461
+
462
+ // ============================================================================
463
+ // Computed - Mode ordre manuel
464
+ // ============================================================================
465
+ const estEnModeModificateurOrdre = computed(() => {
466
+ if (!props.modificateurOrdreChamp) return false
467
+
468
+ if (props.liste == null) return false
469
+
470
+ // Vérifie que le champ existe dans les données (pas nécessairement une colonne visible)
471
+ if (!props.liste.length) return false // liste vide, on attend sans avertissement
472
+
473
+ const premierItem = props.liste[0]
474
+
475
+ if (!(props.modificateurOrdreChamp in premierItem)) {
476
+ console.log(
477
+ `[csqcTable] modificateurOrdreChamp="${props.modificateurOrdreChamp}" n'existe pas dans les données. Le mode d'ordonnancement est désactivé.`,
478
+ )
479
+ return false
480
+ }
481
+
482
+ return true
483
+ })
484
+
485
+ // ============================================================================
486
+ // Computed - Colonnes à envoyer à la modale (liste ordonnée + libellé)
487
+ // ============================================================================
488
+ const colonnesPourChoix = computed(() =>
489
+ props.colonnes
490
+ // on exclut l'action (souvent toujours visible) + colonnes "cachées"
491
+ .filter(c => c.key && c.key !== 'action' && (c as any).align !== 'd-none')
492
+ .map((c, idx) => ({
493
+ title: (c as any).title ?? (c as any).text ?? String((c as any).key ?? (c as any).value),
494
+ value: String((c as any).key ?? (c as any).value), // ← on force string
495
+ ordre: idx + 1,
496
+ })),
497
+ )
498
+
499
+ // ============================================================================
500
+ // Computed - Headers réellement affichés par la table (selon le choix actif)
501
+ // ============================================================================
502
+ const colonnesAffichees = computed(() => {
503
+ // 1) colonnes "disponibles" (on enlève celles explicitement cachées)
504
+ const colonnesFiltre = props.colonnes.filter(c => (c as any).align !== 'd-none')
505
+
506
+ // 2) résoudre la liste selon les vues sauvegardées (choix colonnes)
507
+ let colonnesBase: Colonne[]
508
+ if (!props.permettreChoixColonnes || !choix.value?.length) {
509
+ colonnesBase = colonnesFiltre
510
+ } else {
511
+ // retrouver la vue sélectionnée (sinon la défaut)
512
+ const choixSelection = choix.value.find(
513
+ x => valeurSelectionChoixColonnes.value === x.nomVue || (!valeurSelectionChoixColonnes.value && x.defaut),
514
+ )
515
+ colonnesBase = choixSelection
516
+ ? (choixSelection.colonnes.map(k => colonnesFiltre.find(c => String(c.key) === k)).filter(Boolean) as Colonne[])
517
+ : colonnesFiltre
518
+ }
519
+
520
+ // 3) ordre manuel actif : colonne ancre en tête + toutes les colonnes non triables
521
+ if (estEnModeModificateurOrdre.value) {
522
+ const colonneAncre = { key: 'ancre', title: '', sortable: false, width: '40px' } as Colonne
523
+ return [colonneAncre, ...colonnesBase.map(c => ({ ...c, sortable: false }))]
524
+ }
525
+
526
+ return colonnesBase
527
+ })
528
+
529
+ // ============================================================================
530
+ // Computed - Tri initial (Vuetify sort-by)
531
+ // ============================================================================
532
+ const triDepart = computed<SortItem[] | undefined>(() => {
533
+ // ordre manuel : tri forcé sur le champ ordre, croissant
534
+ if (estEnModeModificateurOrdre.value) return [{ key: props.modificateurOrdreChamp!, order: 'asc' }]
535
+
536
+ if (props.triParDepart == null) return undefined
537
+
538
+ const ordre = props.triDescDepart ? 'desc' : 'asc'
539
+ if (typeof props.triParDepart === 'string') return [{ key: props.triParDepart, order: ordre }]
540
+
541
+ const retour: SortItem[] = []
542
+ for (let i = 0; i < props.triParDepart.length; i += 1) {
543
+ const tri = props.triParDepart[i]!
544
+ if (typeof tri === 'string') retour.push({ key: tri, order: ordre })
545
+ else retour.push(tri)
546
+ }
547
+ return retour
548
+ })
549
+
550
+ const estMultiTriActif = computed(() => {
551
+ if (estEnModeModificateurOrdre.value) return false
552
+ if (props.triParDepart == null) return false
553
+ if (typeof props.triParDepart === 'string') return false
554
+ return true
555
+ })
556
+
557
+ // ============================================================================
558
+ // Computed - Export (filtrage local basé sur recherche)
559
+ // ============================================================================
560
+ const filteredItems = computed(() => {
561
+ if (!recherche.value) return props.liste
562
+ const q = recherche.value.toLowerCase()
563
+ return props.liste.filter(item => Object.values(item).some(val => String(val).toLowerCase().includes(q)))
564
+ })
565
+
566
+ const listeEffective = computed(() => (estEnModeModificateurOrdre.value ? listeInterne.value : props.liste))
567
+
568
+ // ============================================================================
569
+ // Drag & drop - Ordre manuel (Sortable.js)
570
+ // ============================================================================
571
+ let sortableInstance: Sortable | null = null
572
+
573
+ // Synchronise listeInterne quand le parent met à jour props.liste
574
+ watch(
575
+ () => props.liste,
576
+ val => {
577
+ listeInterne.value = [...val]
578
+ },
579
+ { immediate: true, deep: true },
580
+ )
581
+
582
+ // Init/destroy Sortable si la prop change en cours de vie du composant
583
+ watch(estEnModeModificateurOrdre, actif => {
584
+ if (actif) nextTick(initSortable)
585
+ else destroySortable()
586
+ })
587
+
588
+ onMounted(() => {
589
+ if (estEnModeModificateurOrdre.value) nextTick(initSortable)
590
+ })
591
+
592
+ onBeforeUnmount(() => {
593
+ destroySortable()
594
+ })
595
+
596
+ function initSortable() {
597
+ const tableEl = datatable.value?.$el as HTMLElement | undefined
598
+ if (!tableEl) return
599
+ const tbody = tableEl.querySelector('tbody')
600
+ if (!tbody) return
601
+
602
+ destroySortable() // garantit une seule instance active
603
+
604
+ sortableInstance = Sortable.create(tbody, {
605
+ animation: 150,
606
+ handle: '.ancre-drag',
607
+ onEnd(event) {
608
+ const oldIndex = event.oldIndex
609
+ const newIndex = event.newIndex
610
+ if (oldIndex == null || newIndex == null || oldIndex === newIndex) return
611
+
612
+ const items = [...listeInterne.value]
613
+ const moved = items.splice(oldIndex, 1)[0]
614
+ items.splice(newIndex, 0, moved)
615
+
616
+ // Recalcule la valeur du champ ordre pour maintenir la cohérence
617
+ const champ = props.modificateurOrdreChamp!
618
+ items.forEach((item, idx) => {
619
+ item[champ] = idx + 1
620
+ })
621
+
622
+ listeInterne.value = items
623
+ emit('ordreRangeeModifie', [...items])
624
+ },
625
+ })
626
+ }
627
+
628
+ function destroySortable() {
629
+ sortableInstance?.destroy()
630
+ sortableInstance = null
631
+ }
632
+
633
+ // ============================================================================
634
+ // UI Actions - Recherche / Table events
635
+ // ============================================================================
636
+ function chargerRecherche(val: string) {
637
+ recherche.value = val
638
+ pageActuelle.value = 1
639
+ }
640
+
641
+ function ajouter() {
642
+ emit('ajouter')
643
+ }
644
+
645
+ function cliqueLigne(_e: Event, { item }: { item: any }) {
646
+ emit('cliqueLigne', item)
647
+ }
648
+
649
+ function modifier(item: any) {
650
+ emit('modifier', item)
651
+ }
652
+
653
+ // ============================================================================
654
+ // Suppression - Texte + actions
655
+ // ============================================================================
656
+ const supprimerTexte = computed(() => {
657
+ if (itemSelectionne.value == null) return ''
658
+
659
+ if (props.modaleSupprimerTexte) return props.modaleSupprimerTexte
660
+
661
+ return t('csqc.message.supprimerMessage', {
662
+ nom: itemSelectionne?.value?.[props.modaleSupprimerChamp] ?? '',
663
+ })
664
+ })
665
+
666
+ const supprimerTitreTexte = computed(() => props.modaleSupprimerTitre || t('csqc.message.supprimerTitre'))
667
+
668
+ function ouvrirModaleSupprimer(item: any) {
669
+ itemSelectionne.value = item
670
+ emit('supprimer', itemSelectionne.value) // tu sembles gérer la modale ailleurs
671
+ }
672
+
673
+ function supprimer() {
674
+ emit('supprimer', itemSelectionne.value)
675
+ }
676
+
677
+ // ============================================================================
678
+ // UI - Panneau recherche avancée
679
+ // ============================================================================
680
+ function onPanelChange(val: boolean) {
681
+ emit('panneau:etat', val)
682
+ }
683
+ </script>
684
+
685
+ <style scoped lang="css">
686
+ .ControlesDatatable {
687
+ gap: 4px;
688
+ }
689
+
690
+ /* datatable contour */
691
+ .v-data-table {
692
+ border: 1px solid #d3d3d3;
693
+ border-radius: 5px;
694
+ overflow: hidden;
695
+ }
696
+
697
+ /* hover */
698
+ .v-data-table:hover {
699
+ cursor: pointer !important;
700
+ }
701
+
702
+ /* datatable row */
703
+ .v-data-table .v-table__wrapper > table > thead > tr > td,
704
+ .v-data-table .v-table__wrapper > table > thead > tr th,
705
+ .v-data-table .v-table__wrapper > table tbody > tr > td,
706
+ .v-data-table .v-table__wrapper > table tbody > tr th {
707
+ background-color: #d3d3d375 !important;
708
+ }
709
+
710
+ /* datatable header contour */
711
+ .v-data-table .v-table__wrapper > table > thead > tr > th,
712
+ .v-data-table .v-table__wrapper > table tbody > tr > th {
713
+ background-color: white !important;
714
+ border-bottom: 4px #223654 solid !important;
715
+ }
716
+
717
+ /* datatable header commun */
718
+ :deep(.v-data-table-header__content) {
719
+ font-size: 15px;
720
+ font-weight: 600;
721
+ height: 46px;
722
+ }
723
+
724
+ /* standard : en-tête bleu moyen */
725
+ :deep(.entete-standard .v-table__wrapper > table > thead > tr > th),
726
+ :deep(.entete-standard .v-data-table-header__content) {
727
+ background-color: rgb(var(--v-theme-bleuMoyen)) !important;
728
+ color: rgb(var(--v-theme-blanc)) !important;
729
+ }
730
+
731
+ /* standardGris : en-tête gris pâle */
732
+ :deep(.entete-standard-gris .v-table__wrapper > table > thead > tr > th),
733
+ :deep(.entete-standard-gris .v-data-table-header__content) {
734
+ background-color: rgb(var(--v-theme-grisPale)) !important;
735
+ color: rgb(var(--v-theme-bleuFonce)) !important;
736
+ }
737
+
738
+ /* simplifié : en-tête blanc + filet bleuFonce */
739
+ :deep(.entete-simplifie .v-table__wrapper > table > thead > tr > th) {
740
+ background-color: rgb(var(--v-theme-blanc)) !important;
741
+ border-bottom: 4px rgb(var(--v-theme-bleuFonce)) solid !important;
742
+ }
743
+ :deep(.entete-simplifie .v-data-table-header__content) {
744
+ background-color: rgb(var(--v-theme-blanc)) !important;
745
+ color: rgb(var(--v-theme-bleuFonce)) !important;
746
+ }
747
+
748
+ /* datatable footer - pagination design Québec */
749
+ .piv-pagination {
750
+ display: grid;
751
+ grid-template-columns: 1fr auto 1fr;
752
+ align-items: center;
753
+ padding: 12px 16px;
754
+ background-color: rgb(var(--v-theme-blanc));
755
+ border-top: 4px rgb(var(--v-theme-bleuFonce)) solid;
756
+ }
757
+
758
+ .piv-par-page {
759
+ display: flex;
760
+ align-items: center;
761
+ gap: 8px;
762
+ }
763
+
764
+ .piv-total {
765
+ text-align: right;
766
+ }
767
+
768
+ .piv-pagination nav {
769
+ display: flex;
770
+ align-items: center;
771
+ gap: 4px;
772
+ margin: 0;
773
+ padding: 0;
774
+ }
775
+
776
+ .piv-page-btn {
777
+ min-width: 40px;
778
+ height: 40px;
779
+ padding: 0 10px;
780
+ border: 0px solid rgb(var(--v-theme-grisMoyen));
781
+ background: rgb(var(--v-theme-blanc));
782
+ color: rgb(var(--v-theme-bleuPiv));
783
+ font-size: 16px;
784
+ font-weight: 600;
785
+ cursor: pointer;
786
+ border-radius: 4px;
787
+ display: inline-flex;
788
+ align-items: center;
789
+ justify-content: center;
790
+ transition:
791
+ background-color 0.2s,
792
+ color 0.2s;
793
+ }
794
+
795
+ .piv-page-btn:hover:not(.piv-page-active) {
796
+ background-color: rgb(var(--v-theme-bleuPale));
797
+ }
798
+
799
+ .piv-page-btn:focus-visible {
800
+ outline: 3px solid rgb(var(--v-theme-bleuPiv));
801
+ outline-offset: 2px;
802
+ }
803
+
804
+ .piv-page-active {
805
+ color: rgb(var(--v-theme-noir));
806
+ font-weight: bolder;
807
+ }
808
+
809
+ .piv-ellipsis {
810
+ padding: 0 6px;
811
+ color: rgb(var(--v-theme-bleuFonce));
812
+ font-size: 16px;
813
+ font-weight: 600;
814
+ display: inline-flex;
815
+ align-items: center;
816
+ height: 40px;
817
+ }
818
+
819
+ /* datatable row hover */
820
+ :deep(.v-table__wrapper > table tbody > tr:hover > td) {
821
+ background-color: rgb(var(--v-theme-bleuPale)) !important;
822
+ }
823
+
824
+ .iconeSupprimer:hover {
825
+ color: red;
826
+ }
827
+ .v-icon.v-icon.v-icon--link.iconeSupprimer:hover {
828
+ color: red !important;
829
+ }
830
+ .iconeEditer:hover {
831
+ color: #095797;
832
+ }
833
+
834
+ /* hover icone à droite du ajouter*/
835
+ .text-grisMoyen:hover {
836
+ color: #095797 !important;
837
+ }
838
+
839
+ /* icône d'ancre drag & drop */
840
+ .ancre-drag {
841
+ color: #808a9d;
842
+ cursor: grab;
843
+ }
844
+ .ancre-drag:active {
845
+ cursor: grabbing;
846
+ }
847
+ </style>