codevdesign 2.0.19 → 2.0.20

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