TicketDetailView.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { useLocalStorage } from '@vueuse/core'
  4. import { cloneDeep } from 'lodash-es'
  5. import {
  6. computed,
  7. toRef,
  8. provide,
  9. Teleport,
  10. markRaw,
  11. type Component,
  12. reactive,
  13. nextTick,
  14. watch,
  15. useTemplateRef,
  16. ref,
  17. } from 'vue'
  18. import {
  19. NotificationTypes,
  20. useNotifications,
  21. } from '#shared/components/CommonNotifications/index.ts'
  22. import Form from '#shared/components/Form/Form.vue'
  23. import type {
  24. FormSubmitData,
  25. FormValues,
  26. } from '#shared/components/Form/types.ts'
  27. import { useForm } from '#shared/components/Form/useForm.ts'
  28. import { setErrors } from '#shared/components/Form/utils.ts'
  29. import { useConfirmation } from '#shared/composables/useConfirmation.ts'
  30. import {
  31. useTicketMacros,
  32. macroScreenBehaviourMapping,
  33. } from '#shared/entities/macro/composables/useMacros.ts'
  34. import { useTicketArticleReplyAction } from '#shared/entities/ticket/composables/useTicketArticleReplyAction.ts'
  35. import { useTicketEdit } from '#shared/entities/ticket/composables/useTicketEdit.ts'
  36. import { useTicketEditForm } from '#shared/entities/ticket/composables/useTicketEditForm.ts'
  37. import { useTicketLiveUserList } from '#shared/entities/ticket/composables/useTicketLiveUserList.ts'
  38. import type {
  39. TicketArticleTimeAccountingFormData,
  40. TicketUpdateFormData,
  41. } from '#shared/entities/ticket/types.ts'
  42. import type { AppSpecificTicketArticleType } from '#shared/entities/ticket-article/action/plugins/types.ts'
  43. import {
  44. useArticleDataHandler,
  45. type AddArticleCallbackArgs,
  46. } from '#shared/entities/ticket-article/composables/useArticleDataHandler.ts'
  47. import UserError from '#shared/errors/UserError.ts'
  48. import {
  49. EnumTaskbarEntity,
  50. EnumFormUpdaterId,
  51. EnumTaskbarApp,
  52. EnumUserErrorException,
  53. } from '#shared/graphql/types.ts'
  54. import { convertToGraphQLId } from '#shared/graphql/utils.ts'
  55. import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
  56. import { useSessionStore } from '#shared/stores/session.ts'
  57. import { useFlyout } from '#desktop/components/CommonFlyout/useFlyout.ts'
  58. import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
  59. import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
  60. import { useTaskbarTab } from '#desktop/entities/user/current/composables/useTaskbarTab.ts'
  61. import { useTaskbarTabStateUpdates } from '#desktop/entities/user/current/composables/useTaskbarTabStateUpdates.ts'
  62. import TicketDetailBottomBar from '#desktop/pages/ticket/components/TicketDetailView/TicketDetailBottomBar/TicketDetailBottomBar.vue'
  63. import { useTicketScreenBehavior } from '#desktop/pages/ticket/components/TicketDetailView/TicketScreenBehavior/useTicketScreenBehavior.ts'
  64. import { useArticleContainerScroll } from '#desktop/pages/ticket/components/TicketDetailView/useArticleContainerScroll.ts'
  65. import ArticleList from '../components/TicketDetailView/ArticleList.vue'
  66. import ArticleReply from '../components/TicketDetailView/ArticleReply.vue'
  67. import TicketDetailTopBar from '../components/TicketDetailView/TicketDetailTopBar/TicketDetailTopBar.vue'
  68. import TicketSidebar from '../components/TicketSidebar.vue'
  69. import { ARTICLES_INFORMATION_KEY } from '../composables/useArticleContext.ts'
  70. import { useTicketArticleReply } from '../composables/useTicketArticleReply.ts'
  71. import {
  72. initializeTicketInformation,
  73. provideTicketInformation,
  74. } from '../composables/useTicketInformation.ts'
  75. import {
  76. useTicketSidebar,
  77. useProvideTicketSidebar,
  78. } from '../composables/useTicketSidebar.ts'
  79. import {
  80. type TicketSidebarContext,
  81. TicketSidebarScreenType,
  82. } from '../types/sidebar.ts'
  83. interface Props {
  84. internalId: string
  85. }
  86. const props = defineProps<Props>()
  87. const {
  88. activeTaskbarTab,
  89. activeTaskbarTabFormId,
  90. activeTaskbarTabNewArticlePresent,
  91. } = useTaskbarTab(EnumTaskbarEntity.TicketZoom)
  92. const internalId = toRef(props, 'internalId')
  93. const { ticket, ticketId, ...ticketInformation } =
  94. initializeTicketInformation(internalId)
  95. const onAddArticleCallback = ({ articlesQuery }: AddArticleCallbackArgs) => {
  96. return (articlesQuery as QueryHandler).refetch()
  97. }
  98. const { articleResult, articlesQuery, isLoadingArticles } =
  99. useArticleDataHandler(ticketId, { pageSize: 20, onAddArticleCallback })
  100. provide(ARTICLES_INFORMATION_KEY, {
  101. articles: computed(() => articleResult.value),
  102. articlesQuery,
  103. })
  104. const {
  105. form,
  106. values,
  107. flags,
  108. isDisabled,
  109. isDirty,
  110. isInitialSettled,
  111. formReset,
  112. formSubmit,
  113. triggerFormUpdater,
  114. } = useForm()
  115. const contentContainerElement = useTemplateRef('content-container')
  116. const scrollToArticlesEnd = () => {
  117. nextTick(() => {
  118. const scrollHeight = contentContainerElement.value?.scrollHeight
  119. if (scrollHeight)
  120. contentContainerElement.value?.scrollTo({
  121. top: scrollHeight,
  122. })
  123. })
  124. }
  125. const groupId = computed(() =>
  126. isInitialSettled.value && values.value.group_id
  127. ? convertToGraphQLId('Group', values.value.group_id as number)
  128. : undefined,
  129. )
  130. const { setSkipNextStateUpdate } = useTaskbarTabStateUpdates(triggerFormUpdater)
  131. const {
  132. ticketSchema,
  133. articleSchema,
  134. currentArticleType,
  135. ticketArticleTypes,
  136. securityIntegration,
  137. isTicketAgent,
  138. isTicketCustomer,
  139. isTicketEditable,
  140. articleTypeHandler,
  141. articleTypeSelectHandler,
  142. } = useTicketEditForm(ticket, form)
  143. const sidebarContext = computed<TicketSidebarContext>(() => ({
  144. ticket,
  145. isTicketEditable,
  146. screenType: TicketSidebarScreenType.TicketDetailView,
  147. form: form.value,
  148. formValues: {
  149. // TODO: Workaround, to make the sidebars working for now.
  150. customer_id: ticket.value?.customer.internalId,
  151. organization_id: ticket.value?.organization?.internalId,
  152. },
  153. }))
  154. useProvideTicketSidebar(sidebarContext)
  155. const { hasSidebar, activeSidebar, switchSidebar } = useTicketSidebar()
  156. const hasInternalArticle = computed(
  157. () => (values.value as TicketUpdateFormData).article?.internal,
  158. )
  159. const formEditAttributeLocation = computed(() => {
  160. if (activeSidebar.value === 'information') return '#ticketEditAttributeForm'
  161. return '#wrapper-form-ticket-edit'
  162. })
  163. const {
  164. isArticleFormGroupValid,
  165. newTicketArticlePresent,
  166. showTicketArticleReplyForm,
  167. } = useTicketArticleReply(form, activeTaskbarTabNewArticlePresent)
  168. const { liveUserList } = useTicketLiveUserList(
  169. internalId,
  170. isTicketAgent,
  171. EnumTaskbarApp.Desktop,
  172. )
  173. provideTicketInformation({
  174. ticket,
  175. ticketId,
  176. isTicketEditable,
  177. form,
  178. newTicketArticlePresent,
  179. showTicketArticleReplyForm,
  180. ...ticketInformation,
  181. })
  182. const ticketEditSchemaData = reactive({
  183. formEditAttributeLocation,
  184. securityIntegration,
  185. newTicketArticlePresent,
  186. currentArticleType,
  187. })
  188. const ticketEditSchema = [
  189. {
  190. isLayout: true,
  191. component: 'Teleport',
  192. props: {
  193. to: '$formEditAttributeLocation',
  194. },
  195. children: [
  196. {
  197. isLayout: true,
  198. component: 'FormGroup',
  199. props: {
  200. class: '@container/form-group',
  201. showDirtyMark: true,
  202. },
  203. children: [ticketSchema],
  204. },
  205. ],
  206. },
  207. {
  208. if: '$newTicketArticlePresent',
  209. isLayout: true,
  210. component: 'Teleport',
  211. props: {
  212. to: '#ticketArticleReplyForm',
  213. },
  214. children: [
  215. {
  216. isLayout: true,
  217. component: 'FormGroup',
  218. props: {
  219. class: '@container/form-group',
  220. },
  221. children: [articleSchema],
  222. },
  223. ],
  224. },
  225. ]
  226. const { waitForConfirmation, waitForVariantConfirmation } = useConfirmation()
  227. const { handleScreenBehavior } = useTicketScreenBehavior()
  228. const canUseDraft = computed(() => {
  229. return flags.value.hasSharedDraft
  230. })
  231. const hasAvailableDraft = computed(() => {
  232. const sharedDraftZoomId = ticket.value?.sharedDraftZoomId
  233. if (!sharedDraftZoomId) return false
  234. return canUseDraft.value
  235. })
  236. const discardChanges = async () => {
  237. const confirm = await waitForVariantConfirmation('unsaved')
  238. if (confirm) {
  239. newTicketArticlePresent.value = false
  240. await nextTick()
  241. // Skip subscription for the current tab, to avoid not needed form updater requests.
  242. setSkipNextStateUpdate(true)
  243. formReset()
  244. }
  245. }
  246. const { isTicketFormGroupValid, initialTicketValue, editTicket } =
  247. useTicketEdit(ticket, form)
  248. const { openReplyForm } = useTicketArticleReplyAction(
  249. form,
  250. showTicketArticleReplyForm,
  251. )
  252. const isFormValid = computed(() => {
  253. if (!newTicketArticlePresent.value) return isTicketFormGroupValid.value
  254. return isTicketFormGroupValid.value && isArticleFormGroupValid.value
  255. })
  256. const formAdditionalRouteQueryParams = computed(() => ({
  257. taskbarId: activeTaskbarTab.value?.taskbarTabId,
  258. }))
  259. const { notify } = useNotifications()
  260. const { userId } = useSessionStore()
  261. const articleReplyPinned = useLocalStorage(
  262. `${userId}-article-reply-pinned`,
  263. false,
  264. )
  265. const checkSubmitEditTicket = () => {
  266. if (!isFormValid.value) {
  267. if (activeSidebar.value !== 'information') switchSidebar('information')
  268. if (
  269. newTicketArticlePresent.value &&
  270. !isArticleFormGroupValid.value &&
  271. !articleReplyPinned.value
  272. )
  273. scrollToArticlesEnd()
  274. }
  275. formSubmit()
  276. }
  277. const skipValidators = ref<EnumUserErrorException[]>([])
  278. const handleIncompleteChecklist = async (error: UserError) => {
  279. const confirmed = await waitForConfirmation(
  280. __(
  281. 'You have unchecked items in the checklist. Do you want to handle them before closing this ticket?',
  282. ),
  283. {
  284. headerTitle: __('Incomplete Ticket Checklist'),
  285. headerIcon: 'checklist',
  286. buttonLabel: __('Yes, open the checklist'),
  287. cancelLabel: __('No, just close the ticket'),
  288. },
  289. )
  290. if (confirmed) {
  291. if (activeSidebar.value !== 'checklist') switchSidebar('checklist')
  292. return false
  293. }
  294. if (confirmed === false) {
  295. const exception = error.getFirstErrorException()
  296. if (exception) skipValidators.value?.push(exception)
  297. formSubmit()
  298. return true
  299. }
  300. return false
  301. }
  302. const timeAccountingData = ref<TicketArticleTimeAccountingFormData>()
  303. const timeAccountingFlyout = useFlyout({
  304. name: 'ticket-time-accounting',
  305. component: () =>
  306. import('../components/TicketDetailView/TimeAccountingFlyout.vue'),
  307. })
  308. const handleTimeAccounting = (error: UserError) => {
  309. timeAccountingFlyout.open({
  310. onAccountTime: (data: TicketArticleTimeAccountingFormData) => {
  311. timeAccountingData.value = data
  312. formSubmit()
  313. },
  314. onSkip: () => {
  315. const exception = error.getFirstErrorException()
  316. if (exception) skipValidators.value?.push(exception)
  317. formSubmit()
  318. },
  319. })
  320. return false
  321. }
  322. const handleUserErrorException = (error: UserError) => {
  323. if (
  324. error.getFirstErrorException() ===
  325. EnumUserErrorException.ServiceTicketUpdateValidatorChecklistCompletedError
  326. )
  327. return handleIncompleteChecklist(error)
  328. if (
  329. error.getFirstErrorException() ===
  330. EnumUserErrorException.ServiceTicketUpdateValidatorTimeAccountingError
  331. )
  332. return handleTimeAccounting(error)
  333. return true
  334. }
  335. const { activeMacro, executeMacro, disposeActiveMacro } =
  336. useTicketMacros(formSubmit)
  337. const submitEditTicket = async (
  338. formData: FormSubmitData<TicketUpdateFormData>,
  339. ) => {
  340. let data = cloneDeep(formData)
  341. if (currentArticleType.value?.updateForm)
  342. data = currentArticleType.value.updateForm(data)
  343. if (data.article && timeAccountingData.value) {
  344. data.article = {
  345. ...data.article,
  346. timeUnit:
  347. timeAccountingData.value.time_unit !== undefined
  348. ? parseFloat(timeAccountingData.value.time_unit)
  349. : undefined,
  350. accountedTimeTypeId: timeAccountingData.value.accounted_time_type_id
  351. ? convertToGraphQLId(
  352. 'Ticket::TimeAccounting::Type',
  353. timeAccountingData.value.accounted_time_type_id,
  354. )
  355. : undefined,
  356. }
  357. }
  358. return editTicket(data, {
  359. macroId: activeMacro.value?.id,
  360. skipValidators: skipValidators.value,
  361. })
  362. .then((result) => {
  363. if (result?.ticketUpdate?.ticket) {
  364. notify({
  365. id: 'ticket-update',
  366. type: NotificationTypes.Success,
  367. message: __('Ticket updated successfully.'),
  368. })
  369. const screenBehaviour = activeMacro.value
  370. ? macroScreenBehaviourMapping[activeMacro.value?.uxFlowNextUp]
  371. : undefined
  372. handleScreenBehavior({
  373. screenBehaviour,
  374. ticket: result.ticketUpdate.ticket,
  375. })
  376. skipValidators.value.length = 0
  377. timeAccountingData.value = undefined
  378. // Await subscription to update article list before we scroll to the bottom.
  379. watch(articleResult, scrollToArticlesEnd, {
  380. once: true,
  381. })
  382. // Reset article form after ticket update and reset form.
  383. newTicketArticlePresent.value = false
  384. return {
  385. reset: (
  386. values: FormSubmitData<TicketUpdateFormData>,
  387. formNodeValues: FormValues,
  388. ) => {
  389. nextTick(() => {
  390. formReset({ values: { ticket: formNodeValues.ticket } })
  391. })
  392. },
  393. }
  394. }
  395. return false
  396. })
  397. .catch((error) => {
  398. if (error instanceof UserError) {
  399. if (error.getFirstErrorException())
  400. return handleUserErrorException(error)
  401. skipValidators.value.length = 0
  402. timeAccountingData.value = undefined
  403. if (form.value?.formNode) {
  404. setErrors(form.value.formNode, error)
  405. return
  406. }
  407. }
  408. skipValidators.value.length = 0
  409. timeAccountingData.value = undefined
  410. notify({
  411. id: 'ticket-update-failed',
  412. type: NotificationTypes.Error,
  413. message: __('Ticket update failed.'),
  414. })
  415. })
  416. .finally(() => {
  417. disposeActiveMacro()
  418. })
  419. }
  420. const discardReplyForm = async () => {
  421. const confirm = await waitForVariantConfirmation('unsaved')
  422. if (!confirm) return
  423. newTicketArticlePresent.value = false
  424. await nextTick()
  425. // Skip subscription for the current tab, to avoid not needed form updater requests.
  426. setSkipNextStateUpdate(true)
  427. return triggerFormUpdater()
  428. }
  429. const handleShowArticleForm = (
  430. articleType: string,
  431. performReply: AppSpecificTicketArticleType['performReply'],
  432. ) => {
  433. openReplyForm({ articleType, ...performReply?.(ticket.value) })
  434. }
  435. const onEditFormSettled = () => {
  436. watch(
  437. () => flags.value.newArticlePresent,
  438. (newValue) => {
  439. newTicketArticlePresent.value = newValue
  440. },
  441. )
  442. }
  443. // Reset newTicketArticlePresent when ticket changed, that the
  444. // taskbar information is used for the start.
  445. watch(ticketId, () => {
  446. initialTicketValue.value = undefined
  447. newTicketArticlePresent.value = undefined
  448. })
  449. const articleListInstance = useTemplateRef('article-list')
  450. const topBarInstance = useTemplateRef('top-bar')
  451. const {
  452. handleScroll,
  453. isHoveringOnTopBar,
  454. isHidingTicketDetails,
  455. isReachingBottom,
  456. } = useArticleContainerScroll(
  457. ticket,
  458. contentContainerElement,
  459. articleListInstance,
  460. topBarInstance,
  461. )
  462. </script>
  463. <template>
  464. <LayoutContent
  465. name="ticket-detail"
  466. no-padding
  467. background-variant="primary"
  468. :show-sidebar="hasSidebar"
  469. content-alignment="center"
  470. no-scrollable
  471. >
  472. <CommonLoader class="mt-8" :loading="!ticket">
  473. <div
  474. ref="content-container"
  475. class="relative grid h-full w-full overflow-y-auto"
  476. :class="{
  477. 'grid-rows-[max-content_max-content_max-content]':
  478. !newTicketArticlePresent || !articleReplyPinned,
  479. 'grid-rows-[max-content_1fr_max-content]':
  480. newTicketArticlePresent && articleReplyPinned,
  481. }"
  482. @scroll.passive="handleScroll"
  483. >
  484. <div class="sticky top-0 z-10">
  485. <TicketDetailTopBar
  486. :key="`${isHidingTicketDetails}-ticket-detail-top-bar`"
  487. ref="top-bar"
  488. class="invisible"
  489. aria-hidden="true"
  490. data-test-id="invisible-ticket-detail-top-bar"
  491. :hide-details="false"
  492. />
  493. <Transition name="slide-down">
  494. <TicketDetailTopBar
  495. :key="`${isHidingTicketDetails}-top-bar`"
  496. v-model:hover="isHoveringOnTopBar"
  497. data-test-id="visible-ticket-detail-top-bar"
  498. class="absolute left-0 right-0 top-0 w-full"
  499. :hide-details="isHidingTicketDetails"
  500. />
  501. </Transition>
  502. </div>
  503. <ArticleList ref="article-list" :aria-busy="isLoadingArticles" />
  504. <ArticleReply
  505. v-if="ticket?.id && isTicketEditable"
  506. :ticket="ticket"
  507. :new-article-present="newTicketArticlePresent"
  508. :create-article-type="ticket.createArticleType?.name"
  509. :ticket-article-types="ticketArticleTypes"
  510. :is-ticket-customer="isTicketCustomer"
  511. :has-internal-article="hasInternalArticle"
  512. :parent-reached-bottom-scroll="isReachingBottom"
  513. @show-article-form="handleShowArticleForm"
  514. @discard-form="discardReplyForm"
  515. />
  516. <div id="wrapper-form-ticket-edit" class="hidden" aria-hidden="true">
  517. <Form
  518. v-if="ticket?.id && initialTicketValue"
  519. id="form-ticket-edit"
  520. :key="ticket.id"
  521. ref="form"
  522. :form-id="activeTaskbarTabFormId"
  523. :schema="ticketEditSchema"
  524. :disabled="!isTicketEditable"
  525. :flatten-form-groups="['ticket']"
  526. :handlers="[articleTypeHandler()]"
  527. :form-kit-plugins="[articleTypeSelectHandler]"
  528. :schema-data="ticketEditSchemaData"
  529. :initial-values="initialTicketValue"
  530. :initial-entity-object="ticket"
  531. :form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterTicketEdit"
  532. :form-updater-additional-params="formAdditionalRouteQueryParams"
  533. use-object-attributes
  534. :schema-component-library="{
  535. Teleport: markRaw(Teleport) as unknown as Component,
  536. }"
  537. @submit="
  538. submitEditTicket($event as FormSubmitData<TicketUpdateFormData>)
  539. "
  540. @settled="onEditFormSettled"
  541. @changed="setSkipNextStateUpdate(true)"
  542. />
  543. </div>
  544. </div>
  545. </CommonLoader>
  546. <template #sideBar="{ isCollapsed, toggleCollapse }">
  547. <TicketSidebar
  548. :is-collapsed="isCollapsed"
  549. :toggle-collapse="toggleCollapse"
  550. :context="sidebarContext"
  551. />
  552. </template>
  553. <template #bottomBar>
  554. <TicketDetailBottomBar
  555. :can-use-draft="canUseDraft"
  556. :dirty="isDirty"
  557. :disabled="isDisabled"
  558. :form="form"
  559. :group-id="groupId"
  560. :is-ticket-agent="isTicketAgent"
  561. :is-ticket-editable="isTicketEditable"
  562. :has-available-draft="hasAvailableDraft"
  563. :live-user-list="liveUserList"
  564. :shared-draft-id="ticket?.sharedDraftZoomId"
  565. :ticket-id="ticketId"
  566. @submit="checkSubmitEditTicket"
  567. @discard="discardChanges"
  568. @execute-macro="executeMacro"
  569. />
  570. </template>
  571. </LayoutContent>
  572. </template>