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 {