ui: update ProductPerformanceProfitability page to enhance table views and tabs management
This commit is contained in:
@@ -1,5 +1,22 @@
|
||||
<template>
|
||||
<q-page class="product-performance-page q-pa-sm">
|
||||
<q-inner-loading :showing="pageBusy">
|
||||
<q-spinner-gears size="52px" color="primary" />
|
||||
</q-inner-loading>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="pageBusy"
|
||||
class="page-busy-overlay"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
@mouseup.stop
|
||||
@touchstart.stop
|
||||
@wheel.stop
|
||||
>
|
||||
<q-spinner-gears size="56px" color="primary" />
|
||||
<div class="page-busy-label">Yükleniyor...</div>
|
||||
</div>
|
||||
</teleport>
|
||||
<div class="row items-center justify-between q-col-gutter-sm q-mb-sm">
|
||||
<div class="col-12 col-md">
|
||||
<div class="text-h6">Ürün Performans ve Karlılık Analizi</div>
|
||||
@@ -14,6 +31,7 @@
|
||||
type="checkbox"
|
||||
inline
|
||||
dense
|
||||
:disable="pageBusy"
|
||||
class="period-selector bg-white q-px-sm q-py-xs"
|
||||
/>
|
||||
<q-btn-dropdown
|
||||
@@ -22,6 +40,7 @@
|
||||
color="primary"
|
||||
:icon="allProductGroupsExpanded ? 'unfold_less' : 'unfold_more'"
|
||||
:label="allProductGroupsExpanded ? 'Detayları Kapat' : 'Tüm Detayları Aç'"
|
||||
:disable="pageBusy"
|
||||
@click="toggleAllProductGroups"
|
||||
>
|
||||
<div class="detail-level-menu">
|
||||
@@ -33,12 +52,12 @@
|
||||
/>
|
||||
<q-separator class="q-my-sm" />
|
||||
<div class="row items-center justify-end q-gutter-xs">
|
||||
<q-btn dense flat color="grey-7" label="Kapat" @click="expandedGroups = {}" />
|
||||
<q-btn dense color="primary" label="Seçileni Aç" @click="expandSelectedProductGroups" />
|
||||
<q-btn dense flat color="grey-7" label="Kapat" :disable="pageBusy" @click="collapseAllProductGroups" />
|
||||
<q-btn dense color="primary" label="Seçileni Aç" :disable="pageBusy" @click="expandSelectedProductGroups" />
|
||||
</div>
|
||||
</div>
|
||||
</q-btn-dropdown>
|
||||
<q-btn outline color="secondary" icon="refresh" label="Yenile" :loading="loading" @click="reload" />
|
||||
<q-btn outline color="secondary" icon="refresh" label="Yenile" :loading="loading" :disable="pageBusy" @click="reload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,23 +72,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-tabs
|
||||
v-model="activeTab"
|
||||
dense
|
||||
class="bg-white text-grey-8 q-mb-sm"
|
||||
active-color="primary"
|
||||
indicator-color="primary"
|
||||
align="left"
|
||||
>
|
||||
<q-tab name="products" icon="inventory_2" label="Ürün KPI" />
|
||||
<q-tab name="sales_color_yaka_market_customer" icon="palette" label="Renk > Yaka > Piyasa > Müşteri" />
|
||||
<q-tab name="idle" icon="warning" label="Atıl Stok / Maliyet" />
|
||||
<q-tab name="sales_product_country_segment_market_customer" icon="account_tree" label="Ürün > Ülke > Segment > Piyasa > Müşteri" />
|
||||
<q-tab name="sales_market_customer_product" icon="groups" label="Piyasa > Müşteri > Ürün Satış KPI" />
|
||||
<q-tab name="sales_country_segment_market_customer_product" icon="public" label="Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI" />
|
||||
<q-tab name="order_product_customers" icon="assignment_ind" label="Ürün > Piyasa > Müşteri Sipariş" />
|
||||
<q-tab name="order_market_details" icon="receipt_long" label="Piyasa > Müşteri > Ürün Sipariş" />
|
||||
</q-tabs>
|
||||
<div class="performance-tab-grid q-mb-sm">
|
||||
<button
|
||||
v-for="tab in performanceTabs"
|
||||
:key="tab.name"
|
||||
type="button"
|
||||
:class="['performance-tab-button', { active: activeTab === tab.name }]"
|
||||
:disabled="pageBusy"
|
||||
@click="switchPerformanceTab(tab.name)"
|
||||
>
|
||||
<q-icon :name="tab.icon" size="16px" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
v-if="activeTab === 'general'"
|
||||
@@ -81,6 +96,8 @@
|
||||
:columns="generalColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -189,6 +206,8 @@
|
||||
:columns="orderProductCustomerColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -373,6 +392,8 @@
|
||||
:columns="orderMarketDetailColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -507,6 +528,8 @@
|
||||
:columns="productColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="pagination"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
@request="onRequest"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
@@ -658,6 +681,8 @@
|
||||
:columns="idleColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -838,6 +863,8 @@
|
||||
:columns="marketColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -936,6 +963,8 @@
|
||||
:columns="visibleSalesBreakdownColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -1091,6 +1120,8 @@
|
||||
:columns="customerColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -1190,6 +1221,8 @@
|
||||
:columns="countryColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -1396,6 +1429,8 @@
|
||||
:columns="detailStockColumns"
|
||||
:loading="detailLoading"
|
||||
:pagination="{ rowsPerPage: 50 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="48"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
@@ -1409,6 +1444,8 @@
|
||||
:columns="detailSalesColumns"
|
||||
:loading="detailLoading"
|
||||
:pagination="{ rowsPerPage: 50 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1422,8 +1459,11 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import api from 'src/services/api'
|
||||
import { useProductPerformanceStore } from 'src/stores/productPerformanceStore'
|
||||
import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters'
|
||||
|
||||
const performanceStore = useProductPerformanceStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const generalRows = ref([])
|
||||
@@ -1466,7 +1506,10 @@ const detailSalesRows = ref([])
|
||||
const detailStockSizes = ref([])
|
||||
const backendGroupedRows = ref({})
|
||||
const backendGroupedLoading = ref(false)
|
||||
const imagePrimeLoading = ref(false)
|
||||
const filterBusy = ref(false)
|
||||
let backendGroupedTimer = null
|
||||
let imagePrimeActiveRequests = 0
|
||||
|
||||
const columnFilters = reactive({})
|
||||
const columnFilterSearch = reactive({})
|
||||
@@ -1482,6 +1525,17 @@ const selectedExpandLevelKeysByTab = reactive({
|
||||
order_market_details: ['market_key', 'customer_code', 'product_code', 'color_code', 'yaka_kodu']
|
||||
})
|
||||
const productKpiFetchLimit = 50000
|
||||
const performanceTabs = [
|
||||
{ name: 'products', icon: 'inventory_2', label: 'Ürün KPI' },
|
||||
{ name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Renk > Yaka > Piyasa > Müşteri' },
|
||||
{ name: 'idle', icon: 'warning', label: 'Atıl Stok / Maliyet' },
|
||||
{ name: 'sales_product_country_segment_market_customer', icon: 'account_tree', label: 'Ürün > Ülke > Segment > Piyasa > Müşteri' },
|
||||
{ name: 'sales_market_customer_product', icon: 'groups', label: 'Piyasa > Müşteri > Ürün Satış KPI' },
|
||||
{ name: 'sales_country_segment_market_customer_product', icon: 'public', label: 'Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI' },
|
||||
{ name: 'order_product_customers', icon: 'assignment_ind', label: 'Ürün > Piyasa > Müşteri Sipariş' },
|
||||
{ name: 'order_market_details', icon: 'receipt_long', label: 'Piyasa > Müşteri > Ürün Sipariş' }
|
||||
]
|
||||
const pageBusy = computed(() => loading.value || backendGroupedLoading.value || imagePrimeLoading.value || filterBusy.value)
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
@@ -2516,13 +2570,17 @@ function toggleGroup (key) {
|
||||
function toggleAllProductGroups () {
|
||||
const keys = productAutoExpandKeys.value
|
||||
if (allProductGroupsExpanded.value) {
|
||||
expandedGroups.value = {}
|
||||
scheduleLoadBackendGroupedRows()
|
||||
collapseAllProductGroups()
|
||||
return
|
||||
}
|
||||
expandSelectedProductGroups()
|
||||
}
|
||||
|
||||
function collapseAllProductGroups () {
|
||||
expandedGroups.value = {}
|
||||
scheduleLoadBackendGroupedRows()
|
||||
}
|
||||
|
||||
function expandSelectedProductGroups () {
|
||||
const keys = productAutoExpandKeys.value
|
||||
const next = { ...expandedGroups.value }
|
||||
@@ -2617,19 +2675,47 @@ function isColumnFilterValueSelected (tableKey, name, value) {
|
||||
}
|
||||
|
||||
function toggleColumnFilterValue (tableKey, name, value) {
|
||||
const key = filterKey(tableKey, name)
|
||||
const selected = selectedColumnFilters(tableKey, name)
|
||||
columnFilters[key] = selected.includes(value)
|
||||
? selected.filter(item => item !== value)
|
||||
: [...selected, value]
|
||||
runWithFilterBusy(() => {
|
||||
const key = filterKey(tableKey, name)
|
||||
const selected = selectedColumnFilters(tableKey, name)
|
||||
columnFilters[key] = selected.includes(value)
|
||||
? selected.filter(item => item !== value)
|
||||
: [...selected, value]
|
||||
})
|
||||
}
|
||||
|
||||
function clearColumnFilter (tableKey, name) {
|
||||
columnFilters[filterKey(tableKey, name)] = []
|
||||
runWithFilterBusy(() => {
|
||||
columnFilters[filterKey(tableKey, name)] = []
|
||||
})
|
||||
}
|
||||
|
||||
function selectAllColumnFilterOptions (tableKey, name) {
|
||||
columnFilters[filterKey(tableKey, name)] = columnFilterOptions(tableKey, name).map(option => option.value)
|
||||
runWithFilterBusy(() => {
|
||||
columnFilters[filterKey(tableKey, name)] = columnFilterOptions(tableKey, name).map(option => option.value)
|
||||
})
|
||||
}
|
||||
|
||||
function runWithFilterBusy (apply) {
|
||||
filterBusy.value = true
|
||||
const done = () => {
|
||||
filterBusy.value = false
|
||||
}
|
||||
const execute = () => {
|
||||
apply()
|
||||
if (typeof window === 'undefined') {
|
||||
setTimeout(done, 0)
|
||||
return
|
||||
}
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(done)
|
||||
})
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
execute()
|
||||
return
|
||||
}
|
||||
window.requestAnimationFrame(execute)
|
||||
}
|
||||
|
||||
function filterRowsForTable (tableKey, sourceRows, sourceColumns) {
|
||||
@@ -2931,6 +3017,12 @@ async function fetchProductImagesForRow (row) {
|
||||
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) {
|
||||
return imageListByKey.value[key]
|
||||
}
|
||||
if (performanceStore.hasImageKey(key)) {
|
||||
const urls = performanceStore.imageList(key)
|
||||
imageListByKey.value = { ...imageListByKey.value, [key]: urls }
|
||||
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
|
||||
return urls
|
||||
}
|
||||
|
||||
const code = String(row?.image_product_code || row?.product_code || '').trim()
|
||||
if (!code) return []
|
||||
@@ -2949,10 +3041,12 @@ async function fetchProductImagesForRow (row) {
|
||||
})
|
||||
const list = Array.isArray(resp?.data) ? resp.data : []
|
||||
const urls = list.map(resolveProductImageUrl).filter(Boolean)
|
||||
performanceStore.setImageCache(key, urls)
|
||||
imageListByKey.value = { ...imageListByKey.value, [key]: urls }
|
||||
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
|
||||
return urls
|
||||
} catch {
|
||||
performanceStore.setImageCache(key, [])
|
||||
imageListByKey.value = { ...imageListByKey.value, [key]: [] }
|
||||
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' }
|
||||
return []
|
||||
@@ -2960,44 +3054,38 @@ async function fetchProductImagesForRow (row) {
|
||||
}
|
||||
|
||||
function getCachedProductImageUrl (row) {
|
||||
return imageUrlByKey.value[productImageKey(row)] || ''
|
||||
const key = productImageKey(row)
|
||||
return imageUrlByKey.value[key] || performanceStore.imageUrl(key) || ''
|
||||
}
|
||||
|
||||
async function primeProductImages (sourceRows) {
|
||||
imagePrimeActiveRequests += 1
|
||||
imagePrimeLoading.value = true
|
||||
const items = []
|
||||
const seen = new Set()
|
||||
for (const row of sourceRows || []) {
|
||||
const key = productImageKey(row)
|
||||
if (!key || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) continue
|
||||
seen.add(key)
|
||||
items.push({
|
||||
key,
|
||||
code: String(row?.image_product_code || row?.product_code || '').trim(),
|
||||
dim1: String(row?.image_color_code || row?.color_code || '').trim(),
|
||||
dim3: String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
|
||||
})
|
||||
if (items.length >= 160) break
|
||||
}
|
||||
if (!items.length) return
|
||||
|
||||
try {
|
||||
const resp = await api.post('/product-images/batch', { items }, { timeout: 60000 })
|
||||
for (const row of sourceRows || []) {
|
||||
const key = productImageKey(row)
|
||||
const code = String(row?.image_product_code || row?.product_code || '').trim()
|
||||
if (!key || !code || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) continue
|
||||
seen.add(key)
|
||||
items.push({
|
||||
key,
|
||||
code,
|
||||
dim1: String(row?.image_color_code || row?.color_code || '').trim(),
|
||||
dim3: String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
|
||||
})
|
||||
if (items.length >= 160) break
|
||||
}
|
||||
if (!items.length) return
|
||||
|
||||
const batch = await performanceStore.fetchImageBatch(items)
|
||||
const nextLists = { ...imageListByKey.value }
|
||||
const nextUrls = { ...imageUrlByKey.value }
|
||||
const returned = new Set()
|
||||
for (const item of Array.isArray(resp?.data) ? resp.data : []) {
|
||||
const key = String(item?.key || '').trim()
|
||||
if (!key) continue
|
||||
returned.add(key)
|
||||
const urls = (Array.isArray(item.images) ? item.images : []).map(resolveProductImageUrl).filter(Boolean)
|
||||
nextLists[key] = urls
|
||||
nextUrls[key] = urls[0] || ''
|
||||
}
|
||||
for (const item of items) {
|
||||
if (!returned.has(item.key)) {
|
||||
nextLists[item.key] = []
|
||||
nextUrls[item.key] = ''
|
||||
}
|
||||
const urls = batch?.lists?.[item.key] || performanceStore.imageList(item.key) || []
|
||||
nextLists[item.key] = urls
|
||||
nextUrls[item.key] = batch?.urls?.[item.key] || urls[0] || ''
|
||||
}
|
||||
imageListByKey.value = nextLists
|
||||
imageUrlByKey.value = nextUrls
|
||||
@@ -3007,6 +3095,9 @@ async function primeProductImages (sourceRows) {
|
||||
color_code: item.dim1,
|
||||
yaka_kodu: item.dim3
|
||||
})))
|
||||
} finally {
|
||||
imagePrimeActiveRequests = Math.max(0, imagePrimeActiveRequests - 1)
|
||||
imagePrimeLoading.value = imagePrimeActiveRequests > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3080,29 +3171,34 @@ function activeExpandedGroupKeys () {
|
||||
}
|
||||
|
||||
function scheduleLoadBackendGroupedRows () {
|
||||
if (backendGroupedSupportedTab(activeTab.value)) backendGroupedLoading.value = true
|
||||
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
|
||||
backendGroupedTimer = window.setTimeout(() => {
|
||||
void loadBackendGroupedRows()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function switchPerformanceTab (tabName) {
|
||||
if (!tabName || tabName === activeTab.value || pageBusy.value) return
|
||||
backendGroupedLoading.value = true
|
||||
activeTab.value = tabName
|
||||
}
|
||||
|
||||
async function loadBackendGroupedRows () {
|
||||
const tabKey = activeTab.value
|
||||
if (!backendGroupedSupportedTab(tabKey)) return
|
||||
backendGroupedLoading.value = true
|
||||
const params = {
|
||||
mode: tabKey,
|
||||
groupLevels: activeGroupLevels.value.map(level => level.key),
|
||||
expandedKeys: activeExpandedGroupKeys(),
|
||||
limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000
|
||||
}
|
||||
try {
|
||||
const resp = await api.get('/pricing/product-performance/grouped', {
|
||||
params: {
|
||||
mode: tabKey,
|
||||
group_levels: activeGroupLevels.value.map(level => level.key).join(','),
|
||||
expanded_keys: activeExpandedGroupKeys().join(','),
|
||||
limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000
|
||||
},
|
||||
timeout: 90000
|
||||
})
|
||||
const rows = await performanceStore.fetchGroupedRows(params)
|
||||
backendGroupedRows.value = {
|
||||
...backendGroupedRows.value,
|
||||
[tabKey]: Array.isArray(resp?.data) ? resp.data : []
|
||||
[tabKey]: Array.isArray(rows) ? rows : []
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('product performance grouped rows failed', err)
|
||||
@@ -3168,7 +3264,7 @@ async function reload () {
|
||||
await loadOrderMarketDetails(false)
|
||||
}
|
||||
await loadBackendGroupedRows()
|
||||
void primeProductImages(rows.value)
|
||||
await primeProductImages(rows.value)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
|
||||
} finally {
|
||||
@@ -3182,7 +3278,7 @@ async function loadOrderProductCustomers (showLoading = true) {
|
||||
const resp = await api.get('/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 })
|
||||
orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow)
|
||||
orderProductCustomersLoaded.value = true
|
||||
void primeProductImages(orderProductCustomerRows.value)
|
||||
await primeProductImages(orderProductCustomerRows.value)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün müşteri sipariş analizi alınamadı' })
|
||||
} finally {
|
||||
@@ -3215,7 +3311,7 @@ async function loadOrderAnalysis (showLoading = true) {
|
||||
orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow)
|
||||
orderCustomerRows.value = (Array.isArray(customerResp?.data) ? customerResp.data : []).map(normalizeOrderGroupRow)
|
||||
orderAnalysisLoaded.value = true
|
||||
void primeProductImages(orderAnalysisRows.value)
|
||||
await primeProductImages(orderAnalysisRows.value)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Sipariş analiz verisi alınamadı' })
|
||||
} finally {
|
||||
@@ -3271,9 +3367,70 @@ onMounted(reload)
|
||||
|
||||
<style scoped>
|
||||
.product-performance-page {
|
||||
position: relative;
|
||||
background: #f6f7f9;
|
||||
}
|
||||
|
||||
.page-busy-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: rgba(246, 247, 249, 0.82);
|
||||
backdrop-filter: blur(2px);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.page-busy-label {
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.performance-tab-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.performance-tab-button {
|
||||
min-height: 38px;
|
||||
padding: 6px 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
border: 1px solid #d5dce5;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #4b5563;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.performance-tab-button span {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.performance-tab-button.active {
|
||||
border-color: var(--q-primary);
|
||||
color: var(--q-primary);
|
||||
background: #eef6ff;
|
||||
}
|
||||
|
||||
.performance-tab-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 78px;
|
||||
}
|
||||
@@ -3311,8 +3468,17 @@ onMounted(reload)
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody tr),
|
||||
.performance-table :deep(.q-table tbody td) {
|
||||
height: auto;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody td) {
|
||||
max-width: 320px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody td span:not(.q-badge):not(.q-icon)) {
|
||||
@@ -3323,6 +3489,13 @@ onMounted(reload)
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody td .q-field),
|
||||
.performance-table :deep(.q-table tbody td input),
|
||||
.performance-table :deep(.q-table tbody td textarea) {
|
||||
min-width: 96px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table th:first-child),
|
||||
.performance-table :deep(.q-table td:first-child) {
|
||||
border-left: 1px solid #d5dce5;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import api from 'src/services/api'
|
||||
|
||||
const GROUPED_TTL_MS = 2 * 60 * 1000
|
||||
const IMAGE_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
function normalizeUploadsPath (storagePath) {
|
||||
const raw = String(storagePath || '').trim()
|
||||
if (!raw) return ''
|
||||
const normalized = raw.replace(/\\/g, '/')
|
||||
const idx = normalized.toLowerCase().indexOf('/uploads/')
|
||||
if (idx >= 0) return normalized.slice(idx)
|
||||
if (normalized.toLowerCase().startsWith('uploads/')) return `/${normalized}`
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveProductImageUrl (item) {
|
||||
if (!item || typeof item !== 'object') return ''
|
||||
|
||||
const contentURL = String(item.content_url || item.ContentURL || '').trim()
|
||||
if (contentURL.startsWith('/api/')) return contentURL
|
||||
if (contentURL.startsWith('/')) return `/api${contentURL}`
|
||||
|
||||
const imageId = Number(item.id || item.ID || 0)
|
||||
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
|
||||
|
||||
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
|
||||
if (thumbURL) return thumbURL
|
||||
|
||||
const fullURL = String(item.full_url || item.fullUrl || '').trim()
|
||||
if (fullURL) return fullURL
|
||||
|
||||
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
|
||||
if (uploadsPath) return uploadsPath
|
||||
|
||||
const fileName = String(item.file_name || item.FileName || '').trim()
|
||||
return fileName ? `/uploads/image/${fileName}` : ''
|
||||
}
|
||||
|
||||
function sortedList (value) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.map(x => String(x || '').trim()).filter(Boolean).sort()
|
||||
}
|
||||
|
||||
export const useProductPerformanceStore = defineStore('product-performance-store', {
|
||||
state: () => ({
|
||||
groupedRowsByKey: {},
|
||||
groupedLoadedAtByKey: {},
|
||||
groupedLoadingByKey: {},
|
||||
groupedInFlightByKey: {},
|
||||
imageUrlByKey: {},
|
||||
imageListByKey: {},
|
||||
imageLoadedAtByKey: {}
|
||||
}),
|
||||
|
||||
getters: {
|
||||
groupedRows: state => key => state.groupedRowsByKey[key] || [],
|
||||
hasImageKey: state => key => Object.prototype.hasOwnProperty.call(state.imageListByKey, key),
|
||||
imageUrl: state => key => state.imageUrlByKey[key] || '',
|
||||
imageList: state => key => state.imageListByKey[key] || []
|
||||
},
|
||||
|
||||
actions: {
|
||||
groupedCacheKey (params = {}) {
|
||||
return JSON.stringify({
|
||||
mode: String(params.mode || ''),
|
||||
groupLevels: sortedList(params.groupLevels),
|
||||
expandedKeys: sortedList(params.expandedKeys),
|
||||
limit: Number(params.limit || 0)
|
||||
})
|
||||
},
|
||||
|
||||
async fetchGroupedRows (params = {}, options = {}) {
|
||||
const key = this.groupedCacheKey(params)
|
||||
const now = Date.now()
|
||||
const loadedAt = Number(this.groupedLoadedAtByKey[key] || 0)
|
||||
const cached = this.groupedRowsByKey[key]
|
||||
if (!options.force && Array.isArray(cached) && now - loadedAt < GROUPED_TTL_MS) return cached
|
||||
if (this.groupedInFlightByKey[key]) return this.groupedInFlightByKey[key]
|
||||
|
||||
this.groupedLoadingByKey = { ...this.groupedLoadingByKey, [key]: true }
|
||||
const request = api.get('/pricing/product-performance/grouped', {
|
||||
params: {
|
||||
mode: params.mode,
|
||||
group_levels: Array.isArray(params.groupLevels) ? params.groupLevels.join(',') : params.groupLevels,
|
||||
expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys.join(',') : params.expandedKeys,
|
||||
limit: params.limit
|
||||
},
|
||||
timeout: 90000
|
||||
}).then(resp => {
|
||||
const rows = Array.isArray(resp?.data) ? resp.data : []
|
||||
this.groupedRowsByKey = { ...this.groupedRowsByKey, [key]: rows }
|
||||
this.groupedLoadedAtByKey = { ...this.groupedLoadedAtByKey, [key]: Date.now() }
|
||||
return rows
|
||||
}).finally(() => {
|
||||
const nextLoading = { ...this.groupedLoadingByKey }
|
||||
const nextInFlight = { ...this.groupedInFlightByKey }
|
||||
delete nextLoading[key]
|
||||
delete nextInFlight[key]
|
||||
this.groupedLoadingByKey = nextLoading
|
||||
this.groupedInFlightByKey = nextInFlight
|
||||
})
|
||||
|
||||
this.groupedInFlightByKey = { ...this.groupedInFlightByKey, [key]: request }
|
||||
return request
|
||||
},
|
||||
|
||||
setImageCache (key, urls = []) {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
if (!normalizedKey) return
|
||||
const list = Array.isArray(urls) ? urls.filter(Boolean) : []
|
||||
this.imageListByKey = { ...this.imageListByKey, [normalizedKey]: list }
|
||||
this.imageUrlByKey = { ...this.imageUrlByKey, [normalizedKey]: list[0] || '' }
|
||||
this.imageLoadedAtByKey = { ...this.imageLoadedAtByKey, [normalizedKey]: Date.now() }
|
||||
},
|
||||
|
||||
async fetchImageBatch (items = [], options = {}) {
|
||||
const now = Date.now()
|
||||
const pending = []
|
||||
const seen = new Set()
|
||||
for (const item of items) {
|
||||
const key = String(item?.key || '').trim()
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const loadedAt = Number(this.imageLoadedAtByKey[key] || 0)
|
||||
if (!options.force && Object.prototype.hasOwnProperty.call(this.imageListByKey, key) && now - loadedAt < IMAGE_TTL_MS) continue
|
||||
pending.push({
|
||||
key,
|
||||
code: String(item?.code || '').trim(),
|
||||
dim1: String(item?.dim1 || '').trim(),
|
||||
dim3: String(item?.dim3 || '').trim()
|
||||
})
|
||||
}
|
||||
|
||||
if (!pending.length) {
|
||||
const keys = Array.from(seen.values())
|
||||
return {
|
||||
lists: Object.fromEntries(keys.map(key => [key, this.imageListByKey[key] || []])),
|
||||
urls: Object.fromEntries(keys.map(key => [key, this.imageUrlByKey[key] || '']))
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await api.post('/product-images/batch', { items: pending }, { timeout: 60000 })
|
||||
const returned = new Set()
|
||||
const lists = {}
|
||||
const urlsByKey = {}
|
||||
for (const item of Array.isArray(resp?.data) ? resp.data : []) {
|
||||
const key = String(item?.key || '').trim()
|
||||
if (!key) continue
|
||||
returned.add(key)
|
||||
const urls = (Array.isArray(item.images) ? item.images : []).map(resolveProductImageUrl).filter(Boolean)
|
||||
this.setImageCache(key, urls)
|
||||
lists[key] = urls
|
||||
urlsByKey[key] = urls[0] || ''
|
||||
}
|
||||
for (const item of pending) {
|
||||
if (returned.has(item.key)) continue
|
||||
this.setImageCache(item.key, [])
|
||||
lists[item.key] = []
|
||||
urlsByKey[item.key] = ''
|
||||
}
|
||||
|
||||
return { lists, urls: urlsByKey }
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user