TicketList.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { useWindowSize } from '@vueuse/core'
  4. import { isEqual } from 'lodash-es'
  5. import { storeToRefs } from 'pinia'
  6. import {
  7. computed,
  8. onActivated,
  9. onDeactivated,
  10. ref,
  11. type Ref,
  12. toRef,
  13. useTemplateRef,
  14. watch,
  15. } from 'vue'
  16. import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
  17. import { useSorting } from '#shared/composables/list/useSorting.ts'
  18. import { usePagination } from '#shared/composables/usePagination.ts'
  19. import { useQueryPolling } from '#shared/composables/useQueryPolling.ts'
  20. import type { TicketById } from '#shared/entities/ticket/types.ts'
  21. import {
  22. EnumObjectManagerObjects,
  23. EnumOrderDirection,
  24. type TicketsCachedByOverviewQueryVariables,
  25. } from '#shared/graphql/types.ts'
  26. import { getIdFromGraphQLId } from '#shared/graphql/utils.ts'
  27. import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
  28. import { useApplicationStore } from '#shared/stores/application.ts'
  29. import { useSessionStore } from '#shared/stores/session.ts'
  30. import type { ObjectWithId } from '#shared/types/utils.ts'
  31. import hasPermission from '#shared/utils/hasPermission.ts'
  32. import { edgesToArray } from '#shared/utils/helpers.ts'
  33. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  34. import CommonAdvancedTable from '#desktop/components/CommonTable/CommonAdvancedTable.vue'
  35. import CommonTableSkeleton from '#desktop/components/CommonTable/Skeleton/CommonTableSkeleton.vue'
  36. import CommonTicketPriorityIndicatorIcon from '#desktop/components/CommonTicketPriorityIndicator/CommonTicketPriorityIndicatorIcon.vue'
  37. import CommonTicketStateIndicatorIcon from '#desktop/components/CommonTicketStateIndicator/CommonTicketStateIndicatorIcon.vue'
  38. import { useElementScroll } from '#desktop/composables/useElementScroll.ts'
  39. import { useScrollPosition } from '#desktop/composables/useScrollPosition.ts'
  40. import { useTicketsCachedByOverviewCache } from '#desktop/entities/ticket/composables/useTicketsCachedByOverviewCache.ts'
  41. import { useTicketsCachedByOverviewQuery } from '#desktop/entities/ticket/graphql/queries/ticketsCachedByOverview.api.ts'
  42. import { useTicketOverviewsStore } from '#desktop/entities/ticket/stores/ticketOverviews.ts'
  43. import { useLifetimeCustomerTicketsCount } from '#desktop/entities/user/current/composables/useLifetimeCustomerTicketsCount.ts'
  44. import TicketOverviewsEmptyText from '#desktop/pages/ticket-overviews/components/TicketOverviewsEmptyText.vue'
  45. interface Props {
  46. overviewId: string
  47. orderBy: string
  48. orderDirection: EnumOrderDirection
  49. headers: string[]
  50. overviewName: string
  51. groupBy?: string
  52. overviewCount?: number
  53. }
  54. const props = defineProps<Props>()
  55. const router = useRouter()
  56. const { readTicketsByOverviewCache, forceTicketsByOverviewCacheOnlyFirstPage } =
  57. useTicketsCachedByOverviewCache()
  58. const { queryPollingConfig } = storeToRefs(useTicketOverviewsStore())
  59. let lastFirstPageCollectionSignature: string
  60. const foreground = ref(true)
  61. const pollingInterval = computed(
  62. () =>
  63. (foreground.value
  64. ? queryPollingConfig.value.foreground.interval_sec
  65. : queryPollingConfig.value.background.interval_sec) * 1000,
  66. )
  67. const ticketsQueryVariables = computed<TicketsCachedByOverviewQueryVariables>(
  68. (currentVariables) => {
  69. const newVariables: TicketsCachedByOverviewQueryVariables = {
  70. pageSize: queryPollingConfig.value.page_size,
  71. overviewId: props.overviewId,
  72. orderBy: props.orderBy,
  73. orderDirection: props.orderDirection,
  74. cacheTtl: queryPollingConfig.value.foreground.cache_ttl_sec,
  75. renewCache: currentVariables?.overviewId !== props.overviewId,
  76. }
  77. const cachedTickets = readTicketsByOverviewCache(newVariables)
  78. newVariables.knownCollectionSignature =
  79. cachedTickets?.ticketsCachedByOverview?.collectionSignature
  80. if (currentVariables && isEqual(currentVariables, newVariables)) {
  81. return currentVariables
  82. }
  83. return newVariables
  84. },
  85. )
  86. const ticketsQuery = new QueryHandler(
  87. useTicketsCachedByOverviewQuery(ticketsQueryVariables, {
  88. fetchPolicy: 'cache-and-network',
  89. nextFetchPolicy: 'cache-and-network',
  90. context: {
  91. batch: {
  92. active: false,
  93. },
  94. },
  95. }),
  96. )
  97. const ticketsResult = ticketsQuery.result()
  98. const loading = ticketsQuery.loading()
  99. const currentCollectionSignature = computed(() => {
  100. return ticketsResult.value?.ticketsCachedByOverview?.collectionSignature
  101. })
  102. const isLoadingTickets = computed(() => {
  103. if (ticketsResult.value !== undefined) return false
  104. return loading.value
  105. })
  106. const tickets = computed(() =>
  107. edgesToArray(ticketsResult.value?.ticketsCachedByOverview),
  108. )
  109. onActivated(() => {
  110. if (foreground.value) return
  111. ticketsQuery.refetch({
  112. renewCache: true,
  113. })
  114. foreground.value = true
  115. })
  116. onDeactivated(() => {
  117. foreground.value = false
  118. })
  119. const pagination = usePagination(
  120. ticketsQuery,
  121. 'ticketsCachedByOverview',
  122. queryPollingConfig.value.page_size,
  123. () => ({
  124. knownCollectionSignature: null,
  125. renewCache: false,
  126. }),
  127. )
  128. const scrollContainerElement = useTemplateRef('scroll-container')
  129. const {
  130. sort,
  131. orderBy: localOrderBy,
  132. orderDirection: localOrderDirection,
  133. isSorting,
  134. } = useSorting(
  135. ticketsQuery,
  136. toRef(props, 'orderBy'),
  137. toRef(props, 'orderDirection'),
  138. scrollContainerElement,
  139. )
  140. const resort = (column: string, direction: EnumOrderDirection) => {
  141. forceTicketsByOverviewCacheOnlyFirstPage(
  142. {
  143. ...ticketsQueryVariables.value,
  144. orderBy: localOrderBy.value,
  145. orderDirection: localOrderDirection.value,
  146. },
  147. lastFirstPageCollectionSignature,
  148. queryPollingConfig.value.page_size,
  149. )
  150. const cachedTickets = readTicketsByOverviewCache({
  151. ...ticketsQueryVariables.value,
  152. orderBy: column,
  153. orderDirection: direction,
  154. })
  155. sort(column, direction, {
  156. knownCollectionSignature:
  157. cachedTickets?.ticketsCachedByOverview?.collectionSignature,
  158. renewCache: false,
  159. })
  160. }
  161. const { startPolling, stopPolling } = useQueryPolling(
  162. ticketsQuery,
  163. pollingInterval,
  164. () => ({
  165. knownCollectionSignature: currentCollectionSignature.value,
  166. renewCache: false,
  167. pageSize: queryPollingConfig.value.page_size * pagination.currentPage,
  168. }),
  169. () => ({
  170. enabled: queryPollingConfig.value.enabled && !isSorting.value,
  171. }),
  172. )
  173. ticketsQuery.watchOnceOnResult((result) => {
  174. if (!queryPollingConfig.value.enabled) return
  175. lastFirstPageCollectionSignature =
  176. result.ticketsCachedByOverview.collectionSignature
  177. startPolling()
  178. })
  179. onBeforeRouteLeave(() => {
  180. forceTicketsByOverviewCacheOnlyFirstPage(
  181. ticketsQueryVariables.value,
  182. lastFirstPageCollectionSignature,
  183. queryPollingConfig.value.page_size,
  184. )
  185. })
  186. watch(
  187. () => props.overviewId,
  188. () => {
  189. ticketsQuery.watchOnceOnResult((result) => {
  190. if (!queryPollingConfig.value.enabled) return
  191. lastFirstPageCollectionSignature =
  192. result.ticketsCachedByOverview.collectionSignature
  193. startPolling()
  194. })
  195. },
  196. )
  197. onBeforeRouteUpdate(() => {
  198. forceTicketsByOverviewCacheOnlyFirstPage(
  199. ticketsQueryVariables.value,
  200. lastFirstPageCollectionSignature,
  201. queryPollingConfig.value.page_size,
  202. )
  203. if (!queryPollingConfig.value.enabled) return
  204. stopPolling()
  205. })
  206. ticketsQuery.onResult((result) => {
  207. if (isSorting.value && !result.loading) {
  208. // If sorting comes from the cache, we immediately dispose the loading state
  209. isSorting.value = false
  210. }
  211. })
  212. const totalCount = computed(
  213. () => ticketsResult.value?.ticketsCachedByOverview.totalCount || 0,
  214. )
  215. const loadMore = async () => pagination.fetchNextPage()
  216. const { config } = storeToRefs(useApplicationStore())
  217. const { user, userId } = storeToRefs(useSessionStore())
  218. const storageKeyId = computed(
  219. () => `${userId.value}-table-headers-${props.overviewId}`,
  220. )
  221. // Scrolling position is preserved when user visits another page and returns to overview page
  222. const { scrollPosition, restoreScrollPosition } = useScrollPosition(
  223. scrollContainerElement,
  224. )
  225. const resetScrollPosition = () => {
  226. scrollPosition.value = 0
  227. restoreScrollPosition()
  228. }
  229. // Reset scroll-position back to the start, when user navigates between overviews
  230. onBeforeRouteUpdate(resetScrollPosition)
  231. const { reachedTop } = useElementScroll(
  232. scrollContainerElement as Ref<HTMLDivElement>,
  233. )
  234. const { hasAnyTicket } = useLifetimeCustomerTicketsCount()
  235. const isCustomerAndCanCreateTickets = computed(
  236. () =>
  237. hasPermission('ticket.customer', user.value?.permissions?.names ?? []) &&
  238. config.value.customer_ticket_create,
  239. )
  240. const goToTicket = (ticket: ObjectWithId) =>
  241. router.push(`/tickets/${getIdFromGraphQLId(ticket.id)}`)
  242. const goToTicketLinkColumn = {
  243. internal: true,
  244. getLink: (item: ObjectWithId) => `/tickets/${getIdFromGraphQLId(item.id)}`,
  245. }
  246. const localHeaders = computed(() => {
  247. const extendedHeaders = [...props.headers]
  248. extendedHeaders.unshift('stateIcon')
  249. if (config.value.ui_ticket_priority_icons) {
  250. extendedHeaders.unshift('priorityIcon')
  251. }
  252. return extendedHeaders
  253. })
  254. const maxItems = computed(() => config.value.ui_ticket_overview_ticket_limit)
  255. const { height: screenHeight } = useWindowSize()
  256. const visibleOverviewCount = computed(() => {
  257. const maxVisibleRowCount = Math.ceil(screenHeight.value / 40)
  258. if (props.overviewCount && props.overviewCount > maxVisibleRowCount)
  259. return maxVisibleRowCount
  260. return props.overviewCount
  261. })
  262. </script>
  263. <template>
  264. <div
  265. ref="scroll-container"
  266. class="overflow-y-auto focus-visible:outline-none"
  267. >
  268. <div v-if="isLoadingTickets && !pagination.loadingNewPage">
  269. <CommonTableSkeleton
  270. data-test-id="table-skeleton"
  271. :rows="visibleOverviewCount"
  272. />
  273. </div>
  274. <template v-else-if="!isLoadingTickets && !tickets.length">
  275. <TicketOverviewsEmptyText
  276. v-if="isCustomerAndCanCreateTickets && !hasAnyTicket"
  277. class="space-y-2.5"
  278. :title="$t('Welcome!')"
  279. >
  280. <CommonLabel class="block" tag="p">{{
  281. $t('You have not created a ticket yet.')
  282. }}</CommonLabel>
  283. <CommonLabel class="block" tag="p">{{
  284. $t('The way to communicate with us is this thing called "ticket".')
  285. }}</CommonLabel>
  286. <CommonLabel class="block" tag="p">{{
  287. $t('Please click on the button below to create your first one.')
  288. }}</CommonLabel>
  289. <CommonButton
  290. size="large"
  291. class="mx-auto !mt-8"
  292. variant="primary"
  293. @click="router.push({ name: 'TicketCreate' })"
  294. >{{ $t('Create your first ticket') }}
  295. </CommonButton>
  296. </TicketOverviewsEmptyText>
  297. <TicketOverviewsEmptyText
  298. v-else
  299. :title="$t('Empty Overview')"
  300. :text="$t('No tickets in this state.')"
  301. with-illustration
  302. />
  303. </template>
  304. <div v-else-if="tickets.length">
  305. <CommonAdvancedTable
  306. v-model:order-by="localOrderBy"
  307. v-model:order-direction="localOrderDirection"
  308. :caption="$t('Overview: %s', overviewName)"
  309. :headers="localHeaders"
  310. :object="EnumObjectManagerObjects.Ticket"
  311. :group-by="groupBy"
  312. :reached-scroll-top="reachedTop"
  313. :table-id="overviewId"
  314. :scroll-container="scrollContainerElement"
  315. :attributes="[
  316. {
  317. name: 'priorityIcon',
  318. label: __('Priority Icon'),
  319. headerPreferences: {
  320. noResize: true,
  321. hideLabel: true,
  322. displayWidth: 25,
  323. },
  324. columnPreferences: {},
  325. dataType: 'icon',
  326. },
  327. {
  328. name: 'stateIcon',
  329. label: __('State Icon'),
  330. headerPreferences: {
  331. noResize: true,
  332. hideLabel: true,
  333. displayWidth: 30,
  334. },
  335. columnPreferences: {},
  336. dataType: 'icon',
  337. },
  338. ]"
  339. :attribute-extensions="{
  340. title: {
  341. columnPreferences: {
  342. link: goToTicketLinkColumn,
  343. },
  344. },
  345. number: {
  346. label: config.ticket_hook,
  347. columnPreferences: {
  348. link: goToTicketLinkColumn,
  349. },
  350. },
  351. }"
  352. :items="tickets"
  353. :total-items="totalCount"
  354. :storage-key-id="storageKeyId"
  355. :max-items="maxItems"
  356. :is-sorting="isSorting"
  357. :is-loading="loading"
  358. @load-more="loadMore"
  359. @click-row="goToTicket"
  360. @sort="resort"
  361. >
  362. <template #column-cell-priorityIcon="{ item, isRowSelected }">
  363. <CommonTicketPriorityIndicatorIcon
  364. :ui-color="(item as TicketById).priority?.uiColor"
  365. with-text-color
  366. class="shrink-0 group-hover:text-black group-focus-visible:text-white group-active:text-white group-hover:dark:text-white group-active:dark:text-white"
  367. :class="{
  368. 'ltr:text-black rtl:text-black dark:text-white': isRowSelected,
  369. }"
  370. />
  371. </template>
  372. <template #column-cell-stateIcon="{ item, isRowSelected }">
  373. <CommonTicketStateIndicatorIcon
  374. class="shrink-0 group-hover:text-black group-focus-visible:text-white group-active:text-white group-hover:dark:text-white group-active:dark:text-white"
  375. :class="{
  376. 'ltr:text-black rtl:text-black dark:text-white': isRowSelected,
  377. }"
  378. :color-code="(item as TicketById).stateColorCode"
  379. :label="(item as TicketById).state.name"
  380. :aria-labelledby="(item as TicketById).id"
  381. icon-size="tiny"
  382. />
  383. </template>
  384. </CommonAdvancedTable>
  385. </div>
  386. </div>
  387. </template>