TicketDetailView.vue 18 KB

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