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

This commit is contained in:
M_Kececi
2026-07-02 17:39:22 +03:00
parent 1bf51b9e82
commit d98ce372c7
8 changed files with 1982 additions and 105 deletions
+1 -1
View File
@@ -356,7 +356,7 @@ const menuItems = [
},
{
label: 'Ürün Fiyatlandırma',
label: 'Ürün Fiyatlandırma ve Analiz',
icon: 'sell',
children: [
+500 -58
View File
@@ -32,38 +32,6 @@
</div>
</div>
<q-card flat bordered class="q-mb-sm">
<q-card-section class="row q-col-gutter-sm items-end">
<div class="col-12 col-md-3">
<q-input v-model="filters.q" dense outlined clearable label="Ürün / açıklama ara" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-input v-model="filters.product_code" dense outlined clearable label="Ürün kodu" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-input v-model="filters.kategori" dense outlined clearable label="Kategori" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-input v-model="filters.seri" dense outlined clearable label="Ürün Ana Grubu" @keyup.enter="reload" />
</div>
<div class="col-6 col-md-2">
<q-select
v-model="filters.bucket"
dense
outlined
clearable
emit-value
map-options
label="Durum"
:options="bucketOptions"
/>
</div>
<div class="col-12 col-md-1">
<q-btn class="full-width" color="secondary" outline icon="search" label="Ara" @click="reload" />
</div>
</q-card-section>
</q-card>
<q-tabs
v-model="activeTab"
dense
@@ -73,6 +41,9 @@
align="left"
>
<q-tab name="products" icon="inventory_2" label="Ürün KPI" />
<q-tab name="general" icon="analytics" label="KPI Genel" />
<q-tab name="order_product_customers" icon="assignment_ind" label="Ürün > Müşteri Sipariş" />
<q-tab name="order_market_details" icon="account_tree" label="Piyasa > Sipariş Detay" />
<q-tab name="idle" icon="warning" label="Atıl Stok / Maliyet" />
<q-tab name="markets" icon="query_stats" label="Piyasa Grup Kıyası" />
<q-tab name="countries" icon="public" label="Ülke / Segment" />
@@ -80,7 +51,121 @@
</q-tabs>
<q-table
v-if="activeTab === 'products'"
v-if="activeTab === 'general'"
flat
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="generalRows"
:columns="generalColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
>
<template #body-cell-image="props">
<q-td :props="props" class="product-image-cell">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<q-img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
fit="contain"
class="product-thumb"
no-spinner
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</q-td>
</template>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'order_product_customers'"
flat
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="orderProductCustomerRows"
:columns="orderProductCustomerColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }"
>
<template #body-cell-image="props">
<q-td :props="props" class="product-image-cell">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<q-img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
fit="contain"
class="product-thumb"
no-spinner
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</q-td>
</template>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
<template #body-cell-net_stock_after_order="props">
<q-td :props="props" class="text-right" :class="Number(props.row.net_stock_after_order || 0) < 0 ? 'text-negative text-weight-bold' : ''">
{{ formatNumber(props.row.net_stock_after_order, 0) }}
</q-td>
</template>
<template #body-cell-expected_profit_cost_usd="props">
<q-td :props="props" class="text-right" :class="Number(props.row.expected_profit_cost_usd || 0) < 0 ? 'text-negative text-weight-bold' : 'text-positive'">
{{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
</q-td>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'order_market_details'"
flat
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="orderMarketDetailRows"
:columns="orderMarketDetailColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }"
>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
<template #body-cell-net_stock_after_order="props">
<q-td :props="props" class="text-right" :class="Number(props.row.net_stock_after_order || 0) < 0 ? 'text-negative text-weight-bold' : ''">
{{ formatNumber(props.row.net_stock_after_order, 0) }}
</q-td>
</template>
<template #body-cell-expected_profit_cost_usd="props">
<q-td :props="props" class="text-right" :class="Number(props.row.expected_profit_cost_usd || 0) < 0 ? 'text-negative text-weight-bold' : 'text-positive'">
{{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
</q-td>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'products'"
flat
bordered
row-key="row_key"
@@ -99,15 +184,22 @@
</q-tr>
<q-tr class="table-filter-row">
<q-th v-for="col in props.cols" :key="`filter-${col.name}`">
<q-input
<q-select
v-if="isColumnFilterable(col.name)"
v-model="columnFilters[col.name]"
:options="columnFilterOptions(col.name)"
dense
borderless
clearable
debounce="250"
multiple
use-chips
emit-value
map-options
options-dense
class="header-filter"
placeholder="Filtre"
popup-content-class="header-filter-popup"
placeholder="Seç"
@click.stop
/>
</q-th>
</q-tr>
@@ -135,7 +227,10 @@
Ort.Satış {{ formatMoney(props.row.avg_price_usd_90d, 'USD') }} ·
Taban {{ formatMoney(props.row.avg_base_price_usd, 'USD') }} ·
Çıplak {{ formatMoney(props.row.avg_cost_price_usd, 'USD') }} ·
Marj {{ formatPercent(props.row.avg_gross_margin_90d) }}
90G K/Z {{ formatMoney(props.row.gross_profit_usd_90d, 'USD') }} ·
90G Marj {{ formatPercent(props.row.gross_margin_90d) }} ·
180G K/Z {{ formatMoney(props.row.gross_profit_usd_180d, 'USD') }} ·
180G Marj {{ formatPercent(props.row.gross_margin_180d) }}
</span>
</q-td>
</q-tr>
@@ -400,7 +495,7 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Notify } from 'quasar'
import api from 'src/services/api'
import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters'
@@ -412,6 +507,16 @@ const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
const loading = ref(false)
const refreshing = ref(false)
const rows = ref([])
const generalRows = ref([])
const orderAnalysisRows = ref([])
const orderMarketRows = ref([])
const orderCustomerRows = ref([])
const orderProductCustomerRows = ref([])
const orderMarketDetailRows = ref([])
const orderAnalysisLoaded = ref(false)
const orderProductCustomersLoaded = ref(false)
const orderMarketDetailsLoaded = ref(false)
const orderAnalysisMode = ref('product')
const marketRows = ref([])
const countryRows = ref([])
const customerRows = ref([])
@@ -433,13 +538,6 @@ const detailLoading = ref(false)
const detailSalesRows = ref([])
const detailStockSizes = ref([])
const filters = reactive({
q: '',
product_code: '',
kategori: '',
seri: '',
bucket: ''
})
const columnFilters = reactive({})
const expandedGroups = ref({})
@@ -510,6 +608,8 @@ const columns = [
{ name: 'unit_profit_cost_90d', label: '90G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_90d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_180d', label: '180G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_180d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_180d', label: '180G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_90d', label: '90G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_180d', label: '180G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_90d', label: 'Marj', field: 'gross_margin_90d', align: 'right', sortable: true },
{ name: 'gross_margin_180d', label: '180G Marj', field: 'gross_margin_180d', align: 'right', sortable: true },
{ name: 'market_count_90d', label: '90G Piyasa', field: row => formatNumber(row.market_count_90d, 0), align: 'right', sortable: true },
@@ -520,6 +620,155 @@ const columns = [
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
const generalColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left', sortable: true },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left', sortable: true },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ name: 'period_start', label: 'Başlangıç', field: 'period_start', align: 'left', sortable: true },
{ name: 'period_end', label: 'Bitiş', field: 'period_end', align: 'left', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'sales_qty_total', label: 'Toplam Satış', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
{ name: 'sales_usd_total', label: 'Toplam USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'avg_daily_sales_total', label: 'Günlük Ort.', field: row => formatNumber(row.avg_daily_sales_total, 3), align: 'right', sortable: true },
{ name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true },
{ name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_total', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_total, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_total', label: 'P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_total', label: 'Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_total', label: 'Genel Marj', field: row => formatPercent(row.gross_margin_total), align: 'right', sortable: true },
{ name: 'market_count_total', label: 'Toplam Piyasa', field: row => formatNumber(row.market_count_total, 0), align: 'right', sortable: true },
{ 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_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 },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
const orderAnalysisColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left', sortable: true },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left', sortable: true },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_usd', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_usd', label: 'P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Taban Toplam K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Çıplak Toplam K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Beklenen Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'market_count', label: 'Piyasa', field: row => formatNumber(row.market_count, 0), align: 'right', sortable: true },
{ name: 'customer_count', label: 'Müşteri', field: row => formatNumber(row.customer_count, 0), align: 'right', sortable: true },
{ name: 'order_count', label: 'Sipariş', field: row => formatNumber(row.order_count, 0), align: 'right', sortable: true },
{ name: 'line_count', label: 'Satır', field: row => formatNumber(row.line_count, 0), align: 'right', sortable: true },
{ name: 'earliest_due_date', label: 'İlk Termin', field: 'earliest_due_date', align: 'left', sortable: true },
{ name: 'overdue_qty', label: 'Geciken Adet', field: row => formatNumber(row.overdue_qty, 0), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const orderGroupColumns = [
{ name: 'group_key', label: 'Kırılım', field: row => orderGroupLabel(row), align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'base_cost_usd', label: 'Taban Maliyet USD', field: row => formatMoney(row.base_cost_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_amount_usd', label: 'Çıplak Maliyet USD', field: row => formatMoney(row.cost_amount_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Yakl. Net K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_base', label: 'Brüt Marj', field: row => formatPercent(row.expected_margin_base), align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Yakl. Net Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'market_count', label: 'Piyasa', field: row => formatNumber(row.market_count, 0), align: 'right', sortable: true },
{ name: 'customer_count', label: 'Müşteri', field: row => formatNumber(row.customer_count, 0), align: 'right', sortable: true },
{ name: 'order_count', label: 'Sipariş', field: row => formatNumber(row.order_count, 0), align: 'right', sortable: true },
{ name: 'line_count', label: 'Satır', field: row => formatNumber(row.line_count, 0), align: 'right', sortable: true },
{ name: 'overdue_qty', label: 'Geciken Adet', field: row => formatNumber(row.overdue_qty, 0), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const orderProductCustomerColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'base_price_usd', label: 'Taban USD', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'Çıplak USD', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Yakl. Net K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_base', label: 'Brüt Marj', field: row => formatPercent(row.expected_margin_base), align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Yakl. Net Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'order_count', label: 'Sipariş', field: row => formatNumber(row.order_count, 0), align: 'right', sortable: true },
{ name: 'line_count', label: 'Satır', field: row => formatNumber(row.line_count, 0), align: 'right', sortable: true },
{ name: 'overdue_qty', label: 'Geciken Adet', field: row => formatNumber(row.overdue_qty, 0), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const orderMarketDetailColumns = [
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'order_number', label: 'Sipariş No', field: 'order_number', align: 'left', sortable: true },
{ name: 'order_date', label: 'Sipariş Tarihi', field: 'order_date', align: 'left', sortable: true },
{ name: 'due_date', label: 'Termin', field: 'due_date', align: 'left', sortable: true },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'order_qty', label: 'Adet', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Yakl. Net K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_base', label: 'Brüt Marj', field: row => formatPercent(row.expected_margin_base), align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Yakl. Net Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'is_overdue', label: 'Gecikti', field: row => row.is_overdue ? 'Evet' : 'Hayır', align: 'left', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const idleColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
@@ -612,6 +861,17 @@ const idleRows = computed(() => rows.value
))
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0)))
const visibleOrderAnalysisRows = computed(() => {
if (orderAnalysisMode.value === 'market') return orderMarketRows.value
if (orderAnalysisMode.value === 'customer') return orderCustomerRows.value
return orderAnalysisRows.value
})
const visibleOrderAnalysisColumns = computed(() => {
if (orderAnalysisMode.value === 'market' || orderAnalysisMode.value === 'customer') return orderGroupColumns
return orderAnalysisColumns
})
const performanceCards = computed(() => {
const row = performanceDialogRow.value || {}
return [
@@ -642,8 +902,30 @@ const filterableColumnNames = new Set([
'urun_ana_grubu',
'urun_alt_grubu',
'market_key',
'performance_bucket',
'recommendation'
'stock_qty',
'sales_qty_90d',
'sales_qty_180d',
'sales_usd_90d',
'sales_usd_180d',
'stock_days_90d',
'stock_days_180d',
'avg_price_usd_90d',
'avg_price_usd_180d',
'base_price_usd',
'cost_price_usd',
'unit_profit_base_90d',
'unit_profit_cost_90d',
'unit_profit_base_180d',
'unit_profit_cost_180d',
'gross_profit_usd_90d',
'gross_profit_usd_180d',
'gross_margin_90d',
'gross_margin_180d',
'market_count_90d',
'customer_count_90d',
'sales_index_90d',
'performance_score',
'performance_bucket'
])
const groupLevels = [
@@ -659,12 +941,33 @@ const groupLevels = [
const filteredProductRows = computed(() => rows.value.filter(row => {
return columns.every(col => {
if (!isColumnFilterable(col.name)) return true
const needle = String(columnFilters[col.name] || '').trim().toLocaleLowerCase('tr-TR')
if (!needle) return true
return String(rawProductCellValue(row, col.name) || '').toLocaleLowerCase('tr-TR').includes(needle)
const selected = Array.isArray(columnFilters[col.name]) ? columnFilters[col.name] : []
if (!selected.length) return true
return selected.includes(columnFilterValue(row, col.name))
})
}))
const columnFilterOptionMap = computed(() => {
const out = {}
for (const col of columns) {
if (!isColumnFilterable(col.name)) continue
const seen = new Map()
for (const row of rows.value) {
const value = columnFilterValue(row, col.name)
if (!value) continue
if (!seen.has(value)) {
seen.set(value, {
label: columnFilterLabel(row, col.name),
value
})
}
}
out[col.name] = Array.from(seen.values())
.sort((a, b) => String(a.label).localeCompare(String(b.label), 'tr', { numeric: true }))
}
return out
})
const productTableRows = computed(() => {
const out = []
appendGroupRows(out, filteredProductRows.value, 0, [])
@@ -709,13 +1012,16 @@ function makeGroupRow (key, level, label, groupRows) {
sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'),
sales_usd_90d: sumRows(groupRows, 'sales_usd_90d'),
sales_usd_180d: sumRows(groupRows, 'sales_usd_180d'),
gross_profit_usd_90d: sumRows(groupRows, 'gross_profit_usd_90d'),
gross_profit_usd_180d: sumRows(groupRows, 'gross_profit_usd_180d'),
market_count_90d: distinctCount(groupRows, 'market_key'),
customer_count_90d: sumRows(groupRows, 'customer_count_90d'),
avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'),
avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'),
avg_base_price_usd: averageRows(groupRows, 'base_price_usd'),
avg_cost_price_usd: averageRows(groupRows, 'cost_price_usd'),
avg_gross_margin_90d: averageRows(groupRows, 'gross_margin_90d')
gross_margin_90d: ratio(sumRows(groupRows, 'gross_profit_usd_90d'), sumRows(groupRows, 'sales_usd_90d')),
gross_margin_180d: ratio(sumRows(groupRows, 'gross_profit_usd_180d'), sumRows(groupRows, 'sales_usd_180d'))
}
}
@@ -732,6 +1038,12 @@ function distinctCount (sourceRows, field) {
return values.size
}
function ratio (amount, base) {
const divisor = Number(base || 0)
if (!divisor) return 0
return Number(amount || 0) / divisor
}
function averageRows (sourceRows, field) {
const values = sourceRows.map(row => Number(row[field] || 0)).filter(value => Number.isFinite(value) && value !== 0)
if (!values.length) return 0
@@ -764,6 +1076,18 @@ function isColumnFilterable (name) {
return filterableColumnNames.has(name)
}
function columnFilterOptions (name) {
return columnFilterOptionMap.value[name] || []
}
function columnFilterValue (row, name) {
return String(rawProductCellValue(row, name) ?? '').trim()
}
function columnFilterLabel (row, name) {
return formatProductCell(row, name) || columnFilterValue(row, name)
}
function rawProductCellValue (row, name) {
switch (name) {
case 'urun_ilk_grubu': return row.urun_ilk_grubu || row.yas_grubu || ''
@@ -796,6 +1120,8 @@ function formatProductCell (row, name) {
case 'unit_profit_cost_90d':
case 'unit_profit_base_180d':
case 'unit_profit_cost_180d':
case 'gross_profit_usd_90d':
case 'gross_profit_usd_180d':
return formatMoney(row[name], 'USD')
case 'gross_margin_90d':
case 'gross_margin_180d':
@@ -820,6 +1146,63 @@ function normalizeRow (row) {
}
}
function normalizeGeneralRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
return {
...row,
row_key: `general|${product}|${color}|${yaka}|${market}`
}
}
function normalizeOrderAnalysisRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
return {
...row,
row_key: `orders|${product}|${color}|${yaka}|${market}`
}
}
function normalizeOrderGroupRow (row) {
const key = String(row?.group_key || '').trim()
const mode = String(row?.breakdown || '').trim()
return {
...row,
row_key: `order-group|${mode}|${key}|${row?.market_key || ''}|${row?.customer_code || ''}`
}
}
function normalizeOrderProductCustomerRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
const customer = String(row?.customer_code || '').trim()
return {
...row,
row_key: `order-product-customer|${product}|${color}|${yaka}|${market}|${customer}`
}
}
function normalizeOrderMarketDetailRow (row) {
return {
...row,
row_key: `order-market-detail|${row?.market_key || ''}|${row?.customer_code || ''}|${row?.order_number || ''}|${row?.product_code || ''}|${row?.color_code || ''}|${row?.yaka_kodu || ''}`
}
}
function orderGroupLabel (row) {
if (row?.breakdown === 'customer') {
return [row.customer_code, row.customer_name].filter(Boolean).join(' - ') || '-'
}
return row?.group_key || row?.market_key || '-'
}
function productImageKey (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
@@ -961,22 +1344,18 @@ async function openPerformanceDialog (row) {
async function reload () {
loading.value = true
try {
const [summaryResp, listResp, marketResp, countryResp, customerResp] = await Promise.all([
const [summaryResp, listResp, generalResp, marketResp, countryResp, customerResp] = await Promise.all([
api.get('/pricing/product-performance/summary', { timeout: 60000 }),
api.get('/pricing/product-performance', {
params: {
page: pagination.value.page,
limit: pagination.value.rowsPerPage,
q: filters.q || undefined,
product_code: filters.product_code || undefined,
kategori: filters.kategori || undefined,
seri: filters.seri || undefined,
bucket: filters.bucket || undefined,
sort_by: pagination.value.sortBy || undefined,
descending: pagination.value.descending ? 'true' : 'false'
},
timeout: 60000
}),
api.get('/pricing/product-performance/general', { params: { limit: 500 }, timeout: 60000 }),
api.get('/pricing/product-performance/markets', { params: { limit: 200 }, timeout: 60000 }),
api.get('/pricing/product-performance/countries', { params: { limit: 200 }, timeout: 60000 }),
api.get('/pricing/product-performance/customers', {
@@ -986,6 +1365,7 @@ async function reload () {
])
summary.value = summaryResp?.data || {}
rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(normalizeRow)
generalRows.value = (Array.isArray(generalResp?.data) ? generalResp.data : []).map(normalizeGeneralRow)
marketRows.value = Array.isArray(marketResp?.data) ? marketResp.data : []
countryRows.value = (Array.isArray(countryResp?.data) ? countryResp.data : []).map(row => ({
...row,
@@ -996,7 +1376,13 @@ async function reload () {
customer_key: `${row.breakdown || '-'}|${row.market_key || '-'}|${row.country || '-'}|${row.customer_segment || '-'}|${row.customer_code || '-'}`
}))
pagination.value.rowsNumber = Number(listResp?.data?.total_count || 0)
void primeProductImages(rows.value)
if (activeTab.value === 'order_product_customers') {
await loadOrderProductCustomers(false)
}
if (activeTab.value === 'order_market_details') {
await loadOrderMarketDetails(false)
}
void primeProductImages([...rows.value, ...generalRows.value])
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
} finally {
@@ -1004,6 +1390,53 @@ async function reload () {
}
}
async function loadOrderProductCustomers (showLoading = true) {
if (showLoading) loading.value = true
try {
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)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün müşteri sipariş analizi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadOrderMarketDetails (showLoading = true) {
if (showLoading) loading.value = true
try {
const resp = await api.get('/pricing/product-performance/orders/market-details', { params: { limit: 800 }, timeout: 90000 })
orderMarketDetailRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderMarketDetailRow)
orderMarketDetailsLoaded.value = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Piyasa sipariş detay analizi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadOrderAnalysis (showLoading = true) {
if (showLoading) loading.value = true
try {
const [productResp, marketResp, customerResp] = await Promise.all([
api.get('/pricing/product-performance/orders', { params: { limit: 500 }, timeout: 90000 }),
api.get('/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'market' }, timeout: 90000 }),
api.get('/pricing/product-performance/orders/groups', { params: { limit: 500, breakdown: 'customer' }, timeout: 90000 })
])
orderAnalysisRows.value = (Array.isArray(productResp?.data) ? productResp.data : []).map(normalizeOrderAnalysisRow)
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)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Sipariş analiz verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function runDelta () {
refreshing.value = true
try {
@@ -1040,6 +1473,15 @@ function bucketColor (value) {
}
}
watch(activeTab, tab => {
if (tab === 'order_product_customers' && !orderProductCustomersLoaded.value) {
void loadOrderProductCustomers(true)
}
if (tab === 'order_market_details' && !orderMarketDetailsLoaded.value) {
void loadOrderMarketDetails(true)
}
})
onMounted(reload)
</script>