TicketCreateContent.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { isEqual } from 'lodash-es'
  4. import { computed, markRaw, reactive } from 'vue'
  5. import { useRouter, useRoute } from 'vue-router'
  6. import Form from '#shared/components/Form/Form.vue'
  7. import type { FormSubmitData } from '#shared/components/Form/types.ts'
  8. import { useForm } from '#shared/components/Form/useForm.ts'
  9. import { useConfirmation } from '#shared/composables/useConfirmation.ts'
  10. import { useTicketSignature } from '#shared/composables/useTicketSignature.ts'
  11. import { useTicketCreate } from '#shared/entities/ticket/composables/useTicketCreate.ts'
  12. import { useTicketCreateArticleType } from '#shared/entities/ticket/composables/useTicketCreateArticleType.ts'
  13. import { useTicketFormOrganizationHandler } from '#shared/entities/ticket/composables/useTicketFormOrganizationHandler.ts'
  14. import type { TicketFormData } from '#shared/entities/ticket/types.ts'
  15. import { defineFormSchema } from '#shared/form/defineFormSchema.ts'
  16. import {
  17. EnumFormUpdaterId,
  18. EnumObjectManagerObjects,
  19. } from '#shared/graphql/types.ts'
  20. import { useWalker } from '#shared/router/walker.ts'
  21. import { useApplicationStore } from '#shared/stores/application.ts'
  22. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  23. import CommonContentPanel from '#desktop/components/CommonContentPanel/CommonContentPanel.vue'
  24. import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
  25. import { usePage } from '#desktop/composables/usePage.ts'
  26. import { useTicketCreateTitle } from '#desktop/entities/ticket/composables/useTicketCreateTitle.ts'
  27. import { useTaskbarTab } from '#desktop/entities/user/current/composables/useTaskbarTab.ts'
  28. import { useTaskbarTabStateUpdates } from '#desktop/entities/user/current/composables/useTaskbarTabStateUpdates.ts'
  29. import type { TaskbarTabContext } from '#desktop/entities/user/current/types.ts'
  30. import {
  31. useProvideTicketSidebar,
  32. useTicketSidebar,
  33. } from '../../composables/useTicketSidebar.ts'
  34. import {
  35. TicketSidebarScreenType,
  36. type TicketSidebarContext,
  37. } from '../../types/sidebar.ts'
  38. import TicketSidebar from '../TicketSidebar.vue'
  39. import ApplyTemplate from './ApplyTemplate.vue'
  40. import TicketDuplicateDetectionAlert from './TicketDuplicateDetectionAlert.vue'
  41. interface Props {
  42. tabId?: string
  43. }
  44. defineProps<Props>()
  45. const router = useRouter()
  46. const walker = useWalker()
  47. const route = useRoute()
  48. const {
  49. form,
  50. isDisabled,
  51. isDirty,
  52. isInitialSettled,
  53. formNodeId,
  54. values,
  55. triggerFormUpdater,
  56. } = useForm()
  57. const currentTitle = computed(() => values.value.title as string)
  58. const currentArticleType = computed(
  59. () => values.value.articleSenderType as string,
  60. )
  61. const { currentViewTitle } = useTicketCreateTitle(
  62. currentTitle,
  63. currentArticleType,
  64. )
  65. usePage({
  66. metaTitle: currentViewTitle,
  67. })
  68. const application = useApplicationStore()
  69. const redirectAfterCreate = (internalId?: number) => {
  70. if (internalId) {
  71. router.replace(`/tickets/${internalId}`)
  72. return
  73. }
  74. // Fallback redirect, in case the user has no access to the ticket they just created.
  75. router.replace({ name: 'Dashboard' })
  76. }
  77. const goBack = () => {
  78. walker.back('/')
  79. }
  80. const { ticketArticleSenderTypeField } = useTicketCreateArticleType()
  81. const { createTicket, isTicketCustomer } = useTicketCreate(
  82. form,
  83. redirectAfterCreate,
  84. )
  85. const defaultTitle = __('New Ticket')
  86. const formSchema = defineFormSchema([
  87. {
  88. isLayout: true,
  89. component: 'CommonContentPanel',
  90. children: [
  91. {
  92. isLayout: true,
  93. element: 'h1',
  94. attrs: {
  95. class:
  96. 'py-2.5 text-center text-xl font-medium leading-snug text-black dark:text-white',
  97. ariaCurrent: 'page',
  98. },
  99. children: '$values.title || $t($defaultTitle)',
  100. },
  101. {
  102. if: '$isTicketCustomer === false',
  103. ...ticketArticleSenderTypeField,
  104. outerClass: 'flex justify-center',
  105. },
  106. {
  107. isLayout: true,
  108. element: 'div',
  109. attrs: {
  110. class: 'grid grid-cols-1 gap-2.5',
  111. role: 'tabpanel',
  112. ariaLabelledby: '$getTabLabel($values.articleSenderType)',
  113. id: '$getTabPanelId($values.articleSenderType)',
  114. },
  115. children: [
  116. {
  117. if: '$existingAdditionalCreateNotes() && $getAdditionalCreateNote($values.articleSenderType) !== undefined',
  118. isLayout: true,
  119. component: 'CommonAlert',
  120. props: {
  121. variant: 'warning',
  122. },
  123. children: '$t($getAdditionalCreateNote($values.articleSenderType))',
  124. },
  125. {
  126. if: '$values.ticket_duplicate_detection.count > 0',
  127. isLayout: true,
  128. component: 'TicketDuplicateDetectionAlert',
  129. props: {
  130. tickets: '$values.ticket_duplicate_detection.items',
  131. },
  132. children: '',
  133. },
  134. {
  135. screen: 'create_top',
  136. object: EnumObjectManagerObjects.Ticket,
  137. },
  138. // Because of the current field screen settings in the backend
  139. // seed we need to add this manually.
  140. {
  141. if: '$values.articleSenderType === "email-out"',
  142. name: 'cc',
  143. label: __('CC'),
  144. type: 'recipient',
  145. props: {
  146. multiple: true,
  147. clearable: true,
  148. },
  149. },
  150. {
  151. if: '$securityIntegration === true && $values.articleSenderType === "email-out"',
  152. name: 'security',
  153. label: __('Security'),
  154. type: 'security',
  155. },
  156. {
  157. name: 'body',
  158. screen: 'create_top',
  159. object: EnumObjectManagerObjects.TicketArticle,
  160. required: true,
  161. props: {
  162. meta: {
  163. mentionText: {
  164. customerNodeName: 'customer_id',
  165. },
  166. mentionUser: {
  167. groupNodeName: 'group_id',
  168. },
  169. mentionKnowledgeBase: {
  170. attachmentsNodeName: 'attachments',
  171. },
  172. },
  173. },
  174. },
  175. {
  176. type: 'file',
  177. name: 'attachments',
  178. label: __('Attachment'),
  179. labelSrOnly: true,
  180. props: {
  181. multiple: true,
  182. },
  183. },
  184. {
  185. name: 'ticket_duplicate_detection',
  186. type: 'hidden',
  187. value: {
  188. count: 0,
  189. items: [],
  190. },
  191. },
  192. {
  193. name: 'link_ticket_id',
  194. type: 'hidden',
  195. },
  196. {
  197. name: 'shared_draft_id',
  198. type: 'hidden',
  199. },
  200. {
  201. name: 'externalReferences',
  202. type: 'hidden',
  203. },
  204. ],
  205. },
  206. ],
  207. },
  208. {
  209. isLayout: true,
  210. component: 'CommonContentPanel',
  211. children: [
  212. {
  213. isLayout: true,
  214. element: 'div',
  215. attrs: {
  216. class: 'grid grid-cols-2-uneven gap-2.5',
  217. },
  218. children: [
  219. {
  220. screen: 'create_middle',
  221. object: EnumObjectManagerObjects.Ticket,
  222. },
  223. ],
  224. },
  225. {
  226. screen: 'create_bottom',
  227. object: EnumObjectManagerObjects.Ticket,
  228. },
  229. ],
  230. },
  231. ])
  232. const securityIntegration = computed<boolean>(
  233. () =>
  234. (application.config.smime_integration ||
  235. application.config.pgp_integration) ??
  236. false,
  237. )
  238. const additionalCreateNotes = computed(
  239. () =>
  240. (application.config.ui_ticket_create_notes as Record<string, string>) || {},
  241. )
  242. const schemaData = reactive({
  243. defaultTitle,
  244. isTicketCustomer,
  245. securityIntegration,
  246. getTabLabel: (value: string) => `tab-label-${value}`,
  247. getTabPanelId: (value: string) => `tab-panel-${value}`,
  248. existingAdditionalCreateNotes: () => {
  249. return Object.keys(additionalCreateNotes).length > 0
  250. },
  251. getAdditionalCreateNote: (value: string) => {
  252. return additionalCreateNotes.value[value]
  253. },
  254. })
  255. const changedFields = reactive({
  256. // Workaround until the object attribute for body is required so core worklow is returning it correctly.
  257. body: {
  258. required: true,
  259. },
  260. })
  261. const { signatureHandling } = useTicketSignature()
  262. const tabContext = computed<TaskbarTabContext>((currentContext) => {
  263. if (!isInitialSettled.value) return {}
  264. const newContext = {
  265. formValues: values.value,
  266. formIsDirty: isDirty.value,
  267. }
  268. if (currentContext && isEqual(newContext, currentContext))
  269. return currentContext
  270. return newContext
  271. })
  272. const {
  273. currentTaskbarTab,
  274. currentTaskbarTabId,
  275. currentTaskbarTabFormId,
  276. currentTaskbarTabDelete,
  277. } = useTaskbarTab(tabContext)
  278. const { setSkipNextStateUpdate } = useTaskbarTabStateUpdates(
  279. currentTaskbarTabId,
  280. form,
  281. triggerFormUpdater,
  282. )
  283. const sidebarContext = computed<TicketSidebarContext>(() => ({
  284. screenType: TicketSidebarScreenType.TicketCreate,
  285. form: form.value,
  286. formValues: values.value,
  287. currentTaskbarTabId,
  288. }))
  289. useProvideTicketSidebar(sidebarContext)
  290. const { hasSidebar } = useTicketSidebar()
  291. const { waitForVariantConfirmation } = useConfirmation()
  292. const discardChanges = async () => {
  293. const confirm = await waitForVariantConfirmation('unsaved')
  294. if (!confirm) return
  295. goBack()
  296. currentTaskbarTabDelete()
  297. }
  298. const applyTemplate = (templateId: string) => {
  299. // Skip subscription for the current tab, to avoid not needed form updater requests.
  300. setSkipNextStateUpdate(true)
  301. triggerFormUpdater({
  302. includeDirtyFields: true,
  303. additionalParams: {
  304. templateId,
  305. },
  306. })
  307. }
  308. const formAdditionalRouteQueryParams = computed(() => ({
  309. taskbarId: currentTaskbarTab.value?.taskbarTabId,
  310. ...(route.query || {}),
  311. }))
  312. const submitCreateTicket = async (event: FormSubmitData<TicketFormData>) => {
  313. return createTicket(event).then((result) => {
  314. if (!result || result === null || result === undefined) return
  315. if (typeof result === 'function') result()
  316. currentTaskbarTabDelete()
  317. })
  318. }
  319. </script>
  320. <template>
  321. <LayoutContent
  322. name="ticket-create"
  323. background-variant="primary"
  324. content-alignment="center"
  325. :show-sidebar="hasSidebar"
  326. >
  327. <div class="w-full max-w-screen-xl px-28 py-3.5">
  328. <Form
  329. id="ticket-create"
  330. ref="form"
  331. :key="tabId"
  332. :form-id="currentTaskbarTabFormId"
  333. :schema="formSchema"
  334. :schema-component-library="{
  335. CommonContentPanel: markRaw(CommonContentPanel),
  336. TicketDuplicateDetectionAlert: markRaw(TicketDuplicateDetectionAlert),
  337. }"
  338. :schema-data="schemaData"
  339. :form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterTicketCreate"
  340. :handlers="[
  341. useTicketFormOrganizationHandler(),
  342. signatureHandling('body'),
  343. ]"
  344. :change-fields="changedFields"
  345. :form-updater-additional-params="formAdditionalRouteQueryParams"
  346. use-object-attributes
  347. form-class="flex flex-col gap-3"
  348. @submit="submitCreateTicket($event as FormSubmitData<TicketFormData>)"
  349. @changed="setSkipNextStateUpdate(true)"
  350. />
  351. </div>
  352. <template #sideBar="{ isCollapsed, toggleCollapse }">
  353. <TicketSidebar
  354. :context="sidebarContext"
  355. :is-collapsed="isCollapsed"
  356. :toggle-collapse="toggleCollapse"
  357. />
  358. </template>
  359. <template #bottomBar>
  360. <template v-if="isInitialSettled">
  361. <CommonButton
  362. v-if="isDirty"
  363. size="large"
  364. variant="danger"
  365. :disabled="isDisabled"
  366. @click="discardChanges"
  367. >{{ __('Discard Changes') }}</CommonButton
  368. >
  369. <CommonButton v-else size="large" variant="secondary" @click="goBack">{{
  370. __('Cancel & Go Back')
  371. }}</CommonButton>
  372. </template>
  373. <ApplyTemplate @select-template="applyTemplate" />
  374. <CommonButton
  375. size="large"
  376. variant="submit"
  377. type="submit"
  378. :form="formNodeId"
  379. :disabled="isDisabled"
  380. >{{ __('Create') }}</CommonButton
  381. >
  382. </template>
  383. </LayoutContent>
  384. </template>