diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 6eeadfd..51f40eb 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -343,7 +343,7 @@
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 {