TicketDetailViewContent.vue 20 KB

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