diff --git a/svc/routes/product_performance.go b/svc/routes/product_performance.go index f453379..47e2f07 100644 --- a/svc/routes/product_performance.go +++ b/svc/routes/product_performance.go @@ -16,7 +16,7 @@ func GetProductPerformanceHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() f := queries.ProductPerformanceFilters{ @@ -90,7 +90,7 @@ func GetProductPerformanceSummaryHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() summary, err := queries.GetProductPerformanceSummary(ctx, pg) @@ -106,7 +106,7 @@ func GetProductPerformanceGeneralHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() rows, err := queries.ListProductPerformanceGeneral(ctx, pg, intQuery(r, "limit", 50000)) @@ -122,7 +122,7 @@ func GetProductPerformanceOrdersHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() rows, err := queries.ListProductPerformanceOrderAnalysis(ctx, pg, intQuery(r, "limit", 50000)) @@ -138,7 +138,7 @@ func GetProductPerformanceOrderGroupsHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() rows, err := queries.ListProductPerformanceOrderGroups( @@ -159,7 +159,7 @@ func GetProductPerformanceOrderProductCustomersHandler(pg *sql.DB) http.HandlerF return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() rows, err := queries.ListProductPerformanceOrderProductCustomers(ctx, pg, intQuery(r, "limit", 50000)) @@ -175,7 +175,7 @@ func GetProductPerformanceOrderMarketDetailsHandler(pg *sql.DB) http.HandlerFunc return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() rows, err := queries.ListProductPerformanceOrderMarketDetails(ctx, pg, intQuery(r, "limit", 50000)) @@ -191,7 +191,7 @@ func GetProductPerformanceMarketsHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() rows, err := queries.ListProductPerformanceMarkets(ctx, pg, intQuery(r, "limit", 50000)) @@ -207,7 +207,7 @@ func GetProductPerformanceCountriesHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() rows, err := queries.ListProductPerformanceCountries(ctx, pg, intQuery(r, "limit", 50000)) @@ -223,7 +223,7 @@ func GetProductPerformanceCustomersHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 30*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() rows, err := queries.ListProductPerformanceCustomers( @@ -244,7 +244,7 @@ func GetProductPerformanceSalesBreakdownHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 45*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() rows, err := queries.ListProductPerformanceSalesBreakdown( @@ -265,7 +265,7 @@ func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(r) - ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second) + ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 180*time.Second) defer cancel() mode := strings.TrimSpace(r.URL.Query().Get("mode")) diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index b5d7454..9e43bb7 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -14,7 +14,8 @@ @wheel.stop > -
Yükleniyor...
+
{{ loadingMessage }}
+
{{ loadingElapsedLabel }}
@@ -113,7 +114,7 @@ :rows="filteredGeneralRows" :columns="generalColumns" :loading="loading" - :pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }" + v-model:pagination="tablePagination.general" virtual-scroll :virtual-scroll-item-size="productThumbVirtualItemSize" :virtual-scroll-slice-size="tableVirtualSliceSize" @@ -224,8 +225,8 @@ class="performance-table sticky-dim-table sticky-dim-8 bg-white" :rows="displayOrderProductCustomerTableRows" :columns="orderProductCustomerColumns" - :loading="loading || backendGroupedLoading" - :pagination="{ rowsPerPage: 0, sortBy: 'order_usd', descending: true }" + :loading="loading" + v-model:pagination="tablePagination.order_product_customers" :sort-method="sortProductGroupedTableRows" virtual-scroll :virtual-scroll-item-size="productThumbVirtualItemSize" @@ -415,8 +416,8 @@ class="performance-table sticky-dim-table sticky-dim-10 bg-white" :rows="displayOrderMarketDetailTableRows" :columns="orderMarketDetailColumns" - :loading="loading || backendGroupedLoading" - :pagination="{ rowsPerPage: 0, sortBy: 'order_date', descending: true }" + :loading="loading" + v-model:pagination="tablePagination.order_market_details" :sort-method="sortProductGroupedTableRows" virtual-scroll :virtual-scroll-item-size="64" @@ -558,8 +559,8 @@ ]" :rows="displayProductKpiTableRows" :columns="activeProductColumns" - :loading="loading || backendGroupedLoading" - :pagination="pagination" + :loading="loading" + v-model:pagination="activeProductPagination" :sort-method="sortProductGroupedTableRows" virtual-scroll :virtual-scroll-item-size="productThumbVirtualItemSize" @@ -714,8 +715,8 @@ class="performance-table sticky-dim-table sticky-dim-11 bg-white" :rows="displayIdleTableRows" :columns="idleColumns" - :loading="loading || backendGroupedLoading" - :pagination="{ rowsPerPage: 0 }" + :loading="loading" + v-model:pagination="tablePagination.idle" :sort-method="sortProductGroupedTableRows" virtual-scroll :virtual-scroll-item-size="productThumbVirtualItemSize" @@ -902,7 +903,7 @@ :rows="filteredMarketRows" :columns="marketColumns" :loading="loading" - :pagination="{ rowsPerPage: 0 }" + v-model:pagination="tablePagination.markets" virtual-scroll :virtual-scroll-item-size="64" :virtual-scroll-slice-size="tableVirtualSliceSize" @@ -1008,8 +1009,8 @@ ]" :rows="displayActiveSalesBreakdownTableRows" :columns="visibleSalesBreakdownColumns" - :loading="loading || backendGroupedLoading" - :pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }" + :loading="loading" + v-model:pagination="activeSalesBreakdownPagination" :sort-method="sortProductGroupedTableRows" virtual-scroll :virtual-scroll-item-size="productThumbVirtualItemSize" @@ -1168,7 +1169,7 @@ :rows="filteredCustomerRows" :columns="customerColumns" :loading="loading" - :pagination="{ rowsPerPage: 0 }" + v-model:pagination="tablePagination.customers" virtual-scroll :virtual-scroll-item-size="64" :virtual-scroll-slice-size="tableVirtualSliceSize" @@ -1270,7 +1271,7 @@ :rows="filteredCountryRows" :columns="countryColumns" :loading="loading" - :pagination="{ rowsPerPage: 0 }" + v-model:pagination="tablePagination.countries" virtual-scroll :virtual-scroll-item-size="64" :virtual-scroll-slice-size="tableVirtualSliceSize" @@ -1566,6 +1567,7 @@ const selectedDetailMainGroup = ref('') const imageUrlByKey = ref({}) const imageListByKey = ref({}) const imageFailedKeys = ref(new Set()) +const imageLoadingKeys = ref(new Set()) const imageDialog = ref(false) const imageDialogUrls = ref([]) const imageDialogTitle = ref('') @@ -1584,6 +1586,9 @@ const backendGroupedLoading = ref(false) const imagePrimeLoading = ref(false) const filterBusy = ref(false) const bulkExpandLoading = ref(false) +const loadingStartedAt = ref(0) +const loadingNow = ref(0) +const loadingStage = ref('') const generalRowsLoaded = ref(false) const detailLevelMenuOpen = ref(false) const topScrollbarRef = ref(null) @@ -1593,6 +1598,7 @@ let imagePrimeActiveRequests = 0 let activeTableMiddleEl = null let topScrollbarResizeObserver = null let topScrollbarSyncing = false +let loadingTimer = null const columnFilters = reactive({}) const columnFilterSearch = reactive({}) @@ -1631,14 +1637,54 @@ const performanceTabs = [ { 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 || filterBusy.value || bulkExpandLoading.value) - -const pagination = ref({ - page: 1, - rowsPerPage: 0, - sortBy: 'performance_score_total', - descending: true +const pageBusy = computed(() => loading.value) +const loadingElapsedLabel = computed(() => { + if (!loadingStartedAt.value || !loadingNow.value) return '' + const seconds = Math.max(0, Math.floor((loadingNow.value - loadingStartedAt.value) / 1000)) + const minutes = Math.floor(seconds / 60) + const rest = seconds % 60 + return minutes > 0 ? `${minutes}:${String(rest).padStart(2, '0')}` : `${seconds} sn` }) +const loadingMessage = computed(() => loadingStage.value || 'Rapor verileri yükleniyor...') + +const tablePagination = reactive({ + general: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }, + products: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }, + product_detail: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }, + order_product_customers: { page: 1, rowsPerPage: 0, sortBy: 'order_usd', descending: true }, + order_market_details: { page: 1, rowsPerPage: 0, sortBy: 'order_date', descending: true }, + idle: { page: 1, rowsPerPage: 0, sortBy: 'idle_cost_usd', descending: true }, + markets: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_90d', descending: true }, + countries: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_90d', descending: true }, + customers: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_365d', descending: true }, + sales_color_yaka_market_customer: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }, + sales_product_country_segment_market_customer: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }, + sales_market_customer_product: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }, + sales_country_segment_market_customer_product: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true } +}) + +const activeProductPagination = computed({ + get: () => tablePagination[activeTab.value === 'product_detail' ? 'product_detail' : 'products'], + set: value => { + tablePagination[activeTab.value === 'product_detail' ? 'product_detail' : 'products'] = normalizeTablePagination(value) + } +}) + +const activeSalesBreakdownPagination = computed({ + get: () => tablePagination[activeTab.value] || tablePagination.sales_color_yaka_market_customer, + set: value => { + tablePagination[activeTab.value] = normalizeTablePagination(value) + } +}) + +function normalizeTablePagination (value) { + return { + page: Number(value?.page || 1), + rowsPerPage: 0, + sortBy: value?.sortBy || '', + descending: value?.descending === true + } +} const bucketOptions = [ { label: 'Yıldız Ürün', value: 'YILDIZ_URUN' }, @@ -2739,8 +2785,7 @@ const productAutoExpandKeys = computed(() => { }) const allProductGroupsExpanded = computed(() => { - const keys = productAutoExpandKeys.value - return keys.length > 0 && keys.every(key => expandedGroups.value[key] === true) + return selectedExpandThroughLevel.value >= autoExpandThroughLevel.value }) function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels) { @@ -2784,22 +2829,13 @@ function buildGroupedTableRows (tableKey, sourceRows) { } function backendRowsForTab (tabKey, fallbackRows) { - const rows = backendGroupedRows.value[tabKey] - const backendRows = Array.isArray(rows) && rows.length ? rows : [] - if (hasActiveTableFilters(tabKey)) { - return tabKey === 'product_detail' - ? filterRowsBySelectedDetailMainGroup(fallbackRows) - : fallbackRows - } - const sourceRows = tabKey === 'product_detail' - ? filterRowsBySelectedDetailMainGroup(backendRows.length ? backendRows : fallbackRows) - : (backendRows.length ? backendRows : fallbackRows) - return sourceRows + return tabKey === 'product_detail' + ? filterRowsBySelectedDetailMainGroup(fallbackRows) + : fallbackRows } function filterSourceRowsForTab (tabKey, fallbackRows) { - const rows = backendGroupedRows.value[tabKey] - return Array.isArray(rows) && rows.length ? rows : fallbackRows + return fallbackRows } function columnsForTableKey (tabKey) { @@ -3387,7 +3423,10 @@ function shouldSkipGroupValue (field, value) { } function isGroupExpanded (key) { - return expandedGroups.value[key] === true + const tabKey = tabKeyFromGroupKey(key) + const depth = groupKeyDepth(key) + const level = selectedExpandThroughLevelForTab(tabKey) + return depth >= 0 && level >= depth } function groupKeyDepth (key) { @@ -3395,6 +3434,20 @@ function groupKeyDepth (key) { return Math.max(-1, parts.length - 2) } +function tabKeyFromGroupKey (key) { + const first = String(key || '').split('|')[0] || '' + return first.startsWith('tab:') ? first.slice(4) : activeTab.value +} + +function selectedExpandThroughLevelForTab (tabKey) { + const levels = tabGroupLevels[tabKey] || groupLevels + const selected = new Set(selectedExpandLevelKeysByTab[tabKey] || []) + const indexes = levels + .map((level, index) => selected.has(level.key) ? index : -1) + .filter(index => index >= 0) + return indexes.length ? Math.max(...indexes) : -1 +} + function activeTabPrefix () { return `tab:${activeTab.value}|` } @@ -3423,24 +3476,22 @@ function setSelectedExpandLevelsThrough (level) { function applyActiveExpansionThroughLevel (level) { setSelectedExpandLevelsThrough(level) - replaceActiveTabExpandedGroups(expandedLevelKeysThrough(level)) + replaceActiveTabExpandedGroups([]) lastManualExpandedGroupKey.value = '' } function toggleGroup (key) { const level = groupKeyDepth(key) applyActiveExpansionThroughLevel(isGroupExpanded(key) ? level - 1 : level) - scheduleLoadBackendGroupedRows() } async function collapseAllProductGroups () { if (bulkExpandLoading.value) return detailLevelMenuOpen.value = false applyActiveExpansionThroughLevel(-1) - if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer) bulkExpandLoading.value = true try { - await loadBackendGroupedRows({ force: true }) + await nextTick() } finally { bulkExpandLoading.value = false } @@ -3449,21 +3500,10 @@ async function collapseAllProductGroups () { async function expandSelectedProductGroups () { if (bulkExpandLoading.value) return detailLevelMenuOpen.value = false - const keys = productAutoExpandKeys.value - const maxKeys = Math.min(maxBulkExpandKeys, maxBackendExpandedKeysForTab(activeTab.value)) - const expandableKeys = keys.slice(0, maxKeys) - replaceActiveTabExpandedGroups(expandableKeys) - 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.` - }) - } - if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer) + applyActiveExpansionThroughLevel(autoExpandThroughLevel.value) bulkExpandLoading.value = true try { - await loadBackendGroupedRows({ force: true }) + await nextTick() } finally { bulkExpandLoading.value = false } @@ -3649,12 +3689,7 @@ function runWithFilterBusy (apply) { function syncExpansionAfterFilterChange () { if (!backendGroupedSupportedTab(activeTab.value)) return const level = selectedExpandThroughLevel.value - if (level < 0) { - scheduleLoadBackendGroupedRows() - return - } - applyActiveExpansionThroughLevel(autoExpandThroughLevel.value) - scheduleLoadBackendGroupedRows() + if (level >= 0) applyActiveExpansionThroughLevel(autoExpandThroughLevel.value) } function filterRowsForTable (tableKey, sourceRows, sourceColumns) { @@ -4166,7 +4201,21 @@ function getCachedProductImageUrl (row) { if (!canShowProductImage(row)) return '' const key = productImageKey(row) if (imageFailedKeys.value.has(key)) return '' - return imageUrlByKey.value[key] || performanceStore.imageUrl(key) || '' + const url = imageUrlByKey.value[key] || performanceStore.imageUrl(key) || '' + if (!url) queueVisibleProductImage(row) + return url +} + +function queueVisibleProductImage (row) { + const key = productImageKey(row) + if (!key || imageLoadingKeys.value.has(key) || imageFailedKeys.value.has(key)) return + if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) return + imageLoadingKeys.value = new Set([...imageLoadingKeys.value, key]) + void fetchProductImagesForRow(row).finally(() => { + const next = new Set(imageLoadingKeys.value) + next.delete(key) + imageLoadingKeys.value = next + }) } function markProductImageFailed (row) { @@ -4510,30 +4559,112 @@ async function timedProductPerformanceGet (label, url, config = {}) { async function reload () { loading.value = true + startLoadingTimer('Rapor verileri hazırlanıyor...') try { generalRowsLoaded.value = false generalRows.value = [] + orderProductCustomersLoaded.value = false + orderProductCustomerRows.value = [] + orderMarketDetailsLoaded.value = false + orderMarketDetailRows.value = [] + orderAnalysisLoaded.value = false + orderAnalysisRows.value = [] + orderMarketRows.value = [] + orderCustomerRows.value = [] marketRowsLoaded.value = false + marketRows.value = [] countryRowsLoaded.value = false + countryRows.value = [] customerRowsLoaded.value = false + customerRows.value = [] + for (const key of salesBreakdownTabKeys) salesBreakdownRows[key] = [] + + loadingStage.value = 'Ana KPI ve özet yükleniyor...' const [summaryResp, listResp] = await Promise.all([ - timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 60000 }), + timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 180000 }), timedProductPerformanceGet('product-kpi', '/pricing/product-performance', { params: { page: 1, limit: productKpiFetchLimit }, - timeout: 60000 + timeout: 180000 }) ]) summary.value = summaryResp?.data || {} rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(normalizeRow) - await ensureActiveTabData(false) - await loadBackendGroupedRows({ force: true }) + loadingStage.value = 'Tüm rapor sekmeleri cacheleniyor...' + await preloadAllReportTabs() + loadingStage.value = 'Rapor ekranı hazırlanıyor...' + await nextTick() } catch (err) { Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' }) } finally { loading.value = false + stopLoadingTimer() + } +} + +function startLoadingTimer (stage = '') { + loadingStage.value = stage + loadingStartedAt.value = Date.now() + loadingNow.value = Date.now() + if (loadingTimer) window.clearInterval(loadingTimer) + loadingTimer = window.setInterval(() => { + loadingNow.value = Date.now() + }, 1000) +} + +function stopLoadingTimer () { + if (loadingTimer) { + window.clearInterval(loadingTimer) + loadingTimer = null + } + loadingStartedAt.value = 0 + loadingNow.value = 0 + loadingStage.value = '' +} + +async function preloadAllReportTabs () { + const tasks = [ + loadGeneralRows(false), + loadOrderProductCustomers(false), + loadOrderMarketDetails(false), + loadMarketRows(false), + loadCountryRows(false), + loadCustomerRows(false), + loadAllSalesBreakdownRows(false) + ] + const results = await Promise.allSettled(tasks) + const failed = results.filter(item => item.status === 'rejected') + if (failed.length) { + console.warn('[ProductPerformance][preload] partial failure', failed.map(item => item.reason?.message || item.reason)) + } +} + +async function loadAllSalesBreakdownRows (showLoading = true) { + if (showLoading) loading.value = true + try { + await Promise.all(salesBreakdownTabKeys.map(key => loadSalesBreakdownRows(key, false))) + } finally { + if (showLoading) loading.value = false + } +} + +async function loadSalesBreakdownRows (tabKey, showLoading = true) { + if (!salesBreakdownTabKeys.includes(tabKey)) return + if (Array.isArray(salesBreakdownRows[tabKey]) && salesBreakdownRows[tabKey].length) return + if (showLoading) loading.value = true + try { + const mode = salesBreakdownModes[tabKey] || tabKey + const resp = await timedProductPerformanceGet(`sales:${tabKey}`, '/pricing/product-performance/sales-breakdown', { + params: { mode, limit: reportFetchLimit }, + timeout: 180000 + }) + salesBreakdownRows[tabKey] = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeSalesBreakdownRow) + } catch (err) { + Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Satış kırılım verisi alınamadı' }) + } finally { + if (showLoading) loading.value = false } } @@ -4541,7 +4672,7 @@ async function loadGeneralRows (showLoading = true) { if (generalRowsLoaded.value) return if (showLoading) loading.value = true try { - const resp = await timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: reportFetchLimit }, timeout: 90000 }) + const resp = await timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: reportFetchLimit }, timeout: 180000 }) const normalizedGeneralRows = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeGeneralRow) generalRows.value = normalizedGeneralRows generalRowsLoaded.value = true @@ -4559,9 +4690,10 @@ async function loadGeneralRows (showLoading = true) { } async function loadOrderProductCustomers (showLoading = true) { + if (orderProductCustomersLoaded.value) return if (showLoading) loading.value = true try { - const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: reportFetchLimit }, timeout: 90000 }) + const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: reportFetchLimit }, timeout: 180000 }) orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow) orderProductCustomersLoaded.value = true } catch (err) { @@ -4572,9 +4704,10 @@ async function loadOrderProductCustomers (showLoading = true) { } async function loadOrderMarketDetails (showLoading = true) { + if (orderMarketDetailsLoaded.value) return if (showLoading) loading.value = true try { - const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: reportFetchLimit }, timeout: 90000 }) + const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: reportFetchLimit }, timeout: 180000 }) orderMarketDetailRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderMarketDetailRow) orderMarketDetailsLoaded.value = true } catch (err) { @@ -4588,7 +4721,7 @@ async function loadMarketRows (showLoading = true) { if (marketRowsLoaded.value) return if (showLoading) loading.value = true try { - const resp = await timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: reportFetchLimit }, timeout: 60000 }) + const resp = await timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: reportFetchLimit }, timeout: 180000 }) marketRows.value = Array.isArray(resp?.data) ? resp.data : [] marketRowsLoaded.value = true } catch (err) { @@ -4602,7 +4735,7 @@ async function loadCountryRows (showLoading = true) { if (countryRowsLoaded.value) return if (showLoading) loading.value = true try { - const resp = await timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: reportFetchLimit }, timeout: 60000 }) + const resp = await timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: reportFetchLimit }, timeout: 180000 }) countryRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(row => ({ ...row, country_key: `${row.country || '-'}|${row.customer_segment || '-'}|${row.market_key || '-'}` @@ -4621,7 +4754,7 @@ async function loadCustomerRows (showLoading = true) { try { const resp = await timedProductPerformanceGet('customers', '/pricing/product-performance/customers', { params: { limit: reportFetchLimit, breakdown: customerBreakdown.value }, - timeout: 60000 + timeout: 180000 }) customerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(row => ({ ...row, @@ -4641,6 +4774,9 @@ async function ensureActiveTabData (showLoading = true) { if (tab === 'markets') return loadMarketRows(showLoading) if (tab === 'countries') return loadCountryRows(showLoading) if (tab === 'customers') return loadCustomerRows(showLoading) + if (tab === 'order_product_customers') return loadOrderProductCustomers(showLoading) + if (tab === 'order_market_details') return loadOrderMarketDetails(showLoading) + if (salesBreakdownTabKeys.includes(tab)) return loadSalesBreakdownRows(tab, showLoading) } async function loadOrderAnalysis (showLoading = true) { @@ -4680,7 +4816,6 @@ function bucketColor (value) { watch(activeTab, () => { void ensureActiveTabData(false) - scheduleLoadBackendGroupedRows() setupTopScrollbarSync() }) @@ -4704,14 +4839,9 @@ watch(detailMainGroupOptions, () => { watch(selectedDetailMainGroup, () => { if (activeTab.value === 'product_detail') { - backendGroupedRows.value = { - ...backendGroupedRows.value, - product_detail: [] - } void nextTick(() => { const level = selectedExpandThroughLevel.value applyActiveExpansionThroughLevel(level >= 0 ? autoExpandThroughLevel.value : -1) - scheduleLoadBackendGroupedRows() }) } }) @@ -4733,6 +4863,7 @@ onMounted(() => { onBeforeUnmount(() => { cleanupTopScrollbarSync() if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer) + stopLoadingTimer() }) @@ -4762,6 +4893,12 @@ onBeforeUnmount(() => { font-weight: 700; } +.page-busy-time { + color: #5f6b7a; + font-size: 13px; + font-weight: 600; +} + .performance-top-bar { min-height: 34px; }