ui: update ProductPerformanceProfitability page to enhance table views and tabs management

This commit is contained in:
M_Kececi
2026-07-02 20:02:19 +03:00
parent 593a7d4816
commit 2fc9699ff1
3 changed files with 359 additions and 103 deletions
+279 -62
View File
@@ -449,32 +449,28 @@
</template>
<template #body="props">
<q-tr v-if="props.row.__group" :props="props" class="group-row">
<q-td :colspan="columns.length" :style="{ paddingLeft: `${8 + props.row.level * 18}px` }">
<q-btn
flat
dense
round
size="sm"
: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-meta">
{{ formatNumber(props.row.count, 0) }} satır ·
Stok {{ formatNumber(props.row.stock_qty, 0) }} ·
90G {{ formatNumber(props.row.sales_qty_90d, 0) }} ·
180G {{ formatNumber(props.row.sales_qty_180d, 0) }} ·
Piyasa {{ formatNumber(props.row.market_count_90d, 0) }} ·
Müşteri {{ formatNumber(props.row.customer_count_90d, 0) }} ·
Ort.Satış {{ formatMoney(props.row.avg_price_usd_90d, 'USD') }} ·
Taban {{ formatMoney(props.row.avg_base_price_usd, 'USD') }} ·
Çıplak {{ formatMoney(props.row.avg_cost_price_usd, 'USD') }} ·
90G K/Z {{ formatMoney(props.row.gross_profit_usd_90d, 'USD') }} ·
90G Marj {{ formatPercent(props.row.gross_margin_90d) }} ·
180G K/Z {{ formatMoney(props.row.gross_profit_usd_180d, 'USD') }} ·
180G Marj {{ formatPercent(props.row.gross_margin_180d) }}
</span>
<q-tr
v-if="props.row.__group"
:props="props"
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<div
v-if="col.name === groupLabelColumnName(props.row)"
class="group-cell-label"
:style="{ paddingLeft: `${props.row.level * 18}px` }"
>
<q-btn
flat
dense
round
size="sm"
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col.name) }}</span>
</q-td>
</q-tr>
<q-tr v-else :props="props" class="cursor-pointer" @click="openPerformanceDialog(props.row)">
@@ -1059,10 +1055,11 @@ const detailStockSizes = ref([])
const columnFilters = reactive({})
const columnFilterSearch = reactive({})
const expandedGroups = ref({})
const productKpiFetchLimit = 50000
const pagination = ref({
page: 1,
rowsPerPage: 500,
rowsPerPage: 100,
rowsNumber: 0,
sortBy: 'performance_score',
descending: true
@@ -1129,8 +1126,10 @@ const columns = [
{ name: 'unit_profit_cost_180d', label: '180G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_90d', label: '90G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_180d', label: '180G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_90d', label: 'Marj', field: 'gross_margin_90d', align: 'right', sortable: true },
{ name: 'gross_margin_180d', label: '180G Marj', field: 'gross_margin_180d', align: 'right', sortable: true },
{ name: 'gross_margin_base_90d', label: '90G Taban Marj', field: row => formatPercent(row.gross_margin_base_90d), align: 'right', sortable: true },
{ name: 'gross_margin_cost_90d', label: '90G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_90d), align: 'right', sortable: true },
{ name: 'gross_margin_base_180d', label: '180G Taban Marj', field: row => formatPercent(row.gross_margin_base_180d), align: 'right', sortable: true },
{ name: 'gross_margin_cost_180d', label: '180G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_180d), align: 'right', sortable: true },
{ name: 'market_count_90d', label: '90G Piyasa', field: row => formatNumber(row.market_count_90d, 0), align: 'right', sortable: true },
{ name: 'customer_count_90d', label: '90G Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true },
{ name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
@@ -1164,7 +1163,8 @@ const generalColumns = [
{ name: 'unit_profit_base_total', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_total, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_total', label: 'P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_total', label: 'Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_total', label: 'Genel Marj', field: row => formatPercent(row.gross_margin_total), align: 'right', sortable: true },
{ name: 'gross_margin_base_total', label: 'Genel Taban Marj', field: row => formatPercent(row.gross_margin_base_total), align: 'right', sortable: true },
{ name: 'gross_margin_cost_total', label: 'Genel Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_total), align: 'right', sortable: true },
{ name: 'market_count_total', label: 'Toplam Piyasa', field: row => formatNumber(row.market_count_total, 0), align: 'right', sortable: true },
{ name: 'customer_count_total', label: 'Toplam Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
{ name: 'invoice_count_total', label: 'Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true },
@@ -1368,17 +1368,52 @@ const summaryCards = computed(() => [
{ key: 'risk_cost', label: 'Risk Maliyeti', value: formatMoney(summary.value.risk_cost_usd, 'USD') }
])
const idleRows = computed(() => rows.value
.map(row => ({
...row,
idle_cost_usd: Number(row.stock_qty || 0) * Number(row.cost_price_usd || 0)
}))
.filter(row => Number(row.stock_qty || 0) > 0 && (
row.performance_bucket === 'STOK_RISKI' ||
Number(row.sales_qty_90d || 0) === 0 ||
Number(row.stock_days_90d || 0) > 180
))
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0)))
const idleRows = computed(() => {
const variants = new Map()
for (const row of rows.value) {
const key = `${row.product_code || ''}|${row.color_code || ''}|${row.yaka_kodu || ''}`
const current = variants.get(key)
if (!current) {
variants.set(key, {
...row,
market_key: row.market_key && row.market_key !== 'STOK' ? row.market_key : '',
sales_qty_90d: Number(row.sales_qty_90d || 0),
sales_usd_90d: Number(row.sales_usd_90d || 0),
gross_profit_usd_90d: Number(row.gross_profit_usd_90d || 0),
customer_count_90d: Number(row.customer_count_90d || 0),
market_count_90d: row.market_key && row.market_key !== 'STOK' ? 1 : 0,
has_stock_risk: row.performance_bucket === 'STOK_RISKI'
})
continue
}
current.market_key = distinctTextSummary(current.market_key, row.market_key)
current.sales_qty_90d += Number(row.sales_qty_90d || 0)
current.sales_usd_90d += Number(row.sales_usd_90d || 0)
current.gross_profit_usd_90d += Number(row.gross_profit_usd_90d || 0)
current.customer_count_90d += Number(row.customer_count_90d || 0)
current.market_count_90d += row.market_key && row.market_key !== 'STOK' ? 1 : 0
current.has_stock_risk = current.has_stock_risk || row.performance_bucket === 'STOK_RISKI'
}
return Array.from(variants.values())
.map(row => {
const stockQty = Number(row.stock_qty || 0)
const salesQty90 = Number(row.sales_qty_90d || 0)
const stockDays90 = salesQty90 > 0 ? stockQty / (salesQty90 / 90) : 0
return {
...row,
stock_days_90d: stockDays90,
idle_cost_usd: stockQty * Number(row.cost_price_usd || 0),
performance_bucket: row.has_stock_risk ? 'STOK_RISKI' : row.performance_bucket
}
})
.filter(row => Number(row.stock_qty || 0) > 0 && (
row.performance_bucket === 'STOK_RISKI' ||
Number(row.sales_qty_90d || 0) === 0 ||
Number(row.stock_days_90d || 0) > 180
))
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0))
})
const visibleOrderAnalysisRows = computed(() => {
if (orderAnalysisMode.value === 'market') return orderMarketRows.value
@@ -1405,7 +1440,8 @@ const performanceCards = computed(() => {
{ key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(row.cost_price_usd, 'USD') },
{ key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(row.unit_profit_base_90d, 'USD') },
{ key: 'profitCost', label: 'P.Başı Çıplak K/Z', value: formatMoney(row.unit_profit_cost_90d, 'USD') },
{ key: 'margin', label: '90G Marj', value: formatPercent(row.gross_margin_90d) },
{ key: 'marginBase', label: '90G Taban Marj', value: formatPercent(row.gross_margin_base_90d) },
{ key: 'marginCost', label: '90G Çıplak Marj', value: formatPercent(row.gross_margin_cost_90d) },
{ key: 'bucket', label: 'Durum', value: bucketLabel(row.performance_bucket) }
]
})
@@ -1488,21 +1524,33 @@ function appendGroupRows (out, sourceRows, level, parentKeys) {
.sort((a, b) => a[0].localeCompare(b[0], 'tr'))
.forEach(([value, groupRows]) => {
const key = [...parentKeys, `${groupDef.key}:${value}`].join('|')
out.push(makeGroupRow(key, level, `${groupDef.label}: ${value}`, groupRows))
out.push(makeGroupRow(key, level, groupDef, value, groupRows))
if (isGroupExpanded(key)) {
appendGroupRows(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`])
}
})
}
function makeGroupRow (key, level, label, groupRows) {
function makeGroupRow (key, level, groupDef, value, groupRows) {
return {
__group: true,
row_key: `group|${key}`,
key,
level,
label,
group_field: groupDef.key,
group_value: value,
label: `${groupDef.label}: ${value}`,
count: groupRows.length,
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'),
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', 'yas_grubu'),
askili_yan: groupDef.key === 'askili_yan' ? value : distinctSummary(groupRows, 'askili_yan'),
urun_ana_grubu: groupDef.key === 'urun_ana_grubu' ? value : distinctSummary(groupRows, 'urun_ana_grubu', 'seri'),
urun_alt_grubu: groupDef.key === 'urun_alt_grubu' ? value : distinctSummary(groupRows, 'urun_alt_grubu'),
market_key: distinctSummary(groupRows, 'market_key'),
stock_qty: sumRows(groupRows, 'stock_qty'),
sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'),
sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'),
@@ -1514,10 +1562,24 @@ function makeGroupRow (key, level, label, groupRows) {
customer_count_90d: sumRows(groupRows, 'customer_count_90d'),
avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'),
avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'),
avg_base_price_usd: averageRows(groupRows, 'base_price_usd'),
avg_cost_price_usd: averageRows(groupRows, 'cost_price_usd'),
base_price_usd: weightedAverageOrAverage(groupRows, 'base_price_usd', 'sales_qty_90d'),
cost_price_usd: weightedAverageOrAverage(groupRows, 'cost_price_usd', 'sales_qty_90d'),
unit_profit_base_90d: weightedAverageOrAverage(groupRows, 'unit_profit_base_90d', 'sales_qty_90d'),
unit_profit_cost_90d: weightedAverageOrAverage(groupRows, 'unit_profit_cost_90d', 'sales_qty_90d'),
unit_profit_base_180d: weightedAverageOrAverage(groupRows, 'unit_profit_base_180d', 'sales_qty_180d'),
unit_profit_cost_180d: weightedAverageOrAverage(groupRows, 'unit_profit_cost_180d', 'sales_qty_180d'),
gross_margin_base_90d: marginFromRows(groupRows, 'sales_usd_90d', 'sales_qty_90d', 'base_price_usd'),
gross_margin_cost_90d: marginFromRows(groupRows, 'sales_usd_90d', 'sales_qty_90d', 'cost_price_usd'),
gross_margin_base_180d: marginFromRows(groupRows, 'sales_usd_180d', 'sales_qty_180d', 'base_price_usd'),
gross_margin_cost_180d: marginFromRows(groupRows, 'sales_usd_180d', 'sales_qty_180d', 'cost_price_usd'),
gross_margin_90d: ratio(sumRows(groupRows, 'gross_profit_usd_90d'), sumRows(groupRows, 'sales_usd_90d')),
gross_margin_180d: ratio(sumRows(groupRows, 'gross_profit_usd_180d'), sumRows(groupRows, 'sales_usd_180d'))
gross_margin_180d: ratio(sumRows(groupRows, 'gross_profit_usd_180d'), sumRows(groupRows, 'sales_usd_180d')),
stock_days_90d: weightedAverageOrAverage(groupRows, 'stock_days_90d', 'stock_qty'),
stock_days_180d: weightedAverageOrAverage(groupRows, 'stock_days_180d', 'stock_qty'),
sales_index_90d: averageRows(groupRows, 'sales_index_90d'),
performance_score: weightedAverageOrAverage(groupRows, 'performance_score', 'sales_qty_90d'),
performance_bucket: dominantValue(groupRows, 'performance_bucket'),
recommendation: `${formatNumber(groupRows.length, 0)} satır`
}
}
@@ -1534,6 +1596,35 @@ function distinctCount (sourceRows, field) {
return values.size
}
function distinctSummary (sourceRows, field, fallbackField) {
const values = new Set()
for (const row of sourceRows) {
const value = String(row[field] || (fallbackField ? row[fallbackField] : '') || '').trim()
if (value && value !== '-') values.add(value)
if (values.size > 1) return `${values.size} farklı`
}
return Array.from(values)[0] || ''
}
function distinctTextSummary (current, next) {
const left = String(current || '').trim()
const right = String(next || '').trim()
if (!right || right === 'STOK') return left
if (!left) return right
if (left === right || left === 'Çoklu') return left
return 'Çoklu'
}
function dominantValue (sourceRows, field) {
const counts = new Map()
for (const row of sourceRows) {
const value = String(row[field] || '').trim()
if (!value) continue
counts.set(value, (counts.get(value) || 0) + 1)
}
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || ''
}
function ratio (amount, base) {
const divisor = Number(base || 0)
if (!divisor) return 0
@@ -1552,6 +1643,35 @@ function weightedAverage (sourceRows, amountField, qtyField) {
return sumRows(sourceRows, amountField) / qty
}
function weightedAverageOrAverage (sourceRows, valueField, qtyField) {
const weightedRows = sourceRows
.map(row => ({
value: Number(row[valueField] || 0),
qty: Number(row[qtyField] || 0)
}))
.filter(row => Number.isFinite(row.value) && Number.isFinite(row.qty) && row.qty > 0)
const qty = weightedRows.reduce((sum, row) => sum + row.qty, 0)
if (qty > 0) {
return weightedRows.reduce((sum, row) => sum + row.value * row.qty, 0) / qty
}
return averageRows(sourceRows, valueField)
}
function marginFromSalesCost (salesUSD, qty, unitCost) {
const sales = Number(salesUSD || 0)
if (!sales) return 0
return (sales - (Number(qty || 0) * Number(unitCost || 0))) / sales
}
function marginFromRows (sourceRows, salesField, qtyField, unitCostField) {
const sales = sumRows(sourceRows, salesField)
if (!sales) return 0
const costAmount = sourceRows.reduce((sum, row) => {
return sum + Number(row[qtyField] || 0) * Number(row[unitCostField] || 0)
}, 0)
return (sales - costAmount) / sales
}
function normalizeGroupValue (value) {
const text = String(value || '').trim()
return text || '-'
@@ -1568,6 +1688,40 @@ function toggleGroup (key) {
}
}
function groupLabelColumnName (row) {
return row?.group_field || 'product_code'
}
function groupCellClass (name) {
return ['stock_qty', 'sales_qty_90d', 'sales_qty_180d', 'sales_usd_90d', 'sales_usd_180d', 'stock_days_90d', 'stock_days_180d', 'avg_price_usd_90d', 'avg_price_usd_180d', 'base_price_usd', 'cost_price_usd', 'unit_profit_base_90d', 'unit_profit_cost_90d', 'unit_profit_base_180d', 'unit_profit_cost_180d', 'gross_profit_usd_90d', 'gross_profit_usd_180d', 'gross_margin_base_90d', 'gross_margin_cost_90d', 'gross_margin_base_180d', 'gross_margin_cost_180d', 'gross_margin_90d', 'gross_margin_180d', 'market_count_90d', 'customer_count_90d', 'sales_index_90d', 'performance_score'].includes(name)
? 'text-right'
: ''
}
function formatGroupCell (row, name) {
if (name === 'image') return ''
if (name === 'recommendation') return row.recommendation || ''
return formatProductCell(row, name)
}
function withProductMargins (row) {
return {
...row,
gross_margin_base_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.base_price_usd),
gross_margin_cost_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.cost_price_usd),
gross_margin_base_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.base_price_usd),
gross_margin_cost_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.cost_price_usd)
}
}
function withGeneralMargins (row) {
return {
...row,
gross_margin_base_total: marginFromSalesCost(row?.sales_usd_total, row?.sales_qty_total, row?.base_price_usd),
gross_margin_cost_total: marginFromSalesCost(row?.sales_usd_total, row?.sales_qty_total, row?.cost_price_usd)
}
}
function filterKey (tableKey, name) {
return `${tableKey}:${name}`
}
@@ -1690,6 +1844,12 @@ function formatProductCell (row, name) {
case 'gross_profit_usd_90d':
case 'gross_profit_usd_180d':
return formatMoney(row[name], 'USD')
case 'gross_margin_base_90d':
case 'gross_margin_cost_90d':
case 'gross_margin_base_180d':
case 'gross_margin_cost_180d':
case 'gross_margin_base_total':
case 'gross_margin_cost_total':
case 'gross_margin_90d':
case 'gross_margin_180d':
return formatPercent(row[name])
@@ -1707,10 +1867,10 @@ function normalizeRow (row) {
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
return {
return withProductMargins({
...row,
row_key: `${product}|${color}|${yaka}|${market}`
}
})
}
function normalizeGeneralRow (row) {
@@ -1718,10 +1878,10 @@ function normalizeGeneralRow (row) {
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
return {
return withGeneralMargins({
...row,
row_key: `general|${product}|${color}|${yaka}|${market}`
}
})
}
function normalizeOrderAnalysisRow (row) {
@@ -1915,8 +2075,8 @@ async function reload () {
api.get('/pricing/product-performance/summary', { timeout: 60000 }),
api.get('/pricing/product-performance', {
params: {
page: pagination.value.page,
limit: pagination.value.rowsPerPage,
page: 1,
limit: productKpiFetchLimit,
sort_by: pagination.value.sortBy || undefined,
descending: pagination.value.descending ? 'true' : 'false'
},
@@ -2060,11 +2220,28 @@ onMounted(reload)
max-height: calc(100vh - 320px);
}
.performance-table :deep(.q-table) {
border-collapse: separate;
border-spacing: 0;
}
.performance-table :deep(.q-table th),
.performance-table :deep(.q-table td) {
border-right: 1px solid #d5dce5;
border-bottom: 1px solid #d5dce5;
}
.performance-table :deep(.q-table th:first-child),
.performance-table :deep(.q-table td:first-child) {
border-left: 1px solid #d5dce5;
}
.performance-table :deep(th) {
position: sticky;
top: 0;
z-index: 1;
background: #fff;
background: #f8fbff;
border-top: 1px solid #d5dce5;
}
.filterable-header-cell {
@@ -2128,18 +2305,58 @@ onMounted(reload)
}
.group-row td {
background: #eef2f6;
background: #eaf4ff;
font-weight: 600;
color: #17324f;
border-color: #c3d4e6;
white-space: nowrap;
}
.group-row-level-0 td {
background: #1565c0;
color: #fff;
border-color: #0d47a1;
}
.group-row-level-1 td {
background: #2f7fd0;
color: #fff;
border-color: #1565c0;
}
.group-row-level-2 td {
background: #6da9df;
color: #0c2f52;
border-color: #3c83c7;
}
.group-row-level-3 td {
background: #a9cdef;
color: #12385c;
border-color: #78addb;
}
.group-row-level-4 td {
background: #d1e5f8;
color: #17324f;
border-color: #9fc5e8;
}
.group-row-level-5 td {
background: #eaf4ff;
color: #17324f;
border-color: #c3d4e6;
}
.group-cell-label {
display: flex;
align-items: center;
min-width: 180px;
}
.group-title {
margin-left: 4px;
}
.group-meta {
margin-left: 12px;
color: #5f6b7a;
font-weight: 500;
white-space: normal;
}
.product-image-cell {