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
+42 -4
View File
@@ -670,8 +670,10 @@ func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanc
return nil, 0, err
}
limit := f.Limit
if limit <= 0 || limit > 500 {
if limit <= 0 {
limit = 100
} else if limit > 50000 {
limit = 50000
}
page := f.Page
if page <= 0 {
@@ -741,6 +743,25 @@ KPI AS (
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT kpi_date FROM Latest)
),
VariantStock AS (
SELECT
product_code,
color_code,
yaka_kodu,
MAX(stock_qty) AS stock_qty,
MAX(cost_price_usd) AS cost_price_usd,
BOOL_OR(performance_bucket IN ('STOK_RISKI','TAKIP')) AS has_risk_bucket,
SUM(COALESCE(sales_qty_90d,0)) AS sales_qty_90d
FROM KPI
GROUP BY product_code, color_code, yaka_kodu
),
StockTotals AS (
SELECT
COALESCE(SUM(stock_qty),0) AS total_stock,
COALESCE(SUM(stock_qty * cost_price_usd),0) AS stock_cost_usd,
COALESCE(SUM(CASE WHEN has_risk_bucket AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0) AS risk_cost_usd
FROM VariantStock
),
Sales90 AS (
SELECT
COUNT(DISTINCT market_key) AS market_count_90d,
@@ -754,9 +775,9 @@ SELECT
COUNT(*) FILTER (WHERE performance_bucket='YILDIZ_URUN'),
COUNT(*) FILTER (WHERE performance_bucket='STOK_RISKI'),
COUNT(*) FILTER (WHERE performance_bucket='STOKSUZ_TALEP'),
COALESCE(SUM(stock_qty),0),
COALESCE(SUM(stock_qty * cost_price_usd),0),
COALESCE(SUM(CASE WHEN performance_bucket IN ('STOK_RISKI','TAKIP') AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0),
COALESCE(MAX(StockTotals.total_stock),0),
COALESCE(MAX(StockTotals.stock_cost_usd),0),
COALESCE(MAX(StockTotals.risk_cost_usd),0),
COALESCE(SUM(sales_qty_90d),0),
COALESCE(SUM(sales_usd_90d),0),
COALESCE(SUM(gross_profit_usd_90d),0),
@@ -765,6 +786,7 @@ SELECT
COALESCE(to_char(MAX(updated_at),'YYYY-MM-DD HH24:MI:SS'),'')
FROM KPI
CROSS JOIN Sales90
CROSS JOIN StockTotals
`).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.MarketCount90, &s.CustomerCount90, &s.UpdatedAt)
return s, err
}
@@ -2007,10 +2029,22 @@ func ListProductPerformanceStockSizes(ctx context.Context, productCode, colorCod
return nil, fmt.Errorf("mssql db nil")
}
rows, err := db.MssqlDB.QueryContext(ctx, `
;WITH ActiveWarehouses AS (
SELECT WarehouseCode
FROM (VALUES
('1-0-12'),('1.01.2014'),('1.02.2005'),('1.02.2004'),('1-0-43'),
('4.02.2001'),('1-0-55'),('1.01.2003'),('1-0-21'),('1-0-2'),
('1.01.2004'),('1-0-49'),('1-0-37'),('1-0-29'),('1-0-28'),
('1-0-10'),('100'),('1.02.2006'),('1-0-42'),('1.01.2002'),
('1-0-52')
) W(WarehouseCode)
)
SELECT
SizeCode = LTRIM(RTRIM(ISNULL(S.ItemDim1Code, ''))),
StockQty = SUM(S.InventoryQty1)
FROM StockWithCost S WITH(NOLOCK)
INNER JOIN ActiveWarehouses W
ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode))
WHERE S.ItemTypeCode = 1
AND LTRIM(RTRIM(S.ItemCode)) = @p1
AND LTRIM(RTRIM(ISNULL(S.ColorCode, ''))) = @p2
@@ -2093,6 +2127,10 @@ func productPerformanceOrderBy(sortBy string, desc bool) string {
"unit_profit_cost_90d": "unit_profit_cost_90d",
"unit_profit_base_180d": "unit_profit_base_180d",
"unit_profit_cost_180d": "unit_profit_cost_180d",
"gross_margin_base_90d": "(CASE WHEN COALESCE(sales_usd_90d,0) <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * COALESCE(base_price_usd,0))) / NULLIF(sales_usd_90d,0) END)",
"gross_margin_cost_90d": "gross_margin_90d",
"gross_margin_base_180d": "(CASE WHEN COALESCE(sales_usd_180d,0) <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * COALESCE(base_price_usd,0))) / NULLIF(sales_usd_180d,0) END)",
"gross_margin_cost_180d": "gross_margin_180d",
"gross_margin_90d": "gross_margin_90d",
"gross_margin_180d": "gross_margin_180d",
"market_count_90d": "market_count_90d",
+5 -4
View File
@@ -216,10 +216,11 @@ func productPerformanceStockSQL() string {
;WITH ActiveWarehouses AS (
SELECT WarehouseCode
FROM (VALUES
('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'),
('100'),('1-0-28'),('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'),
('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'),('1-0-33'),('101'),
('1-014'),('1-0-49'),('1-0-36')
('1-0-12'),('1.01.2014'),('1.02.2005'),('1.02.2004'),('1-0-43'),
('4.02.2001'),('1-0-55'),('1.01.2003'),('1-0-21'),('1-0-2'),
('1.01.2004'),('1-0-49'),('1-0-37'),('1-0-29'),('1-0-28'),
('1-0-10'),('100'),('1.02.2006'),('1-0-42'),('1.01.2002'),
('1-0-52')
) W(WarehouseCode)
),
Raw AS (
+264 -47
View File
@@ -449,8 +449,17 @@
</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-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
@@ -460,21 +469,8 @@
@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>
</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 => ({
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,
idle_cost_usd: Number(row.stock_qty || 0) * Number(row.cost_price_usd || 0)
}))
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)))
.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 {