codevdesign 2.0.2 → 2.0.4

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,677 +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
- v-bind="$attrs"
10
- :afficher="rechercheAfficher"
11
- :recherche-texte="rechercheTexte"
12
- :chargement="chargementListe"
13
- :recherche="recherche"
14
- :recherche-avancee="rechercheAvancee"
15
- :recherche-avancee-texte="rechercheAvanceeTexte"
16
- :recherche-avancee-largeur="rechercheAvanceeLargeur"
17
- :recherche-avancee-style="rechercheAvanceeStyle"
18
- class="flex-grow-1"
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="itemsParPage"
83
- :item-per-page-options="itemsParPageOptions"
84
- :sort-by="triDepart"
85
- :multi-sort="estMultiTriActif"
86
- striped="even"
87
- v-bind="$attrs"
88
- @click:row="cliqueLigne"
89
- >
90
- <!-- utilisation des slots via le component parent-->
91
- <!-- eslint-disable-next-line -->
92
- <template
93
- v-for="(_, slot) of $slots"
94
- :key="slot"
95
- #[slot]="scope"
96
- >
97
- <slot
98
- :name="slot"
99
- v-bind="scope"
100
- />
101
- </template>
102
-
103
- <!-- Icône ancre (drag & drop) -->
104
- <template
105
- v-if="estEnModeModificateurOrdre"
106
- #item.ancre
107
- >
108
- <v-icon class="ancre-drag">mdi-drag-vertical</v-icon>
109
- </template>
110
-
111
- <!-- boutons supprimer et modifier -->
112
- <!-- eslint-disable-next-line -->
113
- <template v-slot:item.action="{ item }">
114
- <slot name="actionsCustom"></slot>
115
- <v-icon
116
- v-if="props.btnSupprimer"
117
- size="large"
118
- class="iconeSupprimer float-right"
119
- @click.stop.prevent="ouvrirModaleSupprimer(item)"
120
- >
121
- mdi-delete
122
- </v-icon>
123
-
124
- <v-icon
125
- v-if="props.btnModifier"
126
- size="large"
127
- class="iconeEditer float-right"
128
- @click.stop.prevent="modifier(item)"
129
- >
130
- mdi-pencil
131
- </v-icon>
132
- </template>
133
- </v-data-table>
134
-
135
- <!-- Fenêtre de suppression -->
136
- <confirmation
137
- v-if="props.btnSupprimer"
138
- ref="modaleSupprimer"
139
- :texte="supprimerTexte"
140
- :titre="supprimerTitreTexte"
141
- :largeur="modaleSupprimerLargeur"
142
- @confirmer="supprimer"
143
- />
144
- <modale-choix
145
- v-if="permettreChoixColonnes"
146
- ref="modaleChoix"
147
- :urlbase="urlbase"
148
- :formulaire-id="formulaireId"
149
- :identifiant="identifiant"
150
- :colonnes="colonnesPourChoix"
151
- :choix-origine="choixOrigineNormalise"
152
- @selection="selectionChoix"
153
- @sauvegarde="sauvegardeChoix"
154
- />
155
- </v-col>
156
- </v-row>
157
- </div>
158
- </template>
159
- <script setup lang="ts">
160
- /* eslint-disable @typescript-eslint/no-explicit-any */
161
- import { computed, onBeforeUnmount, nextTick, onMounted, ref, watch, type PropType, type Slots } from 'vue'
162
- import { useI18n } from 'vue-i18n'
163
- import type { SortItem } from 'vuetify/lib/components/VDataTable/composables/sort.mjs'
164
-
165
- import Recherche from '../csqcRecherche.vue'
166
- import confirmation from '../csqcConfirmation.vue'
167
- import exportExcelComponent from './csqcTableExportExcel.vue'
168
- import ModaleChoix from './csqcTableModaleChoixColonnes.vue'
169
- import axios from '../../outils/appAxios'
170
- import Colonne from '../../modeles/composants/datatableColonne'
171
- import Sortable from 'sortablejs'
172
-
173
- type VueColonnes = {
174
- nomVue: string
175
- colonnes: string[]
176
- defaut?: boolean
177
- }
178
-
179
- const slots = defineSlots<Slots>()
180
- const emit = defineEmits([
181
- 'ajouter',
182
- 'cliqueLigne',
183
- 'supprimer',
184
- 'modifier',
185
- 'donneesExportees',
186
- 'panneau:etat',
187
- 'ordreRangeeModifie',
188
- ])
189
-
190
- // ============================================================================
191
- // Props
192
- // ============================================================================
193
- const props = defineProps({
194
- barreHautAfficher: { type: Boolean, default: true },
195
-
196
- btnAjouter: { type: Boolean, default: true },
197
- btnAjouterTexte: { type: String, default: '' },
198
- btnModifier: { type: Boolean, default: true },
199
- btnSupprimer: { type: Boolean, default: true },
200
-
201
- chargementListe: { type: Boolean, default: false },
202
- operationEnCours: { type: Boolean, default: false },
203
-
204
- // Headers Vuetify
205
- colonnes: {
206
- type: Array as PropType<Colonne[]>,
207
- default: () => [],
208
- },
209
-
210
- densite: { type: String as PropType<'default' | 'comfortable' | 'compact'>, default: 'default' },
211
-
212
- excel: { type: Boolean, default: false },
213
- excelNomFichier: { type: String, default: 'csqc' },
214
-
215
- // Choix colonnes
216
- formulaireId: { type: Number, default: -1 },
217
- identifiant: { type: String, default: '' },
218
- urlbase: { type: String, default: '' },
219
- permettreChoixColonnes: { type: Boolean, default: false },
220
-
221
- // Table
222
- itemKey: { type: String, default: 'id' },
223
- itemsParPage: { type: Number, default: 10 },
224
- itemsParPageOptions: { type: Array, default: () => [10, 25, 30, -1] },
225
- liste: {
226
- type: Array as PropType<Record<string, any>[]>,
227
- default: () => [],
228
- },
229
- hover: { type: Boolean, default: false },
230
-
231
- // Recherche
232
- rechercheTexte: { type: String, default: '' },
233
- rechercheAfficher: { type: Boolean, default: true },
234
- rechercheAvancee: { type: Boolean, default: false },
235
- rechercheAvanceeTexte: { type: String, default: '' },
236
- rechercheAvanceeLargeur: { type: Number, default: 12 },
237
- rechercheAvanceeStyle: { type: String, default: '' },
238
-
239
- // Modale suppression
240
- modaleSupprimerChamp: { type: String, default: '' },
241
- modaleSupprimerTexte: { type: String, default: '' },
242
- modaleSupprimerTitre: { type: String, default: '' },
243
- modaleSupprimerLargeur: { type: String, default: '525px' },
244
-
245
- // Tri
246
- triDescDepart: { type: Boolean, default: false },
247
- triParDepart: {
248
- type: [Array, String] as PropType<string | string[] | SortItem[] | undefined>,
249
- default: undefined,
250
- },
251
-
252
- // Ordre manuel (drag & drop)
253
- modificateurOrdreChamp: { type: String, default: undefined },
254
- })
255
-
256
- const { t } = useI18n({ useScope: 'global' })
257
- const recherche = ref<string>('')
258
-
259
- // Modale suppression (on garde l’item sélectionné)
260
- const itemSelectionne = ref<any>(null)
261
-
262
- // ============================================================================
263
- // State - Choix de colonnes (vues sauvegardées) + sélection courante
264
- // ============================================================================
265
- const datatable = ref<any>(null)
266
- const listeInterne = ref<Record<string, any>[]>([])
267
- const modaleChoix = ref<InstanceType<typeof ModaleChoix> | null>(null)
268
- const openChoixColonnes = ref(false)
269
- const choix = ref<VueColonnes[]>([]) // la liste des vues disponibles
270
- const vueActive = ref<VueColonnes | null>(null) // optionnel: la vue "appliquée"
271
- const valeurSelectionChoixColonnes = ref<string | null>(null) // nomVue sélectionné dans la modale / UI
272
-
273
- // ============================================================================
274
- // Charger le choix de colonnes sauvegardé s'il y a lieu
275
- // ============================================================================
276
- onMounted(async () => {
277
- if (!props.permettreChoixColonnes) return
278
-
279
- try {
280
- const res: any = await axios
281
- .getAxios()
282
- .get(`${props.urlbase}/api/ComposantUI/Colonnes/${props.formulaireId}/Identifiant/${props.identifiant}`)
283
-
284
- const payload = res?.data ?? res
285
-
286
- if (!payload) {
287
- choix.value = []
288
- return
289
- }
290
-
291
- choix.value = typeof payload === 'string' ? JSON.parse(payload) : payload
292
- } catch (e) {
293
- choix.value = []
294
- // console.debug(e)
295
- }
296
- })
297
-
298
- // ============================================================================
299
- // Computed - Normaliser pour la modale Choix colonne
300
- // ============================================================================
301
- const choixOrigineNormalise = computed(() =>
302
- (choix.value ?? []).map(c => ({
303
- _id: '',
304
- nomVue: c.nomVue,
305
- colonnes: c.colonnes ?? [],
306
- defaut: c.defaut ?? false,
307
- })),
308
- )
309
-
310
- // ============================================================================
311
- // Actions - Modale choix colonnes
312
- // ============================================================================
313
- function ouvrirChoixColonnes() {
314
- modaleChoix.value?.ouvrir()
315
- }
316
-
317
- function appliquerVue(vue: VueColonnes) {
318
- vueActive.value = vue
319
- openChoixColonnes.value = false
320
- }
321
-
322
- function selectionChoix(vue: VueColonnes) {
323
- // on garde une trace du nom sélectionné (pour retrouver la vue plus tard)
324
- valeurSelectionChoixColonnes.value = vue.nomVue
325
- appliquerVue(vue)
326
- }
327
-
328
- function sauvegardeChoix(vues: VueColonnes[]) {
329
- if (!props.permettreChoixColonnes) return
330
-
331
- // si la sélection courante n’existe plus, on reset
332
- if (
333
- valeurSelectionChoixColonnes.value != null &&
334
- vues.find(x => x.nomVue === valeurSelectionChoixColonnes.value) == null
335
- ) {
336
- valeurSelectionChoixColonnes.value = null
337
- }
338
-
339
- choix.value = vues
340
-
341
- // optionnel: appliquer automatiquement la vue par défaut
342
- const defaut = vues.find(v => v.defaut)
343
- if (defaut) appliquerVue(defaut)
344
- }
345
-
346
- // ============================================================================
347
- // Computed - Mode ordre manuel
348
- // ============================================================================
349
- const estEnModeModificateurOrdre = computed(() => {
350
- if (!props.modificateurOrdreChamp) return false
351
-
352
- if (props.liste == null) return false
353
-
354
- // Vérifie que le champ existe dans les données (pas nécessairement une colonne visible)
355
- if (!props.liste.length) return false // liste vide, on attend sans avertissement
356
-
357
- const premierItem = props.liste[0]
358
-
359
- if (!(props.modificateurOrdreChamp in premierItem)) {
360
- console.log(
361
- `[csqcTable] modificateurOrdreChamp="${props.modificateurOrdreChamp}" n'existe pas dans les données. Le mode d'ordonnancement est désactivé.`,
362
- )
363
- return false
364
- }
365
-
366
- return true
367
- })
368
-
369
- // ============================================================================
370
- // Computed - Colonnes à envoyer à la modale (liste ordonnée + libellé)
371
- // ============================================================================
372
- const colonnesPourChoix = computed(() =>
373
- props.colonnes
374
- // on exclut l'action (souvent toujours visible) + colonnes "cachées"
375
- .filter(c => c.key && c.key !== 'action' && (c as any).align !== 'd-none')
376
- .map((c, idx) => ({
377
- title: (c as any).title ?? (c as any).text ?? String((c as any).key ?? (c as any).value),
378
- value: String((c as any).key ?? (c as any).value), // ← on force string
379
- ordre: idx + 1,
380
- })),
381
- )
382
-
383
- // ============================================================================
384
- // Computed - Headers réellement affichés par la table (selon le choix actif)
385
- // ============================================================================
386
- const colonnesAffichees = computed(() => {
387
- // 1) colonnes "disponibles" (on enlève celles explicitement cachées)
388
- const colonnesFiltre = props.colonnes.filter(c => (c as any).align !== 'd-none')
389
-
390
- // 2) résoudre la liste selon les vues sauvegardées (choix colonnes)
391
- let colonnesBase: Colonne[]
392
- if (!props.permettreChoixColonnes || !choix.value?.length) {
393
- colonnesBase = colonnesFiltre
394
- } else {
395
- // retrouver la vue sélectionnée (sinon la défaut)
396
- const choixSelection = choix.value.find(
397
- x => valeurSelectionChoixColonnes.value === x.nomVue || (!valeurSelectionChoixColonnes.value && x.defaut),
398
- )
399
- colonnesBase = choixSelection
400
- ? (choixSelection.colonnes.map(k => colonnesFiltre.find(c => String(c.key) === k)).filter(Boolean) as Colonne[])
401
- : colonnesFiltre
402
- }
403
-
404
- // 3) ordre manuel actif : colonne ancre en tête + toutes les colonnes non triables
405
- if (estEnModeModificateurOrdre.value) {
406
- const colonneAncre = { key: 'ancre', title: '', sortable: false, width: '40px' } as Colonne
407
- return [colonneAncre, ...colonnesBase.map(c => ({ ...c, sortable: false }))]
408
- }
409
-
410
- return colonnesBase
411
- })
412
-
413
- // ============================================================================
414
- // Computed - Tri initial (Vuetify sort-by)
415
- // ============================================================================
416
- const triDepart = computed<SortItem[] | undefined>(() => {
417
- // ordre manuel : tri forcé sur le champ ordre, croissant
418
- if (estEnModeModificateurOrdre.value) return [{ key: props.modificateurOrdreChamp!, order: 'asc' }]
419
-
420
- if (props.triParDepart == null) return undefined
421
-
422
- const ordre = props.triDescDepart ? 'desc' : 'asc'
423
- if (typeof props.triParDepart === 'string') return [{ key: props.triParDepart, order: ordre }]
424
-
425
- const retour: SortItem[] = []
426
- for (let i = 0; i < props.triParDepart.length; i += 1) {
427
- const tri = props.triParDepart[i]!
428
- if (typeof tri === 'string') retour.push({ key: tri, order: ordre })
429
- else retour.push(tri)
430
- }
431
- return retour
432
- })
433
-
434
- const estMultiTriActif = computed(() => {
435
- if (estEnModeModificateurOrdre.value) return false
436
- if (props.triParDepart == null) return false
437
- if (typeof props.triParDepart === 'string') return false
438
- return true
439
- })
440
-
441
- // ============================================================================
442
- // Computed - Export (filtrage local basé sur recherche)
443
- // ============================================================================
444
- const filteredItems = computed(() => {
445
- if (!recherche.value) return props.liste
446
- const q = recherche.value.toLowerCase()
447
- return props.liste.filter(item => Object.values(item).some(val => String(val).toLowerCase().includes(q)))
448
- })
449
-
450
- const listeEffective = computed(() => (estEnModeModificateurOrdre.value ? listeInterne.value : props.liste))
451
-
452
- // ============================================================================
453
- // Drag & drop - Ordre manuel (Sortable.js)
454
- // ============================================================================
455
- let sortableInstance: Sortable | null = null
456
-
457
- // Synchronise listeInterne quand le parent met à jour props.liste
458
- watch(
459
- () => props.liste,
460
- val => {
461
- listeInterne.value = [...val]
462
- },
463
- { immediate: true, deep: true },
464
- )
465
-
466
- // Init/destroy Sortable si la prop change en cours de vie du composant
467
- watch(estEnModeModificateurOrdre, actif => {
468
- if (actif) nextTick(initSortable)
469
- else destroySortable()
470
- })
471
-
472
- onMounted(() => {
473
- if (estEnModeModificateurOrdre.value) nextTick(initSortable)
474
- })
475
-
476
- onBeforeUnmount(() => {
477
- destroySortable()
478
- })
479
-
480
- function initSortable() {
481
- const tableEl = datatable.value?.$el as HTMLElement | undefined
482
- if (!tableEl) return
483
- const tbody = tableEl.querySelector('tbody')
484
- if (!tbody) return
485
-
486
- destroySortable() // garantit une seule instance active
487
-
488
- sortableInstance = Sortable.create(tbody, {
489
- animation: 150,
490
- handle: '.ancre-drag',
491
- onEnd(event) {
492
- const oldIndex = event.oldIndex
493
- const newIndex = event.newIndex
494
- if (oldIndex == null || newIndex == null || oldIndex === newIndex) return
495
-
496
- const items = [...listeInterne.value]
497
- const moved = items.splice(oldIndex, 1)[0]
498
- items.splice(newIndex, 0, moved)
499
-
500
- // Recalcule la valeur du champ ordre pour maintenir la cohérence
501
- const champ = props.modificateurOrdreChamp!
502
- items.forEach((item, idx) => {
503
- item[champ] = idx + 1
504
- })
505
-
506
- listeInterne.value = items
507
- emit('ordreRangeeModifie', [...items])
508
- },
509
- })
510
- }
511
-
512
- function destroySortable() {
513
- sortableInstance?.destroy()
514
- sortableInstance = null
515
- }
516
-
517
- // ============================================================================
518
- // UI Actions - Recherche / Table events
519
- // ============================================================================
520
- function chargerRecherche(val: string) {
521
- recherche.value = val
522
- }
523
-
524
- function ajouter() {
525
- emit('ajouter')
526
- }
527
-
528
- function cliqueLigne(_e: Event, { item }: { item: any }) {
529
- emit('cliqueLigne', item)
530
- }
531
-
532
- function modifier(item: any) {
533
- emit('modifier', item)
534
- }
535
-
536
- // ============================================================================
537
- // Suppression - Texte + actions
538
- // ============================================================================
539
- const supprimerTexte = computed(() => {
540
- if (itemSelectionne.value == null) return ''
541
-
542
- if (props.modaleSupprimerTexte) return props.modaleSupprimerTexte
543
-
544
- return t('csqc.message.supprimerMessage', {
545
- nom: itemSelectionne?.value?.[props.modaleSupprimerChamp] ?? '',
546
- })
547
- })
548
-
549
- const supprimerTitreTexte = computed(() => props.modaleSupprimerTitre || t('csqc.message.supprimerTitre'))
550
-
551
- function ouvrirModaleSupprimer(item: any) {
552
- itemSelectionne.value = item
553
- emit('supprimer', itemSelectionne.value) // tu sembles gérer la modale ailleurs
554
- }
555
-
556
- function supprimer() {
557
- emit('supprimer', itemSelectionne.value)
558
- }
559
-
560
- // ============================================================================
561
- // UI - Panneau recherche avancée
562
- // ============================================================================
563
- function onPanelChange(val: boolean) {
564
- emit('panneau:etat', val)
565
- }
566
- </script>
567
-
568
- <style scoped lang="css">
569
- .ControlesDatatable {
570
- gap: 4px;
571
- }
572
-
573
- /* barre de recherche design QC*/
574
- .BarreRecherche {
575
- border-radius: 4px 0 0 4px !important;
576
- min-height: 40px;
577
- }
578
-
579
- .BarreRechercheBackIcone {
580
- background-color: #095797 !important;
581
- border-bottom: 1px solid #808a9d;
582
- border-radius: 0 4px 4px 0 !important;
583
- border-right: 1px solid #808a9d;
584
- border-top: 1px solid #808a9d;
585
- height: 40px !important;
586
- margin-left: -16px;
587
- max-width: 40px !important;
588
- min-width: 40px !important;
589
- padding-left: 0 !important;
590
- padding-right: 0 !important;
591
- width: 40px !important;
592
- }
593
-
594
- /* icone loupe */
595
- .BarreRechercheIcone {
596
- color: white !important;
597
- font-size: 34px !important;
598
- margin-left: 1px !important;
599
- margin-top: 2px !important;
600
- }
601
-
602
- /* datatable contour */
603
- .v-data-table {
604
- border: 1px solid #d3d3d3;
605
- border-radius: 5px;
606
- overflow: hidden;
607
- }
608
-
609
- /* hover */
610
- .v-data-table:hover {
611
- cursor: pointer !important;
612
- }
613
-
614
- /* datatable row */
615
- .v-data-table .v-table__wrapper > table > thead > tr > td,
616
- .v-data-table .v-table__wrapper > table > thead > tr th,
617
- .v-data-table .v-table__wrapper > table tbody > tr > td,
618
- .v-data-table .v-table__wrapper > table tbody > tr th {
619
- background-color: #d3d3d375 !important;
620
- }
621
-
622
- /* datatable header contour */
623
- .v-data-table .v-table__wrapper > table > thead > tr > th,
624
- .v-data-table .v-table__wrapper > table tbody > tr > th {
625
- background-color: white !important;
626
- border-bottom: 4px #223654 solid !important;
627
- }
628
-
629
- :deep(.v-table__wrapper > table > thead > tr > th) {
630
- background-color: rgb(var(--v-theme-bleuMoyen)) !important;
631
- }
632
-
633
- /* datatable header intérieur*/
634
- :deep(.v-data-table-header__content) {
635
- background-color: rgb(var(--v-theme-bleuMoyen)) !important;
636
- color: rgb(var(--v-theme-blanc)) !important;
637
- font-size: 15px;
638
- font-weight: 600;
639
- height: 46px;
640
- }
641
-
642
- /* datatable footer */
643
- .v-data-table-footer {
644
- background-color: white !important;
645
- border-top: 4px #223654 solid !important;
646
- }
647
-
648
- /* datatable row hover */
649
- .v-data-table .v-table__wrapper > table tbody > tr:hover > td,
650
- .v-data-table .v-table__wrapper > table tbody > tr:hover {
651
- background-color: #e0e0e0 !important;
652
- }
653
-
654
- .iconeSupprimer:hover {
655
- color: red;
656
- }
657
- .v-icon.v-icon.v-icon--link.iconeSupprimer:hover {
658
- color: red !important;
659
- }
660
- .iconeEditer:hover {
661
- color: #095797;
662
- }
663
-
664
- /* hover icone à droite du ajouter*/
665
- .text-grisMoyen:hover {
666
- color: #095797 !important;
667
- }
668
-
669
- /* icône d'ancre drag & drop */
670
- .ancre-drag {
671
- color: #808a9d;
672
- cursor: grab;
673
- }
674
- .ancre-drag:active {
675
- cursor: grabbing;
676
- }
677
- </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: 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: 'simplifie',
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>