diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index 754c7b0..8c34cbc 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -3234,7 +3234,7 @@ SELECT jsonb_build_object( 'group_value', group_value, 'label', group_value, 'count', row_count, - 'recommendation', row_count::text || ' satir', + 'recommendation', CASE WHEN recommendation <> '' THEN recommendation ELSE row_count::text || ' satir' END, 'image_product_code', image_product_code, 'image_color_code', image_color_code, 'image_yaka_kodu', image_yaka_kodu, @@ -3356,6 +3356,7 @@ FROM ( CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS margin_index_90d, CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(performance_score * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS performance_score, MODE() WITHIN GROUP (ORDER BY performance_bucket) AS performance_bucket, + COALESCE(MODE() WITHIN GROUP (ORDER BY NULLIF(recommendation,'')), '') AS recommendation, COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN idle_cost_usd ELSE 0 END),0) AS idle_cost_usd FROM ( SELECT @@ -3537,6 +3538,8 @@ func productPerformanceSQLGroupExpr(field string) (string, bool) { return "CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END", true case "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu": return field, true + case "color_yaka": + return "concat_ws('/', NULLIF(btrim(COALESCE(color_code,'')), ''), NULLIF(btrim(COALESCE(yaka_kodu,'')), ''))", true case "market_key": return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true default: @@ -3939,7 +3942,7 @@ func maxFloat(a, b float64) float64 { func sanitizeProductPerformanceGroupLevels(levels []string) []string { allowed := map[string]bool{ "kategori": true, "askili_yan": true, "urun_ilk_grubu": true, "urun_ana_grubu": true, "urun_alt_grubu": true, - "product_code": true, "item_description": true, "color_code": true, "yaka_kodu": true, "market_key": true, "customer_code": true, + "product_code": true, "item_description": true, "color_code": true, "yaka_kodu": true, "color_yaka": true, "market_key": true, "customer_code": true, "customer_name": true, "country": true, "customer_segment": true, } out := make([]string, 0, len(levels)) @@ -3957,9 +3960,9 @@ func sanitizeProductPerformanceGroupLevels(levels []string) []string { func defaultProductPerformanceGroupLevels(mode string) []string { switch mode { case "sales_color_yaka_market_customer": - return []string{"color_code", "yaka_kodu", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "country", "market_key", "customer_segment", "customer_code", "customer_name"} + return []string{"color_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "country", "market_key", "customer_segment", "customer_code", "customer_name"} case "product_detail": - return []string{"urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu", "market_key"} + return []string{"urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_yaka", "market_key"} case "idle": return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu"} case "sales_product_country_segment_market_customer": @@ -3983,6 +3986,15 @@ func mapGroupValue(row map[string]any, field string) string { return cleanProductPerformanceFirstGroup(stringFromMap(row, "urun_ilk_grubu")) case "market_key": return displayProductPerformanceMarketName(stringFromMap(row, "market_key")) + case "color_yaka": + parts := make([]string, 0, 2) + if color := strings.TrimSpace(stringFromMap(row, "color_code")); color != "" { + parts = append(parts, color) + } + if yaka := strings.TrimSpace(stringFromMap(row, "yaka_kodu")); yaka != "" { + parts = append(parts, yaka) + } + return strings.Join(parts, "/") default: return stringFromMap(row, field) } diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 74ad639..bb9dbcf 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -543,7 +543,12 @@ flat bordered row-key="row_key" - class="performance-table product-breakdown-table bg-white" + :class="[ + 'performance-table', + 'product-breakdown-table', + 'bg-white', + activeTab === 'product_detail' ? 'product-detail-table' : 'product-summary-table' + ]" :rows="displayProductKpiTableRows" :columns="activeProductColumns" :loading="loading || backendGroupedLoading" @@ -984,7 +989,13 @@ flat bordered row-key="row_key" - class="performance-table sticky-dim-table sticky-dim-9 bg-white" + :class="[ + 'performance-table', + 'sticky-dim-table', + salesBreakdownStickyClass, + activeTab === 'sales_color_yaka_market_customer' ? 'sales-color-yaka-table' : '', + 'bg-white' + ]" :rows="displayActiveSalesBreakdownTableRows" :columns="visibleSalesBreakdownColumns" :loading="loading || backendGroupedLoading" @@ -1564,8 +1575,8 @@ const expandedGroups = ref({}) const lastManualExpandedGroupKey = ref('') const selectedExpandLevelKeysByTab = reactive({ products: ['urun_ilk_grubu', 'askili_yan', 'kategori', 'urun_ana_grubu', 'urun_alt_grubu'], - product_detail: ['urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'color_code', 'yaka_kodu'], - sales_color_yaka_market_customer: ['color_code', 'yaka_kodu', 'urun_ilk_grubu', 'askili_yan'], + product_detail: ['urun_ana_grubu', 'urun_alt_grubu', 'product_code', 'color_yaka'], + sales_color_yaka_market_customer: ['color_yaka', 'urun_ilk_grubu', 'askili_yan'], idle: ['urun_ilk_grubu', 'askili_yan', 'kategori', 'urun_ana_grubu', 'urun_alt_grubu'], sales_product_country_segment_market_customer: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'], sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'], @@ -1656,13 +1667,13 @@ const columns = [ { name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' }, { name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true }, { name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }, - { name: 'performance_score_90d', label: '90G Skor', field: row => formatNumber(row.performance_score_90d, 1), align: 'right', sortable: true }, + { name: 'performance_score_90d', label: '90G Skor', field: row => formatNumber(row.performance_score_90d, 2), align: 'right', sortable: true }, { name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, - { name: 'performance_score_180d', label: '180G Skor', field: row => formatNumber(row.performance_score_180d, 1), align: 'right', sortable: true }, + { name: 'performance_score_180d', label: '180G Skor', field: row => formatNumber(row.performance_score_180d, 2), align: 'right', sortable: true }, { name: 'sales_qty_180d', label: '180G Satış', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true }, - { name: 'performance_score_365d', label: '360G Skor', field: row => formatNumber(row.performance_score_365d, 1), align: 'right', sortable: true }, + { name: 'performance_score_365d', label: '360G Skor', field: row => formatNumber(row.performance_score_365d, 2), align: 'right', sortable: true }, { name: 'sales_qty_365d', label: '360G Satış', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true }, - { name: 'performance_score_total', label: 'Genel Skor', field: row => formatNumber(row.performance_score_total, 1), align: 'right', sortable: true }, + { name: 'performance_score_total', label: 'Genel Skor', field: row => formatNumber(row.performance_score_total, 2), align: 'right', sortable: true }, { name: 'sales_qty_total', label: 'Genel Satış', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true }, { name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true }, { name: 'sales_usd_180d', label: '180G USD', field: 'sales_usd_180d', align: 'right', sortable: true }, @@ -1703,7 +1714,7 @@ const columns = [ { name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true }, { name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true }, { name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true }, - { name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 1), align: 'right', sortable: true }, + { name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 2), align: 'right', sortable: true }, { name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' }, { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } ] @@ -1895,7 +1906,7 @@ const generalColumns = [ { 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 }, { name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true }, - { name: 'performance_score', label: 'Genel Skor', field: row => formatNumber(row.performance_score, 1), align: 'right', sortable: true }, + { name: 'performance_score', label: 'Genel Skor', field: row => formatNumber(row.performance_score, 2), align: 'right', sortable: true }, { name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true }, { name: 'first_sale_date', label: 'İlk Satış', field: 'first_sale_date', align: 'left', sortable: true }, { name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true }, @@ -2089,7 +2100,7 @@ const customerColumns = [ { name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true }, { name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true }, { name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true }, - { name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 1), align: 'right', sortable: true }, + { name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 2), align: 'right', sortable: true }, { name: 'base_price_usd_90d', label: '90G Taban', field: row => formatMoney(row.base_price_usd_90d, 'USD'), align: 'right', sortable: true }, { name: 'cost_price_usd_90d', label: '90G Çıplak', field: row => formatMoney(row.cost_price_usd_90d, 'USD'), align: 'right', sortable: true }, { name: 'gross_profit_base_usd_90d', label: '90G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_90d, 'USD'), align: 'right', sortable: true }, @@ -2137,7 +2148,7 @@ const salesBreakdownColumns = [ { name: 'gross_profit_cost_usd_180d', label: '180G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_180d, 'USD'), 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: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 1), align: 'right', sortable: true }, + { name: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 2), align: 'right', sortable: true }, { name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true }, { name: 'sales_usd_365d', label: '360G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'avg_price_usd_365d', label: '360G Ort. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true }, @@ -2148,7 +2159,7 @@ const salesBreakdownColumns = [ { name: 'gross_profit_cost_usd_365d', label: '360G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_365d, 'USD'), align: 'right', sortable: true }, { name: 'gross_margin_base_365d', label: '360G Taban Marj', field: row => formatPercent(row.gross_margin_base_365d), align: 'right', sortable: true }, { name: 'gross_margin_cost_365d', label: '360G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_365d), align: 'right', sortable: true }, - { name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 1), align: 'right', sortable: true }, + { name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 2), align: 'right', sortable: true }, { name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true }, { name: 'invoice_count_total', label: 'Genel Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true }, { name: 'sales_qty_total', label: 'Genel Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true }, @@ -2161,26 +2172,26 @@ const salesBreakdownColumns = [ { name: 'gross_profit_cost_usd_total', label: 'Genel Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_total, 'USD'), 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: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 1), align: 'right', sortable: true }, + { name: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 2), align: 'right', sortable: true }, { name: 'sales_index_90d', label: 'Endeks', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true }, - { name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 1), align: 'right', sortable: true }, + { name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 2), align: 'right', sortable: true }, { name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true }, { name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true }, { name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' } ] const productPeriodScoreColumns = [ - { name: 'performance_score_90d', label: '90G Ürün Skor', field: row => formatNumber(row.performance_score_90d, 1), align: 'right', sortable: true }, - { name: 'performance_score_180d', label: '180G Ürün Skor', field: row => formatNumber(row.performance_score_180d, 1), align: 'right', sortable: true }, - { name: 'performance_score_365d', label: '360G Ürün Skor', field: row => formatNumber(row.performance_score_365d, 1), align: 'right', sortable: true }, - { name: 'performance_score_total', label: 'Genel Ürün Skor', field: row => formatNumber(row.performance_score_total, 1), align: 'right', sortable: true } + { name: 'performance_score_90d', label: '90G Ürün Skor', field: row => formatNumber(row.performance_score_90d, 2), align: 'right', sortable: true }, + { name: 'performance_score_180d', label: '180G Ürün Skor', field: row => formatNumber(row.performance_score_180d, 2), align: 'right', sortable: true }, + { name: 'performance_score_365d', label: '360G Ürün Skor', field: row => formatNumber(row.performance_score_365d, 2), align: 'right', sortable: true }, + { name: 'performance_score_total', label: 'Genel Ürün Skor', field: row => formatNumber(row.performance_score_total, 2), align: 'right', sortable: true } ] const customerPeriodScoreColumns = [ - { name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 1), align: 'right', sortable: true }, - { name: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 1), align: 'right', sortable: true }, - { name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 1), align: 'right', sortable: true }, - { name: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 1), align: 'right', sortable: true } + { name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 2), align: 'right', sortable: true }, + { name: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 2), align: 'right', sortable: true }, + { name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 2), align: 'right', sortable: true }, + { name: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 2), align: 'right', sortable: true } ] function ensureColumns (targetColumns, columnsToEnsure) { @@ -2278,7 +2289,27 @@ function ensureColorYakaColumn (targetColumns) { attachColumnSorts(detailStockColumns) attachColumnSorts(detailSalesColumns) -const visibleSalesBreakdownColumns = computed(() => orderedMetricColumns(salesBreakdownColumns.filter(visiblePeriodColumn))) +const visibleSalesBreakdownColumns = computed(() => { + const base = orderedMetricColumns(salesBreakdownColumns.filter(visiblePeriodColumn)) + if (activeTab.value !== 'sales_color_yaka_market_customer') return base + + const priority = ['performance_bucket', 'recommendation'] + const pulled = priority + .map(name => base.find(col => col.name === name)) + .filter(Boolean) + const rest = base.filter(col => !priority.includes(col.name)) + const insertAfter = Math.max( + rest.findIndex(col => col.name === 'item_description'), + rest.findIndex(col => col.name === 'product_code') + ) + if (insertAfter < 0) return [...pulled, ...rest] + return [ + ...rest.slice(0, insertAfter + 1), + ...pulled, + ...rest.slice(insertAfter + 1) + ] +}) +const salesBreakdownStickyClass = computed(() => activeTab.value === 'sales_color_yaka_market_customer' ? 'sticky-dim-9' : 'sticky-dim-9') const summaryCards = computed(() => [ { key: 'date', label: 'KPI Tarihi', value: summary.value.kpi_date || '-' }, @@ -2352,7 +2383,7 @@ const visibleOrderAnalysisColumns = computed(() => { function performanceCardsForRow (row) { const source = row || {} return [ - { key: 'score', label: 'Skor', value: formatNumber(source.performance_score, 1) }, + { key: 'score', label: 'Skor', value: formatNumber(source.performance_score, 2) }, { key: 'stock', label: 'Stok', value: formatNumber(source.stock_qty, 0) }, { key: 'sales90', label: '90G Satış', value: formatNumber(source.sales_qty_90d, 0) }, { key: 'sales180', label: '180G Satış', value: formatNumber(source.sales_qty_180d, 0) }, @@ -2411,13 +2442,11 @@ const tabGroupLevels = { { key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' }, { key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' }, { key: 'product_code', label: 'Ürün' }, - { key: 'color_code', label: 'Renk' }, - { key: 'yaka_kodu', label: 'Yaka' }, + { key: 'color_yaka', label: 'Renk/Yaka' }, { key: 'market_key', label: 'Piyasa' } ], sales_color_yaka_market_customer: [ - { key: 'color_code', label: 'Renk' }, - { key: 'yaka_kodu', label: 'Yaka' }, + { key: 'color_yaka', label: 'Renk/Yaka' }, { key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' }, { key: 'askili_yan', label: 'Askılı/Yan' }, { key: 'kategori', label: 'Kategori' }, @@ -2531,7 +2560,8 @@ const selectedExpandThroughLevel = computed(() => { const autoExpandThroughLevel = computed(() => { const selectedLevel = selectedExpandThroughLevel.value - return selectedLevel >= 0 ? selectedLevel : activeGroupLevels.value.length - 1 + const lastGroupLevel = Math.max(0, activeGroupLevels.value.length - 2) + return selectedLevel >= 0 ? Math.min(selectedLevel, lastGroupLevel) : Math.min(2, lastGroupLevel) }) const tableFilterSourceMap = computed(() => ({ @@ -2880,8 +2910,8 @@ function makeGroupRow (key, level, groupDef, value, groupRows) { avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'), avg_price_usd_365d: weightedAverage(groupRows, 'sales_usd_365d', 'sales_qty_365d'), avg_price_usd_total: weightedAverage(groupRows, 'sales_usd_total', 'sales_qty_total'), - base_price_usd: weightedAverageOrAverage(groupRows, 'base_price_usd', 'sales_qty_90d'), - cost_price_usd: weightedAverageOrAverage(groupRows, 'cost_price_usd', 'sales_qty_90d'), + base_price_usd: weightedAverageProductCost(groupRows, 'base_price_usd'), + cost_price_usd: weightedAverageProductCost(groupRows, 'cost_price_usd'), 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'), @@ -2904,7 +2934,7 @@ function makeGroupRow (key, level, groupDef, value, groupRows) { performance_bucket: dominantValue(groupRows, 'performance_bucket'), ...aggregate, performance_score: groupPerformanceScore(aggregate, groupDef.key), - recommendation: `${formatNumber(groupRows.length, 0)} satır` + recommendation: dominantValue(groupRows, 'recommendation') || `${formatNumber(groupRows.length, 0)} satır` } } @@ -2925,6 +2955,10 @@ function aggregateGroupFields (sourceRows, groupField = '') { out[field] = distinctVariantStockCost(sourceRows) continue } + if (field === 'base_price_usd' || field === 'cost_price_usd') { + out[field] = weightedAverageProductCost(sourceRows, field) + continue + } if (shouldSumField(field)) { out[field] = sumRows(sourceRows, field) } else if (shouldAverageField(field)) { @@ -3205,6 +3239,38 @@ function weightedAverageOrAverage (sourceRows, valueField, qtyField) { return 0 } +function weightedAverageProductCost (sourceRows, valueField) { + const salesRows = sourceRows + .map(row => ({ + value: Number(row[valueField] || 0), + qty: Number(row.sales_qty_total || 0) + })) + .filter(row => Number.isFinite(row.value) && Number.isFinite(row.qty) && row.value > 0 && row.qty > 0) + const salesQty = salesRows.reduce((sum, row) => sum + row.qty, 0) + if (salesQty > 0) { + return salesRows.reduce((sum, row) => sum + row.value * row.qty, 0) / salesQty + } + + const variants = new Map() + for (const row of sourceRows) { + const key = productVariantStockKey(row) + if (!key || variants.has(key)) continue + variants.set(key, row) + } + const stockSource = variants.size > 0 ? Array.from(variants.values()) : sourceRows + const stockRows = stockSource + .map(row => ({ + value: Number(row[valueField] || 0), + qty: Number(row.stock_qty || 0) + })) + .filter(row => Number.isFinite(row.value) && Number.isFinite(row.qty) && row.value > 0 && row.qty > 0) + const stockQty = stockRows.reduce((sum, row) => sum + row.qty, 0) + if (stockQty > 0) { + return stockRows.reduce((sum, row) => sum + row.value * row.qty, 0) / stockQty + } + return 0 +} + function marginFromSalesCost (salesUSD, qty, unitCost) { const sales = Number(salesUSD || 0) if (!sales) return 0 @@ -3280,13 +3346,13 @@ function expandSelectedProductGroups () { function groupLabelColumnName (row) { const field = String(row?.group_field || '') - if (field === 'color_code' || field === 'yaka_kodu') return 'color_yaka' + if (field === 'color_code' || field === 'yaka_kodu' || field === 'color_yaka') return 'color_yaka' return field || 'product_code' } function groupDisplayLabel (row) { const field = String(row?.group_field || '') - if (field === 'color_code' || field === 'yaka_kodu') { + if (field === 'color_code' || field === 'yaka_kodu' || field === 'color_yaka') { return colorYakaValue(row) || row?.label || row?.group_value || '' } return row?.label || row?.group_value || '' @@ -3303,13 +3369,13 @@ function hasProductVariantImageKey (row) { } function canShowProductImage (row) { - return activeTab.value === 'products' && hasProductVariantImageKey(row) + return isProductKpiTab.value && hasProductVariantImageKey(row) } function groupShowsImage (row) { - return activeTab.value === 'products' && + return isProductKpiTab.value && row?.__group && - ['product_code', 'color_code', 'yaka_kodu'].includes(String(row.group_field || '')) && + ['product_code', 'color_code', 'yaka_kodu', 'color_yaka'].includes(String(row.group_field || '')) && hasProductVariantImageKey(row) } @@ -3328,7 +3394,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 (name === 'color_yaka' && ['color_code', 'yaka_kodu', 'color_yaka'].includes(String(row?.group_field || ''))) return colorYakaValue(row) if (groupDimensionFields.has(name) && name !== row?.group_field) return '' return formatTableCell(row, colOrName) } @@ -3635,7 +3701,7 @@ function formatTableCell (row, colOrName) { if (isMoneyField(name)) return formatMoney(row[name], 'USD') if (isPercentField(name)) return formatPercent(row[name]) if (isIntegerField(name)) return formatNumber(row[name], 0) - if (isDecimalField(name)) return formatNumber(row[name], name === 'performance_score' ? 1 : 2) + if (isDecimalField(name)) return formatNumber(row[name], 2) if (typeof col?.field === 'function') return col.field(row) return formatProductCell(row, name) } @@ -3679,7 +3745,7 @@ function formatProductCell (row, name) { case 'sales_index_90d': case 'sales_index_total': case 'performance_score': - return formatNumber(row[name], name === 'performance_score' ? 1 : 2) + return formatNumber(row[name], 2) case 'sales_usd_90d': case 'sales_usd_180d': case 'sales_usd_365d': @@ -3955,7 +4021,7 @@ function markProductImageFailed (row) { } async function primeProductImages (sourceRows) { - if (activeTab.value !== 'products') return + if (!isProductKpiTab.value) return imagePrimeActiveRequests += 1 imagePrimeLoading.value = true const items = [] @@ -4570,6 +4636,15 @@ onMounted(reload) .performance-table :deep(.q-table__middle) { max-height: calc(100vh - 220px); + transform: rotateX(180deg); +} + +.performance-table :deep(.q-table__middle > .q-table) { + transform: rotateX(180deg); +} + +.performance-table :deep(.q-table__bottom) { + display: none; } .performance-table :deep(.q-table) { @@ -4647,7 +4722,7 @@ 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: 54px; + min-width: 72px; max-width: 100%; } @@ -4661,10 +4736,10 @@ onMounted(reload) .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; + width: 170px; + min-width: 170px; + max-width: 190px; + font-size: 10.5px; } .performance-table :deep(.q-table th:first-child), @@ -4781,6 +4856,95 @@ onMounted(reload) box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45); } +.product-breakdown-table.product-detail-table :deep(.q-table) { + min-width: 2500px; +} + +.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(n+8)) { + position: static; + left: auto; + z-index: auto; + box-shadow: none; +} + +.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(n+8)) { + left: auto; + z-index: 1; + box-shadow: none; +} + +.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(-n+7)), +.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(-n+7)) { + position: sticky; + z-index: 2; +} + +.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(-n+7)) { + z-index: 4; + background: #f8fbff; +} + +.product-breakdown-table.product-detail-table :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+7)) { + background: #fff; +} + +.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(1)), +.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(1)) { + left: 0; + width: 72px; + min-width: 72px; + max-width: 72px; +} + +.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(2)), +.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(2)) { + left: 72px; + width: 150px; + min-width: 150px; + max-width: 150px; +} + +.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(3)), +.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(3)) { + left: 222px; + width: 150px; + min-width: 150px; + max-width: 150px; +} + +.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(4)), +.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(4)) { + left: 372px; + width: 130px; + min-width: 130px; + max-width: 130px; +} + +.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(5)), +.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(5)) { + left: 502px; + width: 170px; + min-width: 170px; + max-width: 170px; +} + +.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(6)), +.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(6)) { + left: 672px; + width: 170px; + min-width: 170px; + max-width: 190px; +} + +.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(7)), +.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(7)) { + left: 842px; + width: 120px; + min-width: 120px; + max-width: 120px; + box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45); +} + .sticky-dim-table :deep(.q-table) { min-width: 1700px; table-layout: fixed; @@ -4797,12 +4961,17 @@ onMounted(reload) .sticky-dim-table :deep(.q-table td.text-right), .product-breakdown-table :deep(.q-table th.text-right), .product-breakdown-table :deep(.q-table td.text-right) { - min-width: 98px; - width: 98px; + min-width: 124px; + width: 124px; max-width: none; white-space: nowrap; } +.product-breakdown-table :deep(.q-table td.text-right) { + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + .product-breakdown-table :deep(.q-table th:nth-child(n+11):not(.text-right)), .product-breakdown-table :deep(.q-table td:nth-child(n+11):not(.text-right)), .sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(n+4):not(.text-right)), @@ -4939,6 +5108,63 @@ onMounted(reload) box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45); } +.sales-color-yaka-table :deep(.q-table th:nth-child(1)), +.sales-color-yaka-table :deep(.q-table td:nth-child(1)) { + left: 0; + width: 170px; + min-width: 170px; + max-width: 190px; +} + +.sales-color-yaka-table :deep(.q-table th:nth-child(2)), +.sales-color-yaka-table :deep(.q-table td:nth-child(2)) { + left: 170px; +} + +.sales-color-yaka-table :deep(.q-table th:nth-child(3)), +.sales-color-yaka-table :deep(.q-table td:nth-child(3)) { + left: 288px; +} + +.sales-color-yaka-table :deep(.q-table th:nth-child(4)), +.sales-color-yaka-table :deep(.q-table td:nth-child(4)) { + left: 406px; +} + +.sales-color-yaka-table :deep(.q-table th:nth-child(5)), +.sales-color-yaka-table :deep(.q-table td:nth-child(5)) { + left: 524px; +} + +.sales-color-yaka-table :deep(.q-table th:nth-child(6)), +.sales-color-yaka-table :deep(.q-table td:nth-child(6)) { + left: 642px; + width: 130px; + min-width: 130px; +} + +.sales-color-yaka-table :deep(.q-table th:nth-child(7)), +.sales-color-yaka-table :deep(.q-table td:nth-child(7)) { + left: 772px; + width: 170px; + min-width: 170px; +} + +.sales-color-yaka-table :deep(.q-table th:nth-child(8)), +.sales-color-yaka-table :deep(.q-table td:nth-child(8)) { + left: 942px; + width: 116px; + min-width: 116px; +} + +.sales-color-yaka-table :deep(.q-table th:nth-child(9)), +.sales-color-yaka-table :deep(.q-table td:nth-child(9)) { + left: 1058px; + width: 190px; + min-width: 190px; + box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45); +} + .filterable-header-cell { vertical-align: middle; }