TicketCreate.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. <!-- Copyright (C) 2012-2025 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-hidden 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. groupNodeName: 'group_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 securityIntegration = computed<boolean>(
  321. () =>
  322. (application.config.smime_integration ||
  323. application.config.pgp_integration) ??
  324. false,
  325. )
  326. const additionalCreateNotes = computed(
  327. () =>
  328. (application.config.ui_ticket_create_notes as Record<string, string>) || {},
  329. )
  330. const schemaData = reactive({
  331. activeStep,
  332. visitedSteps,
  333. allSteps,
  334. securityIntegration,
  335. existingAdditionalCreateNotes: () => {
  336. return Object.keys(additionalCreateNotes).length > 0
  337. },
  338. getAdditionalCreateNote: (value: string) => {
  339. return i18n.t(additionalCreateNotes.value[value])
  340. },
  341. })
  342. const submitButtonDisabled = computed(() => {
  343. return (
  344. !canSubmit.value ||
  345. (activeStep.value !== lastStepName.value &&
  346. visitedSteps.value.length < stepNames.value.length)
  347. )
  348. })
  349. const moveStep = () => {
  350. if (activeStep.value === lastStepName.value) {
  351. formSubmit()
  352. return
  353. }
  354. setMultiStep()
  355. }
  356. const { stickyStyles, headerElement } = useStickyHeader()
  357. const bodyElement = ref<HTMLElement>()
  358. const isScrolledToBottom = ref(true)
  359. const setIsScrolledToBottom = () => {
  360. isScrolledToBottom.value =
  361. window.innerHeight +
  362. document.documentElement.scrollTop -
  363. (headerElement.value?.clientHeight || 0) >=
  364. (bodyElement.value?.scrollHeight || 0)
  365. }
  366. watch(
  367. () => activeStep.value,
  368. () => {
  369. nextTick(() => {
  370. setIsScrolledToBottom()
  371. })
  372. },
  373. )
  374. useEventListener('scroll', setIsScrolledToBottom)
  375. useEventListener('resize', setIsScrolledToBottom)
  376. onBeforeRouteLeave(async () => {
  377. if (!isDirty.value) return true
  378. const { waitForConfirmation } = useConfirmation()
  379. const confirmed = await waitForConfirmation(
  380. __('Are you sure? You have unsaved changes that will get lost.'),
  381. {
  382. buttonLabel: __('Discard changes'),
  383. buttonVariant: 'danger',
  384. },
  385. )
  386. return confirmed
  387. })
  388. const { signatureHandling } = useTicketSignature()
  389. const ticketDuplicateDetectionDialog = useDialog({
  390. name: 'duplicate-ticket-detection',
  391. component: () =>
  392. import('#mobile/components/Ticket/TicketDuplicateDetectionDialog.vue'),
  393. })
  394. const showTicketDuplicateDetectionDialog = (
  395. data: TicketDuplicateDetectionPayload,
  396. ) => {
  397. ticketDuplicateDetectionDialog.open({
  398. name: 'duplicate-ticket-detection',
  399. tickets: data.items,
  400. })
  401. }
  402. const changedFields = reactive({
  403. // Workaround until the object attribute for body is required so core worklow is returning it correctly.
  404. body: {
  405. required: true,
  406. },
  407. })
  408. </script>
  409. <script lang="ts">
  410. export default {
  411. beforeRouteEnter(to, from, next) {
  412. const { ticketCreateEnabled } = useTicketCreateView()
  413. if (!ticketCreateEnabled.value) {
  414. errorOptions.value = {
  415. title: __('Forbidden'),
  416. message: __('Creating new tickets via web is disabled.'),
  417. statusCode: ErrorStatusCodes.Forbidden,
  418. route: to.fullPath,
  419. }
  420. next({
  421. name: 'Error',
  422. query: {
  423. redirect: '1',
  424. },
  425. replace: true,
  426. })
  427. return
  428. }
  429. next()
  430. },
  431. }
  432. </script>
  433. <template>
  434. <LayoutHeader
  435. ref="headerElement"
  436. class="h-16!"
  437. :style="stickyStyles.header"
  438. back-url="/"
  439. :title="__('Create Ticket')"
  440. >
  441. <template #after>
  442. <CommonButton
  443. variant="submit"
  444. form="ticket-create"
  445. type="submit"
  446. :disabled="submitButtonDisabled"
  447. transparent-background
  448. >
  449. {{ $t('Create') }}
  450. </CommonButton>
  451. </template>
  452. </LayoutHeader>
  453. <div
  454. ref="bodyElement"
  455. :style="stickyStyles.body"
  456. class="flex h-full flex-col px-4"
  457. >
  458. <Form
  459. id="ticket-create"
  460. ref="form"
  461. class="pb-32 text-left"
  462. :schema="formSchema"
  463. :handlers="[
  464. useTicketFormOrganizationHandler(),
  465. signatureHandling('body'),
  466. useTicketDuplicateDetectionHandler(showTicketDuplicateDetectionDialog),
  467. ]"
  468. :flatten-form-groups="Object.keys(allSteps)"
  469. :schema-data="schemaData"
  470. :change-fields="changedFields"
  471. :form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterTicketCreate"
  472. should-autofocus
  473. use-object-attributes
  474. @submit="createTicket($event as FormSubmitData<TicketFormData>)"
  475. />
  476. </div>
  477. <footer
  478. :class="{
  479. 'bg-gray-light backdrop-blur-lg': !isScrolledToBottom,
  480. }"
  481. class="pb-safe fixed bottom-0 z-10 w-full px-4 transition"
  482. >
  483. <FormKit
  484. :variant="lastStepName === activeStep ? 'submit' : 'primary'"
  485. type="button"
  486. outer-class="mt-4 mb-2"
  487. :disabled="lastStepName === activeStep && submitButtonDisabled"
  488. wrapper-class="flex grow justify-center items-center"
  489. input-class="py-2 px-4 w-full h-14 text-lg rounded-xl select-none"
  490. @click="moveStep()"
  491. >
  492. {{ lastStepName === activeStep ? $t('Create ticket') : $t('Continue') }}
  493. </FormKit>
  494. <CommonStepper
  495. v-model="activeStep"
  496. :steps="allSteps"
  497. class="mt-4 mb-8 px-8"
  498. />
  499. </footer>
  500. </template>