TicketCreate.vue 17 KB

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