TicketCreate.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. <!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { computed, nextTick, reactive, ref, watch } from 'vue'
  4. import { onBeforeRouteLeave, useRouter } from 'vue-router'
  5. import { useEventListener } from '@vueuse/core'
  6. import Form from '#shared/components/Form/Form.vue'
  7. import {
  8. EnumFormUpdaterId,
  9. EnumObjectManagerObjects,
  10. type TicketCreateInput,
  11. } from '#shared/graphql/types.ts'
  12. import { useForm } from '#shared/components/Form/useForm.ts'
  13. import { useMultiStepForm } from '#shared/components/Form/useMultiStepForm.ts'
  14. import { useApplicationStore } from '#shared/stores/application.ts'
  15. import { useTicketCreate } from '#shared/entities/ticket/composables/useTicketCreate.ts'
  16. import { useTicketCreateArticleType } from '#shared/entities/ticket/composables/useTicketCreateArticleType.ts'
  17. import { useTicketFormOganizationHandler } from '#shared/entities/ticket/composables/useTicketFormOrganizationHandler.ts'
  18. import type {
  19. FormSubmitData,
  20. FormSchemaNode,
  21. } from '#shared/components/Form/types.ts'
  22. import { i18n } from '#shared/i18n.ts'
  23. import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
  24. import { useObjectAttributes } from '#shared/entities/object-attributes/composables/useObjectAttributes.ts'
  25. import { useObjectAttributeFormData } from '#shared/entities/object-attributes/composables/useObjectAttributeFormData.ts'
  26. import {
  27. NotificationTypes,
  28. useNotifications,
  29. } from '#shared/components/CommonNotifications/index.ts'
  30. import { ErrorStatusCodes, GraphQLErrorTypes } from '#shared/types/error.ts'
  31. import type UserError from '#shared/errors/UserError.ts'
  32. import { defineFormSchema } from '#mobile/form/defineFormSchema.ts'
  33. import { populateEditorNewLines } from '#shared/components/Form/fields/FieldEditor/utils.ts'
  34. import CommonStepper from '#mobile/components/CommonStepper/CommonStepper.vue'
  35. import CommonButton from '#mobile/components/CommonButton/CommonButton.vue'
  36. import CommonBackButton from '#mobile/components/CommonBackButton/CommonBackButton.vue'
  37. import { errorOptions } from '#mobile/router/error.ts'
  38. import { useConfirmationDialog } from '#mobile/components/CommonConfirmation/useConfirmationDialog.ts'
  39. import {
  40. useTicketDuplicateDetectionHandler,
  41. type TicketDuplicateDetectionPayload,
  42. } from '#mobile/pages/ticket/composable/useTicketDuplicateDetectionHandler.ts'
  43. import { useTicketSignature } from '#shared/composables/useTicketSignature.ts'
  44. import type { TicketFormData } from '#shared/entities/ticket/types.ts'
  45. import { convertFilesToAttachmentInput } from '#shared/utils/files.ts'
  46. import { useDialog } from '#shared/composables/useDialog.ts'
  47. import { useStickyHeader } from '#shared/composables/useStickyHeader.ts'
  48. import type { ApolloError } from '@apollo/client'
  49. import { useTicketCreateMutation } from '../graphql/mutations/create.api.ts'
  50. const router = useRouter()
  51. // Add meta header with selected ticket create article type
  52. const { canSubmit, form, node, isDirty, formSubmit } = useForm()
  53. const {
  54. multiStepPlugin,
  55. setMultiStep,
  56. allSteps,
  57. activeStep,
  58. visitedSteps,
  59. stepNames,
  60. lastStepName,
  61. } = useMultiStepForm(node)
  62. const application = useApplicationStore()
  63. const onSubmit = () => {
  64. setMultiStep()
  65. }
  66. const { ticketCreateArticleType, ticketArticleSenderTypeField } =
  67. useTicketCreateArticleType({ onSubmit })
  68. const { isTicketCustomer } = useTicketCreate()
  69. const getFormSchemaGroupSection = (
  70. stepName: string,
  71. sectionTitle: string,
  72. childrens: FormSchemaNode[],
  73. itemsCenter = false,
  74. ) => {
  75. return {
  76. isLayout: true,
  77. element: 'section',
  78. attrs: {
  79. style: {
  80. if: `$activeStep !== "${stepName}"`,
  81. then: 'display: none;',
  82. },
  83. class: {
  84. 'flex flex-col h-full min-h-[calc(100vh_-_15rem)]': true,
  85. 'items-center': itemsCenter,
  86. },
  87. },
  88. children: [
  89. {
  90. type: 'group',
  91. name: stepName,
  92. isGroupOrList: true,
  93. plugins: [multiStepPlugin],
  94. children: [
  95. {
  96. isLayout: true,
  97. element: 'h4',
  98. attrs: {
  99. class: 'my-10 text-base text-center',
  100. },
  101. children: i18n.t(sectionTitle),
  102. },
  103. ...childrens,
  104. ],
  105. },
  106. ],
  107. }
  108. }
  109. const ticketTitleSection = getFormSchemaGroupSection(
  110. 'ticketTitle',
  111. __('Set a title for your ticket'),
  112. [
  113. {
  114. name: 'title',
  115. required: true,
  116. object: EnumObjectManagerObjects.Ticket,
  117. screen: 'create_top',
  118. outerClass:
  119. '$reset formkit-outer w-full grow justify-center flex items-center flex-col',
  120. wrapperClass: '$reset formkit-disabled:opacity-30 flex w-full',
  121. labelClass: '$reset sr-only',
  122. blockClass: '$reset flex w-full',
  123. innerClass: '$reset flex justify-center items-center px-8 w-full',
  124. messagesClass: 'pt-2',
  125. inputClass:
  126. '$reset formkit-input block bg-transparent grow border-b-[0.5px] border-white outline-none text-center text-xl placeholder:text-white placeholder:text-opacity-50',
  127. props: {
  128. placeholder: __('Title'),
  129. onSubmit,
  130. },
  131. },
  132. ],
  133. true,
  134. )
  135. const ticketArticleTypeSection = getFormSchemaGroupSection(
  136. 'ticketArticleType',
  137. __('Select the type of ticket your are creating'),
  138. [
  139. {
  140. ...ticketArticleSenderTypeField,
  141. outerClass: 'w-full flex grow items-center',
  142. fieldsetClass: 'grow px-4',
  143. },
  144. {
  145. if: '$existingAdditionalCreateNotes() && $getAdditionalCreateNote($values.articleSenderType) !== undefined',
  146. isLayout: true,
  147. element: 'p',
  148. attrs: {
  149. // TODO: check styling for this hint
  150. class: 'my-10 text-base text-center text-yellow',
  151. },
  152. children: '$getAdditionalCreateNote($values.articleSenderType)',
  153. },
  154. ],
  155. true,
  156. )
  157. const ticketMetaInformationSection = getFormSchemaGroupSection(
  158. 'ticketMetaInformation',
  159. __('Additional information'),
  160. [
  161. {
  162. isLayout: true,
  163. component: 'FormGroup',
  164. children: [
  165. {
  166. name: 'ticket_duplicate_detection',
  167. type: 'hidden',
  168. value: {
  169. count: 0,
  170. items: [],
  171. },
  172. },
  173. {
  174. screen: 'create_top',
  175. object: EnumObjectManagerObjects.Ticket,
  176. },
  177. // Because of the current field screen settings in the backend
  178. // seed we need to add this manually.
  179. {
  180. if: '$values.articleSenderType === "email-out"',
  181. name: 'cc',
  182. label: __('CC'),
  183. type: 'recipient',
  184. props: {
  185. multiple: true,
  186. },
  187. },
  188. ],
  189. },
  190. {
  191. isLayout: true,
  192. component: 'FormGroup',
  193. children: [
  194. {
  195. screen: 'create_middle',
  196. object: EnumObjectManagerObjects.Ticket,
  197. },
  198. ],
  199. },
  200. {
  201. isLayout: true,
  202. component: 'FormGroup',
  203. children: [
  204. {
  205. screen: 'create_bottom',
  206. object: EnumObjectManagerObjects.Ticket,
  207. },
  208. ],
  209. },
  210. ],
  211. )
  212. const ticketArticleMessageSection = getFormSchemaGroupSection(
  213. 'ticketArticleMessage',
  214. __('Add a message'),
  215. [
  216. {
  217. isLayout: true,
  218. component: 'FormGroup',
  219. children: [
  220. {
  221. if: '$smimeIntegration === true && $values.articleSenderType === "email-out"',
  222. name: 'security',
  223. label: __('Security'),
  224. type: 'security',
  225. },
  226. {
  227. name: 'body',
  228. screen: 'create_top',
  229. object: EnumObjectManagerObjects.TicketArticle,
  230. props: {
  231. meta: {
  232. mentionText: {
  233. customerNodeName: 'customer_id',
  234. },
  235. mentionUser: {
  236. groupNodeName: 'group_id',
  237. },
  238. mentionKnowledgeBase: {
  239. attachmentsNodeName: 'attachments',
  240. },
  241. },
  242. },
  243. triggerFormUpdater: false,
  244. },
  245. ],
  246. },
  247. {
  248. isLayout: true,
  249. component: 'FormGroup',
  250. children: [
  251. {
  252. type: 'file',
  253. name: 'attachments',
  254. props: {
  255. multiple: true,
  256. },
  257. },
  258. ],
  259. },
  260. ],
  261. )
  262. const customerSchema = [
  263. ticketTitleSection,
  264. ticketMetaInformationSection,
  265. ticketArticleMessageSection,
  266. ]
  267. const agentSchema = [
  268. ticketTitleSection,
  269. ticketArticleTypeSection,
  270. ticketMetaInformationSection,
  271. ticketArticleMessageSection,
  272. ]
  273. const formSchema = defineFormSchema(
  274. isTicketCustomer.value ? customerSchema : agentSchema,
  275. )
  276. const ticketCreateMutation = new MutationHandler(useTicketCreateMutation({}), {
  277. errorShowNotification: false,
  278. })
  279. const redirectAfterCreate = (internalId?: number) => {
  280. if (internalId) {
  281. router.replace(`/tickets/${internalId}`)
  282. } else {
  283. router.replace({ name: 'Home' })
  284. }
  285. }
  286. const smimeIntegration = computed(
  287. () => (application.config.smime_integration as boolean) || {},
  288. )
  289. const { notify } = useNotifications()
  290. const notifySuccess = () => {
  291. notify({
  292. type: NotificationTypes.Success,
  293. message: __('Ticket has been created successfully.'),
  294. })
  295. }
  296. const handleTicketCreateError = (error: UserError | ApolloError) => {
  297. if ('graphQLErrors' in error) {
  298. const graphQLErrors = error.graphQLErrors?.[0]
  299. // treat this as successful
  300. if (graphQLErrors?.extensions?.type === GraphQLErrorTypes.Forbidden) {
  301. notifySuccess()
  302. return () => redirectAfterCreate()
  303. }
  304. notify({
  305. message: __('Ticket could not be created.'),
  306. type: NotificationTypes.Error,
  307. })
  308. } else {
  309. notify({
  310. message: error.generalErrors[0],
  311. type: NotificationTypes.Error,
  312. })
  313. }
  314. }
  315. const createTicket = async (formData: FormSubmitData<TicketFormData>) => {
  316. const { attributesLookup: ticketObjectAttributesLookup } =
  317. useObjectAttributes(EnumObjectManagerObjects.Ticket)
  318. const { internalObjectAttributeValues, additionalObjectAttributeValues } =
  319. useObjectAttributeFormData(ticketObjectAttributesLookup.value, formData)
  320. const input = {
  321. ...internalObjectAttributeValues,
  322. article: {
  323. cc: formData.cc,
  324. body: populateEditorNewLines(formData.body),
  325. sender: isTicketCustomer.value
  326. ? 'Customer'
  327. : ticketCreateArticleType[formData.articleSenderType].sender,
  328. type: isTicketCustomer.value
  329. ? 'web'
  330. : ticketCreateArticleType[formData.articleSenderType].type,
  331. contentType: 'text/html',
  332. security: formData.security,
  333. },
  334. objectAttributeValues: additionalObjectAttributeValues,
  335. } as TicketCreateInput
  336. if (formData.attachments && input.article) {
  337. input.article.attachments = convertFilesToAttachmentInput(
  338. formData.formId,
  339. formData.attachments,
  340. )
  341. }
  342. return ticketCreateMutation
  343. .send({ input })
  344. .then((result) => {
  345. if (result?.ticketCreate?.ticket) {
  346. notifySuccess()
  347. return () => {
  348. const ticket = result.ticketCreate?.ticket
  349. redirectAfterCreate(
  350. ticket?.policy.update ? ticket.internalId : undefined,
  351. )
  352. }
  353. }
  354. return null
  355. })
  356. .catch(handleTicketCreateError)
  357. }
  358. const additionalCreateNotes = computed(
  359. () =>
  360. (application.config.ui_ticket_create_notes as Record<string, string>) || {},
  361. )
  362. const schemaData = reactive({
  363. activeStep,
  364. visitedSteps,
  365. allSteps,
  366. smimeIntegration,
  367. existingAdditionalCreateNotes: () => {
  368. return Object.keys(additionalCreateNotes).length > 0
  369. },
  370. getAdditionalCreateNote: (value: string) => {
  371. return i18n.t(additionalCreateNotes.value[value])
  372. },
  373. })
  374. const submitButtonDisabled = computed(() => {
  375. return (
  376. !canSubmit.value ||
  377. (activeStep.value !== lastStepName.value &&
  378. visitedSteps.value.length < stepNames.value.length)
  379. )
  380. })
  381. const moveStep = () => {
  382. if (activeStep.value === lastStepName.value) {
  383. formSubmit()
  384. return
  385. }
  386. setMultiStep()
  387. }
  388. const { stickyStyles, headerElement } = useStickyHeader()
  389. const bodyElement = ref<HTMLElement>()
  390. const isScrolledToBottom = ref(true)
  391. const setIsScrolledToBottom = () => {
  392. isScrolledToBottom.value =
  393. window.innerHeight +
  394. document.documentElement.scrollTop -
  395. (headerElement.value?.clientHeight || 0) >=
  396. (bodyElement.value?.scrollHeight || 0)
  397. }
  398. watch(
  399. () => activeStep.value,
  400. () => {
  401. nextTick(() => {
  402. setIsScrolledToBottom()
  403. })
  404. },
  405. )
  406. useEventListener('scroll', setIsScrolledToBottom)
  407. useEventListener('resize', setIsScrolledToBottom)
  408. const { waitForConfirmation } = useConfirmationDialog()
  409. onBeforeRouteLeave(async () => {
  410. if (!isDirty.value) return true
  411. const confirmed = await waitForConfirmation(
  412. __('Are you sure? You have unsaved changes that will get lost.'),
  413. {
  414. buttonTitle: __('Discard changes'),
  415. buttonVariant: 'danger',
  416. },
  417. )
  418. return confirmed
  419. })
  420. const { signatureHandling } = useTicketSignature()
  421. const ticketDuplicateDetectionDialog = useDialog({
  422. name: 'duplicate-ticket-detection',
  423. component: () =>
  424. import('#mobile/components/Ticket/TicketDuplicateDetectionDialog.vue'),
  425. })
  426. const showTicketDuplicateDetectionDialog = (
  427. data: TicketDuplicateDetectionPayload,
  428. ) => {
  429. ticketDuplicateDetectionDialog.open({
  430. name: 'duplicate-ticket-detection',
  431. tickets: data.items,
  432. })
  433. }
  434. </script>
  435. <script lang="ts">
  436. export default {
  437. beforeRouteEnter(to, from, next) {
  438. const { ticketCreateEnabled } = useTicketCreate()
  439. if (!ticketCreateEnabled.value) {
  440. errorOptions.value = {
  441. title: __('Forbidden'),
  442. message: __('Creating new tickets via web is disabled.'),
  443. statusCode: ErrorStatusCodes.Forbidden,
  444. route: to.fullPath,
  445. }
  446. next({
  447. name: 'Error',
  448. query: {
  449. redirect: '1',
  450. },
  451. replace: true,
  452. })
  453. return
  454. }
  455. next()
  456. },
  457. }
  458. </script>
  459. <template>
  460. <header
  461. ref="headerElement"
  462. :style="stickyStyles.header"
  463. class="border-b-[0.5px] border-white/10 bg-black px-4"
  464. >
  465. <div class="grid h-16 grid-cols-[75px_auto_75px]">
  466. <div
  467. class="flex cursor-pointer items-center justify-self-start text-base"
  468. >
  469. <CommonBackButton fallback="/" />
  470. </div>
  471. <h1
  472. class="flex flex-1 items-center justify-center text-center text-lg font-bold"
  473. >
  474. {{ $t('Create Ticket') }}
  475. </h1>
  476. <div class="flex items-center justify-self-end text-base">
  477. <CommonButton
  478. variant="submit"
  479. form="ticket-create"
  480. type="submit"
  481. :disabled="submitButtonDisabled"
  482. transparent-background
  483. >
  484. {{ $t('Create') }}
  485. </CommonButton>
  486. </div>
  487. </div>
  488. </header>
  489. <div
  490. ref="bodyElement"
  491. :style="stickyStyles.body"
  492. class="flex h-full flex-col px-4"
  493. >
  494. <Form
  495. id="ticket-create"
  496. ref="form"
  497. class="pb-32 text-left"
  498. :schema="formSchema"
  499. :handlers="[
  500. useTicketFormOganizationHandler(),
  501. signatureHandling('body'),
  502. useTicketDuplicateDetectionHandler(showTicketDuplicateDetectionDialog),
  503. ]"
  504. :flatten-form-groups="Object.keys(allSteps)"
  505. :schema-data="schemaData"
  506. :form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterTicketCreate"
  507. :autofocus="true"
  508. use-object-attributes
  509. @submit="createTicket($event as FormSubmitData<TicketFormData>)"
  510. />
  511. </div>
  512. <footer
  513. :class="{
  514. 'bg-gray-light backdrop-blur-lg': !isScrolledToBottom,
  515. }"
  516. class="pb-safe fixed bottom-0 z-10 w-full px-4 transition"
  517. >
  518. <FormKit
  519. :variant="lastStepName === activeStep ? 'submit' : 'primary'"
  520. type="button"
  521. outer-class="mt-4 mb-2"
  522. :disabled="lastStepName === activeStep && submitButtonDisabled"
  523. wrapper-class="flex grow justify-center items-center"
  524. input-class="py-2 px-4 w-full h-14 text-lg rounded-xl select-none"
  525. @click="moveStep()"
  526. >
  527. {{ lastStepName === activeStep ? $t('Create ticket') : $t('Continue') }}
  528. </FormKit>
  529. <CommonStepper
  530. v-model="activeStep"
  531. :steps="allSteps"
  532. class="mb-8 mt-4 px-8"
  533. />
  534. </footer>
  535. </template>