@bildvitta/quasar-ui-asteroid 3.20.0-beta.2 → 3.20.0-beta.21

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.
Files changed (102) hide show
  1. package/package.json +3 -3
  2. package/src/asteroid.js +8 -1
  3. package/src/components/actions/QasActions.vue +12 -2
  4. package/src/components/actions-menu/QasActionsMenu.vue +18 -5
  5. package/src/components/alert/QasAlert.vue +89 -64
  6. package/src/components/app-user/QasAppUser.vue +2 -1
  7. package/src/components/board-generator/QasBoardGenerator.vue +883 -162
  8. package/src/components/board-generator/QasBoardGenerator.yml +83 -2
  9. package/src/components/board-generator/private/PvBoardGeneratorCardsContainer.vue +25 -0
  10. package/src/components/box/QasBox.vue +16 -3
  11. package/src/components/box/QasBox.yml +10 -0
  12. package/src/components/btn/QasBtn.vue +27 -5
  13. package/src/components/btn/QasBtn.yml +10 -1
  14. package/src/components/btn-dropdown/QasBtnDropdown.vue +13 -1
  15. package/src/components/card/QasCard.vue +97 -25
  16. package/src/components/card/QasCard.yml +10 -0
  17. package/src/components/card-image/QasCardImage.vue +10 -1
  18. package/src/components/card-image/QasCardImage.yml +5 -0
  19. package/src/components/chart-view/QasChartView.vue +4 -3
  20. package/src/components/chart-view/QasChartView.yml +5 -0
  21. package/src/components/checkbox/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  22. package/src/components/copy/QasCopy.vue +6 -1
  23. package/src/components/copy/QasCopy.yml +5 -0
  24. package/src/components/date-time-input/QasDateTimeInput.vue +30 -6
  25. package/src/components/dialog/QasDialog.vue +308 -91
  26. package/src/components/dialog/QasDialog.yml +51 -23
  27. package/src/components/dialog/composables/use-cancel.js +1 -1
  28. package/src/components/dialog/composables/use-dynamic-components.js +2 -2
  29. package/src/components/dialog/composables/use-ok.js +1 -0
  30. package/src/components/dialog-router/QasDialogRouter.vue +1 -1
  31. package/src/components/drawer/QasDrawer.vue +76 -26
  32. package/src/components/drawer/QasDrawer.yml +10 -0
  33. package/src/components/expansion-item/QasExpansionItem.yml +5 -0
  34. package/src/components/filters/QasFilters.vue +2 -1
  35. package/src/components/filters/private/PvFiltersActions.vue +79 -13
  36. package/src/components/form-generator/QasFormGenerator.vue +8 -1
  37. package/src/components/form-generator/QasFormGenerator.yml +10 -0
  38. package/src/components/form-view/QasFormView.vue +20 -11
  39. package/src/components/form-view/QasFormView.yml +6 -0
  40. package/src/components/gallery/composables/use-delete.js +2 -3
  41. package/src/components/gallery/private/PvGalleryCarouselDialog.vue +8 -7
  42. package/src/components/grid-item/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/src/components/header/QasHeader.vue +66 -11
  44. package/src/components/header/QasHeader.yml +16 -1
  45. package/src/components/infinite-scroll/QasInfiniteScroll.vue +1 -1
  46. package/src/components/label/QasLabel.vue +3 -1
  47. package/src/components/layout/QasLayout.vue +16 -1
  48. package/src/components/layout/private/PvLayoutNotificationsDrawer.vue +2 -1
  49. package/src/components/layout/private/PvLayoutOverlayDrawer.vue +4 -2
  50. package/src/components/lazy-loading-components/QasLazyLoadingComponents.vue +262 -0
  51. package/src/components/lazy-loading-components/QasLazyLoadingComponents.yml +49 -0
  52. package/src/components/list-view/QasListView.vue +12 -4
  53. package/src/components/list-view/QasListView.yml +12 -0
  54. package/src/components/page-header/QasPageHeader.vue +49 -3
  55. package/src/components/page-header/QasPageHeader.yml +5 -0
  56. package/src/components/router-link/QasRouterLink.vue +72 -0
  57. package/src/components/router-link/QasRouterLink.yml +24 -0
  58. package/src/components/search-box/QasSearchBox.vue +1 -1
  59. package/src/components/select/QasSelect.vue +8 -1
  60. package/src/components/select-list-dialog/QasSelectListDialog.vue +40 -20
  61. package/src/components/select-list-dialog/QasSelectListDialog.yml +14 -2
  62. package/src/components/signature-uploader/QasSignatureUploader.vue +5 -18
  63. package/src/components/single-view/QasSingleView.vue +2 -2
  64. package/src/components/skeleton/QasSkeleton.vue +139 -0
  65. package/src/components/skeleton/QasSkeleton.yml +48 -0
  66. package/src/components/sortable/QasSortable.vue +1 -1
  67. package/src/components/stepper/QasStepper.vue +24 -2
  68. package/src/components/table-generator/QasTableGenerator.vue +186 -35
  69. package/src/components/table-generator/QasTableGenerator.yml +6 -1
  70. package/src/components/tabs-generator/QasTabsGenerator.vue +14 -3
  71. package/src/components/tabs-generator/QasTabsGenerator.yml +5 -1
  72. package/src/components/text-truncate/QasTextTruncate.vue +61 -12
  73. package/src/components/text-truncate/QasTextTruncate.yml +5 -0
  74. package/src/components/toggle-visibility/QasToggleVisibility.vue +2 -1
  75. package/src/components/tooltip/QasTooltip.vue +6 -1
  76. package/src/components/tree-generator/QasTreeGenerator.vue +4 -6
  77. package/src/components/uploader/QasUploader.vue +12 -2
  78. package/src/composables/private/use-view.js +1 -1
  79. package/src/composables/use-overlay-navigation.js +116 -10
  80. package/src/composables/use-screen.js +17 -1
  81. package/src/css/components/button.scss +82 -3
  82. package/src/css/components/item.scss +6 -0
  83. package/src/css/utils/background.scss +5 -0
  84. package/src/css/utils/border.scss +6 -0
  85. package/src/css/utils/container.scss +4 -3
  86. package/src/css/utils/text.scss +9 -0
  87. package/src/helpers/copy-to-clipboard.js +2 -1
  88. package/src/helpers/filters.js +1 -1
  89. package/src/helpers/promise-handler.js +2 -1
  90. package/src/helpers/set-scroll-gradient.js +31 -8
  91. package/src/helpers/set-scroll-on-grab.js +10 -3
  92. package/src/index.scss +1 -0
  93. package/src/mixins/search-filter.js +7 -1
  94. package/src/plugins/delete/Delete.js +7 -9
  95. package/src/plugins/delete/Delete.yml +1 -1
  96. package/src/plugins/dialog/Dialog.yml +1 -1
  97. package/src/plugins/notify-error/NotifyError.yml +1 -1
  98. package/src/plugins/notify-success/NotifySuccess.yml +1 -1
  99. package/src/plugins/screen/Screen.js +17 -1
  100. package/src/plugins/screen/Screen.yml +5 -1
  101. package/src/vue-plugin.js +5 -7
  102. package/src/plugins/index.js +0 -5
@@ -1,40 +1,96 @@
1
1
  <template>
