product performance grouped kpi improvements

This commit is contained in:
M_Kececi
2026-07-03 02:37:58 +03:00
parent e7a4c58934
commit c7ff2328fb
+241 -80
View File
@@ -343,7 +343,7 @@
<div
v-else-if="col.name === groupLabelColumnName(props.row)"
class="group-cell-label"
:style="{ paddingLeft: `${props.row.level * 18}px` }"
:style="{ paddingLeft: groupIndentPx(props.row) }"
>
<q-btn
flat
@@ -496,7 +496,7 @@
<div
v-if="col.name === groupLabelColumnName(props.row)"
class="group-cell-label"
:style="{ paddingLeft: `${props.row.level * 18}px` }"
:style="{ paddingLeft: groupIndentPx(props.row) }"
>
<q-btn
flat
@@ -634,7 +634,7 @@
<div
v-else-if="col.name === groupLabelColumnName(props.row)"
class="group-cell-label"
:style="{ paddingLeft: `${props.row.level * 18}px` }"
:style="{ paddingLeft: groupIndentPx(props.row) }"
>
<q-btn
flat
@@ -819,7 +819,7 @@
<div
v-else-if="col.name === groupLabelColumnName(props.row)"
class="group-cell-label"
:style="{ paddingLeft: `${props.row.level * 18}px` }"
:style="{ paddingLeft: groupIndentPx(props.row) }"
>
<q-btn
flat
@@ -1077,7 +1077,7 @@
<div
v-if="col.name === groupLabelColumnName(props.row)"
class="group-cell-label"
:style="{ paddingLeft: `${props.row.level * 18}px` }"
:style="{ paddingLeft: groupIndentPx(props.row) }"
>
<q-btn
flat
@@ -1523,17 +1523,19 @@ let imagePrimeActiveRequests = 0
const columnFilters = reactive({})
const columnFilterSearch = reactive({})
const expandedGroups = ref({})
const lastManualExpandedGroupKey = ref('')
const selectedExpandLevelKeysByTab = reactive({
products: ['kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu', 'market_key'],
sales_color_yaka_market_customer: ['color_code', 'yaka_kodu', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'country', 'market_key', 'customer_segment', 'customer_code', 'customer_name'],
idle: ['askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'],
sales_product_country_segment_market_customer: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'],
sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'],
sales_country_segment_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'],
order_product_customers: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu'],
order_market_details: ['market_key', 'customer_code', 'customer_name', 'kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'item_description', 'color_code', 'yaka_kodu']
products: ['kategori', 'askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu'],
sales_color_yaka_market_customer: ['color_code', 'yaka_kodu', 'askili_yan', 'urun_ilk_grubu'],
idle: ['askili_yan', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu'],
sales_product_country_segment_market_customer: ['market_key', 'customer_code', 'customer_name', 'kategori'],
sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'kategori'],
sales_country_segment_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'kategori'],
order_product_customers: ['market_key', 'customer_code', 'customer_name', 'kategori'],
order_market_details: ['market_key', 'customer_code', 'customer_name', 'kategori']
})
const productKpiFetchLimit = 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' },
@@ -1668,6 +1670,49 @@ const periodColumnRules = [
{ period: 'total', match: name => name.includes('_total') }
]
const periodOrder = {
90: 1,
180: 2,
360: 3,
total: 4
}
const metricOrderFragments = [
'customer_score',
'performance_score',
'product_count',
'market_count',
'customer_count',
'invoice_count',
'order_count',
'line_count',
'sales_qty',
'order_qty',
'sales_usd',
'order_usd',
'avg_price',
'avg_order_price',
'sales_index',
'avg_daily_sales',
'stock_days',
'stock_turnover',
'base_price',
'cost_price',
'base_cost',
'cost_amount',
'unit_profit_base',
'unit_profit_cost',
'gross_profit_usd',
'gross_profit_base',
'gross_profit_cost',
'expected_profit_base',
'expected_profit_cost',
'gross_margin_base',
'gross_margin_cost',
'expected_margin_base',
'expected_margin_cost'
]
const selectedPeriodSet = computed(() => new Set(selectedPeriods.value))
function columnPeriod (name) {
@@ -1679,7 +1724,44 @@ function visiblePeriodColumn (col) {
return !period || selectedPeriodSet.value.has(period)
}
const productColumns = computed(() => columns.filter(visiblePeriodColumn))
function metricBaseName (name) {
return String(name || '')
.replace(/_90d$|_180d$|_365d$|_total$/g, '')
}
function metricOrder (name) {
const base = metricBaseName(name)
const idx = metricOrderFragments.findIndex(fragment => base.includes(fragment))
return idx >= 0 ? idx : 999
}
function isTrailingInfoColumn (name) {
return ['performance_bucket', 'recommendation', 'last_sale_date', 'first_sale_date', 'is_overdue'].includes(name)
}
function orderedMetricColumns (sourceColumns) {
return [...sourceColumns].sort((a, b) => {
const aPeriod = columnPeriod(a.name)
const bPeriod = columnPeriod(b.name)
const aTrailing = isTrailingInfoColumn(a.name)
const bTrailing = isTrailingInfoColumn(b.name)
if (aTrailing !== bTrailing) return aTrailing ? 1 : -1
if (!aPeriod && !bPeriod) return 0
if (!aPeriod) return -1
if (!bPeriod) return 1
const periodDiff = periodOrder[aPeriod] - periodOrder[bPeriod]
if (periodDiff) return periodDiff
const metricDiff = metricOrder(a.name) - metricOrder(b.name)
return metricDiff || 0
})
}
function reorderColumnsInPlace (targetColumns) {
targetColumns.splice(0, targetColumns.length, ...orderedMetricColumns(targetColumns))
return targetColumns
}
const productColumns = computed(() => orderedMetricColumns(columns.filter(visiblePeriodColumn)))
const generalColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
@@ -1986,7 +2068,21 @@ const salesBreakdownColumns = [
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
const visibleSalesBreakdownColumns = computed(() => salesBreakdownColumns.filter(visiblePeriodColumn))
;[
columns,
generalColumns,
orderAnalysisColumns,
orderGroupColumns,
orderProductCustomerColumns,
orderMarketDetailColumns,
idleColumns,
marketColumns,
countryColumns,
customerColumns,
salesBreakdownColumns
].forEach(reorderColumnsInPlace)
const visibleSalesBreakdownColumns = computed(() => orderedMetricColumns(salesBreakdownColumns.filter(visiblePeriodColumn)))
const summaryCards = computed(() => [
{ key: 'date', label: 'KPI Tarihi', value: summary.value.kpi_date || '-' },
@@ -2369,6 +2465,7 @@ function backendRowsForTab (tabKey, fallbackRows) {
}
function collectGroupKeys (out, sourceRows, level, parentKeys, levels = groupLevels, maxLevel = levels.length - 1) {
if (out.length >= maxBulkExpandKeys) return
if (level >= levels.length) return
const groupDef = levels[level]
@@ -2382,6 +2479,7 @@ function collectGroupKeys (out, sourceRows, level, parentKeys, levels = groupLev
Array.from(grouped.entries())
.sort((a, b) => a[0].localeCompare(b[0], 'tr'))
.forEach(([value, groupRows]) => {
if (out.length >= maxBulkExpandKeys) return
const key = [...parentKeys, `${groupDef.key}:${value}`].join('|')
out.push(key)
if (level < maxLevel) {
@@ -2774,6 +2872,7 @@ function isGroupExpanded (key) {
}
function toggleGroup (key) {
lastManualExpandedGroupKey.value = key
expandedGroups.value = {
...expandedGroups.value,
[key]: !isGroupExpanded(key)
@@ -2792,14 +2891,26 @@ function toggleAllProductGroups () {
function collapseAllProductGroups () {
expandedGroups.value = {}
lastManualExpandedGroupKey.value = ''
scheduleLoadBackendGroupedRows()
}
function expandSelectedProductGroups () {
const keys = productAutoExpandKeys.value
const next = { ...expandedGroups.value }
for (const key of keys) next[key] = true
const expandableKeys = keys.slice(0, maxBulkExpandKeys)
const tabPrefix = `tab:${activeTab.value}|`
const next = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith(tabPrefix))
)
for (const key of expandableKeys) next[key] = true
expandedGroups.value = next
lastManualExpandedGroupKey.value = ''
if (keys.length > expandableKeys.length) {
Notify.create({
type: 'warning',
message: `${formatNumber(expandableKeys.length, 0)} grup açıldı. Daha derin detaylar için satırları kademeli açın.`
})
}
scheduleLoadBackendGroupedRows()
}
@@ -2807,6 +2918,10 @@ function groupLabelColumnName (row) {
return row?.group_field || 'product_code'
}
function groupIndentPx (row) {
return '0px'
}
function groupShowsImage (row) {
return Number(row?.level ?? 0) < autoExpandThroughLevel.value
}
@@ -3412,11 +3527,31 @@ function backendGroupedSupportedTab (tabKey = activeTab.value) {
function activeExpandedGroupKeys () {
const tabPrefix = `tab:${activeTab.value}|`
const validFields = new Set(activeGroupLevels.value.map(level => level.key))
return Object.entries(expandedGroups.value)
const keys = Object.entries(expandedGroups.value)
.filter(([, value]) => value === true)
.map(([key]) => key)
.filter(key => key.startsWith(tabPrefix))
.filter(key => key.split('|').slice(1).every(part => validFields.has(String(part).split(':')[0])))
const manualKey = lastManualExpandedGroupKey.value
const out = []
if (manualKey && keys.includes(manualKey)) out.push(manualKey)
for (const key of keys) {
if (out.length >= maxBulkExpandKeys) break
if (key !== manualKey) out.push(key)
}
return out
}
function pruneActiveExpandedGroups () {
const tabPrefix = `tab:${activeTab.value}|`
const allowed = new Set(activeExpandedGroupKeys())
const next = {}
for (const [key, value] of Object.entries(expandedGroups.value)) {
if (!value) continue
if (key.startsWith(tabPrefix) && !allowed.has(key)) continue
next[key] = value
}
expandedGroups.value = next
}
function scheduleLoadBackendGroupedRows () {
@@ -3436,6 +3571,7 @@ function switchPerformanceTab (tabName) {
async function loadBackendGroupedRows () {
const tabKey = activeTab.value
if (!backendGroupedSupportedTab(tabKey)) return
pruneActiveExpandedGroups()
backendGroupedLoading.value = true
const params = {
mode: tabKey,
@@ -3824,6 +3960,12 @@ onMounted(reload)
padding: 4px 5px;
}
.performance-table :deep(.q-table th) {
height: 42px;
min-height: 42px;
padding: 3px 4px;
}
.performance-table :deep(.q-table tbody tr),
.performance-table :deep(.q-table tbody td) {
height: auto;
@@ -3905,7 +4047,7 @@ onMounted(reload)
}
.product-breakdown-table :deep(.q-table) {
min-width: 2500px;
min-width: 3000px;
table-layout: fixed;
}
@@ -3935,81 +4077,81 @@ onMounted(reload)
.product-breakdown-table :deep(.q-table th:nth-child(2)),
.product-breakdown-table :deep(.q-table td:nth-child(2)) {
left: 72px;
width: 100px;
min-width: 100px;
max-width: 100px;
width: 140px;
min-width: 140px;
max-width: 140px;
}
.product-breakdown-table :deep(.q-table th:nth-child(3)),
.product-breakdown-table :deep(.q-table td:nth-child(3)) {
left: 172px;
width: 54px;
min-width: 54px;
max-width: 54px;
left: 212px;
width: 110px;
min-width: 110px;
max-width: 110px;
}
.product-breakdown-table :deep(.q-table th:nth-child(4)),
.product-breakdown-table :deep(.q-table td:nth-child(4)) {
left: 226px;
width: 54px;
min-width: 54px;
max-width: 54px;
left: 322px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table :deep(.q-table th:nth-child(5)),
.product-breakdown-table :deep(.q-table td:nth-child(5)) {
left: 280px;
width: 160px;
min-width: 160px;
max-width: 160px;
left: 472px;
width: 170px;
min-width: 170px;
max-width: 170px;
}
.product-breakdown-table :deep(.q-table th:nth-child(6)),
.product-breakdown-table :deep(.q-table td:nth-child(6)) {
left: 440px;
width: 92px;
min-width: 92px;
max-width: 92px;
left: 642px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table :deep(.q-table th:nth-child(7)),
.product-breakdown-table :deep(.q-table td:nth-child(7)) {
left: 532px;
width: 86px;
min-width: 86px;
max-width: 86px;
left: 792px;
width: 130px;
min-width: 130px;
max-width: 130px;
}
.product-breakdown-table :deep(.q-table th:nth-child(8)),
.product-breakdown-table :deep(.q-table td:nth-child(8)) {
left: 618px;
width: 110px;
min-width: 110px;
max-width: 110px;
left: 922px;
width: 180px;
min-width: 180px;
max-width: 180px;
}
.product-breakdown-table :deep(.q-table th:nth-child(9)),
.product-breakdown-table :deep(.q-table td:nth-child(9)) {
left: 728px;
width: 110px;
min-width: 110px;
max-width: 110px;
left: 1102px;
width: 90px;
min-width: 90px;
max-width: 90px;
}
.product-breakdown-table :deep(.q-table th:nth-child(10)),
.product-breakdown-table :deep(.q-table td:nth-child(10)) {
left: 838px;
width: 110px;
min-width: 110px;
max-width: 110px;
left: 1192px;
width: 80px;
min-width: 80px;
max-width: 80px;
}
.product-breakdown-table :deep(.q-table th:nth-child(11)),
.product-breakdown-table :deep(.q-table td:nth-child(11)) {
left: 948px;
width: 160px;
min-width: 160px;
max-width: 160px;
left: 1272px;
width: 130px;
min-width: 130px;
max-width: 130px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
@@ -4020,9 +4162,9 @@ onMounted(reload)
.sticky-dim-table :deep(.q-table th),
.sticky-dim-table :deep(.q-table td) {
min-width: 104px;
width: 104px;
max-width: 150px;
min-width: 118px;
width: 118px;
max-width: 180px;
}
.sticky-dim-table :deep(.q-table th.text-right),
@@ -4068,52 +4210,52 @@ onMounted(reload)
.sticky-dim-table :deep(.q-table th:nth-child(2)),
.sticky-dim-table :deep(.q-table td:nth-child(2)) {
left: 104px;
left: 118px;
}
.sticky-dim-table :deep(.q-table th:nth-child(3)),
.sticky-dim-table :deep(.q-table td:nth-child(3)) {
left: 208px;
left: 236px;
}
.sticky-dim-table :deep(.q-table th:nth-child(4)),
.sticky-dim-table :deep(.q-table td:nth-child(4)) {
left: 312px;
left: 354px;
}
.sticky-dim-table :deep(.q-table th:nth-child(5)),
.sticky-dim-table :deep(.q-table td:nth-child(5)) {
left: 416px;
left: 472px;
}
.sticky-dim-table :deep(.q-table th:nth-child(6)),
.sticky-dim-table :deep(.q-table td:nth-child(6)) {
left: 520px;
left: 590px;
}
.sticky-dim-table :deep(.q-table th:nth-child(7)),
.sticky-dim-table :deep(.q-table td:nth-child(7)) {
left: 624px;
left: 708px;
}
.sticky-dim-table :deep(.q-table th:nth-child(8)),
.sticky-dim-table :deep(.q-table td:nth-child(8)) {
left: 728px;
left: 826px;
}
.sticky-dim-table :deep(.q-table th:nth-child(9)),
.sticky-dim-table :deep(.q-table td:nth-child(9)) {
left: 832px;
left: 944px;
}
.sticky-dim-table :deep(.q-table th:nth-child(10)),
.sticky-dim-table :deep(.q-table td:nth-child(10)) {
left: 936px;
left: 1062px;
}
.sticky-dim-table :deep(.q-table th:nth-child(11)),
.sticky-dim-table :deep(.q-table td:nth-child(11)) {
left: 1040px;
left: 1180px;
}
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)),
@@ -4177,27 +4319,31 @@ onMounted(reload)
.header-with-filter {
display: grid;
grid-template-columns: minmax(0, 1fr) 24px;
grid-template-columns: minmax(0, 1fr) 18px;
align-items: center;
gap: 4px;
min-width: 104px;
gap: 2px;
min-width: 82px;
width: 100%;
}
.header-with-filter > span {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
line-height: 1.18;
line-height: 1.12;
white-space: normal;
overflow: hidden;
overflow-wrap: anywhere;
text-overflow: clip;
font-size: 10px;
font-weight: 700;
}
.header-filter-btn {
width: 24px;
height: 24px;
min-height: 24px;
width: 18px;
height: 18px;
min-height: 18px;
padding: 0;
}
.header-filter-ghost {
@@ -4290,12 +4436,27 @@ onMounted(reload)
.group-cell-label {
display: flex;
align-items: center;
min-width: 180px;
gap: 3px;
width: 100%;
min-width: 0;
max-width: 100%;
}
.group-cell-label :deep(.q-btn) {
flex: 0 0 20px;
width: 20px;
height: 20px;
min-height: 20px;
}
.group-title {
margin-left: 4px;
flex: 1 1 auto;
min-width: 0;
margin-left: 0;
white-space: normal;
overflow: visible;
overflow-wrap: anywhere;
line-height: 1.18;
}
.product-image-cell {