TicketDetailViewContent.vue 19 KB

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