product performance grouped kpi improvements

This commit is contained in:
M_Kececi
2026-07-03 19:53:16 +03:00
parent b133d43ebe
commit a45478d15f
5 changed files with 446 additions and 83 deletions
+156 -45
View File
@@ -95,7 +95,7 @@
:rows="filteredGeneralRows"
:columns="generalColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score_total', descending: true }"
:pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }"
virtual-scroll
:virtual-scroll-item-size="64"
>
@@ -206,7 +206,7 @@
:rows="displayOrderProductCustomerTableRows"
:columns="orderProductCustomerColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }"
:pagination="{ rowsPerPage: 0, sortBy: 'order_usd', descending: true }"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="64"
@@ -354,7 +354,7 @@
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
@@ -396,7 +396,7 @@
:rows="displayOrderMarketDetailTableRows"
:columns="orderMarketDetailColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }"
:pagination="{ rowsPerPage: 0, sortBy: 'order_date', descending: true }"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="64"
@@ -508,7 +508,7 @@
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
@@ -646,7 +646,7 @@
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col.name) }}</span>
</q-td>
@@ -688,7 +688,7 @@
:rows="displayIdleTableRows"
:columns="idleColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100 }"
:pagination="{ rowsPerPage: 0 }"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="64"
@@ -832,7 +832,7 @@
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
@@ -874,7 +874,7 @@
:rows="filteredMarketRows"
:columns="marketColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
:pagination="{ rowsPerPage: 0 }"
virtual-scroll
:virtual-scroll-item-size="64"
>
@@ -974,7 +974,7 @@
:rows="displayActiveSalesBreakdownTableRows"
:columns="visibleSalesBreakdownColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score_total', descending: true }"
:pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="64"
@@ -1091,7 +1091,7 @@
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
@@ -1132,7 +1132,7 @@
:rows="filteredCustomerRows"
:columns="customerColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
:pagination="{ rowsPerPage: 0 }"
virtual-scroll
:virtual-scroll-item-size="64"
>
@@ -1233,7 +1233,7 @@
:rows="filteredCountryRows"
:columns="countryColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
:pagination="{ rowsPerPage: 0 }"
virtual-scroll
:virtual-scroll-item-size="64"
>
@@ -1476,10 +1476,26 @@ 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()
function formatNumber (value, fraction = 2) {
const n = Number(value || 0)
const digits = Math.max(0, Math.min(2, Number(fraction || 0)))
return n.toLocaleString('tr-TR', {
minimumFractionDigits: digits,
maximumFractionDigits: digits
})
}
function formatMoney (value, currency = 'USD') {
return `${formatNumber(value, 2)} ${currency}`
}
function formatPercent (value) {
return `${formatNumber(Number(value || 0) * 100, 2)}%`
}
const loading = ref(false)
const rows = ref([])
const generalRows = ref([])
@@ -1542,10 +1558,11 @@ const selectedExpandLevelKeysByTab = reactive({
order_market_details: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan']
})
const productKpiFetchLimit = 50000
const reportFetchLimit = 50000
const maxBulkExpandKeys = 1200
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: '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_country_segment_market_customer_product', icon: 'public', label: 'Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI' },
@@ -1556,7 +1573,7 @@ const pageBusy = computed(() => loading.value || backendGroupedLoading.value ||
const pagination = ref({
page: 1,
rowsPerPage: 100,
rowsPerPage: 0,
sortBy: 'performance_score_total',
descending: true
})
@@ -1691,6 +1708,7 @@ const periodOrder = {
const hiddenTableColumns = new Set(['item_description', 'performance_score'])
const dimensionColumnNames = [
'product_code',
'color_yaka',
'color_code',
'yaka_kodu',
'item_description',
@@ -2158,6 +2176,36 @@ function removeColumnsInPlace (targetColumns, names) {
return targetColumns
}
function ensureColorYakaColumn (targetColumns) {
const colorIndex = targetColumns.findIndex(col => col.name === 'color_code')
const yakaIndex = targetColumns.findIndex(col => col.name === 'yaka_kodu')
const insertAt = colorIndex >= 0 ? colorIndex : yakaIndex
if (insertAt >= 0 && !targetColumns.some(col => col.name === 'color_yaka')) {
targetColumns.splice(insertAt, 0, {
name: 'color_yaka',
label: 'Renk/Yaka',
field: row => colorYakaValue(row),
align: 'left',
sortable: true,
classes: 'color-yaka-cell',
headerClasses: 'color-yaka-cell'
})
}
removeColumnsInPlace(targetColumns, ['color_code', 'yaka_kodu'])
return targetColumns
}
;[
columns,
generalColumns,
orderAnalysisColumns,
orderGroupColumns,
orderProductCustomerColumns,
orderMarketDetailColumns,
idleColumns,
salesBreakdownColumns
].forEach(ensureColorYakaColumn)
;[
columns,
generalColumns,
@@ -2725,6 +2773,9 @@ function sortValueForColumnName (row, columnName) {
function makeGroupRow (key, level, groupDef, value, groupRows) {
const imageSource = groupRows.find(row => row?.product_code) || {}
const aggregate = aggregateGroupFields(groupRows, groupDef.key)
const colorSummary = groupDef.key === 'color_code' ? value : distinctSummary(groupRows, 'color_code')
const yakaSummary = groupDef.key === 'yaka_kodu' ? value : distinctSummary(groupRows, 'yaka_kodu')
const colorDescriptionSummary = distinctSummary(groupRows, 'color_description')
return {
__group: true,
row_key: `group|${key}`,
@@ -2732,14 +2783,16 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
level,
group_field: groupDef.key,
group_value: value,
label: value,
label: colorYakaGroupLabel(groupDef.key, value, colorSummary, yakaSummary),
count: groupRows.length,
image_product_code: imageSource.product_code || '',
image_color_code: imageSource.color_code || '',
image_color_description: imageSource.color_description || '',
image_yaka_kodu: imageSource.yaka_kodu || '',
product_code: groupDef.key === 'product_code' ? value : distinctSummary(groupRows, 'product_code'),
color_code: groupDef.key === 'color_code' ? value : distinctSummary(groupRows, 'color_code'),
yaka_kodu: groupDef.key === 'yaka_kodu' ? value : distinctSummary(groupRows, 'yaka_kodu'),
color_code: colorSummary,
color_description: colorDescriptionSummary,
yaka_kodu: yakaSummary,
item_description: distinctSummary(groupRows, 'item_description'),
kategori: groupDef.key === 'kategori' ? value : distinctSummary(groupRows, 'kategori'),
urun_ilk_grubu: groupDef.key === 'urun_ilk_grubu' ? value : distinctSummary(groupRows, 'urun_ilk_grubu'),
@@ -3170,7 +3223,17 @@ function expandSelectedProductGroups () {
}
function groupLabelColumnName (row) {
return row?.group_field || 'product_code'
const field = String(row?.group_field || '')
if (field === 'color_code' || field === 'yaka_kodu') return 'color_yaka'
return field || 'product_code'
}
function groupDisplayLabel (row) {
const field = String(row?.group_field || '')
if (field === 'color_code' || field === 'yaka_kodu') {
return colorYakaValue(row) || row?.label || row?.group_value || ''
}
return row?.label || row?.group_value || ''
}
function groupIndentPx (row) {
@@ -3209,6 +3272,7 @@ function formatGroupCell (row, colOrName) {
const name = colOrName?.name || colOrName
if (name === 'image') return ''
if (name === 'recommendation') return row.recommendation || ''
if (name === 'color_yaka' && ['color_code', 'yaka_kodu'].includes(String(row?.group_field || ''))) return colorYakaValue(row)
if (groupDimensionFields.has(name) && name !== row?.group_field) return ''
return formatTableCell(row, colOrName)
}
@@ -3347,6 +3411,42 @@ function columnFilterLabel (row, col) {
return formattedColumnCellValue(row, col) || columnFilterValue(row, col)
}
function colorYakaValue (row) {
const groupField = String(row?.group_field || '')
const groupValue = String(row?.group_value || row?.label || '').trim()
const color = String(row?.image_color_code || row?.color_code || '').trim()
const colorDescription = String(row?.image_color_description || row?.color_description || '').trim()
const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
const colorLabel = colorDisplayLabel(groupField === 'color_code' ? groupValue || color : color, colorDescription)
if (row?.__group && groupField === 'color_code') return colorLabel
if (row?.__group && groupField === 'yaka_kodu') {
const parentColor = colorLabel && colorLabel !== '-' ? colorLabel : ''
const groupYaka = groupValue && groupValue !== '-' ? groupValue : yaka
return [parentColor, groupYaka].filter(Boolean).join('/')
}
return [colorLabel, yaka].filter(value => value && value !== '-').join('/')
}
function colorDisplayLabel (code, description) {
const colorCode = String(code || '').trim()
const desc = String(description || '').trim()
if (!colorCode) return ''
if (!desc) return colorCode
return `${colorCode}-${desc.toLocaleUpperCase('tr-TR')}`
}
function colorYakaGroupLabel (groupField, groupValue, colorSummary, yakaSummary) {
const field = String(groupField || '')
const value = String(groupValue || '').trim()
if (field === 'color_code') return value
if (field === 'yaka_kodu') {
const color = String(colorSummary || '').trim()
const yaka = value || String(yakaSummary || '').trim()
return [color, yaka].filter(part => part && part !== '-').join('/')
}
return value
}
function productCategoryValue (row) {
return row?.kategori || ''
}
@@ -3380,6 +3480,7 @@ function cleanProductFirstGroupValue (value) {
function rawProductCellValue (row, name) {
switch (name) {
case 'color_yaka': return colorYakaValue(row)
case 'kategori': return productCategoryValue(row)
case 'urun_ilk_grubu': return productFirstGroupValue(row)
case 'askili_yan': return cleanOptionalGroupValue(row?.askili_yan)
@@ -3393,6 +3494,7 @@ function rawProductCellValue (row, name) {
function rawColumnCellValue (row, col) {
const name = col?.name || col
switch (name) {
case 'color_yaka': return colorYakaValue(row)
case 'kategori': return productCategoryValue(row)
case 'urun_ilk_grubu': return productFirstGroupValue(row)
case 'askili_yan': return cleanOptionalGroupValue(row?.askili_yan)
@@ -3469,6 +3571,7 @@ function formatTableCell (row, colOrName) {
const col = typeof colOrName === 'object' ? colOrName : null
const name = col?.name || colOrName
if (name === 'image') return ''
if (name === 'color_yaka') return colorYakaValue(row)
if (['kategori', 'urun_ilk_grubu', 'urun_ana_grubu'].includes(name)) return rawProductCellValue(row, name) || ''
if (name === 'market_key') return displayMarketName(row.market_key)
if (name === 'performance_bucket') return bucketLabel(row.performance_bucket)
@@ -4042,7 +4145,7 @@ async function reload () {
try {
const salesBreakdownRequests = salesBreakdownTabKeys.map(tabKey => {
return timedProductPerformanceGet(`sales-breakdown:${tabKey}`, '/pricing/product-performance/sales-breakdown', {
params: { limit: 800, mode: salesBreakdownModes[tabKey] },
params: { limit: reportFetchLimit, mode: salesBreakdownModes[tabKey] },
timeout: 60000
})
})
@@ -4055,11 +4158,11 @@ async function reload () {
},
timeout: 60000
}),
timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: 500 }, timeout: 60000 }),
timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: 500 }, timeout: 60000 }),
timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: 500 }, timeout: 60000 }),
timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: reportFetchLimit }, timeout: 60000 }),
timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: reportFetchLimit }, timeout: 60000 }),
timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: reportFetchLimit }, timeout: 60000 }),
timedProductPerformanceGet('customers', '/pricing/product-performance/customers', {
params: { limit: 500, breakdown: customerBreakdown.value },
params: { limit: reportFetchLimit, breakdown: customerBreakdown.value },
timeout: 60000
}),
...salesBreakdownRequests
@@ -4101,7 +4204,7 @@ async function reload () {
async function loadOrderProductCustomers (showLoading = true) {
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 })
const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: reportFetchLimit }, timeout: 90000 })
orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow)
orderProductCustomersLoaded.value = true
} catch (err) {
@@ -4114,7 +4217,7 @@ async function loadOrderProductCustomers (showLoading = true) {
async function loadOrderMarketDetails (showLoading = true) {
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: 800 }, timeout: 90000 })
const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: reportFetchLimit }, timeout: 90000 })
orderMarketDetailRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderMarketDetailRow)
orderMarketDetailsLoaded.value = true
} catch (err) {
@@ -4128,9 +4231,9 @@ async function loadOrderAnalysis (showLoading = true) {
if (showLoading) loading.value = true
try {
const [productResp, marketResp, customerResp] = await Promise.all([
timedProductPerformanceGet('orders:products', '/pricing/product-performance/orders', { params: { limit: 500 }, timeout: 90000 }),
timedProductPerformanceGet('orders:groups:market', '/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'market' }, timeout: 90000 }),
timedProductPerformanceGet('orders:groups:customer', '/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'customer' }, timeout: 90000 })
timedProductPerformanceGet('orders:products', '/pricing/product-performance/orders', { params: { limit: reportFetchLimit }, timeout: 90000 }),
timedProductPerformanceGet('orders:groups:market', '/pricing/product-performance/orders/groups', { params: { limit: reportFetchLimit, breakdown: 'market' }, timeout: 90000 }),
timedProductPerformanceGet('orders:groups:customer', '/pricing/product-performance/orders/groups', { params: { limit: reportFetchLimit, breakdown: 'customer' }, timeout: 90000 })
])
orderAnalysisRows.value = (Array.isArray(productResp?.data) ? productResp.data : []).map(normalizeOrderAnalysisRow)
orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow)
@@ -4417,18 +4520,26 @@ onMounted(reload)
.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: 72px;
min-width: 54px;
max-width: 100%;
}
.performance-table :deep(.q-table tbody td .q-field__control),
.performance-table :deep(.q-table tbody td .q-field__native),
.performance-table :deep(.q-table tbody td .q-field__input) {
min-height: 26px;
font-size: 11px;
min-height: 24px;
font-size: 10px;
line-height: 1.2;
}
.performance-table :deep(.q-table th.color-yaka-cell),
.performance-table :deep(.q-table td.color-yaka-cell) {
width: 132px;
min-width: 132px;
max-width: 150px;
font-size: 10px;
}
.performance-table :deep(.q-table th:first-child),
.performance-table :deep(.q-table td:first-child) {
border-left: 1px solid #d5dce5;
@@ -4778,39 +4889,39 @@ onMounted(reload)
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-0 td) {
background: #2f5f8f;
background: #123b63;
color: #fff;
border-color: #244b72;
border-color: #0b2845;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-1 td) {
background: #4f7fad;
background: #1f6f54;
color: #fff;
border-color: #426d96;
border-color: #18563f;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-2 td) {
background: #d8e6f4;
background: #ffe2a8;
color: #1f2937;
border-color: #b8cce0;
border-color: #d7a84d;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-3 td) {
background: #e6eef7;
background: #d8f0dc;
color: #1f2937;
border-color: #c4d2e2;
border-color: #9bc7a3;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-4 td) {
background: #eef4fa;
background: #e7ecff;
color: #1f2937;
border-color: #cbd8e6;
border-color: #b8c2ef;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-5 td) {
background: #f4f8fc;
background: #f2f3f5;
color: #1f2937;
border-color: #d5dce5;
border-color: #c4c9d1;
}
.performance-table :deep(.q-table tbody tr.group-row td span),