diff --git a/core/components/minishop3/lexicon/en/vue.inc.php b/core/components/minishop3/lexicon/en/vue.inc.php
index 3e59cca7d..14e504192 100644
--- a/core/components/minishop3/lexicon/en/vue.inc.php
+++ b/core/components/minishop3/lexicon/en/vue.inc.php
@@ -165,6 +165,7 @@
$_lang['ms3_vue_xtype_combo_vendor'] = 'Vendor (combo)';
$_lang['ms3_vue_xtype_combo_autocomplete'] = 'Autocomplete (combo)';
$_lang['ms3_vue_xtype_combo_options'] = 'Product Options (chips)';
+$_lang['ms3_vue_xtype_datefield'] = 'Date';
// Dropdown list settings
$_lang['ms3_vue_select_options_label'] = 'List Options';
@@ -215,6 +216,7 @@
$_lang['ms3_vue_dbtype_text'] = 'TEXT (text)';
$_lang['ms3_vue_dbtype_int'] = 'INT (integer)';
$_lang['ms3_vue_dbtype_decimal'] = 'DECIMAL (decimal)';
+$_lang['ms3_vue_dbtype_date'] = 'DATE (date only)';
$_lang['ms3_vue_dbtype_datetime'] = 'DATETIME (date and time)';
$_lang['ms3_vue_dbtype_timestamp'] = 'TIMESTAMP';
$_lang['ms3_vue_dbtype_tinyint'] = 'TINYINT (0/1)';
diff --git a/core/components/minishop3/lexicon/ru/vue.inc.php b/core/components/minishop3/lexicon/ru/vue.inc.php
index 64d600371..6c8705e36 100644
--- a/core/components/minishop3/lexicon/ru/vue.inc.php
+++ b/core/components/minishop3/lexicon/ru/vue.inc.php
@@ -165,6 +165,7 @@
$_lang['ms3_vue_xtype_combo_vendor'] = 'Производитель (combo)';
$_lang['ms3_vue_xtype_combo_autocomplete'] = 'Автодополнение (combo)';
$_lang['ms3_vue_xtype_combo_options'] = 'Опции товара (chips)';
+$_lang['ms3_vue_xtype_datefield'] = 'Дата';
// Настройки выпадающего списка
$_lang['ms3_vue_select_options_label'] = 'Варианты списка';
@@ -215,6 +216,7 @@
$_lang['ms3_vue_dbtype_text'] = 'TEXT (текст)';
$_lang['ms3_vue_dbtype_int'] = 'INT (целое число)';
$_lang['ms3_vue_dbtype_decimal'] = 'DECIMAL (число с точностью)';
+$_lang['ms3_vue_dbtype_date'] = 'DATE (только дата)';
$_lang['ms3_vue_dbtype_datetime'] = 'DATETIME (дата и время)';
$_lang['ms3_vue_dbtype_timestamp'] = 'TIMESTAMP';
$_lang['ms3_vue_dbtype_tinyint'] = 'TINYINT (0/1)';
diff --git a/vueManager/src/components/DynamicField.vue b/vueManager/src/components/DynamicField.vue
index 8cb5e2883..2d063586a 100644
--- a/vueManager/src/components/DynamicField.vue
+++ b/vueManager/src/components/DynamicField.vue
@@ -92,19 +92,25 @@
/>
-
+
+
+
+
Unknown field type: {{ fieldConfig.xtype }}
-
-
+
@@ -256,9 +261,10 @@ import Textarea from 'primevue/textarea'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref, watch } from 'vue'
+import { formatLocalDateYmd } from '../utils/formatLocalDateYmd.js'
import { getKeyValueConfigFromField, serializeKeyValueForPost } from '../utils/keyValueField.js'
import { getRepeaterConfigFromField } from '../utils/repeaterField.js'
-import { parseStructuredExtraFieldValue } from '../utils/structuredExtraField.js'
+import { DATEFIELD_XTYPE, parseDateFieldValue, parseStructuredExtraFieldValue } from '../utils/structuredExtraField.js'
import AutocompleteCombo from './AutocompleteCombo.vue'
import FileBrowser from './FileBrowser.vue'
import KeyValueField from './KeyValueField.vue'
@@ -332,7 +338,7 @@ const isFileBrowserXtype = computed(() => {
* Determine if field is complex type (requires hidden field with JSON)
*/
const isComplexField = computed(() => {
- const complexTypes = ['combobox', 'datefield', 'colorpicker', 'chips', 'multiselect']
+ const complexTypes = ['combobox', 'colorpicker', 'chips', 'multiselect']
return complexTypes.includes(props.fieldConfig.xtype)
})
@@ -368,11 +374,28 @@ const selectOptions = computed(() => {
const repeaterConfig = computed(() => getRepeaterConfigFromField(props.fieldConfig))
const keyValueConfig = computed(() => getKeyValueConfigFromField(props.fieldConfig))
+const isDateField = computed(() => props.fieldConfig.xtype === DATEFIELD_XTYPE)
function normalizeIncomingValue(value) {
return parseStructuredExtraFieldValue(props.fieldConfig.xtype, value)
}
+function normalizedDateString(value) {
+ if (value == null || value === '') {
+ return null
+ }
+
+ if (typeof value === 'string') {
+ return value.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? value
+ }
+
+ return formatLocalDateYmd(value)
+}
+
+function sameCalendarDay(left, right) {
+ return normalizedDateString(left) === normalizedDateString(right)
+}
+
/**
* Serialise the repeater value for the hidden legacy-form input.
* RepeaterField emits an array; the processor expects JSON string or array.
@@ -426,27 +449,61 @@ const serializedValue = computed(() => {
const emit = defineEmits(['update:modelValue', 'blur'])
-// Local value for v-model
-const localValue = ref(normalizeIncomingValue(props.modelValue))
+// Local value for v-model (non-date fields)
+const localValue = ref(
+ isDateField.value ? null : normalizeIncomingValue(props.modelValue)
+)
+
+// DatePicker uses Date internally; parent state stays YYYY-MM-DD string
+const datePickerValue = ref(
+ isDateField.value ? parseDateFieldValue(props.modelValue) : null
+)
// Watch for external changes
watch(
() => props.modelValue,
newValue => {
+ if (isDateField.value) {
+ const parsed = parseDateFieldValue(newValue)
+ if (!sameCalendarDay(datePickerValue.value, parsed)) {
+ datePickerValue.value = parsed
+ }
+ return
+ }
+
localValue.value = normalizeIncomingValue(newValue)
}
)
// Watch for local changes and emit to parent
watch(localValue, newValue => {
+ if (isDateField.value) {
+ return
+ }
+
emit('update:modelValue', newValue)
})
+watch(datePickerValue, newDate => {
+ if (!isDateField.value) {
+ return
+ }
+
+ const serialized = formatLocalDateYmd(newDate) ?? null
+ if (sameCalendarDay(serialized, props.modelValue)) {
+ return
+ }
+
+ emit('update:modelValue', serialized)
+})
+
// Handle blur event
const handleBlur = () => {
emit('blur', {
fieldId: props.fieldConfig.id,
- value: localValue.value,
+ value: isDateField.value
+ ? (formatLocalDateYmd(datePickerValue.value) ?? null)
+ : localValue.value,
})
}
diff --git a/vueManager/src/components/ExtraFieldsManager.vue b/vueManager/src/components/ExtraFieldsManager.vue
index 545ba44f5..e6e3f300d 100644
--- a/vueManager/src/components/ExtraFieldsManager.vue
+++ b/vueManager/src/components/ExtraFieldsManager.vue
@@ -29,6 +29,7 @@ import {
parseRepeaterConfig,
REPEATER_XTYPE,
} from '../utils/repeaterField.js'
+import { DATEFIELD_XTYPE } from '../utils/structuredExtraField.js'
import KeyValueSchemaEditor from './KeyValueSchemaEditor.vue'
import RepeaterSchemaEditor from './RepeaterSchemaEditor.vue'
@@ -106,6 +107,7 @@ const xtypeOptions = computed(() => [
{ label: _('ms3_vue_xtype_combo_vendor'), value: 'ms3-combo-vendor' },
{ label: _('ms3_vue_xtype_combo_autocomplete'), value: 'ms3-combo-autocomplete' },
{ label: _('ms3_vue_xtype_combo_options'), value: 'ms3-combo-options' },
+ { label: _('ms3_vue_xtype_datefield'), value: DATEFIELD_XTYPE },
])
/**
@@ -116,6 +118,7 @@ const dbtypeOptions = computed(() => [
{ label: _('ms3_vue_dbtype_text'), value: 'text' },
{ label: _('ms3_vue_dbtype_int'), value: 'int' },
{ label: _('ms3_vue_dbtype_decimal'), value: 'decimal' },
+ { label: _('ms3_vue_dbtype_date'), value: 'date' },
{ label: _('ms3_vue_dbtype_datetime'), value: 'datetime' },
{ label: _('ms3_vue_dbtype_timestamp'), value: 'timestamp' },
{ label: _('ms3_vue_dbtype_tinyint'), value: 'tinyint' },
@@ -158,18 +161,20 @@ const indexTypeOptions = computed(() => [
const isRepeaterField = computed(() => fieldForm.value.xtype === REPEATER_XTYPE)
const isKeyValueField = computed(() => fieldForm.value.xtype === KEY_VALUE_XTYPE)
+const XTYPE_DB_DEFAULTS = {
+ [REPEATER_XTYPE]: { dbtype: 'json', phptype: 'json', precision: '', null: true },
+ [KEY_VALUE_XTYPE]: { dbtype: 'json', phptype: 'json', precision: '', null: true },
+ [DATEFIELD_XTYPE]: { dbtype: 'date', phptype: 'datetime', precision: '', null: true },
+}
+
watch(
() => fieldForm.value.xtype,
xtype => {
- if (xtype !== REPEATER_XTYPE && xtype !== KEY_VALUE_XTYPE) {
- return
+ const defaults = XTYPE_DB_DEFAULTS[xtype]
+ if (defaults) {
+ Object.assign(fieldForm.value, defaults)
}
- fieldForm.value.dbtype = 'json'
- fieldForm.value.phptype = 'json'
- fieldForm.value.precision = ''
- fieldForm.value.null = true
-
if (xtype === REPEATER_XTYPE && !fieldForm.value.repeater_config?.columns?.length) {
fieldForm.value.repeater_config = defaultRepeaterConfig()
}
diff --git a/vueManager/src/utils/structuredExtraField.js b/vueManager/src/utils/structuredExtraField.js
index f83c14506..301232fec 100644
--- a/vueManager/src/utils/structuredExtraField.js
+++ b/vueManager/src/utils/structuredExtraField.js
@@ -1,13 +1,46 @@
import { KEY_VALUE_XTYPE, parseKeyValueModelValue } from './keyValueField.js'
import { parseRepeaterModelValue, REPEATER_XTYPE } from './repeaterField.js'
+export const DATEFIELD_XTYPE = 'datefield'
+
const STRUCTURED_EXTRA_FIELD_PARSERS = {
[REPEATER_XTYPE]: parseRepeaterModelValue,
[KEY_VALUE_XTYPE]: parseKeyValueModelValue,
}
+const FULL_WIDTH_EXTRA_FIELD_XTYPES = new Set([REPEATER_XTYPE, KEY_VALUE_XTYPE])
+
+/**
+ * Parse stored date (YYYY-MM-DD or ISO) into a local Date for DatePicker.
+ *
+ * @param {string|number|Date|null|undefined} value
+ * @returns {Date|null}
+ */
+export function parseDateFieldValue(value) {
+ if (value == null || value === '') {
+ return null
+ }
+
+ if (value instanceof Date) {
+ return Number.isNaN(value.getTime()) ? null : value
+ }
+
+ if (typeof value !== 'string') {
+ return null
+ }
+
+ const ymd = value.match(/^(\d{4})-(\d{2})-(\d{2})/)
+ if (ymd) {
+ const local = new Date(Number(ymd[1]), Number(ymd[2]) - 1, Number(ymd[3]))
+ return Number.isNaN(local.getTime()) ? null : local
+ }
+
+ const parsed = new Date(value)
+ return Number.isNaN(parsed.getTime()) ? null : parsed
+}
+
export function isFullWidthExtraFieldXtype(xtype) {
- return Object.prototype.hasOwnProperty.call(STRUCTURED_EXTRA_FIELD_PARSERS, xtype)
+ return FULL_WIDTH_EXTRA_FIELD_XTYPES.has(xtype)
}
export function parseStructuredExtraFieldValue(xtype, value) {
diff --git a/vueManager/src/utils/structuredExtraField.test.js b/vueManager/src/utils/structuredExtraField.test.js
new file mode 100644
index 000000000..0835d2fba
--- /dev/null
+++ b/vueManager/src/utils/structuredExtraField.test.js
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+
+import { formatLocalDateYmd } from './formatLocalDateYmd.js'
+import { KEY_VALUE_XTYPE } from './keyValueField.js'
+import { REPEATER_XTYPE } from './repeaterField.js'
+import {
+ DATEFIELD_XTYPE,
+ isFullWidthExtraFieldXtype,
+ parseDateFieldValue,
+ parseStructuredExtraFieldValue,
+} from './structuredExtraField.js'
+
+describe('structuredExtraField datefield', () => {
+ it('does not treat datefield as full-width layout', () => {
+ expect(isFullWidthExtraFieldXtype(DATEFIELD_XTYPE)).toBe(false)
+ })
+
+ it('parses YYYY-MM-DD into local calendar Date', () => {
+ const parsed = parseDateFieldValue('2026-04-20')
+ expect(parsed).toBeInstanceOf(Date)
+ expect(parsed.getFullYear()).toBe(2026)
+ expect(parsed.getMonth()).toBe(3)
+ expect(parsed.getDate()).toBe(20)
+ })
+
+ it('serializes Date without UTC ISO shift', () => {
+ const localMidnight = new Date(2026, 3, 20, 0, 0, 0)
+ expect(formatLocalDateYmd(localMidnight)).toBe('2026-04-20')
+ expect(formatLocalDateYmd(localMidnight)).not.toBe(localMidnight.toISOString())
+ })
+
+ it('leaves datefield scalar in parseStructuredExtraFieldValue', () => {
+ const stored = '2026-04-21'
+ expect(parseStructuredExtraFieldValue(DATEFIELD_XTYPE, stored)).toBe(stored)
+ expect(parseStructuredExtraFieldValue(DATEFIELD_XTYPE, null)).toBeNull()
+ })
+
+ it('still parses repeater and key-value structured values', () => {
+ expect(parseStructuredExtraFieldValue(REPEATER_XTYPE, '[]')).toEqual([])
+ expect(parseStructuredExtraFieldValue(KEY_VALUE_XTYPE, '{}')).toEqual({})
+ })
+})