2
- <qas-grabbable class="qas-board-generator" v-bind="grabbableProps">
3
- <div class="no-wrap q-col-gutter-sm q-px-xl row">
4
- <div v-for="(header, index) in headers" :key="index" class="q-mr-sm">
5
- <qas-box class="q-mb-md" v-bind="headerBoxProps">
6
- <slot :fields="getFieldsByHeader(header)" :header="header" :index="index" name="header-column" />
7
- </qas-box>
8
-
9
- <div ref="columnContainer" class="qas-board-generator__column secondary-scroll" :data-header-key="getKeyByHeader(header)" :style="containerStyle">
10
- <div v-for="item in getItemsByHeader(header)" :id="item[props.itemIdKey]" :key="item[props.itemIdKey]" class="qas-board-generator__item">
11
- <slot :column-index="index" :fields="getFieldsByHeader(header)" :item="item" name="column-item" />
12
- </div>
13
-
14
- <div class="full-width justify-center row">
15
- <qas-btn v-if="hasSeeMore(header)" icon="sym_r_add" label="Ver mais" :use-label-on-small-screen="false" variant="tertiary" @click="fetchColumn(header)" />
16
-
17
- <q-spinner v-if="columnsLoading[getKeyByHeader(header)]" class="q-mb-md" color="grey-4" size="3em" />
18
- </div>
19
-
20
- <qas-empty-result-text v-if="hasEmptyResultText(header)" />
21
- </div>
2
+ <div>
3
+ <qas-grabbable class="qas-board-generator" v-bind="grabbableProps">
4
+ <div ref="columnsContainer" class="no-wrap q-gutter-md q-pb-xs q-px-lg row">
5
+ <qas-lazy-loading-components :key="lazyLoadingKey" v-model:visible-items="visibleItems" direction="horizontal" placeholder-width="350px" :threshold="0">
6
+ <qas-box v-for="(header, index) in normalizedHeaders" :key="index" :class="getColumnClasses(header)" :style="containerStyle">
7
+ <div class="ellipsis q-mb-md text-grey-10">
8
+ <qas-skeleton v-if="props.skeleton" type="text" use-contrast width="80%" />
9
+
10
+ <slot v-else :fields="getFieldsByHeader(header)" :header="header" :index="index" name="header-column" />
11
+ </div>
12
+
13
+ <pv-board-generator-cards-container ref="columnContainer" class="qas-board-generator__column secondary-scroll" v-bind="getCardsContainerProps(header)">
14
+ <!-- COLUNA COM ERRO -->
15
+ <div v-if="columnsWithError[getKeyByHeader(header)]" class="column full-height items-center justify-center">
16
+ <div class="text-center">
17
+ <q-icon color="negative" name="sym_r_error" size="md" />
18
+
19
+ <div class="q-mt-sm text-subtitle1">
20
+ {{ props.errorColumnText }}
21
+ </div>
22
+ </div>
23
+
24
+ <div class="text-center">
25
+ <qas-btn class="q-mt-md" icon="sym_r_refresh" label="Tentar novamente" :loading="columnsLoading[getKeyByHeader(header)]" @click="handleFetchColumnClick(header)" />
26
+ </div>
27
+ </div>
28
+
29
+ <template v-else>
30
+ <qas-lazy-loading-components :threshold="0">
31
+ <div v-for="(item) in getItemsByHeader(header)" :id="item[props.itemIdKey]" :key="item[props.itemIdKey]" class="qas-board-generator__item" :data-disable-drag="updatingPositionItemKeys.has(item[props.itemIdKey])">
32
+ <slot v-if="!props.skeleton" :column-index="index" :fields="getFieldsByHeader(header)" :header="header" :is-updating-position="updatingPositionItemKeys.has(item[props.itemIdKey])" :item="item" name="column-item" />
33
+ </div>
34
+ </qas-lazy-loading-components>
35
+
36
+ <div class="full-width justify-center row">
37
+ <qas-btn v-if="hasSeeMore(header)" icon="sym_r_add" :label="props.seeMoreButtonLabel" :loading="columnsLoading[getKeyByHeader(header)]" :use-label-on-small-screen="false" variant="tertiary" @click="handleFetchColumnClick(header)" />
38
+
39
+ <template v-if="hasSkeletonByHeader(header)">
40
+ <div class="q-col-gutter-y-sm row">
41
+ <div v-for="item in skeletonCards" :key="item[props.itemIdKey]" class="col-12">
42
+ <qas-card v-bind="item">
43
+ <template #default />
44
+ </qas-card>
45
+ </div>
46
+ </div>
47
+ </template>
48
+ </div>
49
+
50
+ <qas-empty-result-text v-if="hasEmptyResultText(header)" />
51
+ </template>
52
+ </pv-board-generator-cards-container>
53
+ </qas-box>
54
+ </qas-lazy-loading-components>
22
55
  </div>
23
- </div>
24
56
 
25
- <qas-dialog v-model="showConfirmDialog" v-bind="defaultConfirmDialogProps" />
26
- </qas-grabbable>
57
+ <qas-dialog v-model="showConfirmDialog" v-bind="defaultConfirmDialogProps" />
58
+ </qas-grabbable>
59
+
60
+ <q-inner-loading :showing="loading">
61
+ <q-spinner
62
+ color="grey"
63
+ size="3em"
64
+ />
65
+ </q-inner-loading>
66
+ </div>
27
67
  </template>
28
68
 
29
69
  <script setup>
70
+ import PvBoardGeneratorCardsContainer from './private/PvBoardGeneratorCardsContainer.vue'
71
+ import QasSkeleton from '../skeleton/QasSkeleton.vue'
72
+ import QasCard from '../card/QasCard.vue'
30
73
  import QasBox from '../box/QasBox.vue'
31
74
  import QasBtn from '../btn/QasBtn.vue'
32
75
  import QasDialog from '../dialog/QasDialog.vue'
33
76
  import QasEmptyResultText from '../empty-result-text/QasEmptyResultText.vue'
34
77
  import QasGrabbable from '../grabbable/QasGrabbable.vue'
78
+ import QasLazyLoadingComponents from '../lazy-loading-components/QasLazyLoadingComponents.vue'
35
79
 
36
- import { ref, watch, computed, onUnmounted, markRaw, inject, onMounted } from 'vue'
37
80
  import promiseHandler from '../../helpers/promise-handler'
81
+ import NotifyError from '../../plugins/notify-error/NotifyError'
82
+
83
+ import {
84
+ ref,
85
+ watch,
86
+ computed,
87
+ onBeforeUnmount,
88
+ markRaw,
89
+ inject,
90
+ provide,
91
+ onMounted,
92
+ nextTick
93
+ } from 'vue'
38
94
 
39
95
  import Sortable from 'sortablejs'
40
96
 
