TicketCreate.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  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 Form from '#shared/components/Form/Form.vue'
  7. import type {
  8. FormSubmitData,
  9. FormSchemaNode,
  10. } from '#shared/components/Form/types.ts'
  11. import { useForm } from '#shared/components/Form/useForm.ts'
  12. import { useMultiStepForm } from '#shared/components/Form/useMultiStepForm.ts'
  13. import { useConfirmation } from '#shared/composables/useConfirmation.ts'
  14. import { useStickyHeader } from '#shared/composables/useStickyHeader.ts'
  15. import { useTicketSignature } from '#shared/composables/useTicketSignature.ts'
  16. import { useTicketCreate } from '#shared/entities/ticket/composables/useTicketCreate.ts'
  17. import { useTicketCreateArticleType } from '#shared/entities/ticket/composables/useTicketCreateArticleType.ts'
  18. import { useTicketCreateView } from '#shared/entities/ticket/composables/useTicketCreateView.ts'
  19. import { useTicketFormOrganizationHandler } from '#shared/entities/ticket/composables/useTicketFormOrganizationHandler.ts'
  20. import type { TicketFormData } from '#shared/entities/ticket/types.ts'
  21. import { useUserQuery } from '#shared/entities/user/graphql/queries/user.api.ts'
  22. import { defineFormSchema } from '#shared/form/defineFormSchema.ts'
  23. import {
  24. EnumFormUpdaterId,
  25. EnumObjectManagerObjects,
  26. } from '#shared/graphql/types.ts'
  27. import { i18n } from '#shared/i18n.ts'
  28. import { errorOptions } from '#shared/router/error.ts'
  29. import { useApplicationStore } from '#shared/stores/application.ts'
  30. import { ErrorStatusCodes } from '#shared/types/error.ts'
  31. import CommonButton from '#mobile/components/CommonButton/CommonButton.vue'
  32. import CommonStepper from '#mobile/components/CommonStepper/CommonStepper.vue'
  33. import LayoutHeader from '#mobile/components/layout/LayoutHeader.vue'
  34. import { useDialog } from '#mobile/composables/useDialog.ts'
  35. import {
  36. useTicketDuplicateDetectionHandler,
  37. type TicketDuplicateDetectionPayload,
  38. } from '../composable/useTicketDuplicateDetectionHandler.ts'
  39. const router = useRouter()
  40. // TODO: Add meta header with selected ticket create article type.
  41. const { canSubmit, form, node, isDirty, formSubmit } = useForm()
  42. const {
  43. multiStepPlugin,
  44. setMultiStep,
  45. allSteps,
  46. activeStep,
  47. visitedSteps,
  48. stepNames,
  49. lastStepName,
  50. } = useMultiStepForm(node)
  51. const application = useApplicationStore()
  52. const onSubmit = () => {
  53. setMultiStep()
  54. }
  55. const { ticketArticleSenderTypeField } = useTicketCreateArticleType({
  56. onSubmit,
  57. buttons: true,
  58. })
  59. const redirectAfterCreate = (internalId?: number) => {
  60. if (internalId) {
  61. router.replace(`/tickets/${internalId}`)
  62. } else {
  63. router.replace({ name: 'Home' })
  64. }
  65. }
  66. const { createTicket, isTicketCustomer } = useTicketCreate(
  67. form,
  68. redirectAfterCreate,
  69. )
  70. const getFormSchemaGroupSection = (
  71. stepName: string,
  72. sectionTitle: string,
  73. children: 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. ...children,
  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. class: 'my-10 text-base text-center text-yellow',
  151. },
  152. children: '$getAdditionalCreateNote($values.articleSenderType)',
  153. },
  154. ],
  155. true,
  156. )
  157. const locationParams = new URL(window.location.href).searchParams
  158. const customUserId = locationParams.get('customer_id') || undefined
  159. const userOptions = ref<unknown[]>([])
  160. const userQuery = useUserQuery(
  161. () => ({
  162. userInternalId: Number(customUserId),
  163. secondaryOrganizationsCount: 3,
  164. }),
  165. {
  166. // we probably opened this because user was already loaded user on another page,
  167. // so we should try to get it from cache first, but if someone passed down id
  168. // we need to still provide correct value
  169. fetchPolicy: 'cache-first',
  170. enabled: !!customUserId,
  171. },
  172. )
  173. userQuery.onResult((r) => {
  174. if (r.loading) return
  175. const user = r.data?.user
  176. if (!user) {
  177. userOptions.value = []
  178. return
  179. }
  180. userOptions.value = [
  181. {
  182. value: user.internalId,
  183. label: user.fullname || user.phone,
  184. heading: user.organization?.name,
  185. user,
  186. },
  187. ]
  188. })
  189. const ticketMetaInformationSection = getFormSchemaGroupSection(
  190. 'ticketMetaInformation',
  191. __('Additional information'),
  192. [
  193. {
  194. isLayout: true,
  195. component: 'FormGroup',
  196. children: [
  197. {
  198. name: 'ticket_duplicate_detection',
  199. type: 'hidden',
  200. value: {
  201. count: 0,
  202. items: [],
  203. },
  204. },
  205. {
  206. screen: 'create_top',
  207. object: EnumObjectManagerObjects.Ticket,
  208. name: 'customer_id',
  209. value: customUserId ? Number(customUserId) : undefined,
  210. props: {
  211. options: userOptions,
  212. },
  213. },
  214. {
  215. screen: 'create_top',
  216. object: EnumObjectManagerObjects.Ticket,
  217. },
  218. // Because of the current field screen settings in the backend
  219. // seed we need to add this manually.
  220. {
  221. if: '$values.articleSenderType === "email-out"',
  222. name: 'cc',
  223. label: __('CC'),
  224. type: 'recipient',
  225. props: {
  226. multiple: true,
  227. },
  228. },
  229. ],
  230. },
  231. {
  232. isLayout: true,
  233. component: 'FormGroup',
  234. children: [
  235. {
  236. screen: 'create_middle',
  237. object: EnumObjectManagerObjects.Ticket,
  238. },
  239. ],
  240. },
  241. {
  242. isLayout: true,
  243. component: 'FormGroup',
  244. children: [
  245. {
  246. screen: 'create_bottom',
  247. object: EnumObjectManagerObjects.Ticket,
  248. },
  249. ],
  250. },
  251. ],
  252. )
  253. const ticketArticleMessageSection = getFormSchemaGroupSection(
  254. 'ticketArticleMessage',
  255. __('Add a message'),
  256. [
  257. {
  258. isLayout: true,
  259. component: 'FormGroup',
  260. children: [
  261. {
  262. if: '$securityIntegration === true && $values.articleSenderType === "email-out"',
  263. name: 'security',
  264. label: __('Security'),
  265. type: 'security',
  266. },
  267. {
  268. name: 'body',
  269. screen: 'create_top',
  270. object: EnumObjectManagerObjects.TicketArticle,
  271. props: {
  272. meta: {
  273. mentionText: {
  274. customerNodeName: 'customer_id',
  275. },
  276. mentionUser: {
  277. groupNodeName: 'group_id',
  278. },
  279. mentionKnowledgeBase: {
  280. attachmentsNodeName: 'attachments',
  281. },
  282. },
  283. },
  284. triggerFormUpdater: false,
  285. },
  286. ],
  287. },
  288. {
  289. isLayout: true,
  290. component: 'FormGroup',
  291. children: [
  292. {
  293. type: 'file',
  294. name: 'attachments',
  295. label: __('Attachment'),
  296. labelSrOnly: true,
  297. props: {
  298. multiple: true,
  299. },
  300. },
  301. ],
  302. },
  303. ],
  304. )
  305. const customerSchema = [
  306. ticketTitleSection,
  307. ticketMetaInformationSection,
  308. ticketArticleMessageSection,
  309. ]
  310. const agentSchema = [
  311. ticketTitleSection,
  312. ticketArticleTypeSection,
  313. ticketMetaInformationSection,
  314. ticketArticleMessageSection,
  315. ]
  316. const formSchema = defineFormSchema(
  317. isTicketCustomer.value ? customerSchema : agentSchema,
  318. )
  319. const securityIntegration = computed<boolean>(
  320. () =>
  321. (application.config.smime_integration ||
  322. application.config.pgp_integration) ??
  323. false,
  324. )
  325. const additionalCreateNotes = computed(
  326. () =>
  327. (application.config.ui_ticket_create_notes as Record<string, string>) || {},
  328. )
  329. const schemaData = reactive({
  330. activeStep,
  331. visitedSteps,
  332. allSteps,
  333. securityIntegration,
  334. existingAdditionalCreateNotes: () => {
  335. return Object.keys(additionalCreateNotes).length > 0
  336. },
  337. getAdditionalCreateNote: (value: string) => {
  338. return i18n.t(additionalCreateNotes.value[value])
  339. },
  340. })
  341. const submitButtonDisabled = computed(() => {
  342. return (
  343. !canSubmit.value ||
  344. (activeStep.value !== lastStepName.value &&
  345. visitedSteps.value.length < stepNames.value.length)
  346. )
  347. })
  348. const moveStep = () => {
  349. if (activeStep.value === lastStepName.value) {
  350. formSubmit()
  351. return
  352. }
  353. setMultiStep()
  354. }
  355. const { stickyStyles, headerElement } = useStickyHeader()
  356. const bodyElement = ref<HTMLElement>()
  357. const isScrolledToBottom = ref(true)
  358. const setIsScrolledToBottom = () => {
  359. isScrolledToBottom.value =
  360. window.innerHeight +
  361. document.documentElement.scrollTop -
  362. (headerElement.value?.clientHeight || 0) >=
  363. (bodyElement.value?.scrollHeight || 0)
  364. }
  365. watch(
  366. () => activeStep.value,
  367. () => {
  368. nextTick(() => {
  369. setIsScrolledToBottom()
  370. })
  371. },
  372. )
  373. useEventListener('scroll', setIsScrolledToBottom)
  374. useEventListener('resize', setIsScrolledToBottom)
  375. onBeforeRouteLeave(async () => {
  376. if (!isDirty.value) return true
  377. const { waitForConfirmation } = useConfirmation()
  378. const confirmed = await waitForConfirmation(
  379. __('Are you sure? You have unsaved changes that will get lost.'),
  380. {
  381. buttonLabel: __('Discard changes'),
  382. buttonVariant: 'danger',
  383. },
  384. )
  385. return confirmed
  386. })
  387. const { signatureHandling } = useTicketSignature()
  388. const ticketDuplicateDetectionDialog = useDialog({
  389. name: 'duplicate-ticket-detection',
  390. component: () =>
  391. import('#mobile/components/Ticket/TicketDuplicateDetectionDialog.vue'),
  392. })
  393. const showTicketDuplicateDetectionDialog = (
  394. data: TicketDuplicateDetectionPayload,
  395. ) => {
  396. ticketDuplicateDetectionDialog.open({
  397. name: 'duplicate-ticket-detection',
  398. tickets: data.items,
  399. })
  400. }
  401. const changedFields = reactive({
  402. // Workaround until the object attribute for body is required so core worklow is returning it correctly.
  403. body: {
  404. required: true,
  405. },
  406. })
  407. </script>
  408. <script lang="ts">
  409. export default {
  410. beforeRouteEnter(to, from, next) {
  411. const { ticketCreateEnabled } = useTicketCreateView()
  412. if (!ticketCreateEnabled.value) {
  413. errorOptions.value = {
  414. title: __('Forbidden'),
  415. message: __('Creating new tickets via web is disabled.'),
  416. statusCode: ErrorStatusCodes.Forbidden,
  417. route: to.fullPath,
  418. }
  419. next({
  420. name: 'Error',
  421. query: {
  422. redirect: '1',
  423. },
  424. replace: true,
  425. })
  426. return
  427. }
  428. next()
  429. },
  430. }
  431. </script>
  432. <template>
  433. <LayoutHeader
  434. ref="headerElement"
  435. class="!h-16"
  436. :style="stickyStyles.header"
  437. back-url="/"
  438. :title="__('Create Ticket')"
  439. >
  440. <template #after>
  441. <CommonButton
  442. variant="submit"
  443. form="ticket-create"
  444. type="submit"
  445. :disabled="submitButtonDisabled"
  446. transparent-background
  447. >
  448. {{ $t('Create') }}
  449. </CommonButton>
  450. </template>
  451. </LayoutHeader>
  452. <div
  453. ref="bodyElement"
  454. :style="stickyStyles.body"
  455. class="flex h-full flex-col px-4"
  456. >
  457. <Form
  458. id="ticket-create"
  459. ref="form"
  460. class="pb-32 text-left"
  461. :schema="formSchema"
  462. :handlers="[
  463. useTicketFormOrganizationHandler(),
  464. signatureHandling('body'),
  465. useTicketDuplicateDetectionHandler(showTicketDuplicateDetectionDialog),
  466. ]"
  467. :flatten-form-groups="Object.keys(allSteps)"
  468. :schema-data="schemaData"
  469. :change-fields="changedFields"
  470. :form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterTicketCreate"
  471. should-autofocus
  472. use-object-attributes
  473. @submit="createTicket($event as FormSubmitData<TicketFormData>)"
  474. />
  475. </div>
  476. <footer
  477. :class="{
  478. 'bg-gray-light backdrop-blur-lg': !isScrolledToBottom,
  479. }"
  480. class="pb-safe fixed bottom-0 z-10 w-full px-4 transition"
  481. >
  482. <FormKit
  483. :variant="lastStepName === activeStep ? 'submit' : 'primary'"
  484. type="button"
  485. outer-class="mt-4 mb-2"
  486. :disabled="lastStepName === activeStep && submitButtonDisabled"
  487. wrapper-class="flex grow justify-center items-center"
  488. input-class="py-2 px-4 w-full h-14 text-lg rounded-xl select-none"
  489. @click="moveStep()"
  490. >
  491. {{ lastStepName === activeStep ? $t('Create ticket') : $t('Continue') }}
  492. </FormKit>
  493. <CommonStepper
  494. v-model="activeStep"
  495. :steps="allSteps"
  496. class="mb-8 mt-4 px-8"
  497. />
  498. </footer>
  499. </template>