diff --git a/vueManager/src/components/DeliveriesGrid.vue b/vueManager/src/components/DeliveriesGrid.vue
index 1ae601dd..7fc22ecc 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, normalizeImagePath } from '../utils/displayFormatters.js'
import ActionsColumn from './ActionsColumn.vue'
import FileBrowser from './FileBrowser.vue'
import ValidationRulesEditor from './ValidationRulesEditor.vue'
@@ -51,45 +56,28 @@ 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,
- }
-
- if (searchQuery.value) {
- params.query = searchQuery.value
+ start,
+ limit,
}
Object.keys(filterValues.value).forEach(key => {
@@ -99,47 +87,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,74 +147,84 @@ function onSelectAllChange(checked) {
}
/**
- * Handle drag-drop reorder
- */
-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()
- }
-}
-
-/**
- * Normalize image path to always start with /
+ * Get fallback grid columns
*/
-function normalizeImagePath(path) {
- if (!path) return ''
- if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('/')) {
- return path
- }
- return '/' + path
+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,
+ },
+ ],
+ },
+ ]
}
/**
* 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 +271,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 +321,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
- )
- }
-
- if (response) {
- toast.add({
- severity: 'success',
- summary: _('success'),
- detail: isNewDelivery.value ? _('delivery_created') : _('delivery_updated'),
- life: 3000,
- })
- editDialogVisible.value = false
- loadDeliveries()
+ await request.put(`/api/mgr/deliveries/${editingDelivery.value.id}`, editingDelivery.value)
+ toastSuccess(_('delivery_updated'))
}
- } 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 +376,7 @@ function deleteDelivery(delivery) {
* Apply filters
*/
function applyFilters() {
- first.value = 0
- loadDeliveries()
+ resetPageAndLoad()
}
/**
@@ -414,8 +384,7 @@ function applyFilters() {
*/
function clearFilters() {
filterValues.value = {}
- first.value = 0
- loadDeliveries()
+ resetPageAndLoad()
}
/**
@@ -429,108 +398,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 +567,7 @@ onMounted(async () => {
- {{ getDisplayName(delivery.name) }}
+ {{ resolveDisplayName(delivery.name) }}
|
@@ -711,7 +590,7 @@ onMounted(async () => {
|
- {{ formatValue(delivery[column.name], column) }}
+ {{ formatCellValue(delivery[column.name], column) }}
|
@@ -796,7 +675,10 @@ onMounted(async () => {
-
+
{
-
+
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/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 () => {
-
+
@@ -350,7 +301,7 @@ onMounted(async () => {
/>
-
+
-
+
diff --git a/vueManager/src/components/OptionGroupsGrid.vue b/vueManager/src/components/OptionGroupsGrid.vue
index e3f9e20e..41f8609b 100644
--- a/vueManager/src/components/OptionGroupsGrid.vue
+++ b/vueManager/src/components/OptionGroupsGrid.vue
@@ -14,6 +14,10 @@ import { useToast } from 'primevue/usetoast'
import { computed, 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 { notifyOptionGroupsChanged } from '../utils/optionGroupsBus.js'
@@ -31,137 +35,103 @@ const toast = useToast()
const confirm = useConfirm()
const { _ } = useLexicon()
-const groups = ref([])
-const loading = ref(false)
-const reordering = ref(false)
const searchQuery = ref('')
-const selectedIds = ref(new Set())
-const editDialogVisible = ref(false)
-const editingGroup = ref(null)
-const isNewGroup = ref(false)
-const saving = ref(false)
+
+const {
+ loading,
+ items: groups,
+ load: loadGroups,
+} = useResourceList({
+ fetchPage: ({ signal }) => request.get('/api/mgr/option-groups', { limit: 0 }, { signal }),
+})
+
+const {
+ visible: editDialogVisible,
+ isNew: isNewGroup,
+ saving,
+ item: editingGroup,
+ openCreate: openCreateDialog,
+ openEdit: openEditDialog,
+ close,
+ runSave,
+ toastSuccess,
+ toastWarn,
+} = useCrudDialog({
+ createDefaults: () => ({ id: null, name: '', description: '' }),
+})
+
+const {
+ selectedItems,
+ selectedIds,
+ selectionCount,
+ isSelected,
+ toggleItem,
+ selectAll,
+ clearSelection,
+} = useSelection({})
+
+const { sorting: reordering, onDragEnd } = useSortableList({
+ items: groups,
+ sortUrl: '/api/mgr/option-groups/positions',
+ method: 'put',
+ reload: loadGroups,
+ onSuccess: () => {
+ notifyOptionGroupsChanged()
+ },
+})
const filteredGroups = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return groups.value
- return groups.value.filter(g =>
- (g.name || '').toLowerCase().includes(q)
- || (g.description || '').toLowerCase().includes(q),
+ return groups.value.filter(
+ g => (g.name || '').toLowerCase().includes(q) || (g.description || '').toLowerCase().includes(q)
)
})
const hasGroups = computed(() => groups.value.length > 0)
-const selectionCount = computed(() => selectedIds.value.size)
-const allSelected = computed(() =>
- hasGroups.value && groups.value.every(g => selectedIds.value.has(g.id)),
-)
-
-async function loadGroups() {
- loading.value = true
- try {
- const response = await request.get('/api/mgr/option-groups', { limit: 0 })
- groups.value = response.results || []
- } catch (e) {
- console.error('Failed to load option groups:', e)
- toast.add({
- severity: 'error',
- summary: _('ms3_error'),
- detail: e?.message || _('ms3_option_groups_load_error'),
- life: 5000,
- })
- } finally {
- loading.value = false
- }
-}
-
-function toggleSelect(id) {
- const next = new Set(selectedIds.value)
- if (next.has(id)) {
- next.delete(id)
- } else {
- next.add(id)
- }
- selectedIds.value = next
-}
+const allSelected = computed(() => hasGroups.value && groups.value.every(g => isSelected(g)))
function toggleSelectAll() {
if (allSelected.value) {
- selectedIds.value = new Set()
+ clearSelection()
} else {
- selectedIds.value = new Set(groups.value.map(g => g.id))
+ selectAll(groups.value)
}
}
-function openCreateDialog() {
- isNewGroup.value = true
- editingGroup.value = { id: null, name: '', description: '' }
- editDialogVisible.value = true
-}
-
-function openEditDialog(group) {
- isNewGroup.value = false
- editingGroup.value = { ...group }
- editDialogVisible.value = true
-}
-
async function saveGroup() {
if (!editingGroup.value) return
const name = (editingGroup.value.name || '').trim()
if (!name) {
- toast.add({
- severity: 'warn',
- summary: _('ms3_warning'),
- detail: _('ms3_option_group_name_required'),
- life: 4000,
- })
+ toastWarn(_('ms3_option_group_name_required'))
return
}
- saving.value = true
- try {
+ const created = isNewGroup.value
+ await runSave(async () => {
const payload = {
name,
description: editingGroup.value.description ?? null,
}
- if (isNewGroup.value) {
+ if (created) {
await request.post('/api/mgr/option-groups', payload)
- toast.add({
- severity: 'success',
- summary: _('ms3_success'),
- detail: _('ms3_option_group_created'),
- life: 3000,
- })
+ toastSuccess(_('ms3_option_group_created'))
} else {
await request.put(`/api/mgr/option-groups/${editingGroup.value.id}`, payload)
- toast.add({
- severity: 'success',
- summary: _('ms3_success'),
- detail: _('ms3_option_group_updated'),
- life: 3000,
- })
+ toastSuccess(_('ms3_option_group_updated'))
}
- editDialogVisible.value = false
editingGroup.value = null
await loadGroups()
notifyOptionGroupsChanged()
- } catch (e) {
- console.error('Failed to save option group:', e)
- toast.add({
- severity: 'error',
- summary: _('ms3_error'),
- detail: e?.message || _('ms3_option_group_save_error'),
- life: 5000,
- })
- } finally {
- saving.value = false
- }
+ })
}
function confirmDelete(group) {
const count = Number(group.options_count) || 0
- const detail = count > 0
- ? _('ms3_option_group_delete_confirm_with_options').replace('{count}', String(count))
- : _('ms3_option_group_delete_confirm')
+ const detail =
+ count > 0
+ ? _('ms3_option_group_delete_confirm_with_options').replace('{count}', String(count))
+ : _('ms3_option_group_delete_confirm')
confirm.require({
message: detail,
@@ -183,7 +153,7 @@ async function deleteGroup(group) {
detail: _('ms3_option_group_deleted'),
life: 3000,
})
- selectedIds.value.delete(group.id)
+ selectedItems.value = selectedItems.value.filter(item => item.id !== group.id)
await loadGroups()
notifyOptionGroupsChanged()
} catch (e) {
@@ -220,7 +190,7 @@ async function bulkDelete(ids) {
detail: _('ms3_option_groups_bulk_deleted').replace('{count}', String(ids.length)),
life: 3000,
})
- selectedIds.value = new Set()
+ clearSelection()
await loadGroups()
notifyOptionGroupsChanged()
} catch (e) {
@@ -234,26 +204,6 @@ async function bulkDelete(ids) {
}
}
-async function onDragEnd() {
- reordering.value = true
- try {
- const ids = groups.value.map(g => g.id)
- await request.put('/api/mgr/option-groups/positions', { ids })
- notifyOptionGroupsChanged()
- } catch (e) {
- console.error('Failed to reorder option groups:', e)
- toast.add({
- severity: 'error',
- summary: _('ms3_error'),
- detail: e?.message || _('ms3_option_group_reorder_error'),
- life: 5000,
- })
- await loadGroups()
- } finally {
- reordering.value = false
- }
-}
-
onMounted(() => {
loadGroups()
})
@@ -285,11 +235,7 @@ onMounted(() => {
/>
-
+
@@ -303,11 +249,7 @@ onMounted(() => {
-
+
diff --git a/vueManager/src/components/OrdersGrid.vue b/vueManager/src/components/OrdersGrid.vue
index cbae3248..403147cd 100644
--- a/vueManager/src/components/OrdersGrid.vue
+++ b/vueManager/src/components/OrdersGrid.vue
@@ -12,11 +12,18 @@ import Select from 'primevue/select'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
-import { computed, onMounted, ref } from 'vue'
+import { onMounted, ref } from 'vue'
-import { useGridFilterParams } from '../composables/useGridFilterParams.js'
+import { useGridConfig } from '../composables/useGridConfig.js'
+import { useGridFilters } from '../composables/useGridFilters.js'
+import { useResourceList } from '../composables/useResourceList.js'
import { useSelection } from '../composables/useSelection.js'
import request from '../request.js'
+import {
+ formatDatePattern,
+ formatPriceConfigured,
+ renderField,
+} from '../utils/displayFormatters.js'
import ActionsColumn from './ActionsColumn.vue'
const toast = useToast()
@@ -38,8 +45,64 @@ function readShowDraftsPreference() {
}
const showDrafts = ref(readShowDraftsPreference())
+const stats = ref({
+ month_sum: '0',
+ month_total: '0',
+})
+
+const {
+ filterValues,
+ sortedFilters,
+ hasActiveFilters,
+ setFilters,
+ setDirectFilterKeys,
+ appendFilterParams,
+ initFilterValues,
+} = useGridFilters()
+
+const { columns, loadGridConfig } = useGridConfig({
+ gridId: 'orders',
+ responseKey: 'columns',
+ getFallbackColumns: getDefaultColumns,
+ onLoaded: response => {
+ setDirectFilterKeys(response?.direct_filter_keys || [])
+ },
+})
+
+const {
+ loading,
+ items: orders,
+ total: totalRecords,
+ first,
+ rows,
+ sortField,
+ sortOrder,
+ load: loadOrders,
+ onPage,
+ onSort,
+} = useResourceList({
+ defaultSortField: 'id',
+ defaultSortOrder: -1,
+ fetchPage: async ({ first: start, rows: limit, sortField: sort, sortOrder: dir, signal }) => {
+ const params = {
+ start,
+ limit,
+ sort: sort || 'id',
+ dir: dir === 1 ? 'ASC' : 'DESC',
+ show_drafts: showDrafts.value ? 1 : 0,
+ }
+
+ appendFilterParams(params)
-// Bulk selection
+ const response = await request.get('/api/mgr/orders', params, { signal })
+ if (response?.stats) {
+ stats.value = response.stats
+ }
+ return response
+ },
+})
+
+// After useResourceList so onSuccess can call loadOrders from the list composable.
const {
selectedItems,
hasSelection,
@@ -56,126 +119,15 @@ const {
getItemName: item => `#${item.num || item.id}`,
})
-const columns = ref([])
-const filters = ref({})
-const { setDirectFilterKeys, addFilterParam } = useGridFilterParams()
-const loading = ref(false)
-const orders = ref([])
-const totalRecords = ref(0)
-const first = ref(0)
-const rows = ref(20)
-const sortField = ref('id')
-const sortOrder = ref(-1) // -1 = DESC, 1 = ASC
-const filterValues = ref({})
-const stats = ref({
- month_sum: '0',
- month_total: '0',
-})
-
-/**
- * Get sorted filters list
- */
-const sortedFilters = computed(() => {
- return Object.entries(filters.value)
- .map(([key, config]) => ({ key, ...config }))
- .sort((a, b) => (a.position || 100) - (b.position || 100))
-})
-
-/**
- * Load orders list
- */
-async function loadOrders() {
- loading.value = true
-
- try {
- const params = {
- start: first.value,
- limit: rows.value,
- sort: sortField.value,
- dir: sortOrder.value === 1 ? 'ASC' : 'DESC',
- show_drafts: showDrafts.value ? 1 : 0,
- }
-
- // Apply filter values
- Object.keys(filterValues.value).forEach(key => {
- const value = filterValues.value[key]
- if (value !== null && value !== undefined && value !== '') {
- 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])
- )
- }
- } else if (filterConfig?.type === 'datepicker' && value) {
- addFilterParam(params, key, formatDateForApi(value))
- } else {
- addFilterParam(params, key, value)
- }
- }
- })
-
- const response = await request.get('/api/mgr/orders', params)
-
- if (response && response.results) {
- orders.value = response.results
- totalRecords.value = response.total || 0
- if (response.stats) {
- stats.value = response.stats
- }
- } else {
- console.error('[OrdersGrid] Invalid response:', response)
- orders.value = []
- totalRecords.value = 0
- }
- } catch (error) {
- console.error('[OrdersGrid] Error loading orders:', 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
- loadOrders()
-}
-
-/**
- * Handle sort
- */
-function onSort(event) {
- sortField.value = event.sortField || 'id'
- sortOrder.value = event.sortOrder ?? -1
+function applyFilters() {
first.value = 0
- loadOrders()
+ return loadOrders()
}
-/**
- * Format date for API (YYYY-MM-DD)
- */
-function formatDateForApi(date) {
- if (!date) return null
- const d = new Date(date)
- return d.toISOString().split('T')[0]
+function clearFilters() {
+ initFilterValues()
+ first.value = 0
+ return loadOrders()
}
/**
@@ -221,60 +173,16 @@ async function deleteOrder(order) {
/**
* Format date with configurable format
- * @param {string} dateString - Date string to format
- * @param {object} column - Column config with optional format property
*/
function formatDate(dateString, column = {}) {
- if (!dateString) return '-'
- const date = new Date(dateString)
-
- // Custom format from column config
- const format = column.format || 'dd.MM.yyyy HH:mm'
-
- // Simple format replacement
- 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()))
+ return formatDatePattern(dateString, column.format || 'dd.MM.yyyy HH:mm')
}
/**
* Format price with configurable options
- * @param {number} value - Price value
- * @param {object} column - Column config with optional formatting properties
*/
function formatPrice(value, column = {}) {
- if (value === null || value === undefined) return '-'
-
- // Get config from column or use defaults from ms3.config
- // Note: ms3 is a global variable (not window.ms3) because it's declared with 'let'
-
- const ms3Config = typeof ms3 !== 'undefined' ? ms3.config : null
- 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'
-
- // Format number
- const parts = Number(value).toFixed(decimals).split('.')
- parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSeparator)
- let formatted = parts.join(decimalSeparator)
-
- // Add currency
- if (currency) {
- formatted =
- currencyPosition === 'before' ? `${currency}${formatted}` : `${formatted} ${currency}`
- }
-
- return formatted
+ return formatPriceConfigured(value, column, ms3Config)
}
/**
@@ -353,54 +261,13 @@ function getBadgeColor(data, column) {
async function loadFiltersConfig() {
try {
const response = await request.get('/api/mgr/orders/filters')
- filters.value = response.filters || response || {}
- initFilterValues()
+ setFilters(response.filters || response || {})
} catch (error) {
console.error('[OrdersGrid] Failed to load filters config:', error)
- filters.value = {}
+ setFilters({})
}
}
-/**
- * Initialize filter values
- */
-function initFilterValues() {
- const newValues = {}
- Object.keys(filters.value).forEach(key => {
- const filterConfig = filters.value[key]
- if (filterConfig.type === 'daterange') {
- newValues[key] = null
- } else {
- newValues[key] = null
- }
- })
- filterValues.value = newValues
-}
-
-/**
- * Apply filters
- */
-function applyFilters() {
- first.value = 0
- loadOrders()
-}
-
-/**
- * Clear filters
- */
-function clearFilters() {
- initFilterValues()
- first.value = 0
- loadOrders()
-}
-
-/**
- * Check if any filter has value
- */
-const hasActiveFilters = computed(() => {
- return Object.values(filterValues.value).some(v => v !== null && v !== '' && v !== undefined)
-})
-
function toggleShowDrafts() {
try {
localStorage.setItem(SHOW_DRAFTS_STORAGE_KEY, showDrafts.value ? '1' : '0')
@@ -411,21 +278,6 @@ function toggleShowDrafts() {
loadOrders()
}
-/**
- * Load grid configuration
- */
-async function loadGridConfig() {
- try {
- const response = await request.get('/api/mgr/grid-config/orders')
- columns.value = response.columns || []
- setDirectFilterKeys(response.direct_filter_keys)
- } catch (error) {
- console.error('[OrdersGrid] Failed to load grid config:', error)
- columns.value = getDefaultColumns()
- setDirectFilterKeys([])
- }
-}
-
/**
* Default columns (if API unavailable)
*/
@@ -543,16 +395,6 @@ function getActionsConfig(column) {
return column.actions
}
-/**
- * Render column value by template
- */
-function renderField(data, column) {
- if (column.template) {
- return column.template.replace(/\{(\w+)\}/g, (match, key) => data[key] || '')
- }
- return data[column.name]
-}
-
/**
* Get customer link URL
*/
diff --git a/vueManager/src/components/PaymentsGrid.vue b/vueManager/src/components/PaymentsGrid.vue
index 80812daa..4bcf0275 100644
--- a/vueManager/src/components/PaymentsGrid.vue
+++ b/vueManager/src/components/PaymentsGrid.vue
@@ -22,9 +22,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, normalizeImagePath } from '../utils/displayFormatters.js'
import ActionsColumn from './ActionsColumn.vue'
import FileBrowser from './FileBrowser.vue'
@@ -49,45 +54,28 @@ const {
getItemName: item => item.name,
})
-const columns = ref([])
-const loading = ref(false)
-const payments = ref([])
-const totalRecords = ref(0)
-const first = ref(0)
-const rows = ref(20)
+const { columns, loadGridConfig } = useGridConfig({
+ gridId: 'payments',
+ 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 editingPayment = ref(null)
-const isNewPayment = ref(false)
-const saving = ref(false)
const activeTab = ref('0')
const selectAll = ref(false)
-// Deliveries tab
-const deliveries = ref([])
-const paymentDeliveries = ref([])
-const loadingDeliveries = ref(false)
-
-const paymentAddCostBadgeKind = computed(() =>
- editingPayment.value ? resolveAddCostPriceBadgeKind(editingPayment.value.price) : null,
-)
-
-/**
- * Load payments list
- */
-async function loadPayments() {
- loading.value = true
-
- try {
+const {
+ loading,
+ items: payments,
+ total: totalRecords,
+ load: loadPayments,
+ resetPageAndLoad,
+} = useResourceList({
+ fetchPage: ({ first: start, rows: limit, signal }) => {
const params = {
- start: first.value,
- limit: rows.value,
- }
-
- if (searchQuery.value) {
- params.query = searchQuery.value
+ start,
+ limit,
}
Object.keys(filterValues.value).forEach(key => {
@@ -97,47 +85,48 @@ async function loadPayments() {
}
})
- const response = await request.get('/api/mgr/payments', params)
+ return request.get('/api/mgr/payments', params, { signal })
+ },
+})
- if (response && response.results) {
- payments.value = response.results
- totalRecords.value = response.total || 0
- } else {
- console.error('[PaymentsGrid] Invalid response:', response)
- payments.value = []
- totalRecords.value = 0
- }
- } catch (error) {
- console.error('[PaymentsGrid] Error loading payments:', error)
- toast.add({
- severity: 'error',
- summary: _('error'),
- detail: error.message || _('error_loading_data'),
- life: 5000,
- })
- } finally {
- loading.value = false
- }
-}
+const {
+ visible: editDialogVisible,
+ isNew: isNewPayment,
+ saving,
+ item: editingPayment,
+ openCreate,
+ openEdit,
+ close,
+ runSave,
+ toastSuccess,
+ toastWarn,
+} = useCrudDialog({
+ createDefaults: () => ({
+ name: '',
+ description: '',
+ price: '0',
+ position: 0,
+ active: true,
+ class: '',
+ logo: '',
+ }),
+})
-/**
- * Handle pagination
- */
-// eslint-disable-next-line no-unused-vars
-function onPage(event) {
- first.value = event.first
- rows.value = event.rows
- loadPayments()
-}
+// Deliveries tab
+const deliveries = ref([])
+const paymentDeliveries = ref([])
+const loadingDeliveries = ref(false)
-/**
- * Handle search
- */
-// eslint-disable-next-line no-unused-vars
-function onSearch() {
- first.value = 0
- loadPayments()
-}
+const paymentAddCostBadgeKind = computed(() =>
+ editingPayment.value ? resolveAddCostPriceBadgeKind(editingPayment.value.price) : null
+)
+
+const { onDragEnd } = useSortableList({
+ items: payments,
+ sortUrl: '/api/mgr/payments/sort',
+ successMessage: _('payment_order_saved'),
+ reload: loadPayments,
+})
/**
* Handle select all checkbox
@@ -152,70 +141,65 @@ function onSelectAllChange(checked) {
}
/**
- * Handle drag-drop reorder
- */
-async function onDragEnd() {
- const ids = payments.value.map(p => p.id)
- try {
- await request.post('/api/mgr/payments/sort', { ids })
- toast.add({
- severity: 'success',
- summary: _('success'),
- detail: _('payment_order_saved'),
- life: 2000,
- })
- } catch (error) {
- console.error('[PaymentsGrid] Error saving order:', error)
- toast.add({
- severity: 'error',
- summary: _('error'),
- detail: error.message || _('error_saving_data'),
- life: 5000,
- })
- loadPayments()
- }
-}
-
-/**
- * Normalize image path to always start with /
+ * Get fallback grid columns
*/
-function normalizeImagePath(path) {
- if (!path) return ''
- if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('/')) {
- return path
- }
- return '/' + path
+function getFallbackColumns() {
+ return [
+ { name: 'id', label: 'ID', visible: true, sortable: true, frozen: true, width: '5rem' },
+ { name: 'name', label: _('payment_name'), visible: true, sortable: true, filterable: true },
+ { name: 'price', label: _('ms3_add_cost'), visible: true, sortable: true, width: '7.5rem' },
+ {
+ name: 'active',
+ label: _('payment_active'),
+ visible: true,
+ sortable: true,
+ type: 'boolean',
+ width: '6.25rem',
+ },
+ {
+ name: 'position',
+ label: _('payment_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,
+ },
+ ],
+ },
+ ]
}
/**
* Open create modal
*/
function createPayment() {
- editingPayment.value = {
- name: '',
- description: '',
- price: '0',
- position: 0,
- active: true,
- class: '',
- logo: '',
- }
- isNewPayment.value = true
+ openCreate()
activeTab.value = '0'
paymentDeliveries.value = []
- editDialogVisible.value = true
}
/**
* Open edit modal
*/
async function editPayment(payment) {
- editingPayment.value = { ...payment }
- isNewPayment.value = false
+ openEdit(payment)
activeTab.value = '0'
- editDialogVisible.value = true
-
- // Load deliveries for this payment
await loadPaymentDeliveries(payment.id)
}
@@ -305,49 +289,21 @@ async function toggleDelivery(deliveryId, newValue) {
*/
async function savePayment() {
if (!editingPayment.value.name) {
- toast.add({
- severity: 'warn',
- summary: _('warning'),
- detail: _('payment_name_required'),
- life: 3000,
- })
+ toastWarn(_('payment_name_required'))
return
}
- saving.value = true
-
- try {
- let response
- if (isNewPayment.value) {
- response = await request.post('/api/mgr/payments', editingPayment.value)
+ const created = isNewPayment.value
+ await runSave(async () => {
+ if (created) {
+ await request.post('/api/mgr/payments', editingPayment.value)
+ toastSuccess(_('payment_created'))
} else {
- response = await request.put(
- `/api/mgr/payments/${editingPayment.value.id}`,
- editingPayment.value
- )
- }
-
- if (response) {
- toast.add({
- severity: 'success',
- summary: _('success'),
- detail: isNewPayment.value ? _('payment_created') : _('payment_updated'),
- life: 3000,
- })
- editDialogVisible.value = false
- loadPayments()
+ await request.put(`/api/mgr/payments/${editingPayment.value.id}`, editingPayment.value)
+ toastSuccess(_('payment_updated'))
}
- } catch (error) {
- console.error('[PaymentsGrid] Error saving payment:', error)
- toast.add({
- severity: 'error',
- summary: _('error'),
- detail: error.message || _('error_saving_data'),
- life: 5000,
- })
- } finally {
- saving.value = false
- }
+ await loadPayments()
+ })
}
/**
@@ -388,8 +344,7 @@ function deletePayment(payment) {
* Apply filters
*/
function applyFilters() {
- first.value = 0
- loadPayments()
+ resetPageAndLoad()
}
/**
@@ -397,8 +352,7 @@ function applyFilters() {
*/
function clearFilters() {
filterValues.value = {}
- first.value = 0
- loadPayments()
+ resetPageAndLoad()
}
/**
@@ -412,87 +366,18 @@ function getActionsConfig(column) {
}))
}
-/**
- * Load grid configuration
- */
-async function loadGridConfig() {
- try {
- const response = await request.get('/api/mgr/grid-config/payments')
-
- 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: _('payment_name'), visible: true, sortable: true, filterable: true },
- { name: 'price', label: _('ms3_add_cost'), visible: true, sortable: true, width: '7.5rem' },
- {
- name: 'active',
- label: _('payment_active'),
- visible: true,
- sortable: true,
- type: 'boolean',
- width: '6.25rem',
- },
- {
- name: 'position',
- label: _('payment_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('[PaymentsGrid] 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)
- return translated !== name ? translated : name
+function resolveDisplayName(name) {
+ return getDisplayName(name, _)
}
onMounted(async () => {
@@ -650,7 +535,7 @@ onMounted(async () => {
- {{ getDisplayName(payment.name) }}
+ {{ resolveDisplayName(payment.name) }}
|
@@ -673,7 +558,7 @@ onMounted(async () => {
|
- {{ formatValue(payment[column.name], column) }}
+ {{ formatCellValue(payment[column.name], column) }}
|
@@ -797,7 +682,7 @@ onMounted(async () => {
:alt="data.name"
class="delivery-logo-small"
/>
- {{ getDisplayName(data.name) }}
+ {{ resolveDisplayName(data.name) }}
@@ -823,12 +708,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..95b1b865 100644
--- a/vueManager/src/components/VendorsGrid.vue
+++ b/vueManager/src/components/VendorsGrid.vue
@@ -19,8 +19,12 @@ 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 { formatValue, normalizeImagePath } from '../utils/displayFormatters.js'
import ActionsColumn from './ActionsColumn.vue'
import DynamicField from './DynamicField.vue'
import FileBrowser from './FileBrowser.vue'
@@ -46,14 +50,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 +58,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 +194,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 +343,7 @@ function deleteVendor(vendor) {
* Apply filters
*/
function applyFilters() {
- first.value = 0
- loadVendors()
+ resetPageAndLoad()
}
/**
@@ -364,36 +351,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 +391,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)
*/
@@ -509,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, _)
}
/**
@@ -715,7 +635,7 @@ onMounted(async () => {
- {{ formatValue(vendor[column.name], column) }}
+ {{ formatCellValue(vendor[column.name], column) }}
|
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/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..78b5c248
--- /dev/null
+++ b/vueManager/src/composables/useResourceList.js
@@ -0,0 +1,57 @@
+import { useLexicon } from '@vuetools/useLexicon'
+import { useToast } from 'primevue/usetoast'
+
+import { createResourceList } from './resourceListCore.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()
+
+ function showLoadError(error) {
+ if (onError) {
+ onError(error)
+ return
+ }
+ toast.add({
+ severity: 'error',
+ summary: _('error'),
+ detail: error?.message || _('error_loading_data'),
+ life: 5000,
+ })
+ }
+
+ 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
+ }
+ })
+})
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..b8065077
--- /dev/null
+++ b/vueManager/src/utils/displayFormatters.js
@@ -0,0 +1,123 @@
+/**
+ * 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
+}
+
+/**
+ * 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
new file mode 100644
index 00000000..c2ba65a9
--- /dev/null
+++ b/vueManager/src/utils/displayFormatters.test.js
@@ -0,0 +1,69 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import {
+ formatDate,
+ formatDatePattern,
+ formatPrice,
+ formatPriceConfigured,
+ formatValue,
+ getDisplayName,
+ normalizeImagePath,
+ 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.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')
+ })
+})
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 })
+ })
+})