Просмотр исходного кода

Maintenance: Desktop view - Enhance datetime selector to enter times quicker.

Dusan Vuckovic 1 месяц назад
Родитель
Сommit
0fc327faeb

+ 250 - 22
app/frontend/apps/desktop/components/Form/fields/FieldDate/FieldDateTimeInput.vue

@@ -1,18 +1,26 @@
 <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
 <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
 
 
+<!-- eslint-disable zammad/zammad-detect-translatable-string -->
+
 <script setup lang="ts">
 <script setup lang="ts">
+import { getNode, type FormKitNode } from '@formkit/core'
 import VueDatePicker, { type DatePickerInstance } from '@vuepic/vue-datepicker'
 import VueDatePicker, { type DatePickerInstance } from '@vuepic/vue-datepicker'
+import { isValid, format, parse } from 'date-fns'
+import { isEqual } from 'lodash-es'
 import { storeToRefs } from 'pinia'
 import { storeToRefs } from 'pinia'
-import { computed, nextTick, ref, toRef } from 'vue'
+import { computed, nextTick, ref, toRef, watch } from 'vue'
+import { IMask, useIMask } from 'vue-imask'
 
 
 import useValue from '#shared/components/Form/composables/useValue.ts'
 import useValue from '#shared/components/Form/composables/useValue.ts'
 import type { DateTimeContext } from '#shared/components/Form/fields/FieldDate/types.ts'
 import type { DateTimeContext } from '#shared/components/Form/fields/FieldDate/types.ts'
 import { useDateTime } from '#shared/components/Form/fields/FieldDate/useDateTime.ts'
 import { useDateTime } from '#shared/components/Form/fields/FieldDate/useDateTime.ts'
+import dateRange from '#shared/form/validation/rules/date-range.ts'
 import { EnumTextDirection } from '#shared/graphql/types.ts'
 import { EnumTextDirection } from '#shared/graphql/types.ts'
 import { i18n } from '#shared/i18n.ts'
 import { i18n } from '#shared/i18n.ts'
 import testFlags from '#shared/utils/testFlags.ts'
 import testFlags from '#shared/utils/testFlags.ts'
 
 
 import { useThemeStore } from '#desktop/stores/theme.ts'
 import { useThemeStore } from '#desktop/stores/theme.ts'
+
 import '@vuepic/vue-datepicker/dist/main.css'
 import '@vuepic/vue-datepicker/dist/main.css'
 
 
 interface Props {
 interface Props {
@@ -48,7 +56,7 @@ const actionRow = computed(() => ({
   showSelect: false,
   showSelect: false,
   showCancel: false,
   showCancel: false,
   // Do not show 'Today' for range selection, because it will close the picker
   // Do not show 'Today' for range selection, because it will close the picker
-  //  even if only one date was selected.
+  //   even if only one date was selected.
   showNow: !props.context.range,
   showNow: !props.context.range,
   showPreview: false,
   showPreview: false,
 }))
 }))
@@ -63,6 +71,203 @@ const picker = ref<DatePickerInstance>()
 
 
 const { isDarkMode } = storeToRefs(useThemeStore())
 const { isDarkMode } = storeToRefs(useThemeStore())
 
 