@@ -56,11 +112,6 @@ const props = defineProps({
56
112
  default: () => ({})
57
113
  },
58
114
 
59
- headerBoxProps: {
60
- type: Object,
61
- default: () => ({})
62
- },
63
-
64
115
  columnIdKey: {
65
116
  type: String,
66
117
  required: true
@@ -81,6 +132,11 @@ const props = defineProps({
81
132
  default: () => ({})
82
133
  },
83
134
 
135
+ errorColumnText: {
136
+ type: String,
137
+ default: 'Não foi possível carregar os itens desta coluna.'
138
+ },
139
+
84
140
  height: {
85
141
  type: String,
86
142
  default: ''
@@ -96,9 +152,18 @@ const props = defineProps({
96
152
  default: 12
97
153
  },
98
154
 
155
+ loading: {
156
+ type: Boolean
157
+ },
158
+
99
159
  columnWidth: {
100
160
  type: String,
101
- default: '300px'
161
+ default: '350px'
162
+ },
163
+
164
+ seeMoreButtonLabel: {
165
+ type: String,
166
+ default: 'Ver mais'
102
167
  },
103
168
 
104
169
  sortableConfig: {
@@ -106,9 +171,8 @@ const props = defineProps({
106
171
  default: () => ({})
107
172
  },
108
173
 
109
- useMarkRaw: {
110
- type: Boolean,
111
- default: true
174
+ skeleton: {
175
+ type: Boolean
112
176
  },
113
177
 
114
178
  useDragAndDropX: {
@@ -120,7 +184,7 @@ const props = defineProps({
120
184
  },
121
185
 
122
186
  updatePositionUrl: {
123
- type: String,
187
+ type: [String, Function],
124
188
  default: ''
125
189
  },
126
190
 
@@ -145,23 +209,49 @@ const emit = defineEmits([
145
209
  'update-error'
146
210
  ])
147
211
 
148
- defineExpose({ fetchColumns, fetchColumn, reset, cancelDrop })
212
+ defineExpose({
213
+ fetchColumns,
214
+ fetchColumn,
215
+ reset,
216
+ cancelDrop,
217
+ refreshColumn,
218
+ transferItemToColumn,
219
+ removeItemFromList,
220
+ updateItemInList,
221
+ refetchColumns: fetchColumnsValues
222
+ })
149
223
 
150
- // Inject
224
+ // globals
151
225
  const axios = inject('axios')
152
226
 
153
- const isFetchSuccessHeader = inject('isFetchListSucceeded', false)
154
-
155
- const isInsideListView = inject('isListView', false)
156
-
157
- // Refs
227
+ // refs
158
228
  const columnContainer = ref(null)
159
229
  const columnsPagination = ref({})
160
230
  const columnsLoading = ref({})
161
231
  const columnsFieldsModel = ref({})
162
232
  const showConfirmDialog = ref(false)
163
- const isDragging = ref(false)
164
233
  const isLoadingUpdatePosition = ref(false)
234
+ const hideSkeleton = ref(false)
235
+ const columnsWithError = ref({})
236
+
237
+ /**
238
+ * Set de IDs dos itens com update de posição em andamento.
239
+ * Usamos Set para evitar duplicatas e ter O(1) no has().
240
+ */
241
+ const updatingPositionItemKeys = ref(new Set())
242
+
243
+ /**
244
+ * Índices das colunas visíveis no viewport
245
+ * Populado pelo QasLazyLoadingComponents via v-model:visible-items
246
+ */
247
+ const visibleItems = ref([])
248
+
249
+ /**
250
+ * Chave reativa para forçar o remount do QasLazyLoadingComponents externo.
251
+ * Ao incrementar, o componente é destruído e recriado com estado limpo,
252
+ * evitando render de VNodes obsoletos que causam crash.
253
+ */
254
+ const lazyLoadingKey = ref(0)
165
255
 
166
256
  /**
167
257
  * Instâncias do sortable, que são utilizadas para realizar o destroy ao sair da página
@@ -179,7 +269,67 @@ const onConfirmDrop = ref(() => {})
179
269
  */
180
270
  const isUpdatingPosition = ref(false)
181
271
 
182
- // Consts
272
+ /**
273
+ * Contador de drags ativos. Usado em vez de um booleano com toggle para
274
+ * evitar race conditions quando múltiplos drags acontecem em sequência rápida.
275
+ * isDragging continua existindo como computed derivado deste contador.
276
+ */
277
+ const draggingCount = ref(0)
278
+
279
+ /**
280
+ * Fila de chamadas de updatePosition para garantir execução sequencial.
281
+ * Cada entrada é uma função que retorna uma Promise (a chamada updatePosition).
282
+ * Isso evita race conditions causadas por múltiplas requests concorrentes
283
+ * que compartilham estado (updatingPositionItemKey, isLoadingUpdatePosition, etc).
284
+ */
285
+ let updatePositionQueue = Promise.resolve()
286
+
287
+ /**
288
+ * Mapa de AbortControllers por chave de coluna para cancelar requests em andamento.
289
+ * Não é reativo pois não precisamos de reatividade sobre os controllers.
290
+ */
291
+ let columnAbortControllers = {}
292
+
293
+ /**
294
+ * Contador de sessão para o fetchColumns em execução.
295
+ * Incrementado a cada nova chamada, permitindo que sessões antigas detectem que foram substituídas.
296
+ */
297
+ let currentFetchColumnsSession = 0
298
+
299
+ /**
300
+ * Estado do smooth scroll horizontal.
301
+ * Acumula o target e interpola suavemente via requestAnimationFrame (rAF).
302
+ *
303
+ * rAF (requestAnimationFrame) sincroniza a animação com o refresh da tela (~60fps).
304
+ * Isso evita "saltos" de scroll, tornando o movimento suave e fluido.
305
+ */
306
+ const SMOOTH_SCROLL_LERP = 0.2
307
+
308
+ /**
309
+ * Velocidade de scroll vertical (dentro da coluna), independente do scrollSpeed horizontal.
310
+ * O offsetY recebido no scrollFn é proporcional ao scrollSpeed (60), então normalizamos
311
+ * para que o scroll vertical se comporte como se scrollSpeed fosse 10 (padrão do SortableJS).
312
+ */
313
+ const HORIZONTAL_SCROLL_SPEED = 60
314
+ const VERTICAL_SCROLL_SPEED = 20
315
+
316
+ /**
317
+ * Sensibilidade para iniciar o scroll automático ao arrastar próximo às bordas do container.
318
+ */
319
+ const HORIZONTAL_SCROLL_SENSITIVITY = 150
320
+ const VERTICAL_SCROLL_SENSITIVITY = 80
321
+
322
+ let smoothScrollTarget = 0
323
+ let smoothScrollRafId = null
324
+ let smoothScrollEl = null
325
+
326
+ /**
327
+ * Geração por coluna para invalidar callbacks onLoading de requests canceladas.
328
+ * Evita que o onLoading(false) de uma request cancelada sobrescreva o estado da nova request.
329
+ */
330
+ const columnFetchGenerations = {}
331
+
332
+ // consts
183
333
  const hasDragAndDrop = !!props.useDragAndDropX || !!props.useDragAndDropY
184
334
 
185
335
  const grabbableProps = {
@@ -190,44 +340,38 @@ const grabbableProps = {
190
340
  })
191
341
  }
192
342
 
193
- // Watchers
194
- watch(
195
- () => isFetchSuccessHeader.value,
196
- value => {
197
- /**
198
- * isFetchSuccessHeader é uma variavel que pego do listView por inject/provide, no qual caso eu faça request do header e dê sucesso, eu chamo as demais funções.
199
- * Valido se não houve sucesso na requisição do header ou se não é uma atualização de posição, para assim não bater novamente nas colunas apenas no header.
200
- */
201
- if (!value || isUpdatingPosition.value) return
202
-
203
- fetchColumnsValues()
343
+ /**
344
+ * Gera cards de skeleton para exibir enquanto carrega os itens da coluna, o mesmo é usado para preencher a coluna
345
+ * quando a prop "skeleton" for true, indicando que é para simular o carregamento.
346
+ */
347
+ const skeletonCards = Array.from({ length: 6 }).map(() => {
348
+ return {
349
+ skeleton: true,
350
+ title: '-',
351
+ expansionProps: { label: '-' },
352
+ actionsMenuProps: { list: {} },
353
+ useSelection: true
204
354
  }
205
- )
355
+ })
206
356
 
207
- watch(
208
- () => props.headers,
209
- () => {
210
- if (isUpdatingPosition.value) return
357
+ // computeds
358
+ const columnContainerElements = computed(() => {
359
+ return columnContainer.value?.map(columnProxy => columnProxy.$el) || []
360
+ })
211
361
 
212
- isUpdatingPosition.value = false
362
+ const normalizedHeaders = computed(() => {
363
+ // retorna dados fakes para criar colunas de skeleton, caso a prop skeleton seja true.
364
+ if (props.skeleton) {
365
+ return Array.from({ length: 8 }).map((_, index) => {
366
+ return {
367
+ [props.columnIdKey]: `${props.columnIdKey}-${index}`
368
+ }
369
+ })
213
370
  }
214
- )
215
-
216
- watch(columnContainer, setColumnHeightContainer)
217
-
218
- // Lifecycles
219
- onMounted(() => {
220
- /**
221
- * Caso eu use o listView (valor pego por provide), a request é feito pelo watch quando se ocorre o sucesso do `fetchList`
222
- */
223
- if (isInsideListView) return
224
371
 
225
- fetchColumnsValues()
372
+ return props.headers
226
373
  })
227
374
 
228
- onUnmounted(destroySortable)
229
-
230
- // Computeds
231
375
  const columnsResultsModel = computed({
232
376
  get () {
233
377
  return props.results
@@ -238,12 +382,12 @@ const columnsResultsModel = computed({
238
382
  }
239
383
  })
240
384
 
385
+ const isDragging = computed(() => draggingCount.value > 0)
386
+
241
387
  const hasColumnsLength = computed(() => !!Object.keys(columnsResultsModel.value).length)
242
388
 
243
389
  const containerStyle = computed(() => `width: ${props.columnWidth};`)
244
390
 
245
- const hasConfirmDialogProps = computed(() => !!Object.keys(props.confirmDialogProps).length)
246
-
247
391
  const defaultConfirmDialogProps = computed(() => {
248
392
  const defaultProps = {
249
393
  ok: {
@@ -263,49 +407,211 @@ const defaultConfirmDialogProps = computed(() => {
263
407
  }
264
408
  })
265
409
 
410
+ // provide
411
+ provide('isDragging', isDragging)
412
+
413
+ // watchers
414
+ watch(
415
+ () => normalizedHeaders.value,
416
+ value => {
417
+ if (isLoadingUpdatePosition.value || !value?.length) return
418
+
419
+ fetchColumnsValues()
420
+ }
421
+ )
422
+
423
+ watch(() => columnContainerElements.value, () => {
424
+ setColumnHeightContainer()
425
+ handleElementsList()
426
+ })
427
+
428
+ // hooks
429
+ onMounted(() => {
430
+ if (normalizedHeaders.value.length) {
431
+ fetchColumnsValues()
432
+ }
433
+
434
+ window.addEventListener('resize', setColumnHeightContainer)
435
+ })
436
+
437
+ onBeforeUnmount(() => {
438
+ abortAllColumnRequests()
439
+ destroySortable()
440
+
441
+ window.removeEventListener('resize', setColumnHeightContainer)
442
+ })
443
+
266
444
  // functions
267
445
  /*
268
446
  * Setar o tamanho do container do board, onde deverá ser a altura passada via prop, ou o default será ocupar o maximo
269
447
  * de espaço que ele conseguir considerando a altura do container em relação ao topo.
270
448
  */
271
449
  function setColumnHeightContainer () {
272
- columnContainer.value?.forEach(columnElement => {
273
- const heightToTop = columnElement?.getBoundingClientRect()?.top
274
- const paddingSpacing = 60
275
- const value = heightToTop + paddingSpacing
450
+ if (props.height) {
451
+ columnContainerElements.value.forEach(columnElement => {
452
+ columnElement.style.height = props.height
453
+ })
454
+
455
+ return
456
+ }
457
+
458
+ // Primeira etapa: calcula e aplica a altura inicial de cada coluna
459
+ columnContainerElements.value.forEach(columnElement => {
460
+ // Pega a posição atual da coluna em relação ao topo da viewport
461
+ const rect = columnElement.getBoundingClientRect()
462
+ const heightToTop = rect.top
463
+
464
+ // clientHeight dá a altura da viewport SEM incluir as scrollbars
465
+ const viewportHeight = document.documentElement.clientHeight
276
466
 
277
- columnElement.style.setProperty('height', props.height ? props.height : `calc(100vh - ${value}px)`)
467
+ // Padding inferior aplicado no container para dar espaçamento visual
468
+ const paddingBottom = 8
469
+
470
+ // Calcula quanto de espaço temos disponível do topo da coluna até o fim da tela
471
+ const availableHeight = viewportHeight - heightToTop - paddingBottom
472
+
473
+ // Aplica essa altura na coluna
474
+ columnElement.style.height = `${availableHeight}px`
475
+ })
476
+
477
+ // Segunda etapa: após o DOM atualizar, verifica se algum scroll vertical foi criado
478
+ nextTick(() => {
479
+ /**
480
+ * scrollHeight é a altura total do conteúdo (incluindo o que não está visível)
481
+ * clientHeight é a altura visível da viewport
482
+ * Se scrollHeight > clientHeight, significa que há conteúdo "sobrando" e foi criado scroll vertical.
483
+ */
484
+ const hasVerticalScroll = document.documentElement.scrollHeight > document.documentElement.clientHeight
485
+
486
+ if (hasVerticalScroll) {
487
+ // Calcula exatamente quantos pixels estão "sobrando" e causando o scroll
488
+ const adjustment = document.documentElement.scrollHeight - document.documentElement.clientHeight
489
+
490
+ // Reduz a altura de todas as colunas pelo valor exato do scroll + 2px de margem de segurança
491
+ columnContainerElements.value.forEach(columnElement => {
492
+ const safetyMargin = 2
493
+
494
+ const currentHeight = parseInt(columnElement.style.height, 10)
495
+ columnElement.style.height = `${currentHeight - adjustment - safetyMargin}px`
496
+ })
497
+ }
278
498
  })
279
499
  }
280
500
 
281
501
  /*
282
502
  * Bater API pra cada header
503
+ * Etapa 1: faz as requests das colunas visíveis no carregamento inicial
504
+ * Etapa 2: após finalizar, faz as requests das colunas não visíveis
283
505
  */
284
506
  async function fetchColumns () {
285
- const promises = props.headers.map(header => fetchColumn(header))
507
+ if (props.skeleton) return
508
+
509
+ /**
510
+ * Cada chamada a fetchColumns recebe um ID de sessão único.
511
+ * Se um novo fetchColumnsValues for chamado durante a execução, a sessão anterior
512
+ * detecta que foi substituída e para de processar, evitando emissão de eventos falsos.
513
+ */
514
+ const mySession = ++currentFetchColumnsSession
286
515
 
287
- const { error } = await promiseHandler(promises, { useLoading: false })
516
+ // Mapeia os índices visíveis para os IDs de coluna correspondentes
517
+ const visibleKeys = new Set(visibleItems.value.map(item => getKeyByHeader(normalizedHeaders.value[item])))
288
518
 
289
- if (error) {
290
- emit('fetch-columns-error', error)
519
+ const visibleHeaders = visibleKeys.size
520
+ ? normalizedHeaders.value.filter(header => visibleKeys.has(getKeyByHeader(header)))
521
+ : normalizedHeaders.value
522
+
523
+ const hiddenHeaders = visibleKeys.size
524
+ ? normalizedHeaders.value.filter(header => !visibleKeys.has(getKeyByHeader(header)))
525
+ : []
526
+
527
+ /**
528
+ * Isto é necessário para setar o loading das colunas ocultas mesmo antes de elas serem visíveis
529
+ * e suas requests serem feitas, garantindo que ao entrar no viewport elas já estejam com o estado
530
+ * de loading correto, evitando bugs visuais.
531
+ */
532
+ hiddenHeaders.forEach(header => {
533
+ const headerKey = getKeyByHeader(header)
534
+
535
+ columnsLoading.value[headerKey] = true
536
+ })
537
+
538
+ // Etapa 1: colunas visíveis (prioridade)
539
+ const visibleColumns = await Promise.allSettled(visibleHeaders.map(header => fetchColumn(header, false)))
540
+
541
+ // Sessão foi substituída por um novo fetchColumnsValues — encerra sem emitir eventos
542
+ if (currentFetchColumnsSession !== mySession) return
543
+
544
+ // Etapa 2: colunas não visíveis (só executa após etapa 1 finalizar, menor prioridade)
545
+ const hiddenColumns = await Promise.allSettled(hiddenHeaders.map(header => fetchColumn(header, false)))
546
+
547
+ // Verifica novamente pois outro fetchColumnsValues pode ter sido chamado durante a etapa 2
548
+ if (currentFetchColumnsSession !== mySession) return
549
+
550
+ const allPromises = [...visibleColumns, ...hiddenColumns]
551
+ console.log('🚀 ~ fetchColumns ~ allPromises:', allPromises)
552
+
553
+ const hasAllPromisesSucceeded = allPromises.every(promise => promise.status === 'fulfilled')
554
+ const hasAllPromisesFailed = allPromises.length > 0 && allPromises.every(promise => promise.status === 'rejected')
555
+
556
+ if (hasAllPromisesFailed) {
557
+ emit('fetch-columns-error')
558
+
559
+ NotifyError('Ocorreu um erro ao carregar as colunas. Tente novamente mais tarde.')
291
560
 
292
561
  return
293
562
  }
294
563
 
295
- emit('fetch-columns-success')
564
+ if (hasAllPromisesSucceeded) {
565
+ emit('fetch-columns-success')
566
+ }
296
567
 
297
568
  if (hasDragAndDrop) handleElementsList()
298
569
  }
299
570
 
300
571
  /*
301
- * Busca a coluna com base no header recebido.
572
+ * Wrapper para chamadas de fetchColumn originadas de eventos de clique do usuário.
573
+ * fetchColumn relança o erro para que Promise.allSettled em fetchColumns consiga
574
+ * detectar colunas com falha; aqui o erro já foi tratado internamente, portanto
575
+ * é suprimido para evitar o warning "Unhandled error during execution of component
576
+ * event handler" do Vue.
302
577
  */
303
- async function fetchColumn (header) {
578
+ function handleFetchColumnClick (header) {
579
+ fetchColumn(header, true)
580
+ }
581
+
582
+ /**
583
+ * Busca a coluna com base no header recebido.
584
+ *
585
+ * @param header - payload do header da coluna a ser buscada, exemplo: { date: '2024-02-12', ... }
586
+ * @param shouldHideSkeleton - flag para controlar se o skeleton de carregamento deve ser ocultado durante a busca
587
+ */
588
+ async function fetchColumn (header, shouldHideSkeleton) {
304
589
  const headerKey = getKeyByHeader(header)
590
+
591
+ /**
592
+ * Cancela qualquer request anterior para esta mesma coluna antes de iniciar a nova.
593
+ * Garante que nunca haja duas requests paralelas para a mesma coluna.
594
+ */
595
+ abortColumnRequest(headerKey)
596
+
597
+ /**
598
+ * Incrementa a geração desta coluna para invalidar callbacks onLoading da request cancelada,
599
+ * evitando que o onLoading(false) antigo sobrescreva o estado da nova request.
600
+ * Usa ?? 0 pois ++undefined retorna NaN, e NaN === NaN é sempre false, travando o skeleton.
601
+ */
602
+ columnFetchGenerations[headerKey] = (columnFetchGenerations[headerKey] ?? 0) + 1
603
+ const generation = columnFetchGenerations[headerKey]
604
+
605
+ const abortController = new AbortController()
606
+ columnAbortControllers[headerKey] = abortController
607
+
305
608
  const { limit, offset } = columnsPagination.value[headerKey] || {}
306
609
 
610
+ hideSkeleton.value = shouldHideSkeleton
611
+
307
612
  const { data: response, error } = await promiseHandler(
308
613
  axios.get(`${props.columnUrl}/${headerKey}`, {
614
+ signal: abortController.signal,
309
615
  params: {
310
616
  ...props.columnParams,
311
617
  limit,
@@ -314,19 +620,35 @@ async function fetchColumn (header) {
314
620
  }),
315
621
  {
316
622
  onLoading: value => {
317
- columnsLoading.value[headerKey] = value
623
+ /**
624
+ * Só atualiza o loading se ainda for a request atual desta coluna,
625
+ * evitando que o onLoading(false) de uma request cancelada sobrescreva o da nova.
626
+ */
627
+ if (columnFetchGenerations[headerKey] === generation) {
628
+ columnsLoading.value[headerKey] = value
629
+ }
318
630
  },
319
- useLoading: false,
320
- errorMessage: 'Não conseguimos buscar as colunas do board. Por favor, tente novamente em alguns minutos.'
631
+ useLoading: false
321
632
  }
322
633
  )
323
634
 
635
+ delete columnAbortControllers[headerKey]
636
+
637
+ hideSkeleton.value = false
638
+
324
639
  if (error) {
640
+ // Request cancelada intencionalmente (novo fetchColumnsValues chamado): ignora silenciosamente.
641
+ if (isCancelledError(error)) return
642
+
325
643
  emit('fetch-column-error', error)
326
644
 
645
+ columnsWithError.value[headerKey] = true
646
+
327
647
  throw error
328
648
  }
329
649
 
650
+ columnsWithError.value[headerKey] = false
651
+
330
652
  const newValues = response.data?.results || []
331
653
  const resultsModel = columnsResultsModel.value[headerKey] || []
332
654
 
@@ -346,7 +668,7 @@ async function fetchColumn (header) {
346
668
  * onde cada item do objeto é uma coluna no board. O mesmo vale para "columnsFieldsModel", "columnsLoading" e
347
669
  * "columnPagination", organizando os fields, loadings e o controle de paginação por chave identificadora do header.
348
670
  */
349
- columnsResultsModel.value[headerKey] = props.useMarkRaw ? markRaw(newColumnValues) : newColumnValues
671
+ columnsResultsModel.value[headerKey] = hasDragAndDrop ? newColumnValues : markRaw(newColumnValues)
350
672
 
351
673
  /*
352
674
  * Pode acontecer das options nos fields da segunda página serem diferentes da primeira página,
@@ -364,6 +686,15 @@ async function fetchColumn (header) {
364
686
  emit('fetch-column-success', { response, header })
365
687
  }
366
688
 
689
+ function refreshColumn (header) {
690
+ const headerKey = getKeyByHeader(header)
691
+
692
+ columnsResultsModel.value[headerKey] = []
693
+ columnsPagination.value[headerKey] = { limit: props.limitPerColumn, offset: 0 }
694
+
695
+ fetchColumn(header)
696
+ }
697
+
367
698
  /*
368
699
  * Mergeia os options antigos com os novos de cada field.
369
700
  */
@@ -413,7 +744,7 @@ function getColumnItemById (id) {
413
744
  * @returns {Object} // { date: '2024-02-15'... }
414
745
  */
415
746
  function getHeaderById (id) {
416
- return props.headers.find(header => String(getKeyByHeader(header)) === String(id))
747
+ return normalizedHeaders.value.find(header => String(getKeyByHeader(header)) === String(id))
417
748
  }
418
749
 
419
750
  /**
@@ -440,15 +771,28 @@ function setColumnsPagination () {
440
771
  columnsPagination.value = {}
441
772
  columnsLoading.value = {}
442
773
 
443
- props.headers.forEach(header => {
774
+ normalizedHeaders.value.forEach(header => {
444
775
  const headerKey = getKeyByHeader(header)
445
776
 
446
777
  columnsPagination.value[headerKey] = { limit: props.limitPerColumn, offset: 0 }
447
- columnsLoading.value[headerKey] = false
778
+
779
+ /**
780
+ * Inicia como true para exibir skeleton imediatamente, evitando flash de tela vazia
781
+ * entre o reset e o início das novas requests.
782
+ */
783
+ columnsLoading.value[headerKey] = true
448
784
  })
449
785
  }
450
786
 
451
787
  function fetchColumnsValues () {
788
+ /**
789
+ * Cancela todas as requests em andamento antes de iniciar novas, evitando resposta de requests
790
+ * antigas sobrescrevendo dados da nova sessão.
791
+ */
792
+ abortAllColumnRequests()
793
+
794
+ lazyLoadingKey.value++
795
+
452
796
  reset()
453
797
  setColumnHeightContainer()
454
798
  setColumnsPagination()
@@ -456,16 +800,22 @@ function fetchColumnsValues () {
456
800
  }
457
801
 
458
802
  /**
459
- * Descricao:
803
+ * Descrição:
460
804
  * Exibe o texto quando:
461
- * - Nao esta carregando a coluna
462
- * - Nao tem itens na coluna
463
- * - Nao estou fazendo o drag and drop
805
+ * - Não está carregando a coluna
806
+ * - Não tem itens na coluna
807
+ * - Não estou fazendo o drag and drop
808
+ * - Não está exibindo o dialog de confirmação
464
809
  *
465
810
  * @param {Object} header
466
811
  */
467
812
  function hasEmptyResultText (header) {
468
- return !columnsLoading.value[getKeyByHeader(header)] && !getItemsByHeader(header)?.length && !isDragging.value
813
+ if (props.skeleton) return false
814
+
815
+ const headerKey = getKeyByHeader(header)
816
+ const items = getItemsByHeader(header)
817
+
818
+ return !columnsLoading.value[headerKey] && !items?.length && !isDragging.value && !showConfirmDialog.value
469
819
  }
470
820
 
471
821
  /*
@@ -476,7 +826,7 @@ function hasSeeMore (header) {
476
826
  const headerKey = getKeyByHeader(header)
477
827
  const hasMorePagination = columnsResultsModel.value[headerKey]?.length < columnsPagination.value[headerKey]?.count
478
828
 
479
- return hasMorePagination && !columnsLoading.value[headerKey]
829
+ return hasMorePagination
480
830
  }
481
831
 
482
832
  function reset () {
@@ -495,8 +845,11 @@ function getFieldsByHeader (header) {
495
845
  * Loopa todos os itens da coluna com base no ref para pegar o elemento HTML e setar e instaciar o sortable.
496
846
  */
497
847
  function handleElementsList () {
498
- columnContainer.value.forEach((element, index) => {
499
- const sortable = setSortable(element, index)
848
+ columnContainerElements.value.forEach((columnElement, index) => {
849
+ // não adiciona os elementos com erro para o drag and drop.
850
+ if (columnElement.dataset.hasError === 'true') return
851
+
852
+ const sortable = setSortable(columnElement, index)
500
853
 
501
854
  sortableInstances.value.push(sortable)
502
855
  })
@@ -507,15 +860,23 @@ function handleElementsList () {
507
860
  * Seta a instancia do sortable, no qual varia de acordo com as props passadas.
508
861
  *
509
862
  * @param {HTMLElement} element
510
- * @param {Number} index
863
+ * @param {number} index
511
864
  */
512
865
  function setSortable (element, index) {
513
866
  const defaultSortableConfig = {
514
- animation: 500,
867
+ animation: 150,
868
+ group: 'shared',
869
+ ghostClass: 'qas-board-generator__ghost',
515
870
  swapThreshold: 1,
516
- delay: 50,
871
+ delay: 0,
517
872
  delayOnTouchOnly: true,
518
- emptyInsertThreshold: 0
873
+ emptyInsertThreshold: 0,
874
+ filter: '[data-disable-drag="true"]',
875
+ scrollSensitivity: HORIZONTAL_SCROLL_SENSITIVITY,
876
+ scrollSpeed: HORIZONTAL_SCROLL_SPEED,
877
+ bubbleScroll: true,
878
+ forceAutoScrollFallback: true,
879
+ scrollFn: handleSortableScroll
519
880
  }
520
881
 
521
882
  /**
@@ -523,31 +884,223 @@ function setSortable (element, index) {
523
884
  */
524
885
  const useOnlyDragAndDropY = !!props.useDragAndDropY && !props.useDragAndDropX
525
886
 
526
- const sortable = new Sortable(element, {
527
- sort: props.useDragAndDropY,
887
+ /**
888
+ * Flag local por instância do Sortable para rastrear se o drop foi tratado
889
+ * pelo onAdd/onSort. Isso permite que o onEnd saiba se deve chamar stopDragging
890
+ * (caso o usuário arraste e devolva o card sem mover para outra coluna).
891
+ */
892
+ let dropHandled = false
528
893
 
894
+ const sortable = new Sortable(element, {
529
895
  ...defaultSortableConfig,
530
896
 
531
897
  ...props.sortableConfig,
532
898
 
899
+ /**
900
+ * `sort` deve vir APÓS os spreads para não ser sobrescrito pelo defaultSortableConfig.
901
+ * Quando useDragAndDropY é true, o sort precisa estar habilitado para que o SortableJS
902
+ * rastreie ativamente a posição do item dentro da coluna durante o drag. Sem isso,
903
+ * o item oscila entre colunas porque o Sortable do destino não "assume" o controle do item.
904
+ */
905
+ sort: props.useDragAndDropY,
906
+
533
907
  group: useOnlyDragAndDropY ? `column-${index}` : 'shared',
534
908
 
535
- direction: useOnlyDragAndDropY ? 'vertical' : 'horizontal',
909
+ /**
910
+ * invertSwap muda o algoritmo de swap: em vez de trocar quando o centro do item arrastado
911
+ * cruza o centro do alvo (instável, causa oscilação), troca apenas quando cruza a BORDA
912
+ * superior/inferior do alvo. Isso cria uma "zona morta" que elimina o jitter.
913
+ */
914
+ invertSwap: true,
915
+
916
+ onStart: () => {
917
+ dropHandled = false
918
+ startDragging()
919
+ },
536
920
 
537
- onStart: toggleIsDragging,
921
+ onAdd: event => {
922
+ dropHandled = true
923
+ onDropCard(event)
924
+ },
538
925
 
539
- onAdd: event => onDropCard(event),
926
+ /**
927
+ * Chamado sempre que o drag termina, independente do resultado.
928
+ * Só chama stopDragging se onAdd/onSort não trataram o drop,
929
+ * ou seja, o usuário abandonou o drag sem mover o card.
930
+ */
931
+ onEnd: () => {
932
+ if (!dropHandled) stopDragging()
933
+ },
540
934
 
541
935
  ...(props.useDragAndDropY && {
542
- onSort: event => onDropCard(event)
936
+ onSort: event => {
937
+ dropHandled = true
938
+
939
+ /**
940
+ * O SortableJS dispara onSort em AMBAS as listas envolvidas quando um item
941
+ * é arrastado entre colunas (cross-column): na lista de origem porque perdeu
942
+ * um item e na lista de destino porque ganhou um.
943
+ * O onAdd já trata o cross-column na lista de destino, então ao ignorar o
944
+ * onSort para eventos cross-column evitamos processar o mesmo drop duas vezes,
945
+ * o que causava corrupção do model (item duplicado no destino e remoção
946
+ * incorreta do último item da origem via splice(-1, 1)).
947
+ */
948
+ if (event.from !== event.to) return
949
+
950
+ onDropCard(event)
951
+ }
543
952
  })
544
953
  })
545
954
 
546
955
  return sortable
547
956
  }
548
957
 
549
- function toggleIsDragging () {
550
- isDragging.value = !isDragging.value
958
+ function startDragging () {
959
+ draggingCount.value++
960
+ }
961
+
962
+ function stopDragging () {
963
+ draggingCount.value = Math.max(0, draggingCount.value - 1)
964
+ resetSmoothScroll()
965
+ }
966
+
967
+ /**
968
+ * Intercepta cada chamada de scroll do SortableJS.
969
+ * - Scroll vertical (colunas): retorna 'continue' para manter o comportamento nativo.
970
+ * - Scroll horizontal (container do board): aplica smooth scroll interpolado via rAF.
971
+ *
972
+ * rAF (requestAnimationFrame) sincroniza com o refresh da tela (~60fps),
973
+ * tornando o scroll suave. Sem rAF, seria um "salto" abrupto.
974
+ */
975
+ function handleSortableScroll (offsetX, offsetY, evt, _touchEvt, scrollEl) {
976
+ // Obtém o container com scroll horizontal (`.qas-grabbable__container`).
977
+ const horizontalContainer = columnContainerElements.value[0]?.closest('.qas-grabbable__container')
978
+
979
+ // Acumula o offset no target e inicia a interpolação via rAF.
980
+ if (scrollEl === horizontalContainer && offsetX) {
981
+ if (smoothScrollEl !== scrollEl) {
982
+ smoothScrollTarget = scrollEl.scrollLeft
983
+ smoothScrollEl = scrollEl
984
+ }
985
+
986
+ const maxScroll = scrollEl.scrollWidth - scrollEl.clientWidth
987
+
988
+ smoothScrollTarget = Math.max(0, Math.min(smoothScrollTarget + offsetX, maxScroll))
989
+
990
+ // Se não há animação em andamento, inicia uma.
991
+ // rAF (requestAnimationFrame) agenda a função smoothScrollStep para o próximo frame (~16ms a 60fps).
992
+
993
+ /**
994
+ * Se não há animação em andamento, inicia uma. rAF (requestAnimationFrame) agenda a função smoothScrollStep
995
+ * para o próximo frame (~16ms a 60fps).
996
+ */
997
+ if (!smoothScrollRafId) {
998
+ smoothScrollRafId = requestAnimationFrame(smoothScrollStep)
999
+ }
1000
+
1001
+ return
1002
+ }
1003
+
1004
+ /**
1005
+ * SortableJS chama essa função com offsets de scroll.
1006
+ * Para scroll vertical: retorna 'continue' para deixar o comportamento nativo.
1007
+ * Para scroll horizontal: aplica smooth scroll customizado via rAF.
1008
+ */
1009
+ if (offsetY) {
1010
+ const clientY = evt?.clientY ?? evt?.touches?.[0]?.clientY
1011
+
1012
+ if (clientY !== undefined) {
1013
+ const rect = scrollEl.getBoundingClientRect()
1014
+ const distFromEdge = Math.min(clientY - rect.top, rect.bottom - clientY)
1015
+
1016
+ if (distFromEdge > VERTICAL_SCROLL_SENSITIVITY) return
1017
+ }
1018
+
1019
+ scrollEl.scrollTop += offsetY * (VERTICAL_SCROLL_SPEED / HORIZONTAL_SCROLL_SPEED)
1020
+ return
1021
+ }
1022
+
1023
+ return 'continue'
1024
+ }
1025
+
1026
+ /**
1027
+ * Loop de animação que interpola o scrollLeft em direção ao target
1028
+ * usando fator de lerp (move 20% da distância restante por frame).
1029
+ *
1030
+ * Funciona assim:
1031
+ * 1. Calcula a diferença entre a posição atual e a posição desejada (target).
1032
+ * 2. Se ainda há diferença significativa, move 20% da distância restante.
1033
+ * 3. Agenda o próximo frame via rAF para continuar a interpolação.
1034
+ * 4. Quando chega perto o suficiente (~0.5px), para a animação.
1035
+ *
1036
+ * Resultado: scroll suave e progressivo, não um "salto" abrupto.
1037
+ */
1038
+ function smoothScrollStep () {
1039
+ smoothScrollRafId = null
1040
+
1041
+ if (!smoothScrollEl) return
1042
+
1043
+ const current = smoothScrollEl.scrollLeft
1044
+ const diff = smoothScrollTarget - current
1045
+
1046
+ // Se chegou bem perto do target, para na posição exata.
1047
+ if (Math.abs(diff) < 0.5) {
1048
+ smoothScrollEl.scrollLeft = smoothScrollTarget
1049
+ return
1050
+ }
1051
+
1052
+ // Move 20% da distância restante (interpolação suave).
1053
+ smoothScrollEl.scrollLeft = current + diff * SMOOTH_SCROLL_LERP
1054
+
1055
+ // Agenda o próximo frame para continuar a animação (chamada recursiva via rAF).
1056
+ smoothScrollRafId = requestAnimationFrame(smoothScrollStep)
1057
+ }
1058
+
1059
+ function resetSmoothScroll () {
1060
+ // Cancela a animação agendada (rAF) para evitar que continue executando após o drag terminar.
1061
+ if (smoothScrollRafId) {
1062
+ cancelAnimationFrame(smoothScrollRafId)
1063
+ smoothScrollRafId = null
1064
+ }
1065
+
1066
+ smoothScrollEl = null
1067
+ smoothScrollTarget = 0
1068
+ }
1069
+
1070
+ /**
1071
+ * Reverte a manipulação de DOM feita pelo SortableJS.
1072
+ * Deve ser chamada ANTES de qualquer atualização reativa do model,
1073
+ * para que o Vue parta de um estado DOM consistente ao re-renderizar.
1074
+ */
1075
+ function revertDomDrag (event) {
1076
+ if (props.useDragAndDropX) {
1077
+ event.from.insertBefore(event.item, event.from.children[event.oldIndex] || null)
1078
+ }
1079
+
1080
+ if (props.useDragAndDropY) {
1081
+ const oldIndex = event.oldIndex
1082
+ const targetIndex = oldIndex === 0 ? oldIndex : oldIndex + 1
1083
+ const insertBeforeElement = targetIndex < event.from.children.length
1084
+ ? event.from.children[targetIndex]
1085
+ : null
1086
+
1087
+ event.from.insertBefore(event.item, insertBeforeElement)
1088
+ }
1089
+ }
1090
+
1091
+ /**
1092
+ * Adiciona um item na lista de resultados de uma coluna, criando uma nova
1093
+ * referência de array para garantir reatividade com markRaw.
1094
+ */
1095
+ function addItemToList ({ headerKey, item, index }) {
1096
+ const columnItemList = columnsResultsModel.value[headerKey]
1097
+ const updatedList = [...columnItemList]
1098
+ const insertIndex = Math.min(index, updatedList.length)
1099
+
1100
+ updatedList.splice(insertIndex, 0, item)
1101
+
1102
+ columnsResultsModel.value[headerKey] = updatedList
1103
+ columnsPagination.value[headerKey].count += 1
551
1104
  }
552
1105
 
553
1106
  function onDropCard (event) {
@@ -569,7 +1122,7 @@ function onDropCard (event) {
569
1122
  return
570
1123
  }
571
1124
 
572
- hasConfirmDialogProps.value
1125
+ props.useConfirmDialog
573
1126
  ? openConfirmDialog()
574
1127
  : confirmDrop(event)
575
1128
  }
@@ -586,34 +1139,11 @@ function closeConfirmDialog () {
586
1139
  * @param {event} event
587
1140
  */
588
1141
  function cancelDrop (event) {
589
- /**
590
- * Insere na posição antiga que pertencia (event.oldIndex) dentro do seu antigo pai (event.from)
591
- */
592
- if (props.useDragAndDropX) event.from.insertBefore(event.item, event.from.children[event.oldIndex])
593
-
594
- if (props.useDragAndDropY) {
595
- const oldIndex = event.oldIndex
1142
+ revertDomDrag(event)
596
1143
 
597
- /**
598
- * Se oldIndex for 0, o targetIndex deverá ser 0, pois isso indica que se o item é o primeiro da lista, ele não será movido para outra posição.
599
- *
600
- * Caso o oldIndex for diferente, devo incrementar 1 para adicionar, pois isso permite que o item seja inserido logo após sua posição original.
601
- */
602
- const targetIndex = oldIndex === 0 ? oldIndex : oldIndex + 1
1144
+ if (props.useConfirmDialog) closeConfirmDialog()
603
1145
 
604
- /**
605
- * Verifica se o índice alvo é válido, caso contrário, define como o final
606
- */
607
- const insertBeforeElement = targetIndex < event.from.children.length
608
- ? event.from.children[targetIndex]
609
- : null
610
-
611
- event.from.insertBefore(event.item, insertBeforeElement)
612
- }
613
-
614
- if (hasConfirmDialogProps.value) closeConfirmDialog()
615
-
616
- toggleIsDragging()
1146
+ stopDragging()
617
1147
  }
618
1148
 
619
1149
  function confirmDrop (event) {
@@ -622,7 +1152,47 @@ function confirmDrop (event) {
622
1152
  const { headerKey: newHeaderKey } = to.dataset
623
1153
  const { headerKey: oldHeaderKey } = from.dataset
624
1154
 
625
- updatePosition({ newHeaderKey, oldHeaderKey, itemId, event })
1155
+ /**
1156
+ * 1. Reverte o DOM imediatamente para que o Vue parta de um estado consistente.
1157
+ * O SortableJS já moveu o elemento fisicamente, mas precisamos devolvê-lo
1158
+ * antes de atualizar o model reativo.
1159
+ */
1160
+ revertDomDrag(event)
1161
+
1162
+ /**
1163
+ * 2. Atualização otimista: move o item no model ANTES da request de API.
1164
+ * Isso garante que a UI esteja sempre sincronizada com o model,
1165
+ * eliminando race conditions entre múltiplos drops rápidos.
1166
+ */
1167
+ const item = getColumnItemById(itemId)
1168
+
1169
+ removeItemFromList({ headerKey: oldHeaderKey, itemId })
1170
+ addItemToList({ headerKey: newHeaderKey, item, index: event.newIndex })
1171
+
1172
+ stopDragging()
1173
+
1174
+ /**
1175
+ * 3. Marca o item como "em atualização" imediatamente, antes mesmo
1176
+ * da request de API iniciar (pode estar na fila).
1177
+ */
1178
+ updatingPositionItemKeys.value = new Set([...updatingPositionItemKeys.value, itemId])
1179
+
1180
+ /**
1181
+ * 4. Enfileira a chamada de API para confirmação no servidor.
1182
+ * Se falhar, o model é revertido automaticamente.
1183
+ */
1184
+ enqueueUpdatePosition({ newHeaderKey, oldHeaderKey, itemId, event, optimisticItem: item })
1185
+ }
1186
+
1187
+ /**
1188
+ * Enfileira a chamada de updatePosition para execução sequencial.
1189
+ * Garante que a próxima chamada só inicia após a anterior ter finalizado,
1190
+ * evitando race conditions no estado compartilhado.
1191
+ */
1192
+ function enqueueUpdatePosition (params) {
1193
+ updatePositionQueue = updatePositionQueue.then(
1194
+ () => updatePosition(params)
1195
+ )
626
1196
  }
627
1197
 
628
1198
  /**
@@ -644,9 +1214,13 @@ function removeItemFromList ({ headerKey, itemId }) {
644
1214
  const itemIndex = columnItemList.findIndex(itemContent => itemContent[props.itemIdKey] === itemId)
645
1215
 
646
1216
  /**
647
- * Remove o item da listagem com base no index, sendo que preciso subtrair 1 para pegar o index correto
1217
+ * Cria uma nova referência de array para garantir que o Vue detecte a mudança de estado,
1218
+ * mesmo quando o array é marcado com markRaw (que não rastreia mutações in-place).
648
1219
  */
649
- columnItemList.splice(itemIndex, 1)
1220
+ const updatedList = [...columnItemList]
1221
+ updatedList.splice(itemIndex, 1)
1222
+
1223
+ columnsResultsModel.value[headerKey] = updatedList
650
1224
 
651
1225
  /**
652
1226
  * Remove o item do count da coluna para não mostrar o botão de "Ver mais¨.
@@ -654,6 +1228,32 @@ function removeItemFromList ({ headerKey, itemId }) {
654
1228
  columnsPagination.value[headerKey].count -= 1
655
1229
  }
656
1230
 
1231
+ /**
1232
+ * Substitui um item na lista de uma coluna pelos dados atualizados do servidor.
1233
+ *
1234
+ * 1. Localiza a lista de itens da coluna
1235
+ * 2. Encontra o índice do item usando o itemIdKey como identificador
1236
+ * 3. Se encontrado, altera o item localmente (sem criar nova referência de array)
1237
+ *
1238
+ * @param {Object} params
1239
+ * @param {string} params.headerKey - ID da coluna
1240
+ * @param {string|number} params.itemId - ID do item a atualizar (deve corresponder a props.itemIdKey)
1241
+ * @param {Object} params.updatedItem - Novo objeto do item com dados atualizados
1242
+ *
1243
+ */
1244
+ function updateItemInList ({ headerKey, itemId, updatedItem }) {
1245
+ const columnItemList = columnsResultsModel.value[headerKey]
1246
+
1247
+ // Encontra o índice do item na lista usando o identificador (itemIdKey)
1248
+ const itemIndex = columnItemList.findIndex(itemContent => itemContent[props.itemIdKey] === itemId)
1249
+
1250
+ // Se não encontrar (itemIndex === -1), retorna sem fazer nada
1251
+ if (!~itemIndex) return
1252
+
1253
+ // Substitui o item antigo pelo item atualizado na mesma posição
1254
+ columnItemList.splice(itemIndex, 1, updatedItem)
1255
+ }
1256
+
657
1257
  /**
658
1258
  * Método que realiza a request de update
659
1259
  *
@@ -664,15 +1264,23 @@ function removeItemFromList ({ headerKey, itemId }) {
664
1264
  * event: event
665
1265
  * }}
666
1266
  */
667
- async function updatePosition ({ newHeaderKey, oldHeaderKey, itemId, event }) {
1267
+ async function updatePosition ({ newHeaderKey, oldHeaderKey, itemId, event, optimisticItem }) {
668
1268
  const params = {
669
1269
  [props.columnIdKey]: newHeaderKey,
670
1270
  ...(props.useDragAndDropY && { newIndex: event.newIndex }),
671
1271
  ...props.updatePositionParams
672
1272
  }
673
1273
 
1274
+ const isFnUpdatePositionUrl = typeof props.updatePositionUrl === 'function'
1275
+
1276
+ hideSkeleton.value = true
1277
+
1278
+ const url = isFnUpdatePositionUrl
1279
+ ? props.updatePositionUrl({ newHeaderKey, oldHeaderKey, itemId })
1280
+ : `${props.updatePositionUrl}/${itemId}/update-position`
1281
+
674
1282
  const { data, error } = await promiseHandler(
675
- axios.patch(`${props.updatePositionUrl}/${itemId}/update-position`, params),
1283
+ axios.patch(url, params),
676
1284
  {
677
1285
  errorMessage: 'Ocorreu um erro ao atualizar a posição de seu item.',
678
1286
  useLoading: false,
@@ -684,41 +1292,127 @@ async function updatePosition ({ newHeaderKey, oldHeaderKey, itemId, event }) {
684
1292
  }
685
1293
  )
686
1294
 
1295
+ const updatedKeys = new Set(updatingPositionItemKeys.value)
1296
+ updatedKeys.delete(itemId)
1297
+ updatingPositionItemKeys.value = updatedKeys
1298
+
687
1299
  if (error) {
688
- onCancelDrop.value()
1300
+ /**
1301
+ * Reverte a atualização otimista: move o item de volta da coluna de destino
1302
+ * para a coluna de origem no model.
1303
+ */
1304
+ removeItemFromList({ headerKey: newHeaderKey, itemId })
1305
+ addItemToList({ headerKey: oldHeaderKey, item: optimisticItem, index: event.oldIndex })
689
1306
 
690
1307
  emit('update-error', error)
691
1308
 
692
1309
  return
693
1310
  }
694
1311
 
695
- removeItemFromList({ headerKey: oldHeaderKey, itemId })
696
-
697
- setItemList({ headerKey: newHeaderKey, data: data.data, index: event.newIndex })
1312
+ /**
1313
+ * Atualiza o item na coluna de destino com os dados retornados pelo servidor,
1314
+ * que podem conter campos atualizados (timestamps, status, etc).
1315
+ */
1316
+ if (data.data?.result) {
1317
+ updateItemInList({ headerKey: newHeaderKey, itemId, updatedItem: data.data.result })
1318
+ }
698
1319
 
699
1320
  isUpdatingPosition.value = true
700
1321
 
701
- toggleIsDragging()
702
-
703
1322
  closeConfirmDialog()
704
1323
 
705
1324
  emit('update-success', data.data)
706
1325
  }
707
1326
 
708
- function setItemList ({ headerKey, data, index }) {
709
- /**
710
- * Coluna referente ao model de resultado
711
- */
712
- const columnItemList = columnsResultsModel.value[headerKey]
1327
+ function destroySortable () {
1328
+ sortableInstances.value.forEach(sortable => sortable.destroy())
1329
+ }
1330
+
1331
+ /**
1332
+ * Cancela a request em andamento de uma coluna específica.
1333
+ */
1334
+ function abortColumnRequest (headerKey) {
1335
+ columnAbortControllers[headerKey]?.abort()
1336
+ delete columnAbortControllers[headerKey]
1337
+ }
713
1338
 
1339
+ /**
1340
+ * Cancela todas as requests de colunas em andamento.
1341
+ * Chamado em onBeforeUnmount e no início de fetchColumnsValues.
1342
+ */
1343
+ function abortAllColumnRequests () {
714
1344
  /**
715
- * Adiciona o item na posição do event escolhido.
1345
+ * Invalida a sessão atual de fetchColumns para que, após as requests visíveis
1346
+ * serem canceladas, a verificação de sessão impeça o início das requests das
1347
+ * colunas ocultas (que ainda não haviam sido disparadas).
1348
+ * Sem isso, fetchColumns passaria na verificação e iniciaria as hidden columns
1349
+ * mesmo após o componente ter iniciado o processo de unmount.
716
1350
  */
717
- columnItemList.splice(index, 0, data.result)
1351
+ currentFetchColumnsSession++
1352
+
1353
+ Object.keys(columnAbortControllers).forEach(key => {
1354
+ columnAbortControllers[key]?.abort()
1355
+ })
1356
+
1357
+ columnAbortControllers = {}
718
1358
  }
719
1359
 
720
- function destroySortable () {
721
- sortableInstances.value.forEach(sortable => sortable.destroy())
1360
+ /**
1361
+ * Verifica se o erro é resultado de um cancelamento intencional de request
1362
+ * (AbortController.abort() via axios ou fetch).
1363
+ */
1364
+ function isCancelledError (error) {
1365
+ return error?.code === 'ERR_CANCELED' || error?.name === 'CanceledError' || error?.name === 'AbortError'
1366
+ }
1367
+
1368
+ /**
1369
+ * Transfere um item de uma coluna para outra localmente, sem realizar nenhuma requisição.
1370
+ * O item é sempre inserido como primeiro elemento na coluna de destino.
1371
+ * Após a transferência, o SortableJS continua funcionando normalmente sobre os elementos
1372
+ * do DOM atualizados pelo Vue, sem necessidade de reinicialização.
1373
+ *
1374
+ * @param {Object} params
1375
+ * @param {string|number} params.itemId - ID do item a ser movido (valor correspondente à prop `itemIdKey`).
1376
+ * @param {string|number} params.fromColumnId - ID da coluna de origem (valor correspondente à prop `columnIdKey`).
1377
+ * @param {string|number} params.toColumnId - ID da coluna de destino (valor correspondente à prop `columnIdKey`).
1378
+ * @param {Object} [params.updatedItem] - Dados atualizados do item. Quando informado, sobrescreve o item original na coluna de destino.
1379
+ * @returns {void}
1380
+ */
1381
+ function transferItemToColumn ({ itemId, fromColumnId, toColumnId, updatedItem }) {
1382
+ const sourceColumn = columnsResultsModel.value[fromColumnId]
1383
+ const itemIndex = sourceColumn?.findIndex(item => item[props.itemIdKey] === itemId)
1384
+
1385
+ if (!sourceColumn || itemIndex === -1) return
1386
+
1387
+ const item = updatedItem ?? sourceColumn[itemIndex]
1388
+
1389
+ removeItemFromList({ headerKey: fromColumnId, itemId })
1390
+
1391
+ columnsResultsModel.value[toColumnId].splice(0, 0, item)
1392
+ columnsPagination.value[toColumnId].count += 1
1393
+ }
1394
+
1395
+ function hasSkeletonByHeader (header) {
1396
+ const headerKey = getKeyByHeader(header)
1397
+
1398
+ return (props.skeleton || columnsLoading.value[headerKey]) && !hideSkeleton.value
1399
+ }
1400
+
1401
+ function getColumnClasses (header) {
1402
+ const headerKey = getKeyByHeader(header)
1403
+
1404
+ return {
1405
+ 'qas-board-generator__column-error': columnsWithError.value[headerKey]
1406
+ }
1407
+ }
1408
+
1409
+ function getCardsContainerProps (header) {
1410
+ const headerKey = getKeyByHeader(header)
1411
+
1412
+ return {
1413
+ 'data-header-key': headerKey,
1414
+ 'data-has-error': columnsWithError.value[headerKey]
1415
+ }
722
1416
  }
723
1417
  </script>
724
1418
 
@@ -747,5 +1441,32 @@ function destroySortable () {
747
1441
  &__column-items {
748
1442
  height: calc(100% - 60px);
749
1443
  }
1444
+
1445
+ &__column-error {
1446
+ border: 1px solid $negative;
1447
+ }
1448
+
1449
+ &__ghost {
1450
+ border: 1px solid $grey-4 !important;
1451
+ overflow: hidden;
1452
+ border-radius: $generic-border-radius;
1453
+ position: relative;
1454
+ margin-bottom: var(--qas-spacing-sm);
1455
+
1456
+ &::after {
1457
+ align-items: center;
1458
+ background-color: $grey-3;
1459
+ border-radius: inherit;
1460
+ color: $grey-8;
1461
+ content: 'place_item';
1462
+ display: flex;
1463
+ font-family: 'Material Symbols Rounded';
1464
+ font-size: 40px;
1465
+ inset: 0;
1466
+ justify-content: center;
1467
+ position: absolute;
1468
+ z-index: 999;
1469
+ }
1470
+ }
750
1471
  }
751
1472
  </style>