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

This commit is contained in:
M_Kececi
2026-07-03 00:30:54 +03:00
parent c536b85a4c
commit be6feb8aa8
3 changed files with 710 additions and 71 deletions
+244 -71
View File
@@ -1,5 +1,22 @@
<template>
<q-page class="product-performance-page q-pa-sm">
<q-inner-loading :showing="pageBusy">
<q-spinner-gears size="52px" color="primary" />
</q-inner-loading>
<teleport to="body">
<div
v-if="pageBusy"
class="page-busy-overlay"
@click.stop
@mousedown.stop
@mouseup.stop
@touchstart.stop
@wheel.stop
>
<q-spinner-gears size="56px" color="primary" />
<div class="page-busy-label">Yükleniyor...</div>
</div>
</teleport>
<div class="row items-center justify-between q-col-gutter-sm q-mb-sm">
<div class="col-12 col-md">
<div class="text-h6">Ürün Performans ve Karlılık Analizi</div>
@@ -14,6 +31,7 @@
type="checkbox"
inline
dense
:disable="pageBusy"
class="period-selector bg-white q-px-sm q-py-xs"
/>
<q-btn-dropdown
@@ -22,6 +40,7 @@
color="primary"
:icon="allProductGroupsExpanded ? 'unfold_less' : 'unfold_more'"
:label="allProductGroupsExpanded ? 'Detayları Kapat' : 'Tüm Detayları '"
:disable="pageBusy"
@click="toggleAllProductGroups"
>
<div class="detail-level-menu">
@@ -33,12 +52,12 @@
/>
<q-separator class="q-my-sm" />
<div class="row items-center justify-end q-gutter-xs">
<q-btn dense flat color="grey-7" label="Kapat" @click="expandedGroups = {}" />
<q-btn dense color="primary" label="Seçileni Aç" @click="expandSelectedProductGroups" />
<q-btn dense flat color="grey-7" label="Kapat" :disable="pageBusy" @click="collapseAllProductGroups" />
<q-btn dense color="primary" label="Seçileni Aç" :disable="pageBusy" @click="expandSelectedProductGroups" />
</div>
</div>
</q-btn-dropdown>
<q-btn outline color="secondary" icon="refresh" label="Yenile" :loading="loading" @click="reload" />
<q-btn outline color="secondary" icon="refresh" label="Yenile" :loading="loading" :disable="pageBusy" @click="reload" />
</div>
</div>
@@ -53,23 +72,19 @@
</div>
</div>
<q-tabs
v-model="activeTab"
dense
class="bg-white text-grey-8 q-mb-sm"
active-color="primary"
indicator-color="primary"
align="left"
>
<q-tab name="products" icon="inventory_2" label="Ürün KPI" />
<q-tab name="sales_color_yaka_market_customer" icon="palette" label="Renk > Yaka > Piyasa > Müşteri" />
<q-tab name="idle" icon="warning" label="Atıl Stok / Maliyet" />
<q-tab name="sales_product_country_segment_market_customer" icon="account_tree" label="Ürün > Ülke > Segment > Piyasa > Müşteri" />
<q-tab name="sales_market_customer_product" icon="groups" label="Piyasa > Müşteri > Ürün Satış KPI" />
<q-tab name="sales_country_segment_market_customer_product" icon="public" label="Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI" />
<q-tab name="order_product_customers" icon="assignment_ind" label="Ürün > Piyasa > Müşteri Sipariş" />
<q-tab name="order_market_details" icon="receipt_long" label="Piyasa > Müşteri > Ürün Sipariş" />
</q-tabs>
<div class="performance-tab-grid q-mb-sm">
<button
v-for="tab in performanceTabs"
:key="tab.name"
type="button"
:class="['performance-tab-button', { active: activeTab === tab.name }]"
:disabled="pageBusy"
@click="switchPerformanceTab(tab.name)"
>
<q-icon :name="tab.icon" size="16px" />
<span>{{ tab.label }}</span>
</button>
</div>
<q-table
v-if="activeTab === 'general'"
@@ -81,6 +96,8 @@
:columns="generalColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
virtual-scroll
:virtual-scroll-item-size="64"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -189,6 +206,8 @@
:columns="orderProductCustomerColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }"
virtual-scroll
:virtual-scroll-item-size="64"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -373,6 +392,8 @@
:columns="orderMarketDetailColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }"
virtual-scroll
:virtual-scroll-item-size="64"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -507,6 +528,8 @@
:columns="productColumns"
:loading="loading || backendGroupedLoading"
:pagination="pagination"
virtual-scroll
:virtual-scroll-item-size="64"
@request="onRequest"
>
<template #header-cell="props">
@@ -658,6 +681,8 @@
:columns="idleColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100 }"
virtual-scroll
:virtual-scroll-item-size="64"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -838,6 +863,8 @@
:columns="marketColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
virtual-scroll
:virtual-scroll-item-size="64"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -936,6 +963,8 @@
:columns="visibleSalesBreakdownColumns"
:loading="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
virtual-scroll
:virtual-scroll-item-size="64"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -1091,6 +1120,8 @@
:columns="customerColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
virtual-scroll
:virtual-scroll-item-size="64"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -1190,6 +1221,8 @@
:columns="countryColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
virtual-scroll
:virtual-scroll-item-size="64"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
@@ -1396,6 +1429,8 @@
:columns="detailStockColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 50 }"
virtual-scroll
:virtual-scroll-item-size="48"
/>
</div>
<div class="col-12 col-md-8">
@@ -1409,6 +1444,8 @@
:columns="detailSalesColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 50 }"
virtual-scroll
:virtual-scroll-item-size="48"
/>
</div>
</div>
@@ -1422,8 +1459,11 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Notify } from 'quasar'
import api from 'src/services/api'
import { useProductPerformanceStore } from 'src/stores/productPerformanceStore'
import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters'
const performanceStore = useProductPerformanceStore()
const loading = ref(false)
const rows = ref([])
const generalRows = ref([])
@@ -1466,7 +1506,10 @@ const detailSalesRows = ref([])
const detailStockSizes = ref([])
const backendGroupedRows = ref({})
const backendGroupedLoading = ref(false)
const imagePrimeLoading = ref(false)
const filterBusy = ref(false)
let backendGroupedTimer = null
let imagePrimeActiveRequests = 0
const columnFilters = reactive({})
const columnFilterSearch = reactive({})
@@ -1482,6 +1525,17 @@ const selectedExpandLevelKeysByTab = reactive({
order_market_details: ['market_key', 'customer_code', 'product_code', 'color_code', 'yaka_kodu']
})
const productKpiFetchLimit = 50000
const performanceTabs = [
{ name: 'products', icon: 'inventory_2', label: 'Ürün KPI' },
{ name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Renk > Yaka > Piyasa > Müşteri' },
{ name: 'idle', icon: 'warning', label: 'Atıl Stok / Maliyet' },
{ name: 'sales_product_country_segment_market_customer', icon: 'account_tree', label: 'Ürün > Ülke > Segment > Piyasa > Müşteri' },
{ name: 'sales_market_customer_product', icon: 'groups', label: 'Piyasa > Müşteri > Ürün Satış KPI' },
{ name: 'sales_country_segment_market_customer_product', icon: 'public', label: 'Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI' },
{ name: 'order_product_customers', icon: 'assignment_ind', label: 'Ürün > Piyasa > Müşteri Sipariş' },
{ name: 'order_market_details', icon: 'receipt_long', label: 'Piyasa > Müşteri > Ürün Sipariş' }
]
const pageBusy = computed(() => loading.value || backendGroupedLoading.value || imagePrimeLoading.value || filterBusy.value)
const pagination = ref({
page: 1,
@@ -2516,13 +2570,17 @@ function toggleGroup (key) {
function toggleAllProductGroups () {
const keys = productAutoExpandKeys.value
if (allProductGroupsExpanded.value) {
expandedGroups.value = {}
scheduleLoadBackendGroupedRows()
collapseAllProductGroups()
return
}
expandSelectedProductGroups()
}
function collapseAllProductGroups () {
expandedGroups.value = {}
scheduleLoadBackendGroupedRows()
}
function expandSelectedProductGroups () {
const keys = productAutoExpandKeys.value
const next = { ...expandedGroups.value }
@@ -2617,19 +2675,47 @@ function isColumnFilterValueSelected (tableKey, name, value) {
}
function toggleColumnFilterValue (tableKey, name, value) {
const key = filterKey(tableKey, name)
const selected = selectedColumnFilters(tableKey, name)
columnFilters[key] = selected.includes(value)
? selected.filter(item => item !== value)
: [...selected, value]
runWithFilterBusy(() => {
const key = filterKey(tableKey, name)
const selected = selectedColumnFilters(tableKey, name)
columnFilters[key] = selected.includes(value)
? selected.filter(item => item !== value)
: [...selected, value]
})
}
function clearColumnFilter (tableKey, name) {
columnFilters[filterKey(tableKey, name)] = []
runWithFilterBusy(() => {
columnFilters[filterKey(tableKey, name)] = []
})
}
function selectAllColumnFilterOptions (tableKey, name) {
columnFilters[filterKey(tableKey, name)] = columnFilterOptions(tableKey, name).map(option => option.value)
runWithFilterBusy(() => {
columnFilters[filterKey(tableKey, name)] = columnFilterOptions(tableKey, name).map(option => option.value)
})
}
function runWithFilterBusy (apply) {
filterBusy.value = true
const done = () => {
filterBusy.value = false
}
const execute = () => {
apply()
if (typeof window === 'undefined') {
setTimeout(done, 0)
return
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(done)
})
}
if (typeof window === 'undefined') {
execute()
return
}
window.requestAnimationFrame(execute)
}
function filterRowsForTable (tableKey, sourceRows, sourceColumns) {
@@ -2931,6 +3017,12 @@ async function fetchProductImagesForRow (row) {
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) {
return imageListByKey.value[key]
}
if (performanceStore.hasImageKey(key)) {
const urls = performanceStore.imageList(key)
imageListByKey.value = { ...imageListByKey.value, [key]: urls }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
return urls
}
const code = String(row?.image_product_code || row?.product_code || '').trim()
if (!code) return []
@@ -2949,10 +3041,12 @@ async function fetchProductImagesForRow (row) {
})
const list = Array.isArray(resp?.data) ? resp.data : []
const urls = list.map(resolveProductImageUrl).filter(Boolean)
performanceStore.setImageCache(key, urls)
imageListByKey.value = { ...imageListByKey.value, [key]: urls }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
return urls
} catch {
performanceStore.setImageCache(key, [])
imageListByKey.value = { ...imageListByKey.value, [key]: [] }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' }
return []
@@ -2960,44 +3054,38 @@ async function fetchProductImagesForRow (row) {
}
function getCachedProductImageUrl (row) {
return imageUrlByKey.value[productImageKey(row)] || ''
const key = productImageKey(row)
return imageUrlByKey.value[key] || performanceStore.imageUrl(key) || ''
}
async function primeProductImages (sourceRows) {
imagePrimeActiveRequests += 1
imagePrimeLoading.value = true
const items = []
const seen = new Set()
for (const row of sourceRows || []) {
const key = productImageKey(row)
if (!key || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) continue
seen.add(key)
items.push({
key,
code: String(row?.image_product_code || row?.product_code || '').trim(),
dim1: String(row?.image_color_code || row?.color_code || '').trim(),
dim3: String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
})
if (items.length >= 160) break
}
if (!items.length) return
try {
const resp = await api.post('/product-images/batch', { items }, { timeout: 60000 })
for (const row of sourceRows || []) {
const key = productImageKey(row)
const code = String(row?.image_product_code || row?.product_code || '').trim()
if (!key || !code || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) continue
seen.add(key)
items.push({
key,
code,
dim1: String(row?.image_color_code || row?.color_code || '').trim(),
dim3: String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
})
if (items.length >= 160) break
}
if (!items.length) return
const batch = await performanceStore.fetchImageBatch(items)
const nextLists = { ...imageListByKey.value }
const nextUrls = { ...imageUrlByKey.value }
const returned = new Set()
for (const item of Array.isArray(resp?.data) ? resp.data : []) {
const key = String(item?.key || '').trim()
if (!key) continue
returned.add(key)
const urls = (Array.isArray(item.images) ? item.images : []).map(resolveProductImageUrl).filter(Boolean)
nextLists[key] = urls
nextUrls[key] = urls[0] || ''
}
for (const item of items) {
if (!returned.has(item.key)) {
nextLists[item.key] = []
nextUrls[item.key] = ''
}
const urls = batch?.lists?.[item.key] || performanceStore.imageList(item.key) || []
nextLists[item.key] = urls
nextUrls[item.key] = batch?.urls?.[item.key] || urls[0] || ''
}
imageListByKey.value = nextLists
imageUrlByKey.value = nextUrls
@@ -3007,6 +3095,9 @@ async function primeProductImages (sourceRows) {
color_code: item.dim1,
yaka_kodu: item.dim3
})))
} finally {
imagePrimeActiveRequests = Math.max(0, imagePrimeActiveRequests - 1)
imagePrimeLoading.value = imagePrimeActiveRequests > 0
}
}
@@ -3080,29 +3171,34 @@ function activeExpandedGroupKeys () {
}
function scheduleLoadBackendGroupedRows () {
if (backendGroupedSupportedTab(activeTab.value)) backendGroupedLoading.value = true
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
backendGroupedTimer = window.setTimeout(() => {
void loadBackendGroupedRows()
}, 120)
}
function switchPerformanceTab (tabName) {
if (!tabName || tabName === activeTab.value || pageBusy.value) return
backendGroupedLoading.value = true
activeTab.value = tabName
}
async function loadBackendGroupedRows () {
const tabKey = activeTab.value
if (!backendGroupedSupportedTab(tabKey)) return
backendGroupedLoading.value = true
const params = {
mode: tabKey,
groupLevels: activeGroupLevels.value.map(level => level.key),
expandedKeys: activeExpandedGroupKeys(),
limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000
}
try {
const resp = await api.get('/pricing/product-performance/grouped', {
params: {
mode: tabKey,
group_levels: activeGroupLevels.value.map(level => level.key).join(','),
expanded_keys: activeExpandedGroupKeys().join(','),
limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000
},
timeout: 90000
})
const rows = await performanceStore.fetchGroupedRows(params)
backendGroupedRows.value = {
...backendGroupedRows.value,
[tabKey]: Array.isArray(resp?.data) ? resp.data : []
[tabKey]: Array.isArray(rows) ? rows : []
}
} catch (err) {
console.warn('product performance grouped rows failed', err)
@@ -3168,7 +3264,7 @@ async function reload () {
await loadOrderMarketDetails(false)
}
await loadBackendGroupedRows()
void primeProductImages(rows.value)
await primeProductImages(rows.value)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
} finally {
@@ -3182,7 +3278,7 @@ async function loadOrderProductCustomers (showLoading = true) {
const resp = await api.get('/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 })
orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow)
orderProductCustomersLoaded.value = true
void primeProductImages(orderProductCustomerRows.value)
await primeProductImages(orderProductCustomerRows.value)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün müşteri sipariş analizi alınamadı' })
} finally {
@@ -3215,7 +3311,7 @@ async function loadOrderAnalysis (showLoading = true) {
orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow)
orderCustomerRows.value = (Array.isArray(customerResp?.data) ? customerResp.data : []).map(normalizeOrderGroupRow)
orderAnalysisLoaded.value = true
void primeProductImages(orderAnalysisRows.value)
await primeProductImages(orderAnalysisRows.value)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Sipariş analiz verisi alınamadı' })
} finally {
@@ -3271,9 +3367,70 @@ onMounted(reload)
<style scoped>
.product-performance-page {
position: relative;
background: #f6f7f9;
}
.page-busy-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(246, 247, 249, 0.82);
backdrop-filter: blur(2px);
pointer-events: all;
}
.page-busy-label {
color: #1f2937;
font-size: 13px;
font-weight: 700;
}
.performance-tab-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
gap: 6px;
}
.performance-tab-button {
min-height: 38px;
padding: 6px 8px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid #d5dce5;
border-radius: 6px;
background: #fff;
color: #4b5563;
font-size: 11px;
font-weight: 700;
line-height: 1.15;
text-align: center;
cursor: pointer;
}
.performance-tab-button span {
white-space: normal;
overflow-wrap: anywhere;
}
.performance-tab-button.active {
border-color: var(--q-primary);
color: var(--q-primary);
background: #eef6ff;
}
.performance-tab-button:disabled {
cursor: wait;
opacity: 0.62;
}
.metric-card {
min-height: 78px;
}
@@ -3311,8 +3468,17 @@ onMounted(reload)
white-space: normal;
}
.performance-table :deep(.q-table tbody tr),
.performance-table :deep(.q-table tbody td) {
height: auto;
min-height: 48px;
}
.performance-table :deep(.q-table tbody td) {
max-width: 320px;
padding-top: 6px;
padding-bottom: 6px;
overflow-wrap: anywhere;
}
.performance-table :deep(.q-table tbody td span:not(.q-badge):not(.q-icon)) {
@@ -3323,6 +3489,13 @@ onMounted(reload)
line-height: 1.25;
}
.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: 96px;
max-width: 100%;
}
.performance-table :deep(.q-table th:first-child),
.performance-table :deep(.q-table td:first-child) {
border-left: 1px solid #d5dce5;