From dc7bbda2bc482a74f3e5e41e4a2e3e636ebdffcc Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 16 Jul 2026 09:22:42 +0600 Subject: [PATCH 1/3] refactor(vue-manager): extract thin list/config/crud/sort composables Pull shared mgr grid scaffolding into focused composables without a schema-driven mega-grid. Migrate pilot and twin grids; AbortController+seq in useResourceList addresses stale list races (#385 foundation). --- vueManager/src/components/DeliveriesGrid.vue | 407 +++++++----------- vueManager/src/components/LinksGrid.vue | 169 +++----- .../src/components/NotificationsGrid.vue | 160 +++---- .../src/components/OptionGroupsGrid.vue | 200 +++------ vueManager/src/components/OrdersGrid.vue | 308 ++++--------- vueManager/src/components/PaymentsGrid.vue | 350 ++++++--------- vueManager/src/components/StatusesGrid.vue | 194 +++------ vueManager/src/components/VendorsGrid.vue | 150 ++----- vueManager/src/composables/useCrudDialog.js | 99 +++++ vueManager/src/composables/useGridConfig.js | 75 ++++ vueManager/src/composables/useGridFilters.js | 111 +++++ .../src/composables/useOrderFormatters.js | 44 +- vueManager/src/composables/useResourceList.js | 132 ++++++ vueManager/src/composables/useSortableList.js | 86 ++++ vueManager/src/request.js | 7 + vueManager/src/utils/displayFormatters.js | 112 +++++ .../src/utils/displayFormatters.test.js | 61 +++ vueManager/src/utils/resourceListResponse.js | 22 + .../src/utils/resourceListResponse.test.js | 27 ++ 19 files changed, 1374 insertions(+), 1340 deletions(-) create mode 100644 vueManager/src/composables/useCrudDialog.js create mode 100644 vueManager/src/composables/useGridConfig.js create mode 100644 vueManager/src/composables/useGridFilters.js create mode 100644 vueManager/src/composables/useResourceList.js create mode 100644 vueManager/src/composables/useSortableList.js create mode 100644 vueManager/src/utils/displayFormatters.js create mode 100644 vueManager/src/utils/displayFormatters.test.js create mode 100644 vueManager/src/utils/resourceListResponse.js create mode 100644 vueManager/src/utils/resourceListResponse.test.js diff --git a/vueManager/src/components/DeliveriesGrid.vue b/vueManager/src/components/DeliveriesGrid.vue index 1ae601dd..80762773 100644 --- a/vueManager/src/components/DeliveriesGrid.vue +++ b/vueManager/src/components/DeliveriesGrid.vue @@ -23,9 +23,14 @@ import { useToast } from 'primevue/usetoast' import { computed, onMounted, ref } from 'vue' import draggable from 'vuedraggable' +import { useCrudDialog } from '../composables/useCrudDialog.js' +import { useGridConfig } from '../composables/useGridConfig.js' +import { useResourceList } from '../composables/useResourceList.js' import { useSelection } from '../composables/useSelection.js' +import { useSortableList } from '../composables/useSortableList.js' import request from '../request.js' import { resolveAddCostPriceBadgeKind } from '../utils/addCostPriceBadgeKind.js' +import { formatValue, getDisplayName } from '../utils/displayFormatters.js' import ActionsColumn from './ActionsColumn.vue' import FileBrowser from './FileBrowser.vue' import ValidationRulesEditor from './ValidationRulesEditor.vue' @@ -51,41 +56,29 @@ const { getItemName: item => item.name, }) -const columns = ref([]) -const loading = ref(false) -const deliveries = ref([]) -const totalRecords = ref(0) -const first = ref(0) -const rows = ref(20) +const { columns, loadGridConfig } = useGridConfig({ + gridId: 'deliveries', + responseKey: 'fields', + getFallbackColumns, +}) + const filterValues = ref({}) const filterableColumns = computed(() => columns.value.filter(col => col.filterable && col.visible)) const searchQuery = ref('') -const editDialogVisible = ref(false) -const editingDelivery = ref(null) -const isNewDelivery = ref(false) -const saving = ref(false) const activeTab = ref('0') const selectAll = ref(false) -// Payments tab -const payments = ref([]) -const deliveryPayments = ref([]) -const loadingPayments = ref(false) - -const deliveryAddCostBadgeKind = computed(() => - editingDelivery.value ? resolveAddCostPriceBadgeKind(editingDelivery.value.price) : null, -) - -/** - * Load deliveries list - */ -async function loadDeliveries() { - loading.value = true - - try { +const { + loading, + items: deliveries, + total: totalRecords, + load: loadDeliveries, + resetPageAndLoad, +} = useResourceList({ + fetchPage: ({ first: start, rows: limit, signal }) => { const params = { - start: first.value, - limit: rows.value, + start, + limit, } if (searchQuery.value) { @@ -99,47 +92,52 @@ async function loadDeliveries() { } }) - const response = await request.get('/api/mgr/deliveries', params) + return request.get('/api/mgr/deliveries', params, { signal }) + }, +}) - if (response && response.results) { - deliveries.value = response.results - totalRecords.value = response.total || 0 - } else { - console.error('[DeliveriesGrid] Invalid response:', response) - deliveries.value = [] - totalRecords.value = 0 - } - } catch (error) { - console.error('[DeliveriesGrid] Error loading deliveries:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_loading_data'), - life: 5000, - }) - } finally { - loading.value = false - } -} +const { + visible: editDialogVisible, + isNew: isNewDelivery, + saving, + item: editingDelivery, + openCreate, + openEdit, + close, + runSave, + toastSuccess, + toastWarn, +} = useCrudDialog({ + createDefaults: () => ({ + name: '', + description: '', + price: '0', + weight_price: 0, + distance_price: 0, + free_delivery_amount: 0, + position: 0, + active: true, + class: '', + logo: '', + validation_rules: '', + }), +}) -/** - * Handle pagination - */ -// eslint-disable-next-line no-unused-vars -function onPage(event) { - first.value = event.first - rows.value = event.rows - loadDeliveries() -} +// Payments tab +const payments = ref([]) +const deliveryPayments = ref([]) +const loadingPayments = ref(false) -/** - * Handle search - */ -// eslint-disable-next-line no-unused-vars -function onSearch() { - first.value = 0 - loadDeliveries() -} +const deliveryAddCostBadgeKind = computed(() => + editingDelivery.value ? resolveAddCostPriceBadgeKind(editingDelivery.value.price) : null +) + +const { onDragEnd } = useSortableList({ + items: deliveries, + sortUrl: '/api/mgr/deliveries/sort', + successMessage: _('delivery_order_saved'), + reload: loadDeliveries, +}) /** * Handle select all checkbox @@ -154,28 +152,67 @@ function onSelectAllChange(checked) { } /** - * Handle drag-drop reorder + * Get fallback grid columns */ -async function onDragEnd() { - const ids = deliveries.value.map(d => d.id) - try { - await request.post('/api/mgr/deliveries/sort', { ids }) - toast.add({ - severity: 'success', - summary: _('success'), - detail: _('delivery_order_saved'), - life: 2000, - }) - } catch (error) { - console.error('[DeliveriesGrid] Error saving order:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_saving_data'), - life: 5000, - }) - loadDeliveries() - } +function getFallbackColumns() { + return [ + { name: 'id', label: 'ID', visible: true, sortable: true, frozen: true, width: '5rem' }, + { + name: 'name', + label: _('delivery_name'), + visible: true, + sortable: true, + filterable: true, + }, + { + name: 'price', + label: _('delivery_price'), + visible: true, + sortable: true, + width: '7.5rem', + }, + { + name: 'free_delivery_amount', + label: _('delivery_free_amount'), + visible: true, + sortable: true, + width: '9.375rem', + }, + { + name: 'active', + label: _('delivery_active'), + visible: true, + sortable: true, + type: 'boolean', + width: '6.25rem', + }, + { + name: 'position', + label: _('delivery_position'), + visible: true, + sortable: true, + width: '6.25rem', + }, + { + name: 'actions', + label: _('actions'), + visible: true, + frozen: true, + type: 'actions', + width: '7.5rem', + actions: [ + { name: 'edit', handler: 'edit', icon: 'pi-pencil', label: 'edit' }, + { + name: 'delete', + handler: 'delete', + icon: 'pi-trash', + label: 'delete', + severity: 'danger', + confirm: false, + }, + ], + }, + ] } /** @@ -193,35 +230,17 @@ function normalizeImagePath(path) { * Open create modal */ function createDelivery() { - editingDelivery.value = { - name: '', - description: '', - price: '0', - weight_price: 0, - distance_price: 0, - free_delivery_amount: 0, - position: 0, - active: true, - class: '', - logo: '', - validation_rules: '', - } - isNewDelivery.value = true + openCreate() activeTab.value = '0' deliveryPayments.value = [] - editDialogVisible.value = true } /** * Open edit modal */ async function editDelivery(delivery) { - editingDelivery.value = { ...delivery } - isNewDelivery.value = false + openEdit(delivery) activeTab.value = '0' - editDialogVisible.value = true - - // Load payments for this delivery await loadDeliveryPayments(delivery.id) } @@ -268,13 +287,9 @@ function isPaymentEnabled(paymentId) { /** * Get translated payment name - * If translation exists, return it; otherwise return original name */ function getPaymentName(name) { - if (!name) return '' - const translated = _(name) - // If translation returns the same key, it means no translation found - return translated !== name ? translated : name + return getDisplayName(name, _) } /** @@ -322,49 +337,21 @@ async function togglePayment(paymentId, newValue) { */ async function saveDelivery() { if (!editingDelivery.value.name) { - toast.add({ - severity: 'warn', - summary: _('warning'), - detail: _('delivery_name_required'), - life: 3000, - }) + toastWarn(_('delivery_name_required')) return } - saving.value = true - - try { - let response - if (isNewDelivery.value) { - response = await request.post('/api/mgr/deliveries', editingDelivery.value) + const created = isNewDelivery.value + await runSave(async () => { + if (created) { + await request.post('/api/mgr/deliveries', editingDelivery.value) + toastSuccess(_('delivery_created')) } else { - response = await request.put( - `/api/mgr/deliveries/${editingDelivery.value.id}`, - editingDelivery.value - ) + await request.put(`/api/mgr/deliveries/${editingDelivery.value.id}`, editingDelivery.value) + toastSuccess(_('delivery_updated')) } - - if (response) { - toast.add({ - severity: 'success', - summary: _('success'), - detail: isNewDelivery.value ? _('delivery_created') : _('delivery_updated'), - life: 3000, - }) - editDialogVisible.value = false - loadDeliveries() - } - } catch (error) { - console.error('[DeliveriesGrid] Error saving delivery:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_saving_data'), - life: 5000, - }) - } finally { - saving.value = false - } + await loadDeliveries() + }) } /** @@ -405,8 +392,7 @@ function deleteDelivery(delivery) { * Apply filters */ function applyFilters() { - first.value = 0 - loadDeliveries() + resetPageAndLoad() } /** @@ -414,8 +400,7 @@ function applyFilters() { */ function clearFilters() { filterValues.value = {} - first.value = 0 - loadDeliveries() + resetPageAndLoad() } /** @@ -429,108 +414,18 @@ function getActionsConfig(column) { })) } -/** - * Load grid configuration - */ -async function loadGridConfig() { - try { - const response = await request.get('/api/mgr/grid-config/deliveries') - - if (response && response.fields) { - columns.value = response.fields - } else { - // Fallback default columns - columns.value = [ - { name: 'id', label: 'ID', visible: true, sortable: true, frozen: true, width: '5rem' }, - { - name: 'name', - label: _('delivery_name'), - visible: true, - sortable: true, - filterable: true, - }, - { - name: 'price', - label: _('delivery_price'), - visible: true, - sortable: true, - width: '7.5rem', - }, - { - name: 'free_delivery_amount', - label: _('delivery_free_amount'), - visible: true, - sortable: true, - width: '9.375rem', - }, - { - name: 'active', - label: _('delivery_active'), - visible: true, - sortable: true, - type: 'boolean', - width: '6.25rem', - }, - { - name: 'position', - label: _('delivery_position'), - visible: true, - sortable: true, - width: '6.25rem', - }, - { - name: 'actions', - label: _('actions'), - visible: true, - frozen: true, - type: 'actions', - width: '7.5rem', - actions: [ - { name: 'edit', handler: 'edit', icon: 'pi-pencil', label: 'edit' }, - { - name: 'delete', - handler: 'delete', - icon: 'pi-trash', - label: 'delete', - severity: 'danger', - confirm: false, - }, - ], - }, - ] - } - } catch (error) { - console.error('[DeliveriesGrid] Error loading grid config:', error) - } -} - /** * Format value for display */ -function formatValue(value, column) { - if (value === null || value === undefined) return '' - - if (column.type === 'boolean') { - return value ? _('yes') : _('no') - } - - if (column.format === 'number') { - return Number(value).toLocaleString() - } - - return value +function formatCellValue(value, column) { + return formatValue(value, column, _) } /** * Get display name - check if value is a lexicon key - * If translation exists, return it; otherwise return original value */ -function getDisplayName(name) { - if (!name) return '' - const translated = _(name) - // If translation was found (different from key), return it - // Otherwise return original name - return translated !== name ? translated : name +function resolveDisplayName(name) { + return getDisplayName(name, _) } onMounted(async () => { @@ -688,7 +583,7 @@ onMounted(async () => { - {{ getDisplayName(delivery.name) }} + {{ resolveDisplayName(delivery.name) }} @@ -711,7 +606,7 @@ onMounted(async () => { - {{ formatValue(delivery[column.name], column) }} + {{ formatCellValue(delivery[column.name], column) }} @@ -796,7 +691,10 @@ onMounted(async () => {
- + {
diff --git a/vueManager/src/components/LinksGrid.vue b/vueManager/src/components/LinksGrid.vue index d38d0de2..c201dac0 100644 --- a/vueManager/src/components/LinksGrid.vue +++ b/vueManager/src/components/LinksGrid.vue @@ -15,6 +15,8 @@ import { useConfirm } from 'primevue/useconfirm' import { useToast } from 'primevue/usetoast' import { computed, onMounted, ref } from 'vue' +import { useCrudDialog } from '../composables/useCrudDialog.js' +import { useResourceList } from '../composables/useResourceList.js' import { useSelection } from '../composables/useSelection.js' import request from '../request.js' import ActionsColumn from './ActionsColumn.vue' @@ -40,16 +42,46 @@ const { getItemName: item => item.name, }) -const loading = ref(false) -const links = ref([]) -const totalRecords = ref(0) -const first = ref(0) -const rows = ref(20) const linkTypes = ref([]) -const editDialogVisible = ref(false) -const editingLink = ref(null) -const isNewLink = ref(false) -const saving = ref(false) + +const { + loading, + items: links, + total: totalRecords, + first, + rows, + load: loadLinks, + onPage, +} = useResourceList({ + fetchPage: ({ first: start, rows: limit, signal }) => + request.get( + '/api/mgr/links', + { + start, + limit, + }, + { signal } + ), +}) + +const { + visible: editDialogVisible, + isNew: isNewLink, + saving, + item: editingLink, + openCreate, + openEdit, + close, + runSave, + toastSuccess, + toastWarn, +} = useCrudDialog({ + createDefaults: () => ({ + name: '', + type: '', + description: '', + }), +}) /** * Get display name with lexicon translation @@ -74,67 +106,13 @@ async function loadLinkTypes() { } } -/** - * Load links list - */ -async function loadLinks() { - loading.value = true - - try { - const response = await request.get('/api/mgr/links', { - start: first.value, - limit: rows.value, - }) - - if (response && response.results) { - links.value = response.results - totalRecords.value = response.total || 0 - } else { - links.value = [] - totalRecords.value = 0 - } - } catch (error) { - console.error('[LinksGrid] Error loading links:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_loading_data'), - life: 5000, - }) - } finally { - loading.value = false - } -} - -/** - * Handle pagination - */ -function onPage(event) { - first.value = event.first - rows.value = event.rows - loadLinks() -} - /** * Open create modal */ function createLink() { - editingLink.value = { - name: '', + openCreate({ type: linkTypes.value.length > 0 ? linkTypes.value[0].value : '', - description: '', - } - isNewLink.value = true - editDialogVisible.value = true -} - -/** - * Open edit modal - */ -function editLink(link) { - editingLink.value = { ...link } - isNewLink.value = false - editDialogVisible.value = true + }) } /** @@ -142,56 +120,26 @@ function editLink(link) { */ async function saveLink() { if (!editingLink.value.name) { - toast.add({ - severity: 'warn', - summary: _('warning'), - detail: _('link_name_required'), - life: 3000, - }) + toastWarn(_('link_name_required')) return } if (isNewLink.value && !editingLink.value.type) { - toast.add({ - severity: 'warn', - summary: _('warning'), - detail: _('link_type_required'), - life: 3000, - }) + toastWarn(_('link_type_required')) return } - saving.value = true - - try { - let response - if (isNewLink.value) { - response = await request.post('/api/mgr/links', editingLink.value) + const created = isNewLink.value + await runSave(async () => { + if (created) { + await request.post('/api/mgr/links', editingLink.value) + toastSuccess(_('link_created')) } else { - response = await request.put(`/api/mgr/links/${editingLink.value.id}`, editingLink.value) - } - - if (response) { - toast.add({ - severity: 'success', - summary: _('success'), - detail: isNewLink.value ? _('link_created') : _('link_updated'), - life: 3000, - }) - editDialogVisible.value = false - loadLinks() + await request.put(`/api/mgr/links/${editingLink.value.id}`, editingLink.value) + toastSuccess(_('link_updated')) } - } catch (error) { - console.error('[LinksGrid] Error saving link:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_saving_data'), - life: 5000, - }) - } finally { - saving.value = false - } + await loadLinks() + }) } /** @@ -353,7 +301,7 @@ onMounted(() => { :data="data" :actions="getActionsConfig()" grid-id="links" - @edit="editLink" + @edit="openEdit" @delete="deleteLink" @refresh="loadLinks" /> @@ -416,12 +364,7 @@ onMounted(() => {
diff --git a/vueManager/src/components/NotificationsGrid.vue b/vueManager/src/components/NotificationsGrid.vue index cfec621a..940e8f3a 100644 --- a/vueManager/src/components/NotificationsGrid.vue +++ b/vueManager/src/components/NotificationsGrid.vue @@ -16,16 +16,14 @@ import { useConfirm } from 'primevue/useconfirm' import { useToast } from 'primevue/usetoast' import { computed, onMounted, ref } from 'vue' +import { useCrudDialog } from '../composables/useCrudDialog.js' +import { useResourceList } from '../composables/useResourceList.js' import request from '../request.js' const toast = useToast() const confirm = useConfirm() const { _ } = useLexicon() -const loading = ref(false) -const notifications = ref([]) -const totalRecords = ref(0) - const references = ref({ statuses: [], events: [], @@ -37,32 +35,13 @@ const filterStatusId = ref(null) const filterChannel = ref(null) const filterRecipientType = ref(null) -const editDialogVisible = ref(false) -const editingNotification = ref(null) -const saving = ref(false) -const isNewRecord = ref(false) - -/** - * Load references - */ -async function loadReferences() { - try { - const response = await request.get('/api/mgr/notifications/references') - if (response) { - references.value = response - } - } catch (error) { - console.error('[NotificationsGrid] Error loading references:', error) - } -} - -/** - * Load notifications list - */ -async function loadNotifications() { - loading.value = true - - try { +const { + loading, + items: notifications, + load: loadNotifications, + resetPageAndLoad, +} = useResourceList({ + fetchPage: ({ signal }) => { const params = {} if (filterStatusId.value !== null) { @@ -75,41 +54,22 @@ async function loadNotifications() { params.recipient_type = filterRecipientType.value } - const response = await request.get('/api/mgr/notifications', params) - - if (response && response.results) { - notifications.value = response.results - totalRecords.value = response.total || 0 - } else { - notifications.value = [] - totalRecords.value = 0 - } - } catch (error) { - console.error('[NotificationsGrid] Error loading notifications:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_loading_data'), - life: 5000, - }) - } finally { - loading.value = false - } -} - -function applyFilters() { - loadNotifications() -} - -function clearFilters() { - filterStatusId.value = null - filterChannel.value = null - filterRecipientType.value = null - loadNotifications() -} + return request.get('/api/mgr/notifications', params, { signal }) + }, +}) -function createNotification() { - editingNotification.value = { +const { + visible: editDialogVisible, + isNew: isNewRecord, + saving, + item: editingNotification, + openCreate, + openEdit, + close, + runSave, + toastSuccess, +} = useCrudDialog({ + createDefaults: () => ({ event: 'order_status_changed', status_id: null, recipient_type: 'customer', @@ -120,52 +80,47 @@ function createNotification() { delay: 0, position: 0, config: {}, + }), +}) + +/** + * Load references + */ +async function loadReferences() { + try { + const response = await request.get('/api/mgr/notifications/references') + if (response) { + references.value = response + } + } catch (error) { + console.error('[NotificationsGrid] Error loading references:', error) } - isNewRecord.value = true - editDialogVisible.value = true } -function editNotification(notification) { - editingNotification.value = { ...notification } - isNewRecord.value = false - editDialogVisible.value = true +function clearFilters() { + filterStatusId.value = null + filterChannel.value = null + filterRecipientType.value = null + resetPageAndLoad() } async function saveNotification() { if (!editingNotification.value) return - saving.value = true - - try { - if (isNewRecord.value) { + const created = isNewRecord.value + await runSave(async () => { + if (created) { await request.post('/api/mgr/notifications', editingNotification.value) + toastSuccess(_('ms3_notification_created')) } else { await request.put( `/api/mgr/notifications/${editingNotification.value.id}`, editingNotification.value ) + toastSuccess(_('ms3_notification_updated')) } - - toast.add({ - severity: 'success', - summary: _('success'), - detail: isNewRecord.value ? _('ms3_notification_created') : _('ms3_notification_updated'), - life: 3000, - }) - - editDialogVisible.value = false await loadNotifications() - } catch (error) { - console.error('[NotificationsGrid] Error saving notification:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_saving_data'), - life: 5000, - }) - } finally { - saving.value = false - } + }) } function deleteNotification(notification) { @@ -297,11 +252,7 @@ onMounted(async () => { @@ -823,12 +724,7 @@ onMounted(async () => { diff --git a/vueManager/src/components/StatusesGrid.vue b/vueManager/src/components/StatusesGrid.vue index 35f4c334..bb6a4139 100644 --- a/vueManager/src/components/StatusesGrid.vue +++ b/vueManager/src/components/StatusesGrid.vue @@ -14,7 +14,10 @@ import { useToast } from 'primevue/usetoast' import { onMounted, ref } from 'vue' import draggable from 'vuedraggable' +import { useCrudDialog } from '../composables/useCrudDialog.js' +import { useResourceList } from '../composables/useResourceList.js' import { useSelection } from '../composables/useSelection.js' +import { useSortableList } from '../composables/useSortableList.js' import request from '../request.js' import ActionsColumn from './ActionsColumn.vue' @@ -39,13 +42,44 @@ const { getItemName: item => item.name, }) -const loading = ref(false) -const statuses = ref([]) -const totalRecords = ref(0) -const editDialogVisible = ref(false) -const editingStatus = ref(null) -const isNewStatus = ref(false) -const saving = ref(false) +const { + loading, + items: statuses, + total: totalRecords, + load: loadStatuses, +} = useResourceList({ + fetchPage: ({ signal }) => request.get('/api/mgr/statuses', { limit: 0 }, { signal }), +}) + +const { + visible: editDialogVisible, + isNew: isNewStatus, + saving, + item: editingStatus, + openCreate, + openEdit, + close, + runSave, + toastSuccess, + toastWarn, +} = useCrudDialog({ + createDefaults: () => ({ + name: '', + description: '', + color: '000000', + active: true, + final: false, + fixed: false, + }), +}) + +const { onDragEnd } = useSortableList({ + items: statuses, + sortUrl: '/api/mgr/statuses/sort', + successMessage: _('status_order_saved'), + reload: loadStatuses, +}) + const selectAll = ref(false) // Default color palette (similar to ExtJS) @@ -92,108 +126,26 @@ const colorPalette = [ 'FFFFFF', ] -/** - * Load statuses list - */ -async function loadStatuses() { - loading.value = true - - try { - const response = await request.get('/api/mgr/statuses', { limit: 0 }) - - if (response && response.results) { - statuses.value = response.results - totalRecords.value = response.total || 0 - } else { - statuses.value = [] - totalRecords.value = 0 - } - } catch (error) { - console.error('[StatusesGrid] Error loading statuses:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_loading_data'), - life: 5000, - }) - } finally { - loading.value = false - } -} - -/** - * Open create modal - */ -function createStatus() { - editingStatus.value = { - name: '', - description: '', - color: '000000', - active: true, - final: false, - fixed: false, - } - isNewStatus.value = true - editDialogVisible.value = true -} - -/** - * Open edit modal - */ -function editStatus(status) { - editingStatus.value = { ...status } - isNewStatus.value = false - editDialogVisible.value = true -} - /** * Save status (create or update) */ async function saveStatus() { if (!editingStatus.value.name) { - toast.add({ - severity: 'warn', - summary: _('warning'), - detail: _('status_name_required'), - life: 3000, - }) + toastWarn(_('status_name_required')) return } - saving.value = true - - try { - let response - if (isNewStatus.value) { - response = await request.post('/api/mgr/statuses', editingStatus.value) + const created = isNewStatus.value + await runSave(async () => { + if (created) { + await request.post('/api/mgr/statuses', editingStatus.value) + toastSuccess(_('status_created')) } else { - response = await request.put( - `/api/mgr/statuses/${editingStatus.value.id}`, - editingStatus.value - ) - } - - if (response) { - toast.add({ - severity: 'success', - summary: _('success'), - detail: isNewStatus.value ? _('status_created') : _('status_updated'), - life: 3000, - }) - editDialogVisible.value = false - loadStatuses() + await request.put(`/api/mgr/statuses/${editingStatus.value.id}`, editingStatus.value) + toastSuccess(_('status_updated')) } - } catch (error) { - console.error('[StatusesGrid] Error saving status:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_saving_data'), - life: 5000, - }) - } finally { - saving.value = false - } + await loadStatuses() + }) } /** @@ -230,34 +182,6 @@ function deleteStatus(status) { }) } -/** - * Handle drag end (vuedraggable) - */ -async function onDragEnd() { - // Extract IDs in new order - const ids = statuses.value.map(s => s.id) - - try { - await request.post('/api/mgr/statuses/sort', { ids }) - toast.add({ - severity: 'success', - summary: _('success'), - detail: _('status_order_saved'), - life: 2000, - }) - } catch (error) { - console.error('[StatusesGrid] Error saving order:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_saving_data'), - life: 5000, - }) - // Reload to restore original order - loadStatuses() - } -} - /** * Handle select all checkbox */ @@ -342,12 +266,7 @@ onMounted(() => {
-
@@ -453,7 +372,7 @@ onMounted(() => { :data="status" :actions="getActionsConfig()" grid-id="statuses" - @edit="editStatus" + @edit="openEdit" @delete="deleteStatus" @refresh="loadStatuses" /> @@ -543,12 +462,7 @@ onMounted(() => { diff --git a/vueManager/src/components/VendorsGrid.vue b/vueManager/src/components/VendorsGrid.vue index 02e8db0c..77aa7f40 100644 --- a/vueManager/src/components/VendorsGrid.vue +++ b/vueManager/src/components/VendorsGrid.vue @@ -19,7 +19,10 @@ import { useToast } from 'primevue/usetoast' import { computed, onMounted, ref } from 'vue' import draggable from 'vuedraggable' +import { useGridConfig } from '../composables/useGridConfig.js' +import { useResourceList } from '../composables/useResourceList.js' import { useSelection } from '../composables/useSelection.js' +import { useSortableList } from '../composables/useSortableList.js' import request from '../request.js' import ActionsColumn from './ActionsColumn.vue' import DynamicField from './DynamicField.vue' @@ -46,14 +49,7 @@ const { getItemName: item => item.name, }) -const columns = ref([]) -const loading = ref(false) -const vendors = ref([]) -const totalRecords = ref(0) -const first = ref(0) -const rows = ref(20) const filterValues = ref({}) -const filterableColumns = computed(() => columns.value.filter(col => col.filterable && col.visible)) const editDialogVisible = ref(false) const editingVendor = ref(null) const isNewVendor = ref(false) @@ -61,6 +57,48 @@ const saving = ref(false) const activeTab = ref('0') const selectAll = ref(false) +const { columns, loadGridConfig } = useGridConfig({ + gridId: 'vendors', + responseKey: 'columns', + getFallbackColumns: getDefaultColumns, +}) + +const { + loading, + items: vendors, + total: totalRecords, + first, + rows, + load: loadVendors, + onPage, + resetPageAndLoad, +} = useResourceList({ + fetchPage: ({ first: start, rows: limit, signal }) => { + const params = { + start, + limit, + } + + Object.keys(filterValues.value).forEach(key => { + const value = filterValues.value[key] + if (value !== null && value !== undefined && value !== '') { + params[`filter_${key}`] = value + } + }) + + return request.get('/api/mgr/vendors', params, { signal }) + }, +}) + +const { onDragEnd } = useSortableList({ + items: vendors, + sortUrl: '/api/mgr/vendors/sort', + successMessage: _('vendor_order_saved'), + reload: loadVendors, +}) + +const filterableColumns = computed(() => columns.value.filter(col => col.filterable && col.visible)) + // Model fields configuration const fieldsConfig = ref([]) const sectionsConfig = ref([]) @@ -155,57 +193,6 @@ async function loadFieldsConfig() { } } -/** - * Load vendors list - */ -async function loadVendors() { - loading.value = true - - try { - const params = { - start: first.value, - limit: rows.value, - } - - Object.keys(filterValues.value).forEach(key => { - const value = filterValues.value[key] - if (value !== null && value !== undefined && value !== '') { - params[`filter_${key}`] = value - } - }) - - const response = await request.get('/api/mgr/vendors', params) - - if (response && response.results) { - vendors.value = response.results - totalRecords.value = response.total || 0 - } else { - console.error('[VendorsGrid] Invalid response:', response) - vendors.value = [] - totalRecords.value = 0 - } - } catch (error) { - console.error('[VendorsGrid] Error loading vendors:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_loading_data'), - life: 5000, - }) - } finally { - loading.value = false - } -} - -/** - * Handle pagination - */ -function onPage(event) { - first.value = event.first - rows.value = event.rows - loadVendors() -} - /** * Create empty vendor object based on fields config */ @@ -355,8 +342,7 @@ function deleteVendor(vendor) { * Apply filters */ function applyFilters() { - first.value = 0 - loadVendors() + resetPageAndLoad() } /** @@ -364,36 +350,7 @@ function applyFilters() { */ function clearFilters() { filterValues.value = {} - first.value = 0 - loadVendors() -} - -/** - * Handle drag end (vuedraggable) - */ -async function onDragEnd() { - // Extract IDs in new order - const ids = vendors.value.map(v => v.id) - - try { - await request.post('/api/mgr/vendors/sort', { ids }) - toast.add({ - severity: 'success', - summary: _('success'), - detail: _('vendor_order_saved'), - life: 2000, - }) - } catch (error) { - console.error('[VendorsGrid] Error saving order:', error) - toast.add({ - severity: 'error', - summary: _('error'), - detail: error.message || _('error_saving_data'), - life: 5000, - }) - // Reload to restore original order - loadVendors() - } + resetPageAndLoad() } /** @@ -433,19 +390,6 @@ function getActionsConfig(column) { })) } -/** - * Load grid configuration - */ -async function loadGridConfig() { - try { - const response = await request.get('/api/mgr/grid-config/vendors') - columns.value = response.columns || [] - } catch (error) { - console.error('[VendorsGrid] Failed to load grid config:', error) - columns.value = getDefaultColumns() - } -} - /** * Default columns (if API unavailable) */ diff --git a/vueManager/src/composables/useCrudDialog.js b/vueManager/src/composables/useCrudDialog.js new file mode 100644 index 00000000..89c4660f --- /dev/null +++ b/vueManager/src/composables/useCrudDialog.js @@ -0,0 +1,99 @@ +import { useLexicon } from '@vuetools/useLexicon' +import { useToast } from 'primevue/usetoast' +import { ref } from 'vue' + +/** + * CRUD dialog state for mgr grids (open/create/edit/saving + toast helpers). + * Does not own form templates or validation schemas. + * + * @param {Object} [options] + * @param {() => Object} [options.createDefaults] Factory for new-record draft + * @returns {Object} + */ +export function useCrudDialog(options = {}) { + const { createDefaults = () => ({}) } = options + + const toast = useToast() + const { _ } = useLexicon() + + const visible = ref(false) + const isNew = ref(false) + const saving = ref(false) + const item = ref(null) + + function addToast(severity, summaryKey, detail, life) { + toast.add({ + severity, + summary: _(summaryKey), + detail, + life, + }) + } + + function openCreate(overrides = {}) { + item.value = { ...createDefaults(), ...overrides } + isNew.value = true + visible.value = true + } + + function openEdit(record) { + item.value = { ...record } + isNew.value = false + visible.value = true + } + + function close() { + visible.value = false + } + + function toastSuccess(detail, life = 3000) { + addToast('success', 'success', detail, life) + } + + function toastError(detail, life = 5000) { + addToast('error', 'error', detail || _('error_saving_data'), life) + } + + function toastWarn(detail, life = 3000) { + addToast('warn', 'warning', detail, life) + } + + /** + * Run async save body with saving flag + optional auto-close on success. + * Caller owns API calls and validation; returns true if body completed without throw. + * + * @param {() => Promise} saveFn + * @param {{ closeOnSuccess?: boolean }} [opts] + */ + async function runSave(saveFn, opts = {}) { + const { closeOnSuccess = true } = opts + saving.value = true + try { + await saveFn() + if (closeOnSuccess) { + close() + } + return true + } catch (error) { + console.error('[useCrudDialog] save failed:', error) + toastError(error?.message || _('error_saving_data')) + return false + } finally { + saving.value = false + } + } + + return { + visible, + isNew, + saving, + item, + openCreate, + openEdit, + close, + runSave, + toastSuccess, + toastError, + toastWarn, + } +} diff --git a/vueManager/src/composables/useGridConfig.js b/vueManager/src/composables/useGridConfig.js new file mode 100644 index 00000000..403b3158 --- /dev/null +++ b/vueManager/src/composables/useGridConfig.js @@ -0,0 +1,75 @@ +import { ref } from 'vue' + +import request from '../request.js' + +/** + * Load mgr grid column config from `/api/mgr/grid-config/{gridId}` with fallback. + * + * @param {Object} options + * @param {string} options.gridId + * @param {() => Array} options.getFallbackColumns + * @param {'columns'|'fields'} [options.responseKey='columns'] Prefer this key; also tries the other. + * @param {(response: Object) => void} [options.onLoaded] Extra fields from response (e.g. direct_filter_keys) + * @param {(error: Error) => void} [options.onError] Optional UI notify when fallback is used after failure + * @returns {Object} + */ +export function useGridConfig(options = {}) { + const { + gridId, + getFallbackColumns, + responseKey = 'columns', + onLoaded = null, + onError = null, + } = options + + if (!gridId) { + throw new Error('useGridConfig: gridId is required') + } + if (typeof getFallbackColumns !== 'function') { + throw new Error('useGridConfig: getFallbackColumns is required') + } + + const columns = ref([]) + const loading = ref(false) + const loaded = ref(false) + + function resolveColumns(response) { + if (!response || typeof response !== 'object') { + return null + } + + const primary = response[responseKey] + if (Array.isArray(primary)) { + return primary + } + + const altKey = responseKey === 'columns' ? 'fields' : 'columns' + const alternate = response[altKey] + return Array.isArray(alternate) ? alternate : null + } + + async function loadGridConfig() { + loading.value = true + try { + const response = await request.get(`/api/mgr/grid-config/${gridId}`) + const resolved = resolveColumns(response) + columns.value = resolved?.length ? resolved : getFallbackColumns() + onLoaded?.(response) + } catch (error) { + console.error(`[useGridConfig] Failed to load grid-config/${gridId}:`, error) + columns.value = getFallbackColumns() + onLoaded?.({}) + onError?.(error) + } finally { + loading.value = false + loaded.value = true + } + } + + return { + columns, + loading, + loaded, + loadGridConfig, + } +} diff --git a/vueManager/src/composables/useGridFilters.js b/vueManager/src/composables/useGridFilters.js new file mode 100644 index 00000000..b84fc23f --- /dev/null +++ b/vueManager/src/composables/useGridFilters.js @@ -0,0 +1,111 @@ +import { computed, ref } from 'vue' + +import { useGridFilterParams } from './useGridFilterParams.js' + +/** + * Filter state helpers for mgr grids (sorted list, apply/clear, daterange params). + * Builds on useGridFilterParams for direct_filter_keys serialization. + * + * @param {Object} [options] + * @param {() => void|Promise} [options.onApply] Called after apply/clear (usually reload list) + * @param {(first: number) => void} [options.setFirst] Optional pagination reset hook + * @returns {Object} + */ +export function useGridFilters(options = {}) { + const { onApply = null, setFirst = null } = options + const { setDirectFilterKeys, addFilterParam } = useGridFilterParams() + + const filters = ref({}) + const filterValues = ref({}) + + const sortedFilters = computed(() => + Object.entries(filters.value) + .map(([key, config]) => ({ key, ...config })) + .sort((a, b) => (a.position || 100) - (b.position || 100)) + ) + + const hasActiveFilters = computed(() => + Object.values(filterValues.value).some(value => !isEmptyFilterValue(value)) + ) + + function formatDateForApi(date) { + if (!date) return null + return new Date(date).toISOString().split('T')[0] + } + + function initFilterValues() { + filterValues.value = Object.fromEntries(Object.keys(filters.value).map(key => [key, null])) + } + + function setFilters(nextFilters) { + filters.value = nextFilters || {} + initFilterValues() + } + + function appendFilterParams(params) { + for (const [key, value] of Object.entries(filterValues.value)) { + if (isEmptyFilterValue(value)) { + continue + } + + const filterConfig = filters.value[key] + if (filterConfig?.type === 'daterange' && Array.isArray(value)) { + if (value[0]) { + addFilterParam( + params, + filterConfig.fields?.from || `${key}_from`, + formatDateForApi(value[0]) + ) + } + if (value[1]) { + addFilterParam(params, filterConfig.fields?.to || `${key}_to`, formatDateForApi(value[1])) + } + continue + } + + if (filterConfig?.type === 'datepicker' && value) { + addFilterParam(params, key, formatDateForApi(value)) + continue + } + + addFilterParam(params, key, value) + } + } + + async function applyFilters() { + await runFilterAction() + } + + async function clearFilters() { + await runFilterAction(true) + } + + async function runFilterAction(clearValues = false) { + if (clearValues) { + initFilterValues() + } + setFirst?.(0) + if (onApply) { + await onApply() + } + } + + return { + filters, + filterValues, + sortedFilters, + hasActiveFilters, + setFilters, + setDirectFilterKeys, + addFilterParam, + appendFilterParams, + initFilterValues, + applyFilters, + clearFilters, + formatDateForApi, + } +} + +function isEmptyFilterValue(value) { + return value === null || value === undefined || value === '' +} diff --git a/vueManager/src/composables/useOrderFormatters.js b/vueManager/src/composables/useOrderFormatters.js index 241299b1..153d82f8 100644 --- a/vueManager/src/composables/useOrderFormatters.js +++ b/vueManager/src/composables/useOrderFormatters.js @@ -1,30 +1,17 @@ +import { + formatDate, + formatPrice, + renderField as renderProductField, +} from '../utils/displayFormatters.js' + /** * Pure formatters for order UI (no order state). + * Shared date/price/field helpers live in utils/displayFormatters.js. */ export function useOrderFormatters() { - function formatDate(dateString) { - if (!dateString) return '-' - const date = new Date(dateString) - return date.toLocaleString('ru-RU', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }) - } - - function formatPrice(value) { - if (value === null || value === undefined) return '-' - return new Intl.NumberFormat('ru-RU', { - style: 'decimal', - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }).format(value) - } - function formatOptions(options) { if (!options) return [] + let parsed = options if (typeof options === 'string') { try { @@ -33,25 +20,20 @@ export function useOrderFormatters() { return [] } } + if (Array.isArray(parsed)) { return parsed.map(opt => (typeof opt === 'object' ? `${opt.key}: ${opt.value}` : opt)) } - if (typeof parsed === 'object') { + + if (parsed && typeof parsed === 'object') { return Object.entries(parsed).map(([key, value]) => `${key}: ${value}`) } + return [] } function getFieldWidthClass(field) { - const width = field.width || 6 - return `col-${width}` - } - - function renderProductField(data, column) { - if (column.template) { - return column.template.replace(/\{(\w+)\}/g, (match, key) => data[key] ?? '') - } - return data[column.name] + return `col-${field.width || 6}` } function getProductLink(data, column) { diff --git a/vueManager/src/composables/useResourceList.js b/vueManager/src/composables/useResourceList.js new file mode 100644 index 00000000..f1c9ba28 --- /dev/null +++ b/vueManager/src/composables/useResourceList.js @@ -0,0 +1,132 @@ +import { useLexicon } from '@vuetools/useLexicon' +import { useToast } from 'primevue/usetoast' +import { ref } from 'vue' + +import { parsePageResponse } from '../utils/resourceListResponse.js' + +/** + * Thin list + pagination (+ optional sort) for mgr grids. + * Stale responses are ignored via load sequence (#385); AbortController cancels in-flight fetch when supported. + * + * @param {Object} options + * @param {(ctx: { first: number, rows: number, sortField: string|null, sortOrder: number, signal: AbortSignal }) => Promise<{ results?: Array, total?: number }|Array>} options.fetchPage + * @param {number} [options.defaultRows=20] + * @param {string|null} [options.defaultSortField=null] + * @param {number} [options.defaultSortOrder=-1] + * @param {(error: Error) => void} [options.onError] + * @param {string} [options.itemsKey='results'] + * @returns {Object} + */ +export function useResourceList(options = {}) { + const { + fetchPage, + defaultRows = 20, + defaultSortField = null, + defaultSortOrder = -1, + onError = null, + itemsKey = 'results', + } = options + + if (typeof fetchPage !== 'function') { + throw new Error('useResourceList: fetchPage is required') + } + + const toast = useToast() + const { _ } = useLexicon() + + const loading = ref(false) + const items = ref([]) + const total = ref(0) + const first = ref(0) + const rows = ref(defaultRows) + const sortField = ref(defaultSortField) + const sortOrder = ref(defaultSortOrder) + + let loadSeq = 0 + let abortController = null + + function showLoadError(error) { + if (onError) { + onError(error) + return + } + toast.add({ + severity: 'error', + summary: _('error'), + detail: error?.message || _('error_loading_data'), + life: 5000, + }) + } + + async function load() { + const seq = ++loadSeq + abortController?.abort() + abortController = typeof AbortController !== 'undefined' ? new AbortController() : null + + loading.value = true + try { + const response = await fetchPage({ + first: first.value, + rows: rows.value, + sortField: sortField.value, + sortOrder: sortOrder.value, + signal: abortController?.signal, + }) + + if (seq !== loadSeq) { + return + } + + const parsed = parsePageResponse(response, itemsKey) + if (parsed.malformed) { + console.error('[useResourceList] Unexpected response shape:', response) + } + items.value = parsed.items + total.value = parsed.total + } catch (error) { + if (error?.name === 'AbortError' || seq !== loadSeq) { + return + } + console.error('[useResourceList] load failed:', error) + showLoadError(error) + items.value = [] + total.value = 0 + } finally { + if (seq === loadSeq) { + loading.value = false + } + } + } + + function onPage(event) { + first.value = event.first + rows.value = event.rows + return load() + } + + function onSort(event) { + sortField.value = event.sortField ?? defaultSortField + sortOrder.value = event.sortOrder ?? defaultSortOrder + first.value = 0 + return load() + } + + function resetPageAndLoad() { + first.value = 0 + return load() + } + + return { + loading, + items, + total, + first, + rows, + sortField, + sortOrder, + load, + onPage, + onSort, + resetPageAndLoad, + } +} diff --git a/vueManager/src/composables/useSortableList.js b/vueManager/src/composables/useSortableList.js new file mode 100644 index 00000000..a0c0043b --- /dev/null +++ b/vueManager/src/composables/useSortableList.js @@ -0,0 +1,86 @@ +import { useLexicon } from '@vuetools/useLexicon' +import { useToast } from 'primevue/usetoast' +import { ref } from 'vue' + +import request from '../request.js' + +/** + * Drag → POST/PUT sort → reload for mgr sortable lists. + * + * @param {Object} options + * @param {import('vue').Ref} options.items + * @param {string} options.sortUrl + * @param {'post'|'put'} [options.method='post'] + * @param {() => void|Promise} [options.reload] + * @param {string|(() => string)} [options.successMessage] + * @param {(items: Array) => Array} [options.getPayload] Default: `{ ids: items.map(i => i.id) }` + * @param {() => void|Promise} [options.onSuccess] + * @param {(error: Error) => void} [options.onError] + * @returns {Object} + */ +export function useSortableList(options = {}) { + const { + items, + sortUrl, + method = 'post', + reload = null, + successMessage = null, + getPayload = list => ({ ids: list.map(row => row.id) }), + onSuccess = null, + onError = null, + } = options + + if (!items) { + throw new Error('useSortableList: items ref is required') + } + if (!sortUrl) { + throw new Error('useSortableList: sortUrl is required') + } + + const requestMethod = method === 'put' ? 'put' : 'post' + + const toast = useToast() + const { _ } = useLexicon() + const sorting = ref(false) + + function notify(severity, detail, life) { + toast.add({ + severity, + summary: _(severity === 'success' ? 'success' : 'error'), + detail: detail || _('error_saving_data'), + life, + }) + } + + async function onDragEnd() { + sorting.value = true + try { + await request[requestMethod](sortUrl, getPayload(items.value)) + + if (successMessage) { + const detail = typeof successMessage === 'function' ? successMessage() : successMessage + notify('success', detail, 2000) + } + if (onSuccess) { + await onSuccess() + } + } catch (error) { + console.error('[useSortableList] sort failed:', error) + if (onError) { + onError(error) + } else { + notify('error', error?.message, 5000) + } + if (reload) { + await reload() + } + } finally { + sorting.value = false + } + } + + return { + sorting, + onDragEnd, + } +} diff --git a/vueManager/src/request.js b/vueManager/src/request.js index f2f10365..a7159815 100644 --- a/vueManager/src/request.js +++ b/vueManager/src/request.js @@ -94,6 +94,10 @@ class Request { credentials: 'same-origin', } + if (options.signal) { + fetchOptions.signal = options.signal + } + let url if (method === 'GET' && data) { @@ -140,6 +144,9 @@ class Request { return responseData } catch (error) { + if (error?.name === 'AbortError') { + throw error + } if (error instanceof RequestError) { throw error } diff --git a/vueManager/src/utils/displayFormatters.js b/vueManager/src/utils/displayFormatters.js new file mode 100644 index 00000000..8d051adc --- /dev/null +++ b/vueManager/src/utils/displayFormatters.js @@ -0,0 +1,112 @@ +/** + * Shared display formatters for mgr grids / order UI. + * Keep pure (no Vue state). Order-specific helpers stay in useOrderFormatters. + */ + +export function formatDate(dateString, locale = 'ru-RU') { + if (!dateString) return '-' + return new Date(dateString).toLocaleString(locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +/** + * Format date with a simple token pattern (e.g. dd.MM.yyyy HH:mm). + */ +export function formatDatePattern(dateString, format = 'dd.MM.yyyy HH:mm') { + if (!dateString) return '-' + const date = new Date(dateString) + const pad = n => n.toString().padStart(2, '0') + + return format + .replace('yyyy', date.getFullYear()) + .replace('yy', date.getFullYear().toString().slice(-2)) + .replace('MM', pad(date.getMonth() + 1)) + .replace('dd', pad(date.getDate())) + .replace('HH', pad(date.getHours())) + .replace('mm', pad(date.getMinutes())) + .replace('ss', pad(date.getSeconds())) +} + +export function formatPrice(value, locale = 'ru-RU') { + if (value === null || value === undefined) return '-' + return new Intl.NumberFormat(locale, { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(value) +} + +/** + * Format price using column / ms3.config options (thousands, currency, decimals). + */ +export function formatPriceConfigured(value, column = {}, ms3Config = null) { + if (value === null || value === undefined) return '-' + + const decimals = column.decimals ?? ms3Config?.price_decimals ?? 2 + const thousandsSeparator = + column.thousands_separator ?? ms3Config?.price_thousands_separator ?? ' ' + const decimalSeparator = column.decimal_separator ?? ms3Config?.price_decimal_separator ?? ',' + const currency = column.currency ?? ms3Config?.price_currency ?? '' + const currencyPosition = column.currency_position ?? ms3Config?.price_currency_position ?? 'after' + + const [integerPart, fractionPart] = Number(value).toFixed(decimals).split('.') + const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSeparator) + let formatted = fractionPart + ? `${formattedInteger}${decimalSeparator}${fractionPart}` + : formattedInteger + + if (!currency) { + return formatted + } + + return currencyPosition === 'before' ? `${currency}${formatted}` : `${formatted} ${currency}` +} + +/** + * Render a field value for a grid column (template tokens or plain name). + */ +export function renderField(data, column) { + if (!column) return '' + if (column.template) { + return column.template.replace(/\{(\w+)\}/g, (match, key) => data?.[key] ?? '') + } + return data?.[column.name] +} + +/** + * Resolve lexicon key to display name; returns original when no translation exists. + * + * @param {string} name + * @param {(key: string) => string} translate + */ +export function getDisplayName(name, translate) { + if (!name) return '' + const translated = translate(name) + return translated !== name ? translated : name +} + +/** + * Format a grid cell value (boolean, number, plain). + * + * @param {*} value + * @param {{ type?: string, format?: string }} column + * @param {(key: string) => string} translate + */ +export function formatValue(value, column, translate) { + if (value === null || value === undefined) return '' + + if (column?.type === 'boolean') { + return value ? translate('yes') : translate('no') + } + + if (column?.format === 'number') { + return Number(value).toLocaleString() + } + + return value +} diff --git a/vueManager/src/utils/displayFormatters.test.js b/vueManager/src/utils/displayFormatters.test.js new file mode 100644 index 00000000..78124083 --- /dev/null +++ b/vueManager/src/utils/displayFormatters.test.js @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + formatDate, + formatDatePattern, + formatPrice, + formatPriceConfigured, + formatValue, + getDisplayName, + renderField, +} from './displayFormatters.js' + +describe('displayFormatters', () => { + it('formatDate returns dash for empty', () => { + assert.equal(formatDate(null), '-') + assert.equal(formatDate(''), '-') + }) + + it('formatDatePattern applies tokens', () => { + const value = formatDatePattern('2026-07-16T10:05:00', 'dd.MM.yyyy HH:mm') + assert.match(value, /^16\.07\.2026 \d{2}:\d{2}$/) + }) + + it('formatPrice formats numbers', () => { + assert.equal(formatPrice(null), '-') + assert.equal(formatPrice(1234.5), '1\u00a0234,5') + }) + + it('formatPriceConfigured applies currency and separators', () => { + const formatted = formatPriceConfigured(1200, { + decimals: 2, + thousands_separator: ' ', + decimal_separator: ',', + currency: '₽', + currency_position: 'after', + }) + assert.equal(formatted, '1 200,00 ₽') + }) + + it('renderField supports templates and plain fields', () => { + assert.equal(renderField({ a: 1, b: 2 }, { template: '{a}-{b}' }), '1-2') + assert.equal(renderField({ name: 'x' }, { name: 'name' }), 'x') + assert.equal(renderField({}, null), '') + }) + + it('getDisplayName resolves lexicon keys', () => { + const translate = key => (key === 'ms3_foo' ? 'Foo' : key) + assert.equal(getDisplayName('ms3_foo', translate), 'Foo') + assert.equal(getDisplayName('plain', translate), 'plain') + assert.equal(getDisplayName('', translate), '') + }) + + it('formatValue handles boolean and number columns', () => { + const translate = key => (key === 'yes' ? 'Да' : 'Нет') + assert.equal(formatValue(true, { type: 'boolean' }, translate), 'Да') + assert.equal(formatValue(false, { type: 'boolean' }, translate), 'Нет') + assert.match(formatValue(1000, { format: 'number' }, translate), /^1.000$/) + assert.equal(formatValue(null, {}, translate), '') + }) +}) diff --git a/vueManager/src/utils/resourceListResponse.js b/vueManager/src/utils/resourceListResponse.js new file mode 100644 index 00000000..8d618d3e --- /dev/null +++ b/vueManager/src/utils/resourceListResponse.js @@ -0,0 +1,22 @@ +/** + * Normalize list API payloads for useResourceList. + * + * @param {unknown} response + * @param {string} [itemsKey='results'] + * @returns {{ items: Array, total: number, malformed: boolean }} + */ +export function parsePageResponse(response, itemsKey = 'results') { + if (Array.isArray(response)) { + return { items: response, total: response.length, malformed: false } + } + + if (response && Array.isArray(response[itemsKey])) { + return { + items: response[itemsKey], + total: response.total ?? response[itemsKey].length, + malformed: false, + } + } + + return { items: [], total: 0, malformed: true } +} diff --git a/vueManager/src/utils/resourceListResponse.test.js b/vueManager/src/utils/resourceListResponse.test.js new file mode 100644 index 00000000..fd47fffc --- /dev/null +++ b/vueManager/src/utils/resourceListResponse.test.js @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { parsePageResponse } from './resourceListResponse.js' + +describe('parsePageResponse', () => { + it('parses array responses', () => { + assert.deepEqual(parsePageResponse([1, 2]), { + items: [1, 2], + total: 2, + malformed: false, + }) + }) + + it('parses results + total', () => { + assert.deepEqual(parsePageResponse({ results: [{ id: 1 }], total: 9 }), { + items: [{ id: 1 }], + total: 9, + malformed: false, + }) + }) + + it('marks invalid payloads as malformed', () => { + assert.deepEqual(parsePageResponse(null), { items: [], total: 0, malformed: true }) + assert.deepEqual(parsePageResponse({}), { items: [], total: 0, malformed: true }) + }) +}) From 61bacc08f998dbd605f68eaacd7989cb14c6c95b Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 16 Jul 2026 09:23:58 +0600 Subject: [PATCH 2/3] refactor(vue-manager): wire Vendors to shared formatters, drop dead searchQuery Move normalizeImagePath into displayFormatters; bind formatValue in Vendors; remove unused Deliveries/Payments searchQuery; stabilize formatValue number test. --- vueManager/src/components/DeliveriesGrid.vue | 18 +---------- vueManager/src/components/OrdersGrid.vue | 2 +- vueManager/src/components/PaymentsGrid.vue | 18 +---------- vueManager/src/components/VendorsGrid.vue | 32 +++---------------- vueManager/src/utils/displayFormatters.js | 11 +++++++ .../src/utils/displayFormatters.test.js | 10 +++++- 6 files changed, 27 insertions(+), 64 deletions(-) diff --git a/vueManager/src/components/DeliveriesGrid.vue b/vueManager/src/components/DeliveriesGrid.vue index 80762773..7fc22ecc 100644 --- a/vueManager/src/components/DeliveriesGrid.vue +++ b/vueManager/src/components/DeliveriesGrid.vue @@ -30,7 +30,7 @@ import { useSelection } from '../composables/useSelection.js' import { useSortableList } from '../composables/useSortableList.js' import request from '../request.js' import { resolveAddCostPriceBadgeKind } from '../utils/addCostPriceBadgeKind.js' -import { formatValue, getDisplayName } from '../utils/displayFormatters.js' +import { formatValue, getDisplayName, normalizeImagePath } from '../utils/displayFormatters.js' import ActionsColumn from './ActionsColumn.vue' import FileBrowser from './FileBrowser.vue' import ValidationRulesEditor from './ValidationRulesEditor.vue' @@ -64,7 +64,6 @@ const { columns, loadGridConfig } = useGridConfig({ const filterValues = ref({}) const filterableColumns = computed(() => columns.value.filter(col => col.filterable && col.visible)) -const searchQuery = ref('') const activeTab = ref('0') const selectAll = ref(false) @@ -81,10 +80,6 @@ const { limit, } - if (searchQuery.value) { - params.query = searchQuery.value - } - Object.keys(filterValues.value).forEach(key => { const value = filterValues.value[key] if (value !== null && value !== undefined && value !== '') { @@ -215,17 +210,6 @@ function getFallbackColumns() { ] } -/** - * Normalize image path to always start with / - */ -function normalizeImagePath(path) { - if (!path) return '' - if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('/')) { - return path - } - return '/' + path -} - /** * Open create modal */ diff --git a/vueManager/src/components/OrdersGrid.vue b/vueManager/src/components/OrdersGrid.vue index 04ea9a5c..403147cd 100644 --- a/vueManager/src/components/OrdersGrid.vue +++ b/vueManager/src/components/OrdersGrid.vue @@ -102,7 +102,7 @@ const { }, }) -// Bulk selection +// After useResourceList so onSuccess can call loadOrders from the list composable. const { selectedItems, hasSelection, diff --git a/vueManager/src/components/PaymentsGrid.vue b/vueManager/src/components/PaymentsGrid.vue index 45b976f5..4bcf0275 100644 --- a/vueManager/src/components/PaymentsGrid.vue +++ b/vueManager/src/components/PaymentsGrid.vue @@ -29,7 +29,7 @@ import { useSelection } from '../composables/useSelection.js' import { useSortableList } from '../composables/useSortableList.js' import request from '../request.js' import { resolveAddCostPriceBadgeKind } from '../utils/addCostPriceBadgeKind.js' -import { formatValue, getDisplayName } from '../utils/displayFormatters.js' +import { formatValue, getDisplayName, normalizeImagePath } from '../utils/displayFormatters.js' import ActionsColumn from './ActionsColumn.vue' import FileBrowser from './FileBrowser.vue' @@ -62,7 +62,6 @@ const { columns, loadGridConfig } = useGridConfig({ const filterValues = ref({}) const filterableColumns = computed(() => columns.value.filter(col => col.filterable && col.visible)) -const searchQuery = ref('') const activeTab = ref('0') const selectAll = ref(false) @@ -79,10 +78,6 @@ const { limit, } - if (searchQuery.value) { - params.query = searchQuery.value - } - Object.keys(filterValues.value).forEach(key => { const value = filterValues.value[key] if (value !== null && value !== undefined && value !== '') { @@ -190,17 +185,6 @@ function getFallbackColumns() { ] } -/** - * Normalize image path to always start with / - */ -function normalizeImagePath(path) { - if (!path) return '' - if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('/')) { - return path - } - return '/' + path -} - /** * Open create modal */ diff --git a/vueManager/src/components/VendorsGrid.vue b/vueManager/src/components/VendorsGrid.vue index 77aa7f40..95b1b865 100644 --- a/vueManager/src/components/VendorsGrid.vue +++ b/vueManager/src/components/VendorsGrid.vue @@ -24,6 +24,7 @@ import { useResourceList } from '../composables/useResourceList.js' import { useSelection } from '../composables/useSelection.js' import { useSortableList } from '../composables/useSortableList.js' import request from '../request.js' +import { formatValue, normalizeImagePath } from '../utils/displayFormatters.js' import ActionsColumn from './ActionsColumn.vue' import DynamicField from './DynamicField.vue' import FileBrowser from './FileBrowser.vue' @@ -453,33 +454,8 @@ function getDefaultColumns() { ] } -/** - * Format value for display - */ -function formatValue(value, column) { - if (value === null || value === undefined) return '' - - if (column.type === 'boolean') { - return value ? _('yes') : _('no') - } - - if (column.format === 'number') { - return Number(value).toLocaleString() - } - - return value -} - -/** - * Normalize image path to start with / - */ -function normalizeImagePath(path) { - if (!path) return '' - // If path is already absolute URL or starts with /, return as is - if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('/')) { - return path - } - return '/' + path +function formatCellValue(value, column) { + return formatValue(value, column, _) } /** @@ -659,7 +635,7 @@ onMounted(async () => { - {{ formatValue(vendor[column.name], column) }} + {{ formatCellValue(vendor[column.name], column) }} diff --git a/vueManager/src/utils/displayFormatters.js b/vueManager/src/utils/displayFormatters.js index 8d051adc..b8065077 100644 --- a/vueManager/src/utils/displayFormatters.js +++ b/vueManager/src/utils/displayFormatters.js @@ -110,3 +110,14 @@ export function formatValue(value, column, translate) { return value } + +/** + * Normalize media path for : keep absolute URLs, ensure leading slash. + */ +export function normalizeImagePath(path) { + if (!path) return '' + if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('/')) { + return path + } + return `/${path}` +} diff --git a/vueManager/src/utils/displayFormatters.test.js b/vueManager/src/utils/displayFormatters.test.js index 78124083..c2ba65a9 100644 --- a/vueManager/src/utils/displayFormatters.test.js +++ b/vueManager/src/utils/displayFormatters.test.js @@ -8,6 +8,7 @@ import { formatPriceConfigured, formatValue, getDisplayName, + normalizeImagePath, renderField, } from './displayFormatters.js' @@ -55,7 +56,14 @@ describe('displayFormatters', () => { const translate = key => (key === 'yes' ? 'Да' : 'Нет') assert.equal(formatValue(true, { type: 'boolean' }, translate), 'Да') assert.equal(formatValue(false, { type: 'boolean' }, translate), 'Нет') - assert.match(formatValue(1000, { format: 'number' }, translate), /^1.000$/) + assert.equal(formatValue(1000, { format: 'number' }, translate), Number(1000).toLocaleString()) assert.equal(formatValue(null, {}, translate), '') }) + + it('normalizeImagePath keeps absolute URLs and adds leading slash', () => { + assert.equal(normalizeImagePath(''), '') + assert.equal(normalizeImagePath('/assets/a.png'), '/assets/a.png') + assert.equal(normalizeImagePath('https://x/a.png'), 'https://x/a.png') + assert.equal(normalizeImagePath('assets/a.png'), '/assets/a.png') + }) }) From 2dd22b30f35123f9432bc82e9250bb859c9b8685 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 16 Jul 2026 09:25:04 +0600 Subject: [PATCH 3/3] test(vue-manager): cover useResourceList race; share normalizeImagePath Extract createResourceList for Node-testable AbortController/seq races and reuse displayFormatters.normalizeImagePath in FileBrowser. --- vueManager/src/components/FileBrowser.vue | 6 +- .../src/composables/resourceListCore.js | 114 +++++++++++++++++ vueManager/src/composables/useResourceList.js | 93 ++------------ .../composables/useResourceList.race.test.js | 121 ++++++++++++++++++ 4 files changed, 247 insertions(+), 87 deletions(-) create mode 100644 vueManager/src/composables/resourceListCore.js create mode 100644 vueManager/src/composables/useResourceList.race.test.js diff --git a/vueManager/src/components/FileBrowser.vue b/vueManager/src/components/FileBrowser.vue index 973b0fd5..478041cf 100644 --- a/vueManager/src/components/FileBrowser.vue +++ b/vueManager/src/components/FileBrowser.vue @@ -3,6 +3,8 @@ import Button from 'primevue/button' import InputText from 'primevue/inputtext' import { computed, ref, watch } from 'vue' +import { normalizeImagePath } from '../utils/displayFormatters.js' + const props = defineProps({ modelValue: { type: String, @@ -135,9 +137,7 @@ function isImage(path) { * Get full image URL with leading slash */ function getImageUrl(path) { - if (!path) return '' - // Ensure path starts with / - return path.startsWith('/') ? path : '/' + path + return normalizeImagePath(path) } diff --git a/vueManager/src/composables/resourceListCore.js b/vueManager/src/composables/resourceListCore.js new file mode 100644 index 00000000..c1cf5f44 --- /dev/null +++ b/vueManager/src/composables/resourceListCore.js @@ -0,0 +1,114 @@ +import { ref } from 'vue' + +import { parsePageResponse } from '../utils/resourceListResponse.js' + +/** + * Core list + pagination state (no PrimeVue / lexicon inject). + * Used by useResourceList and unit-tested for AbortController/seq races. + * + * @param {Object} options + * @param {Function} options.fetchPage + * @param {number} [options.defaultRows=20] + * @param {string|null} [options.defaultSortField=null] + * @param {number} [options.defaultSortOrder=-1] + * @param {string} [options.itemsKey='results'] + * @param {(error: Error) => void} [options.onLoadError] + * @returns {Object} + */ +export function createResourceList(options = {}) { + const { + fetchPage, + defaultRows = 20, + defaultSortField = null, + defaultSortOrder = -1, + itemsKey = 'results', + onLoadError = null, + } = options + + if (typeof fetchPage !== 'function') { + throw new Error('createResourceList: fetchPage is required') + } + + const loading = ref(false) + const items = ref([]) + const total = ref(0) + const first = ref(0) + const rows = ref(defaultRows) + const sortField = ref(defaultSortField) + const sortOrder = ref(defaultSortOrder) + + let loadSeq = 0 + let abortController = null + + async function load() { + const seq = ++loadSeq + abortController?.abort() + abortController = typeof AbortController !== 'undefined' ? new AbortController() : null + + loading.value = true + try { + const response = await fetchPage({ + first: first.value, + rows: rows.value, + sortField: sortField.value, + sortOrder: sortOrder.value, + signal: abortController?.signal, + }) + + if (seq !== loadSeq) { + return + } + + const parsed = parsePageResponse(response, itemsKey) + if (parsed.malformed) { + console.error('[useResourceList] Unexpected response shape:', response) + } + items.value = parsed.items + total.value = parsed.total + } catch (error) { + if (error?.name === 'AbortError' || seq !== loadSeq) { + return + } + console.error('[useResourceList] load failed:', error) + onLoadError?.(error) + items.value = [] + total.value = 0 + } finally { + if (seq === loadSeq) { + loading.value = false + } + } + } + + function onPage(event) { + first.value = event.first + rows.value = event.rows + return load() + } + + function onSort(event) { + sortField.value = event.sortField ?? defaultSortField + sortOrder.value = event.sortOrder ?? defaultSortOrder + first.value = 0 + return load() + } + + function resetPageAndLoad() { + first.value = 0 + return load() + } + + return { + loading, + items, + total, + first, + rows, + sortField, + sortOrder, + load, + onPage, + onSort, + resetPageAndLoad, + } +} diff --git a/vueManager/src/composables/useResourceList.js b/vueManager/src/composables/useResourceList.js index f1c9ba28..78b5c248 100644 --- a/vueManager/src/composables/useResourceList.js +++ b/vueManager/src/composables/useResourceList.js @@ -1,8 +1,7 @@ import { useLexicon } from '@vuetools/useLexicon' import { useToast } from 'primevue/usetoast' -import { ref } from 'vue' -import { parsePageResponse } from '../utils/resourceListResponse.js' +import { createResourceList } from './resourceListCore.js' /** * Thin list + pagination (+ optional sort) for mgr grids. @@ -34,17 +33,6 @@ export function useResourceList(options = {}) { const toast = useToast() const { _ } = useLexicon() - const loading = ref(false) - const items = ref([]) - const total = ref(0) - const first = ref(0) - const rows = ref(defaultRows) - const sortField = ref(defaultSortField) - const sortOrder = ref(defaultSortOrder) - - let loadSeq = 0 - let abortController = null - function showLoadError(error) { if (onError) { onError(error) @@ -58,75 +46,12 @@ export function useResourceList(options = {}) { }) } - async function load() { - const seq = ++loadSeq - abortController?.abort() - abortController = typeof AbortController !== 'undefined' ? new AbortController() : null - - loading.value = true - try { - const response = await fetchPage({ - first: first.value, - rows: rows.value, - sortField: sortField.value, - sortOrder: sortOrder.value, - signal: abortController?.signal, - }) - - if (seq !== loadSeq) { - return - } - - const parsed = parsePageResponse(response, itemsKey) - if (parsed.malformed) { - console.error('[useResourceList] Unexpected response shape:', response) - } - items.value = parsed.items - total.value = parsed.total - } catch (error) { - if (error?.name === 'AbortError' || seq !== loadSeq) { - return - } - console.error('[useResourceList] load failed:', error) - showLoadError(error) - items.value = [] - total.value = 0 - } finally { - if (seq === loadSeq) { - loading.value = false - } - } - } - - function onPage(event) { - first.value = event.first - rows.value = event.rows - return load() - } - - function onSort(event) { - sortField.value = event.sortField ?? defaultSortField - sortOrder.value = event.sortOrder ?? defaultSortOrder - first.value = 0 - return load() - } - - function resetPageAndLoad() { - first.value = 0 - return load() - } - - return { - loading, - items, - total, - first, - rows, - sortField, - sortOrder, - load, - onPage, - onSort, - resetPageAndLoad, - } + return createResourceList({ + fetchPage, + defaultRows, + defaultSortField, + defaultSortOrder, + itemsKey, + onLoadError: showLoadError, + }) } diff --git a/vueManager/src/composables/useResourceList.race.test.js b/vueManager/src/composables/useResourceList.race.test.js new file mode 100644 index 00000000..cda39062 --- /dev/null +++ b/vueManager/src/composables/useResourceList.race.test.js @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createResourceList } from './resourceListCore.js' + +function deferred() { + let resolve + let reject + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('createResourceList race (#385)', () => { + it('keeps the later response when an earlier fetch resolves last', async () => { + const first = deferred() + const second = deferred() + let call = 0 + + const list = createResourceList({ + fetchPage: () => { + call += 1 + return call === 1 ? first.promise : second.promise + }, + }) + + const loadA = list.load() + const loadB = list.load() + + second.resolve({ results: [{ id: 2 }], total: 1 }) + await loadB + + first.resolve({ results: [{ id: 1 }], total: 1 }) + await loadA + + assert.deepEqual(list.items.value, [{ id: 2 }]) + assert.equal(list.total.value, 1) + assert.equal(list.loading.value, false) + }) + + it('aborts the previous in-flight request when load is called again', async () => { + const signals = [] + const first = deferred() + const second = deferred() + let call = 0 + + const list = createResourceList({ + fetchPage: ({ signal }) => { + call += 1 + signals.push(signal) + return call === 1 ? first.promise : second.promise + }, + }) + + const loadA = list.load() + assert.equal(signals[0]?.aborted, false) + + const loadB = list.load() + assert.equal(signals[0]?.aborted, true) + + second.resolve({ results: [{ id: 9 }], total: 1 }) + await loadB + + first.reject(Object.assign(new Error('aborted'), { name: 'AbortError' })) + await loadA + + assert.deepEqual(list.items.value, [{ id: 9 }]) + assert.equal(list.loading.value, false) + }) + + it('does not call onLoadError on AbortError for a stale load', async () => { + const errors = [] + const first = deferred() + const second = deferred() + let call = 0 + + const list = createResourceList({ + fetchPage: () => { + call += 1 + return call === 1 ? first.promise : second.promise + }, + onLoadError: error => { + errors.push(error) + }, + }) + + const loadA = list.load() + const loadB = list.load() + + second.resolve({ results: [{ id: 3 }], total: 1 }) + await loadB + + first.reject(Object.assign(new Error('aborted'), { name: 'AbortError' })) + await loadA + + assert.equal(errors.length, 0) + assert.deepEqual(list.items.value, [{ id: 3 }]) + }) + + it('logs malformed responses and clears the list', async () => { + const logs = [] + const originalError = console.error + console.error = (...args) => { + logs.push(args.join(' ')) + } + + try { + const list = createResourceList({ + fetchPage: async () => ({ ok: true }), + }) + await list.load() + assert.deepEqual(list.items.value, []) + assert.equal(list.total.value, 0) + assert.match(logs.join('\n'), /Unexpected response shape/) + } finally { + console.error = originalError + } + }) +})