+const localeFormat = computed(() => {
+  if (timePicker.value) return i18n.getDateTimeFormat()
+  return i18n.getDateFormat()
+})
+
+// Date/time placeholders used in the locale format:
+// - 'dd' - 2-digit day
+// - 'd' - day
+// - 'mm' - 2-digit month
+// - 'm' - month
+// - 'yyyy' - year
+// - 'yy' - last 2 digits of year
+// - 'SS' - 2-digit second
+// - 'MM' - 2-digit minute
+// - 'HH' - 2-digit hour (24h)
+// - 'l' - hour (12h)
+// - 'P' - Meridian indicator ('am' or 'pm')
+const inputFormat = computed(() =>
+  localeFormat.value
+    .replace(/MM/, '2DigitMinute') // 'MM' is used for both minute and month
+    .replace(/mm/, 'MM')
+    .replace(/m/, 'M')
+    .replace(/SS/, 'ss')
+    .replace(/2DigitMinute/, 'mm')
+    .replace(/l/, 'hh')
+    .replace(/P/, 'aaa'),
+)
+
+const maskOptions = computed(() => ({
+  mask: contextReactive.value.range
+    ? `${localeFormat.value} - ${localeFormat.value}`
+    : localeFormat.value,
+  blocks: {
+    d: {
+      mask: IMask.MaskedRange,
+      from: 1,
+      to: 31,
+      placeholderChar: 'D',
+    },
+    dd: {
+      mask: IMask.MaskedRange,
+      from: 1,
+      to: 31,
+      placeholderChar: 'D',
+    },
+    m: {
+      mask: IMask.MaskedRange,
+      from: 1,
+      to: 12,
+      placeholderChar: 'M',
+    },
+    mm: {
+      mask: IMask.MaskedRange,
+      from: 1,
+      to: 12,
+      placeholderChar: 'M',
+    },
+    yyyy: {
+      mask: IMask.MaskedRange,
+      from: 1900,
+      to: 2100,
+      placeholderChar: 'Y',
+    },
+    yy: {
+      mask: IMask.MaskedRange,
+      from: 0,
+      to: 99,
+      placeholderChar: 'Y',
+    },
+    ss: {
+      mask: IMask.MaskedRange,
+      from: 0,
+      to: 59,
+      placeholderChar: 's',
+    },
+    MM: {
+      mask: IMask.MaskedRange,
+      from: 0,
+      to: 59,
+      placeholderChar: 'm',
+    },
+    HH: {
+      mask: IMask.MaskedRange,
+      from: 0,
+      to: 23,
+      placeholderChar: 'h',
+    },
+    l: {
+      mask: IMask.MaskedRange,
+      from: 1,
+      to: 12,
+      placeholderChar: 'h',
+    },
+    P: {
+      mask: IMask.MaskedEnum,
+      enum: ['am', 'pm'],
+      placeholderChar: 'p',
+    },
+  },
+  autofix: true,
+  lazy: false,
+  overwrite: true,
+}))
+
+const { el, masked, unmasked } = useIMask(maskOptions)
+
+const parseValue = (value: string) =>
+  parse(value, valueFormat.value, new Date())
+
+const formatValue = (value: Date) => format(value, valueFormat.value)
+
+watch(
+  localValue,
+  (newValue) => {
+    if (!newValue) {
+      masked.value = '' // clear input
+      return
+    }
+
+    if (contextReactive.value.range) {
+      const [startValue, endValue] = newValue
+      if (!startValue || !endValue) return
+
+      const startDate = parseValue(startValue)
+      const endDate = parseValue(endValue)
+      if (!isValid(startDate) || !isValid(endDate)) return
+
+      const value = `${format(startDate, inputFormat.value)} - ${format(endDate, inputFormat.value)}`
+      if (masked.value === value) return
+
+      masked.value = `${format(startDate, inputFormat.value)} - ${format(endDate, inputFormat.value)}`
+
+      return
+    }
+
+    const newDate = parseValue(newValue)
+    const maskedDate = parse(masked.value, inputFormat.value, new Date())
+
+    if (
+      isValid(maskedDate) &&
+      maskedDate.toISOString() === newDate.toISOString()
+    )
+      return
+
+    masked.value = format(newDate, inputFormat.value)
+  },
+  {
+    immediate: true,
+  },
+)
+
+const dateRangeValidation = (value: (string | undefined)[]) => {
+  if (value.includes(undefined)) return false
+  if (dateRange.rule({ value } as FormKitNode<string[]>)) return true
+
+  const node = getNode(contextReactive.value.id)
+  if (!node) return
+
+  // Manually set validation error message.
+  node.setErrors(i18n.t(dateRange.localeMessage()))
+
+  return false
+}
+
+watch(masked, (newValue) => {
+  // empty input
+  if (localValue.value && (!newValue || !unmasked.value)) {
+    localValue.value = null
+    return
+  }
+
+  if (contextReactive.value.range) {
+    const newValues = newValue.split(' - ').map((value) => {
+      const date = parse(value, inputFormat.value, new Date())
+      if (!isValid(date)) return
+      return formatValue(date)
+    })
+
+    if (!dateRangeValidation(newValues) || isEqual(localValue.value, newValues))
+      return
+
+    localValue.value = newValues
+
+    return
+  }
+
+  const newDate = parse(newValue, inputFormat.value, new Date())
+
+  if (
+    !isValid(newDate) ||
+    (isValid(newDate) && localValue.value === formatValue(newDate))
+  )
+    return
+
+  localValue.value = formatValue(newDate)
+})
+
 const open = () => {
 const open = () => {
   nextTick(() => {
   nextTick(() => {
     testFlags.set('field-date-time.opened')
     testFlags.set('field-date-time.opened')
@@ -73,6 +278,40 @@ const closed = () => {
   nextTick(() => {
   nextTick(() => {
     testFlags.set('field-date-time.closed')
     testFlags.set('field-date-time.closed')
   })
   })
+
+  if (!localValue.value && masked.value) {
+    masked.value = '' // clear input
+    return
+  }
+
+  if (contextReactive.value.range) {
+    const maskedValues = masked.value.split(' - ').map((value: string) => {
+      const date = parse(value, inputFormat.value, new Date())
+      if (!isValid(date)) return
+      return formatValue(date)
+    })
+
+    if (isEqual(localValue.value, maskedValues)) return
+
+    const [startValue, endValue] = localValue.value
+    if (!startValue || !endValue) return
+
+    const startDate = parseValue(startValue)
+    const endDate = parseValue(endValue)
+    if (!isValid(startDate) || !isValid(endDate)) return
+
+    masked.value = `${format(startDate, inputFormat.value)} - ${format(endDate, inputFormat.value)}`
+
+    return
+  }
+
+  const maskedDate = parse(masked.value, inputFormat.value, new Date())
+
+  if (isValid(maskedDate) && localValue.value === formatValue(maskedDate))
+    return
+
+  const newDate = parseValue(localValue.value)
+  masked.value = format(newDate, inputFormat.value)
 }
 }
 </script>
 </script>
 
 
@@ -105,43 +344,32 @@ const closed = () => {
       :action-row="actionRow"
       :action-row="actionRow"
       :config="config"
       :config="config"
       :aria-labels="ariaLabels"
       :aria-labels="ariaLabels"
-      :text-input="{ openMenu: 'toggle' }"
+      :text-input="{ openMenu: 'open' }"
       auto-apply
       auto-apply
       offset="12"
       offset="12"
       @open="open"
       @open="open"
       @closed="closed"
       @closed="closed"
       @blur="context.handlers.blur"
       @blur="context.handlers.blur"
     >
     >
-      <template
-        #dp-input="{
-          value,
-          onInput,
-          onEnter,
-          onTab,
-          onBlur,
-          onKeypress,
-          onPaste,
-        }"
-      >
+      <template #dp-input>
         <input
         <input
           :id="context.id"
           :id="context.id"
-          :value="value"
+          ref="el"
           :name="context.node.name"
           :name="context.node.name"
           :class="context.classes.input"
           :class="context.classes.input"
           :disabled="context.disabled"
           :disabled="context.disabled"
           :aria-describedby="context.describedBy"
           :aria-describedby="context.describedBy"
           v-bind="context.attrs"
           v-bind="context.attrs"
           type="text"
           type="text"
-          @input="onInput"
-          @keypress.enter="onEnter"
-          @keypress.tab="onTab"
-          @keypress="onKeypress"
-          @paste="onPaste"
-          @blur="onBlur"
         />
         />
       </template>
       </template>
       <template #input-icon>
       <template #input-icon>
-        <CommonIcon :name="inputIcon" size="tiny" decorative />
+        <CommonIcon
+          :name="inputIcon"
+          size="tiny"
+          decorative
+          @click.stop="picker?.toggleMenu()"
+        />
       </template>
       </template>
       <template #clear-icon>
       <template #clear-icon>
         <CommonIcon
         <CommonIcon

+ 15 - 12
app/frontend/apps/desktop/components/Form/fields/FieldDate/__tests__/FieldDateTime.spec.ts

@@ -47,7 +47,7 @@ describe('Fields - FieldDate', () => {
 
 
       const input = view.getByLabelText('Date')
       const input = view.getByLabelText('Date')
 
 
-      expect(input).toHaveDisplayValue('')
+      expect(input).toHaveDisplayValue('YYYY-MM-DD')
 
 
       await view.events.click(input)
       await view.events.click(input)
       await view.events.click(view.getByText('12'))
       await view.events.click(view.getByText('12'))
@@ -63,7 +63,7 @@ describe('Fields - FieldDate', () => {
 
 
       const input = view.getByLabelText('Date')
       const input = view.getByLabelText('Date')
 
 
-      expect(input).toHaveDisplayValue('')
+      expect(input).toHaveDisplayValue('YYYY-MM-DD')
 
 
       await view.events.type(input, '2021-04-12')
       await view.events.type(input, '2021-04-12')
       await view.events.keyboard('{Enter}')
       await view.events.keyboard('{Enter}')
@@ -80,7 +80,8 @@ describe('Fields - FieldDate', () => {
       })
       })
 
 
       const input = view.getByLabelText('Date')
       const input = view.getByLabelText('Date')
-      expect(input).toHaveDisplayValue('')
+
+      expect(input).toHaveDisplayValue('YYYY-MM-DD - YYYY-MM-DD')
 
 
       await view.events.click(input)
       await view.events.click(input)
 
 
@@ -102,7 +103,7 @@ describe('Fields - FieldDate', () => {
 
 
       const input = view.getByLabelText('Date')
       const input = view.getByLabelText('Date')
 
 
-      expect(input).toHaveDisplayValue('')
+      expect(input).toHaveDisplayValue('YYYY-MM-DD - YYYY-MM-DD')
 
 
       await view.events.type(input, '2021-04-12 - 2021-04-14')
       await view.events.type(input, '2021-04-12 - 2021-04-14')
       await view.events.keyboard('{Enter}')
       await view.events.keyboard('{Enter}')
@@ -120,7 +121,7 @@ describe('Fields - FieldDate', () => {
 
 
       const input = view.getByLabelText('Date')
       const input = view.getByLabelText('Date')
 
 
-      expect(input).toHaveDisplayValue('')
+      expect(input).toHaveDisplayValue('YYYY-MM-DD - YYYY-MM-DD')
 
 
       await view.events.type(input, '2021-04-28 - 2021-04-14')
       await view.events.type(input, '2021-04-28 - 2021-04-14')
       await view.events.keyboard('{Enter}')
       await view.events.keyboard('{Enter}')
@@ -137,7 +138,8 @@ describe('Fields - FieldDate', () => {
       const view = await renderDateField()
       const view = await renderDateField()
 
 
       const input = view.getByLabelText('Date')
       const input = view.getByLabelText('Date')
-      expect(input).toHaveDisplayValue('')
+
+      expect(input).toHaveDisplayValue('YYYY-MM-DD')
 
 
       await view.events.click(input)
       await view.events.click(input)
       await view.events.click(view.getByText('Today'))
       await view.events.click(view.getByText('Today'))
@@ -173,7 +175,7 @@ describe('Fields - FieldDate', () => {
       const emittedInput = view.emitted().inputRaw as Array<Array<InputEvent>>
       const emittedInput = view.emitted().inputRaw as Array<Array<InputEvent>>
 
 
       expect(emittedInput[0][0]).toBeNull()
       expect(emittedInput[0][0]).toBeNull()
-      expect(input).toHaveDisplayValue('')
+      expect(input).toHaveDisplayValue('YYYY-MM-DD')
     })
     })
 
 
     it("doesn't allow changing anything while disabled", async () => {
     it("doesn't allow changing anything while disabled", async () => {
@@ -200,7 +202,7 @@ describe('Fields - FieldDate', () => {
       await view.events.click(input)
       await view.events.click(input)
       await view.events.click(view.getByText('12'))
       await view.events.click(view.getByText('12'))
 
 
-      expect(input).toHaveDisplayValue('')
+      expect(input).toHaveDisplayValue('YYYY-MM-DD')
 
 
       await view.events.click(view.getByText('13'))
       await view.events.click(view.getByText('13'))
 
 
@@ -217,7 +219,7 @@ describe('Fields - FieldDate', () => {
       await view.events.click(input)
       await view.events.click(input)
       await view.events.click(view.getByText('15'))
       await view.events.click(view.getByText('15'))
 
 
-      expect(input).toHaveDisplayValue('')
+      expect(input).toHaveDisplayValue('YYYY-MM-DD')
 
 
       await view.rerender({
       await view.rerender({
         maxDate: '2021-04-15',
         maxDate: '2021-04-15',
@@ -246,6 +248,7 @@ describe('Fields - FieldDate', () => {
       const input = view.getByLabelText('Date')
       const input = view.getByLabelText('Date')
 
 
       await view.events.click(input)
       await view.events.click(input)
+
       const dialog = view.getByRole('dialog')
       const dialog = view.getByRole('dialog')
 
 
       expect(dialog).toHaveClass('dp__theme_dark')
       expect(dialog).toHaveClass('dp__theme_dark')
@@ -260,7 +263,7 @@ describe('Fields - FieldDate', () => {
 
 
       const input = view.getByLabelText('Date')
       const input = view.getByLabelText('Date')
 
 
-      expect(input).toHaveDisplayValue('')
+      expect(input).toHaveDisplayValue('YYYY-MM-DD hh:mm')
 
 
       await view.events.click(input)
       await view.events.click(input)
       await view.events.click(view.getByText('Today'))
       await view.events.click(view.getByText('Today'))
@@ -278,7 +281,7 @@ describe('Fields - FieldDate', () => {
 
 
       const input = view.getByLabelText('Date')
       const input = view.getByLabelText('Date')
 
 
-      expect(input).toHaveDisplayValue('')
+      expect(input).toHaveDisplayValue('YYYY-MM-DD hh:mm')
 
 
       await view.events.type(input, '2021-04-13 11:10')
       await view.events.type(input, '2021-04-13 11:10')
       await view.events.keyboard('{Enter}')
       await view.events.keyboard('{Enter}')
@@ -300,7 +303,7 @@ describe('Fields - FieldDate', () => {
 
 
       const input = view.getByLabelText('Date')
       const input = view.getByLabelText('Date')
 
 
-      expect(input).toHaveDisplayValue('')
+      expect(input).toHaveDisplayValue('MM/DD/YYYY hh:mm pp')
 
 
       await view.events.click(input)
       await view.events.click(input)
       await view.events.click(view.getByText('Today'))
       await view.events.click(view.getByText('Today'))

+ 1 - 56
app/frontend/apps/desktop/components/Form/fields/FieldDate/index.ts

@@ -7,63 +7,8 @@ import formUpdaterTrigger from '#shared/form/features/formUpdaterTrigger.ts'
 
 
 import FieldDateTimeInput from './FieldDateTimeInput.vue'
 import FieldDateTimeInput from './FieldDateTimeInput.vue'
 
 
-import type { FormKitNode, FormKitProps } from '@formkit/core'
-
-const addDateRangeValidation = (node: FormKitNode) => {
-  const addDataRangeValidation = (props: Partial<FormKitProps>) => {
-    const { validation } = props
-    if (Array.isArray(validation)) {
-      validation.push(['date_range'])
-      return
-    }
-
-    if (!validation) {
-      props.validation = 'date_range'
-      return
-    }
-
-    if (!validation.includes('required')) {
-      props.validation = `${validation}|date_range`
-    }
-  }
-
-  const removeDataRangeValidation = (props: Partial<FormKitProps>) => {
-    const { validation } = props
-
-    if (!validation) return
-
-    if (Array.isArray(validation)) {
-      props.validation = validation.filter(([rule]) => rule !== 'date_range')
-      return
-    }
-
-    if (validation.includes('date_range')) {
-      props.validation = validation
-        .split('|')
-        .filter((rule: string) => !rule.includes('date_range'))
-        .join('|')
-    }
-  }
-
-  node.on('created', () => {
-    const { props } = node
-
-    if (props.range) {
-      addDataRangeValidation(props)
-    }
-
-    node.on('prop:range', ({ payload }) => {
-      if (payload) {
-        addDataRangeValidation(props)
-      } else {
-        removeDataRangeValidation(props)
-      }
-    })
-  })
-}
-
 const dateFieldDefinition = createInput(FieldDateTimeInput, dateFieldProps, {
 const dateFieldDefinition = createInput(FieldDateTimeInput, dateFieldProps, {
-  features: [addLink, formUpdaterTrigger(), addDateRangeValidation],
+  features: [addLink, formUpdaterTrigger()],
 })
 })
 
 
 export default [
 export default [

+ 0 - 2
app/frontend/apps/desktop/pages/guided-setup/__tests__/guided-setup-manual-channel-email.spec.ts

@@ -685,8 +685,6 @@ describe('guided setup manual channel email', () => {
         '2025-01-01 00:00',
         '2025-01-01 00:00',
       )
       )
 
 
-      await view.events.keyboard('{enter}')
-
       await getNode('channel-email-inbound-messages')?.settled
       await getNode('channel-email-inbound-messages')?.settled
 
 
       await view.events.click(
       await view.events.click(

+ 5 - 1
app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-out-of-office.spec.ts

@@ -278,7 +278,11 @@ describe('Out of Office page', () => {
       const view = await visitView('/personal-setting/out-of-office')
       const view = await visitView('/personal-setting/out-of-office')
 
 
       expect(view.getByLabelText('Reason for absence')).toHaveValue('')
       expect(view.getByLabelText('Reason for absence')).toHaveValue('')
-      expect(view.getByLabelText('Start and end date')).toHaveValue('')
+
+      expect(view.getByLabelText('Start and end date')).toHaveValue(
+        'YYYY-MM-DD - YYYY-MM-DD',
+      )
+
       expect(view.getByLabelText('Replacement agent')).toHaveValue('')
       expect(view.getByLabelText('Replacement agent')).toHaveValue('')
       expect(view.getByLabelText('Active')).not.toBeChecked()
       expect(view.getByLabelText('Active')).not.toBeChecked()
     })
     })

+ 10 - 0
app/frontend/build/manualChunks.mjs

@@ -23,6 +23,11 @@ const matchers = [
     matcher: (id) => id.includes('@vue/apollo'),
     matcher: (id) => id.includes('@vue/apollo'),
     chunk: 'apollo',
     chunk: 'apollo',
   },
   },
+  {
+    vendor: true,
+    matcher: (id) => id.includes('@vuepic/vue-datepicker'),
+    chunk: 'datepicker',
+  },
   {
   {
     vendor: false,
     vendor: false,
     matcher: (id) => id.includes('frontend/shared/server'),
     matcher: (id) => id.includes('frontend/shared/server'),
@@ -33,6 +38,11 @@ const matchers = [
     matcher: (id) => id.includes('node_modules/lodash-es'),
     matcher: (id) => id.includes('node_modules/lodash-es'),
     chunk: 'lodash',
     chunk: 'lodash',
   },
   },
+  {
+    vendor: true,
+    matcher: (id) => id.includes('node_modules/date-fns'),
+    chunk: 'date',
+  },
   {
   {
     vendor: true,
     vendor: true,
     matcher: (id) => /node_modules\/@formkit/.test(id),
     matcher: (id) => /node_modules\/@formkit/.test(id),

+ 1 - 1
app/frontend/shared/components/Form/fields/FieldDate/useDateTime.ts

@@ -12,7 +12,7 @@ export const useDateTime = (context: Ref<DateTimeContext>) => {
   const timePicker = computed(() => context.value.type === 'datetime')
   const timePicker = computed(() => context.value.type === 'datetime')
 
 
   const valueFormat = computed(() => {
   const valueFormat = computed(() => {
-    if (timePicker.value) return 'iso'
+    if (timePicker.value) return "yyyy-MM-dd'T'HH:mm:ss.SSSX"
     return 'yyyy-MM-dd'
     return 'yyyy-MM-dd'
   })
   })
 
 

+ 10 - 10
i18n/zammad.pot

@@ -1260,7 +1260,7 @@ msgid "Agent idle timeout"
 msgstr ""
 msgstr ""
 
 
 #: app/models/role.rb:156
 #: app/models/role.rb:156
-#: app/models/user.rb:723
+#: app/models/user.rb:727
 msgid "Agent limit exceeded, please check your account settings."
 msgid "Agent limit exceeded, please check your account settings."
 msgstr ""
 msgstr ""
 
 
@@ -1780,7 +1780,7 @@ msgstr ""
 msgid "Assignment timeout in minutes if assigned agent is not working on it. Ticket will be shown as unassigend."
 msgid "Assignment timeout in minutes if assigned agent is not working on it. Ticket will be shown as unassigend."
 msgstr ""
 msgstr ""
 
 
-#: app/models/user.rb:626
+#: app/models/user.rb:630
 msgid "At least one identifier (firstname, lastname, phone, mobile or email) for user is required."
 msgid "At least one identifier (firstname, lastname, phone, mobile or email) for user is required."
 msgstr ""
 msgstr ""
 
 
@@ -1793,7 +1793,7 @@ msgid "At least one object must be selected."
 msgstr ""
 msgstr ""
 
 
 #: app/models/role.rb:128
 #: app/models/role.rb:128
-#: app/models/user.rb:697
+#: app/models/user.rb:701
 msgid "At least one user needs to have admin permissions."
 msgid "At least one user needs to have admin permissions."
 msgstr ""
 msgstr ""
 
 
@@ -2883,7 +2883,7 @@ msgstr ""
 
 
 #: app/assets/javascripts/app/views/generic/searchable_select.jst.eco:56
 #: app/assets/javascripts/app/views/generic/searchable_select.jst.eco:56
 #: app/frontend/apps/desktop/components/Form/fields/FieldAutoComplete/FieldAutoCompleteInput.vue:679
 #: app/frontend/apps/desktop/components/Form/fields/FieldAutoComplete/FieldAutoCompleteInput.vue:679
-#: app/frontend/apps/desktop/components/Form/fields/FieldDate/FieldDateTimeInput.vue:153
+#: app/frontend/apps/desktop/components/Form/fields/FieldDate/FieldDateTimeInput.vue:381
 #: app/frontend/apps/desktop/components/Form/fields/FieldSelect/FieldSelectInput.vue:333
 #: app/frontend/apps/desktop/components/Form/fields/FieldSelect/FieldSelectInput.vue:333
 #: app/frontend/apps/desktop/components/Form/fields/FieldTreeSelect/FieldTreeSelectInput.vue:382
 #: app/frontend/apps/desktop/components/Form/fields/FieldTreeSelect/FieldTreeSelectInput.vue:382
 #: app/frontend/apps/mobile/components/Form/fields/FieldAutoComplete/FieldAutoCompleteInput.vue:155
 #: app/frontend/apps/mobile/components/Form/fields/FieldAutoComplete/FieldAutoCompleteInput.vue:155
@@ -5619,7 +5619,7 @@ msgstr ""
 msgid "Email address"
 msgid "Email address"
 msgstr ""
 msgstr ""
 
 
-#: app/models/user.rb:636
+#: app/models/user.rb:640
 msgid "Email address '%{email}' is already used for another user."
 msgid "Email address '%{email}' is already used for another user."
 msgstr ""
 msgstr ""
 
 
@@ -7968,7 +7968,7 @@ msgstr ""
 msgid "Invalid client_id received!"
 msgid "Invalid client_id received!"
 msgstr ""
 msgstr ""
 
 
-#: app/models/user.rb:581
+#: app/models/user.rb:585
 msgid "Invalid email '%{email}'"
 msgid "Invalid email '%{email}'"
 msgstr ""
 msgstr ""
 
 
@@ -9486,7 +9486,7 @@ msgstr ""
 msgid "More information can be found here."
 msgid "More information can be found here."
 msgstr ""
 msgstr ""
 
 
-#: app/models/user.rb:649
+#: app/models/user.rb:653
 msgid "More than 250 secondary organizations are not allowed."
 msgid "More than 250 secondary organizations are not allowed."
 msgstr ""
 msgstr ""
 
 
@@ -12818,7 +12818,7 @@ msgstr ""
 msgid "Secondary organizations"
 msgid "Secondary organizations"
 msgstr ""
 msgstr ""
 
 
-#: app/models/user.rb:643
+#: app/models/user.rb:647
 msgid "Secondary organizations are only allowed when the primary organization is given."
 msgid "Secondary organizations are only allowed when the primary organization is given."
 msgstr ""
 msgstr ""
 
 
@@ -16582,7 +16582,7 @@ msgstr ""
 msgid "To work on Tickets."
 msgid "To work on Tickets."
 msgstr ""
 msgstr ""
 
 
-#: app/frontend/apps/desktop/components/Form/fields/FieldDate/FieldDateTimeInput.vue:50
+#: app/frontend/apps/desktop/components/Form/fields/FieldDate/FieldDateTimeInput.vue:58
 #: app/frontend/apps/mobile/components/Form/fields/FieldDate/FieldDateTimeInput.vue:109
 #: app/frontend/apps/mobile/components/Form/fields/FieldDate/FieldDateTimeInput.vue:109
 #: public/assets/chat/chat-no-jquery.coffee:215
 #: public/assets/chat/chat-no-jquery.coffee:215
 #: public/assets/chat/chat.coffee:213
 #: public/assets/chat/chat.coffee:213
@@ -19244,7 +19244,7 @@ msgstr ""
 msgid "is the wrong length (should be 1 character)"
 msgid "is the wrong length (should be 1 character)"
 msgstr ""
 msgstr ""
 
 
-#: app/models/user.rb:847
+#: app/models/user.rb:851
 msgid "is too long"
 msgid "is too long"
 msgstr ""
 msgstr ""
 
 

+ 2 - 0
package.json

@@ -156,6 +156,7 @@
     "@vueuse/router": "^11.3.0",
     "@vueuse/router": "^11.3.0",
     "@vueuse/shared": "^11.3.0",
     "@vueuse/shared": "^11.3.0",
     "async-mutex": "^0.5.0",
     "async-mutex": "^0.5.0",
+    "date-fns": "^4.1.0",
     "graphql": "^16.10.0",
     "graphql": "^16.10.0",
     "graphql-ruby-client": "^1.14.5",
     "graphql-ruby-client": "^1.14.5",
     "graphql-tag": "^2.12.6",
     "graphql-tag": "^2.12.6",
@@ -177,6 +178,7 @@
     "vue": "^3.5.13",
     "vue": "^3.5.13",
     "vue-advanced-cropper": "^2.8.9",
     "vue-advanced-cropper": "^2.8.9",
     "vue-easy-lightbox": "1.19.0",
     "vue-easy-lightbox": "1.19.0",
+    "vue-imask": "^7.6.1",
     "vue-router": "^4.5.0",
     "vue-router": "^4.5.0",
     "vue3-draggable-resizable": "^1.6.5",
     "vue3-draggable-resizable": "^1.6.5",
     "workbox-core": "^7.3.0",
     "workbox-core": "^7.3.0",

+ 46 - 0
pnpm-lock.yaml

@@ -172,6 +172,9 @@ importers:
       async-mutex:
       async-mutex:
         specifier: ^0.5.0
         specifier: ^0.5.0
         version: 0.5.0
         version: 0.5.0
+      date-fns:
+        specifier: ^4.1.0
+        version: 4.1.0
       graphql:
       graphql:
         specifier: ^16.10.0
         specifier: ^16.10.0
         version: 16.10.0
         version: 16.10.0
@@ -235,6 +238,9 @@ importers:
       vue-easy-lightbox:
       vue-easy-lightbox:
         specifier: 1.19.0
         specifier: 1.19.0
         version: 1.19.0(vue@3.5.13(typescript@5.7.3))
         version: 1.19.0(vue@3.5.13(typescript@5.7.3))
+      vue-imask:
+        specifier: ^7.6.1
+        version: 7.6.1(vue@3.5.13(typescript@5.7.3))
       vue-router:
       vue-router:
         specifier: ^4.5.0
         specifier: ^4.5.0
         version: 4.5.0(vue@3.5.13(typescript@5.7.3))
         version: 4.5.0(vue@3.5.13(typescript@5.7.3))
@@ -1085,6 +1091,10 @@ packages:
     peerDependencies:
     peerDependencies:
       '@babel/core': ^7.0.0-0
       '@babel/core': ^7.0.0-0
 
 
+  '@babel/runtime-corejs3@7.26.0':
+    resolution: {integrity: sha512-YXHu5lN8kJCb1LOb9PgV6pvak43X2h4HvRApcN5SdWeaItQOzfn1hgP6jasD6KWQyJDBxrVmA9o9OivlnNJK/w==}
+    engines: {node: '>=6.9.0'}
+
   '@babel/runtime@7.23.4':
   '@babel/runtime@7.23.4':
     resolution: {integrity: sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==}
     resolution: {integrity: sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==}
     engines: {node: '>=6.9.0'}
     engines: {node: '>=6.9.0'}
@@ -3059,6 +3069,9 @@ packages:
   core-js-compat@3.26.0:
   core-js-compat@3.26.0:
     resolution: {integrity: sha512-piOX9Go+Z4f9ZiBFLnZ5VrOpBl0h7IGCkiFUN11QTe6LjAvOT3ifL/5TdoizMh99hcGy5SoLyWbapIY/PIb/3A==}
     resolution: {integrity: sha512-piOX9Go+Z4f9ZiBFLnZ5VrOpBl0h7IGCkiFUN11QTe6LjAvOT3ifL/5TdoizMh99hcGy5SoLyWbapIY/PIb/3A==}
 
 
+  core-js-pure@3.39.0:
+    resolution: {integrity: sha512-7fEcWwKI4rJinnK+wLTezeg2smbFFdSBP6E2kQZNbnzM2s1rpKQ6aaRteZSSg7FLU3P0HGGVo/gbpfanU36urg==}
+
   cosmiconfig@8.2.0:
   cosmiconfig@8.2.0:
     resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==}
     resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==}
     engines: {node: '>=14'}
     engines: {node: '>=14'}
@@ -3150,6 +3163,9 @@ packages:
   date-fns@3.6.0:
   date-fns@3.6.0:
     resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
     resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
 
 
+  date-fns@4.1.0:
+    resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
+
   de-indent@1.0.2:
   de-indent@1.0.2:
     resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
     resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
 
 
@@ -4080,6 +4096,10 @@ packages:
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
     hasBin: true
     hasBin: true
 
 
+  imask@7.6.1:
+    resolution: {integrity: sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg==}
+    engines: {npm: '>=4.0.0'}
+
   immutable@3.7.6:
   immutable@3.7.6:
     resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==}
     resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==}
     engines: {node: '>=0.8.0'}
     engines: {node: '>=0.8.0'}
@@ -6433,6 +6453,11 @@ packages:
     peerDependencies:
     peerDependencies:
       eslint: '>=6.0.0'
       eslint: '>=6.0.0'
 
 
+  vue-imask@7.6.1:
+    resolution: {integrity: sha512-/5ZVNerI9Dn6gZ/cSCYGiZK4JHdwsEBgHBTRpVwS2U0URxK/Jt5FlQuoL1DhbxC6t4ElcVMWYOvkE2hR8hdt1w==}
+    peerDependencies:
+      vue: '>=2.7'
+
   vue-router@4.5.0:
   vue-router@4.5.0:
     resolution: {integrity: sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==}
     resolution: {integrity: sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==}
     peerDependencies:
     peerDependencies:
@@ -7468,6 +7493,11 @@ snapshots:
       '@babel/types': 7.25.6
       '@babel/types': 7.25.6
       esutils: 2.0.3
       esutils: 2.0.3
 
 
+  '@babel/runtime-corejs3@7.26.0':
+    dependencies:
+      core-js-pure: 3.39.0
+      regenerator-runtime: 0.14.1
+
   '@babel/runtime@7.23.4':
   '@babel/runtime@7.23.4':
     dependencies:
     dependencies:
       regenerator-runtime: 0.14.1
       regenerator-runtime: 0.14.1
@@ -9872,6 +9902,8 @@ snapshots:
     dependencies:
     dependencies:
       browserslist: 4.23.3
       browserslist: 4.23.3
 
 
+  core-js-pure@3.39.0: {}
+
   cosmiconfig@8.2.0:
   cosmiconfig@8.2.0:
     dependencies:
     dependencies:
       import-fresh: 3.3.0
       import-fresh: 3.3.0
@@ -9973,6 +10005,8 @@ snapshots:
 
 
   date-fns@3.6.0: {}
   date-fns@3.6.0: {}
 
 
+  date-fns@4.1.0: {}
+
   de-indent@1.0.2: {}
   de-indent@1.0.2: {}
 
 
   debounce@1.2.1: {}
   debounce@1.2.1: {}
@@ -11092,6 +11126,10 @@ snapshots:
 
 
   image-size@0.5.5: {}
   image-size@0.5.5: {}
 
 
+  imask@7.6.1:
+    dependencies:
+      '@babel/runtime-corejs3': 7.26.0
+
   immutable@3.7.6: {}
   immutable@3.7.6: {}
 
 
   immutable@5.0.2: {}
   immutable@5.0.2: {}
@@ -13497,6 +13535,14 @@ snapshots:
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
 
 
+  vue-imask@7.6.1(vue@3.5.13(typescript@5.7.3)):
+    dependencies:
+      imask: 7.6.1
+      vue: 3.5.13(typescript@5.7.3)
+      vue-demi: 0.14.10(vue@3.5.13(typescript@5.7.3))
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+
   vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)):
   vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)):
     dependencies:
     dependencies:
       '@vue/devtools-api': 6.6.4
       '@vue/devtools-api': 6.6.4

Некоторые файлы не были показаны из-за большого количества измененных файлов