TicketList.vue 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { storeToRefs } from 'pinia'
  4. import { computed, type Ref, toRef, useTemplateRef } from 'vue'
  5. import { onBeforeRouteUpdate, useRouter } from 'vue-router'
  6. import { useSorting } from '#shared/composables/list/useSorting.ts'
  7. import { useDebouncedLoading } from '#shared/composables/useDebouncedLoading.ts'
  8. import { usePagination } from '#shared/composables/usePagination.ts'
  9. import type { TicketById } from '#shared/entities/ticket/types.ts'
  10. import {
  11. EnumObjectManagerObjects,
  12. EnumOrderDirection,
  13. } from '#shared/graphql/types.ts'
  14. import { getIdFromGraphQLId } from '#shared/graphql/utils.ts'
  15. import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
  16. import { useApplicationStore } from '#shared/stores/application.ts'
  17. import { useSessionStore } from '#shared/stores/session.ts'
  18. import type { ObjectWithId } from '#shared/types/utils.ts'
  19. import hasPermission from '#shared/utils/hasPermission.ts'
  20. import { edgesToArray } from '#shared/utils/helpers.ts'
  21. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  22. import CommonAdvancedTable from '#desktop/components/CommonTable/CommonAdvancedTable.vue'
  23. import CommonTableSkeleton from '#desktop/components/CommonTable/Skeleton/CommonTableSkeleton.vue'
  24. import CommonTicketPriorityIndicatorIcon from '#desktop/components/CommonTicketPriorityIndicator/CommonTicketPriorityIndicatorIcon.vue'
  25. import CommonTicketStateIndicatorIcon from '#desktop/components/CommonTicketStateIndicator/CommonTicketStateIndicatorIcon.vue'
  26. import { useElementScroll } from '#desktop/composables/useElementScroll.ts'
  27. import { useScrollPosition } from '#desktop/composables/useScrollPosition.ts'
  28. import { useTicketsByOverviewQuery } from '#desktop/entities/ticket/graphql/queries/ticketsByOverview.api.ts'
  29. import { useLifetimeCustomerTicketsCount } from '#desktop/entities/user/current/composables/useLifetimeCustomerTicketsCount.ts'
  30. import TicketOverviewsEmptyText from '#desktop/pages/ticket-overviews/components/TicketOverviewsEmptyText.vue'
  31. interface Props {
  32. overviewId: string
  33. orderBy: string
  34. orderDirection: EnumOrderDirection
  35. headers: string[]
  36. groupBy?: string
  37. }
  38. const props = defineProps<Props>()
  39. const router = useRouter()
  40. const TICKETS_COUNT = 30
  41. const ticketsQueryVariables = computed(() => ({
  42. pageSize: TICKETS_COUNT,
  43. overviewId: props.overviewId,
  44. orderBy: props.orderBy,
  45. orderDirection: props.orderDirection,
  46. showUpdatedBy: true,
  47. showPriority: true,
  48. }))
  49. const ticketsQuery = new QueryHandler(
  50. useTicketsByOverviewQuery(ticketsQueryVariables, {
  51. fetchPolicy: 'cache-and-network',
  52. nextFetchPolicy: 'cache-and-network',
  53. }),
  54. )
  55. const ticketsResult = ticketsQuery.result()
  56. const loading = ticketsQuery.loading()
  57. const isLoadingTickets = computed(() => {
  58. if (ticketsResult.value !== undefined) return false
  59. return loading.value
  60. })
  61. const { debouncedLoading } = useDebouncedLoading({
  62. isLoading: isLoadingTickets,
  63. })
  64. const tickets = computed(() =>
  65. edgesToArray(ticketsResult.value?.ticketsByOverview),
  66. )
  67. const totalCount = computed(
  68. () => ticketsResult.value?.ticketsByOverview.totalCount || 0,
  69. )
  70. const {
  71. sort,
  72. orderBy: localOrderBy,
  73. orderDirection: localOrderDirection,
  74. } = useSorting(
  75. ticketsQuery,
  76. toRef(props, 'orderBy'),
  77. toRef(props, 'orderDirection'),
  78. )
  79. const pagination = usePagination(
  80. ticketsQuery,
  81. 'ticketsByOverview',
  82. TICKETS_COUNT,
  83. )
  84. const loadMore = async () => pagination.fetchNextPage()
  85. const { config } = storeToRefs(useApplicationStore())
  86. const { user, userId } = storeToRefs(useSessionStore())
  87. const storageKeyId = computed(
  88. () => `${userId.value}-table-headers-${props.overviewId}`,
  89. )
  90. const scrollContainerElement = useTemplateRef('scroll-container')
  91. // Scrolling position is preserved when user visits another page and returns to overview page
  92. const { scrollPosition, restoreScrollPosition } = useScrollPosition(
  93. scrollContainerElement,
  94. )
  95. const resetScrollPosition = () => {
  96. scrollPosition.value = 0
  97. restoreScrollPosition()
  98. }
  99. // Reset scroll-position back to the start, when user navigates between overviews
  100. onBeforeRouteUpdate(resetScrollPosition)
  101. const { reachedTop } = useElementScroll(
  102. scrollContainerElement as Ref<HTMLDivElement>,
  103. )
  104. const { hasAnyTicket } = useLifetimeCustomerTicketsCount()
  105. const isCustomerAndCanCreateTickets = computed(
  106. () =>
  107. hasPermission('ticket.customer', user.value?.permissions?.names ?? []) &&
  108. config.value.customer_ticket_create,
  109. )
  110. const goToTicket = (ticket: ObjectWithId) =>
  111. router.push(`/tickets/${getIdFromGraphQLId(ticket.id)}`)
  112. const goToTicketLinkColumn = {
  113. internal: true,
  114. getLink: (item: ObjectWithId) => `/tickets/${getIdFromGraphQLId(item.id)}`,
  115. }
  116. const localHeaders = computed(() => {
  117. const extendedHeaders = [...props.headers]
  118. extendedHeaders.unshift('stateIcon')
  119. if (config.value.ui_ticket_priority_icons) {
  120. extendedHeaders.unshift('priorityIcon')
  121. }
  122. return extendedHeaders
  123. })
  124. const maxItems = computed(() => config.value.ui_ticket_overview_ticket_limit)
  125. </script>
  126. <template>
  127. <div
  128. ref="scroll-container"
  129. class="overflow-y-auto focus-visible:outline-none"
  130. >
  131. <div
  132. v-if="debouncedLoading && !pagination.loadingNewPage"
  133. class="p-4 text-center"
  134. >
  135. <CommonTableSkeleton data-test-id="table-skeleton" />
  136. </div>
  137. <template v-else-if="!isLoadingTickets && !tickets.length">
  138. <TicketOverviewsEmptyText
  139. v-if="isCustomerAndCanCreateTickets && !hasAnyTicket"
  140. class="space-y-2.5"
  141. :title="$t('Welcome!')"
  142. >
  143. <CommonLabel class="block" tag="p">{{
  144. $t('You have not created a ticket yet.')
  145. }}</CommonLabel>
  146. <CommonLabel class="block" tag="p">{{
  147. $t('The way to communicate with us is this thing called "ticket".')
  148. }}</CommonLabel>
  149. <CommonLabel class="block" tag="p">{{
  150. $t('Please click on the button below to create your first one.')
  151. }}</CommonLabel>
  152. <CommonButton
  153. size="large"
  154. class="mx-auto !mt-8"
  155. variant="primary"
  156. @click="router.push({ name: 'TicketCreate' })"
  157. >{{ $t('Create your first ticket') }}
  158. </CommonButton>
  159. </TicketOverviewsEmptyText>
  160. <TicketOverviewsEmptyText
  161. v-else
  162. :title="$t('Empty Overview')"
  163. :text="$t('No tickets in this state.')"
  164. />
  165. </template>
  166. <div v-else-if="tickets.length">
  167. <CommonAdvancedTable
  168. v-model:order-by="localOrderBy"
  169. v-model:order-direction="localOrderDirection"
  170. :caption="$t('Ticket Overview')"
  171. :headers="localHeaders"
  172. :object="EnumObjectManagerObjects.Ticket"
  173. :group-by="groupBy"
  174. :reached-scroll-top="reachedTop"
  175. :scroll-container="scrollContainerElement"
  176. :attributes="[
  177. {
  178. name: 'priorityIcon',
  179. label: __('Priority Icon'),
  180. headerPreferences: {
  181. noResize: true,
  182. hideLabel: true,
  183. displayWidth: 25,
  184. },
  185. columnPreferences: {},
  186. dataType: 'icon',
  187. },
  188. {
  189. name: 'stateIcon',
  190. label: __('State Icon'),
  191. headerPreferences: {
  192. noResize: true,
  193. hideLabel: true,
  194. displayWidth: 30,
  195. },
  196. columnPreferences: {},
  197. dataType: 'icon',
  198. },
  199. ]"
  200. :attribute-extensions="{
  201. title: {
  202. columnPreferences: {
  203. link: goToTicketLinkColumn,
  204. },
  205. },
  206. number: {
  207. label: config.ticket_hook,
  208. columnPreferences: {
  209. link: goToTicketLinkColumn,
  210. },
  211. },
  212. }"
  213. :items="tickets"
  214. :total-items="totalCount"
  215. :storage-key-id="storageKeyId"
  216. :max-items="maxItems"
  217. @load-more="loadMore"
  218. @click-row="goToTicket"
  219. @sort="sort"
  220. >
  221. <template #column-cell-priorityIcon="{ item, isRowSelected }">
  222. <CommonTicketPriorityIndicatorIcon
  223. :ui-color="(item as TicketById).priority?.uiColor"
  224. with-text-color
  225. 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"
  226. :class="{
  227. 'ltr:text-black rtl:text-black dark:text-white': isRowSelected,
  228. }"
  229. />
  230. </template>
  231. <template #column-cell-stateIcon="{ item, isRowSelected }">
  232. <CommonTicketStateIndicatorIcon
  233. 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"
  234. :class="{
  235. 'ltr:text-black rtl:text-black dark:text-white': isRowSelected,
  236. }"
  237. :color-code="(item as TicketById).stateColorCode"
  238. :label="(item as TicketById).state.name"
  239. :aria-labelledby="(item as TicketById).id"
  240. icon-size="tiny"
  241. />
  242. </template>
  243. </CommonAdvancedTable>
  244. </div>
  245. </div>
  246. </template>