ui: add B2B olmayan stok (orphans) page
This commit is contained in:
@@ -0,0 +1,774 @@
|
||||
<template>
|
||||
<q-page class="product-performance-page q-pa-sm">
|
||||
<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>
|
||||
<div class="text-caption text-grey-7">
|
||||
Ürün, renk, yaka, piyasa ve müşteri segmenti bazında satış, stok dönüşü ve karlılık KPI'ları.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-auto row items-center q-gutter-sm">
|
||||
<q-btn outline color="secondary" icon="refresh" label="Yenile" :loading="loading" @click="reload" />
|
||||
<q-btn
|
||||
v-if="canUpdate"
|
||||
color="primary"
|
||||
unelevated
|
||||
icon="sync"
|
||||
label="Delta Çalıştır"
|
||||
:loading="refreshing"
|
||||
@click="runDelta"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-sm q-mb-sm">
|
||||
<div v-for="card in summaryCards" :key="card.key" class="col-6 col-md-2">
|
||||
<q-card flat bordered class="metric-card">
|
||||
<q-card-section>
|
||||
<div class="text-caption text-grey-7">{{ card.label }}</div>
|
||||
<div class="metric-value">{{ card.value }}</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</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
|
||||
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="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" />
|
||||
<q-tab name="customers" icon="groups" label="Müşteri Kırılımı" />
|
||||
</q-tabs>
|
||||
|
||||
<q-table
|
||||
v-if="activeTab === 'products'"
|
||||
flat
|
||||
bordered
|
||||
row-key="row_key"
|
||||
class="performance-table bg-white"
|
||||
:rows="rows"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@request="onRequest"
|
||||
>
|
||||
<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-gross_margin_90d="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
{{ formatPercent(props.row.gross_margin_90d) }}
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-sales_usd_90d="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
{{ formatMoney(props.row.sales_usd_90d, 'USD') }}
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<q-table
|
||||
v-else-if="activeTab === 'idle'"
|
||||
flat
|
||||
bordered
|
||||
row-key="row_key"
|
||||
class="performance-table bg-white"
|
||||
:rows="idleRows"
|
||||
:columns="idleColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
>
|
||||
<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-idle_cost_usd="props">
|
||||
<q-td :props="props" class="text-right text-weight-bold">
|
||||
{{ formatMoney(props.row.idle_cost_usd, 'USD') }}
|
||||
</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 === 'markets'"
|
||||
flat
|
||||
bordered
|
||||
row-key="market_key"
|
||||
class="performance-table bg-white"
|
||||
:rows="marketRows"
|
||||
:columns="marketColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
>
|
||||
<template #body-cell-stock_cost_value_usd="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
{{ formatMoney(props.row.stock_cost_value_usd, 'USD') }}
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-risk_stock_cost_value_usd="props">
|
||||
<q-td :props="props" class="text-right text-negative text-weight-bold">
|
||||
{{ formatMoney(props.row.risk_stock_cost_value_usd, 'USD') }}
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-avg_gross_margin_90d="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
{{ formatPercent(props.row.avg_gross_margin_90d) }}
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<div v-else-if="activeTab === 'customers'">
|
||||
<q-card flat bordered class="q-mb-sm">
|
||||
<q-card-section class="row q-col-gutter-sm items-center">
|
||||
<div class="col-12 col-md-4">
|
||||
<q-select
|
||||
v-model="customerBreakdown"
|
||||
dense
|
||||
outlined
|
||||
emit-value
|
||||
map-options
|
||||
label="Kırılım"
|
||||
:options="customerBreakdownOptions"
|
||||
@update:model-value="reload"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-table
|
||||
flat
|
||||
bordered
|
||||
row-key="customer_key"
|
||||
class="performance-table bg-white"
|
||||
:rows="customerRows"
|
||||
:columns="customerColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
>
|
||||
<template #body-cell-sales_usd_90d="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
{{ formatMoney(props.row.sales_usd_90d, 'USD') }}
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-avg_price_usd_90d="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
{{ formatMoney(props.row.avg_price_usd_90d, 'USD') }}
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-sales_usd_365d="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
{{ formatMoney(props.row.sales_usd_365d, 'USD') }}
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
v-else
|
||||
flat
|
||||
bordered
|
||||
row-key="country_key"
|
||||
class="performance-table bg-white"
|
||||
:rows="countryRows"
|
||||
:columns="countryColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
>
|
||||
<template #body-cell-sales_usd_90d="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
{{ formatMoney(props.row.sales_usd_90d, 'USD') }}
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-avg_price_usd_90d="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
{{ formatMoney(props.row.avg_price_usd_90d, 'USD') }}
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<q-dialog v-model="imageDialog" maximized>
|
||||
<q-card class="product-image-dialog">
|
||||
<q-card-section class="row items-center q-pb-sm">
|
||||
<div>
|
||||
<div class="text-h6">{{ imageDialogTitle }}</div>
|
||||
<div class="text-caption text-grey-7">{{ imageDialogSubtitle }}</div>
|
||||
</div>
|
||||
<q-space />
|
||||
<q-btn v-close-popup flat round dense icon="close" />
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section class="product-image-dialog-body">
|
||||
<q-carousel
|
||||
v-if="imageDialogUrls.length"
|
||||
v-model="imageSlide"
|
||||
animated
|
||||
arrows
|
||||
navigation
|
||||
infinite
|
||||
swipeable
|
||||
class="product-image-carousel"
|
||||
>
|
||||
<q-carousel-slide
|
||||
v-for="(url, idx) in imageDialogUrls"
|
||||
:key="`${url}-${idx}`"
|
||||
:name="idx"
|
||||
class="column no-wrap flex-center"
|
||||
>
|
||||
<q-img :src="url" fit="contain" class="product-image-large" />
|
||||
</q-carousel-slide>
|
||||
</q-carousel>
|
||||
<div v-else class="product-image-empty">
|
||||
<q-icon name="image_not_supported" size="48px" />
|
||||
<div class="text-subtitle2 q-mt-sm">Foto bulunamadi</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import api from 'src/services/api'
|
||||
import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters'
|
||||
import { usePermissionStore } from 'stores/permissionStore'
|
||||
|
||||
const perm = usePermissionStore()
|
||||
const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
|
||||
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const rows = ref([])
|
||||
const marketRows = ref([])
|
||||
const countryRows = ref([])
|
||||
const customerRows = ref([])
|
||||
const summary = ref({})
|
||||
const activeTab = ref('products')
|
||||
const customerBreakdown = ref('market_customer')
|
||||
const imageUrlByKey = ref({})
|
||||
const imageListByKey = ref({})
|
||||
const imageDialog = ref(false)
|
||||
const imageDialogUrls = ref([])
|
||||
const imageDialogTitle = ref('')
|
||||
const imageDialogSubtitle = ref('')
|
||||
const imageSlide = ref(0)
|
||||
|
||||
const filters = reactive({
|
||||
q: '',
|
||||
product_code: '',
|
||||
kategori: '',
|
||||
seri: '',
|
||||
bucket: ''
|
||||
})
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
rowsPerPage: 100,
|
||||
rowsNumber: 0,
|
||||
sortBy: 'performance_score',
|
||||
descending: true
|
||||
})
|
||||
|
||||
const bucketOptions = [
|
||||
{ label: 'Yıldız Ürün', value: 'YILDIZ_URUN' },
|
||||
{ label: 'Stoksuz Talep', value: 'STOKSUZ_TALEP' },
|
||||
{ label: 'Stok Riski', value: 'STOK_RISKI' },
|
||||
{ label: 'Fiyat Baskısı', value: 'FIYAT_BASKISI' },
|
||||
{ label: 'Fiyat Fırsatı', value: 'FIYAT_FIRSATI' },
|
||||
{ label: 'Takip', value: 'TAKIP' }
|
||||
]
|
||||
|
||||
const customerBreakdownOptions = [
|
||||
{ label: 'Piyasa > Müşteri', value: 'market_customer' },
|
||||
{ label: 'Ülke > Müşteri', value: 'country_customer' },
|
||||
{ label: 'Piyasa > Ülke > Müşteri', value: 'market_country_customer' }
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ 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' },
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' },
|
||||
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' },
|
||||
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' },
|
||||
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left' },
|
||||
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
|
||||
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left' },
|
||||
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
|
||||
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' },
|
||||
{ name: 'gross_margin_90d', label: 'Marj', field: 'gross_margin_90d', align: 'right', sortable: true },
|
||||
{ name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
|
||||
{ name: 'performance_score', label: 'Skor', field: row => formatNumber(row.performance_score, 1), align: 'right', sortable: true },
|
||||
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
|
||||
{ name: 'recommendation', label: 'Öneri', 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 },
|
||||
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' },
|
||||
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' },
|
||||
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' },
|
||||
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' },
|
||||
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left' },
|
||||
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
|
||||
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left' },
|
||||
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
|
||||
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'cost_price_usd', label: 'Maliyet USD', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_usd', align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true },
|
||||
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
|
||||
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
|
||||
]
|
||||
|
||||
const marketColumns = [
|
||||
{ name: 'market_key', label: 'Piyasa Grubu', field: 'market_key', align: 'left', sortable: true },
|
||||
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' },
|
||||
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => row.urun_ilk_grubu || row.yas_grubu || '', align: 'left' },
|
||||
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
|
||||
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => row.urun_ana_grubu || row.seri || '', align: 'left' },
|
||||
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
|
||||
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
|
||||
{ name: 'star_count', label: 'Yıldız', field: row => formatNumber(row.star_count, 0), align: 'right', sortable: true },
|
||||
{ name: 'stock_risk_count', label: 'Risk', field: row => formatNumber(row.stock_risk_count, 0), align: 'right', sortable: true },
|
||||
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
|
||||
{ name: 'stock_cost_value_usd', label: 'Stok Maliyeti', field: 'stock_cost_value_usd', align: 'right', sortable: true },
|
||||
{ name: 'risk_stock_cost_value_usd', label: 'Risk Maliyeti', field: 'risk_stock_cost_value_usd', align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_90d', label: '90G USD', field: row => formatMoney(row.sales_usd_90d, 'USD'), align: 'right', sortable: true },
|
||||
{ name: 'avg_gross_margin_90d', label: 'Ort. Marj', field: 'avg_gross_margin_90d', align: 'right', sortable: true },
|
||||
{ name: 'avg_stock_days_90d', label: 'Ort. Stok Gün', field: row => formatNumber(row.avg_stock_days_90d, 1), align: 'right', sortable: true }
|
||||
]
|
||||
|
||||
const countryColumns = [
|
||||
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
|
||||
{ name: 'customer_segment', label: 'Müşteri Segmenti', field: 'customer_segment', align: 'left', sortable: true },
|
||||
{ name: 'market_key', label: 'Piyasa Grubu', field: 'market_key', align: 'left' },
|
||||
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'customer_count_90d', label: 'Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'invoice_count_90d', label: 'Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_365d', label: '365G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_365d', label: '365G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true }
|
||||
]
|
||||
|
||||
const customerColumns = [
|
||||
{ name: 'market_key', label: 'Piyasa Grubu', field: 'market_key', align: 'left', sortable: true },
|
||||
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
|
||||
{ name: 'customer_segment', label: 'Segment', field: 'customer_segment', 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: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
|
||||
{ name: 'invoice_count_90d', label: 'Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_qty_365d', label: '365G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
|
||||
{ name: 'sales_usd_365d', label: '365G USD', field: 'sales_usd_365d', align: 'right', sortable: true },
|
||||
{ name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true }
|
||||
]
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{ key: 'date', label: 'KPI Tarihi', value: summary.value.kpi_date || '-' },
|
||||
{ key: 'rows', label: 'Satır', value: formatNumber(summary.value.total_rows, 0) },
|
||||
{ key: 'star', label: 'Yıldız', value: formatNumber(summary.value.star_count, 0) },
|
||||
{ key: 'risk', label: 'Stok Riski', value: formatNumber(summary.value.stock_risk, 0) },
|
||||
{ key: 'stock_cost', label: 'Stok Maliyeti', value: formatMoney(summary.value.stock_cost_usd, 'USD') },
|
||||
{ key: 'risk_cost', label: 'Risk Maliyeti', value: formatMoney(summary.value.risk_cost_usd, 'USD') }
|
||||
])
|
||||
|
||||
const idleRows = computed(() => rows.value
|
||||
.map(row => ({
|
||||
...row,
|
||||
idle_cost_usd: Number(row.stock_qty || 0) * Number(row.cost_price_usd || 0)
|
||||
}))
|
||||
.filter(row => Number(row.stock_qty || 0) > 0 && (
|
||||
row.performance_bucket === 'STOK_RISKI' ||
|
||||
Number(row.sales_qty_90d || 0) === 0 ||
|
||||
Number(row.stock_days_90d || 0) > 180
|
||||
))
|
||||
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0)))
|
||||
|
||||
function normalizeRow (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: `${product}|${color}|${yaka}|${market}`
|
||||
}
|
||||
}
|
||||
|
||||
function productImageKey (row) {
|
||||
const product = String(row?.product_code || '').trim()
|
||||
const color = String(row?.color_code || '').trim()
|
||||
const yaka = String(row?.yaka_kodu || '').trim()
|
||||
return `${product}|${color}|${yaka}`
|
||||
}
|
||||
|
||||
function normalizeUploadsPath (storagePath) {
|
||||
const raw = String(storagePath || '').trim()
|
||||
if (!raw) return ''
|
||||
const normalized = raw.replace(/\\/g, '/')
|
||||
const idx = normalized.toLowerCase().indexOf('/uploads/')
|
||||
if (idx >= 0) return normalized.slice(idx)
|
||||
if (normalized.toLowerCase().startsWith('uploads/')) return `/${normalized}`
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveProductImageUrl (item) {
|
||||
if (!item || typeof item !== 'object') return ''
|
||||
|
||||
const contentURL = String(item.content_url || item.ContentURL || '').trim()
|
||||
if (contentURL.startsWith('/api/')) return contentURL
|
||||
if (contentURL.startsWith('/')) return `/api${contentURL}`
|
||||
|
||||
const imageId = Number(item.id || item.ID || 0)
|
||||
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
|
||||
|
||||
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
|
||||
if (thumbURL) return thumbURL
|
||||
|
||||
const fullURL = String(item.full_url || item.fullUrl || '').trim()
|
||||
if (fullURL) return fullURL
|
||||
|
||||
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
|
||||
if (uploadsPath) return uploadsPath
|
||||
|
||||
const fileName = String(item.file_name || item.FileName || '').trim()
|
||||
return fileName ? `/uploads/image/${fileName}` : ''
|
||||
}
|
||||
|
||||
async function fetchProductImagesForRow (row) {
|
||||
const key = productImageKey(row)
|
||||
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) {
|
||||
return imageListByKey.value[key]
|
||||
}
|
||||
|
||||
const code = String(row?.product_code || '').trim()
|
||||
if (!code) return []
|
||||
|
||||
const color = String(row?.color_code || '').trim()
|
||||
const yaka = String(row?.yaka_kodu || '').trim()
|
||||
|
||||
try {
|
||||
const resp = await api.get('/product-images', {
|
||||
params: {
|
||||
code,
|
||||
dim1: color || undefined,
|
||||
dim3: yaka || undefined
|
||||
},
|
||||
timeout: 60000
|
||||
})
|
||||
const list = Array.isArray(resp?.data) ? resp.data : []
|
||||
const urls = list.map(resolveProductImageUrl).filter(Boolean)
|
||||
imageListByKey.value = { ...imageListByKey.value, [key]: urls }
|
||||
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
|
||||
return urls
|
||||
} catch {
|
||||
imageListByKey.value = { ...imageListByKey.value, [key]: [] }
|
||||
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' }
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedProductImageUrl (row) {
|
||||
return imageUrlByKey.value[productImageKey(row)] || ''
|
||||
}
|
||||
|
||||
async function primeProductImages (sourceRows) {
|
||||
const uniqueRows = []
|
||||
const seen = new Set()
|
||||
for (const row of sourceRows || []) {
|
||||
const key = productImageKey(row)
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
uniqueRows.push(row)
|
||||
if (uniqueRows.length >= 80) break
|
||||
}
|
||||
await Promise.all(uniqueRows.map(row => fetchProductImagesForRow(row)))
|
||||
}
|
||||
|
||||
async function openProductImageDialog (row) {
|
||||
const urls = await fetchProductImagesForRow(row)
|
||||
imageDialogUrls.value = urls
|
||||
imageSlide.value = 0
|
||||
imageDialogTitle.value = String(row?.product_code || '-')
|
||||
imageDialogSubtitle.value = [
|
||||
row?.item_description,
|
||||
row?.color_code ? `Renk: ${row.color_code}` : '',
|
||||
row?.yaka_kodu ? `Yaka: ${row.yaka_kodu}` : ''
|
||||
].filter(Boolean).join(' | ')
|
||||
imageDialog.value = true
|
||||
}
|
||||
|
||||
async function reload () {
|
||||
loading.value = true
|
||||
try {
|
||||
const [summaryResp, listResp, 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
|
||||
},
|
||||
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', {
|
||||
params: { limit: 200, breakdown: customerBreakdown.value },
|
||||
timeout: 60000
|
||||
})
|
||||
])
|
||||
summary.value = summaryResp?.data || {}
|
||||
rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(normalizeRow)
|
||||
marketRows.value = Array.isArray(marketResp?.data) ? marketResp.data : []
|
||||
countryRows.value = (Array.isArray(countryResp?.data) ? countryResp.data : []).map(row => ({
|
||||
...row,
|
||||
country_key: `${row.country || '-'}|${row.customer_segment || '-'}|${row.market_key || '-'}`
|
||||
}))
|
||||
customerRows.value = (Array.isArray(customerResp?.data) ? customerResp.data : []).map(row => ({
|
||||
...row,
|
||||
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)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runDelta () {
|
||||
refreshing.value = true
|
||||
try {
|
||||
const resp = await api.post('/pricing/product-performance/refresh', { mode: 'delta' }, { timeout: 4 * 60 * 60 * 1000 })
|
||||
Notify.create({
|
||||
type: 'positive',
|
||||
message: `Delta tamamlandı: satış ${resp?.data?.sales_rows || 0}, stok ${resp?.data?.stock_rows || 0}, KPI ${resp?.data?.kpi_rows || 0}`
|
||||
})
|
||||
await reload()
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Delta çalıştırılamadı' })
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onRequest (props) {
|
||||
pagination.value = { ...pagination.value, ...props.pagination }
|
||||
reload()
|
||||
}
|
||||
|
||||
function bucketLabel (value) {
|
||||
return bucketOptions.find(x => x.value === value)?.label || value || '-'
|
||||
}
|
||||
|
||||
function bucketColor (value) {
|
||||
switch (value) {
|
||||
case 'YILDIZ_URUN': return 'green-7'
|
||||
case 'STOKSUZ_TALEP': return 'blue-7'
|
||||
case 'STOK_RISKI': return 'orange-8'
|
||||
case 'FIYAT_BASKISI': return 'red-7'
|
||||
case 'FIYAT_FIRSATI': return 'teal-7'
|
||||
default: return 'grey-7'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-performance-page {
|
||||
background: #f6f7f9;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 78px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.performance-table {
|
||||
max-height: calc(100vh - 260px);
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table__middle) {
|
||||
max-height: calc(100vh - 320px);
|
||||
}
|
||||
|
||||
.performance-table :deep(th) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.product-image-cell {
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.product-thumb-button {
|
||||
width: 56px;
|
||||
height: 72px;
|
||||
border: 1px solid #d9dde3;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-thumb,
|
||||
.product-thumb-placeholder {
|
||||
width: 50px;
|
||||
height: 66px;
|
||||
}
|
||||
|
||||
.product-thumb-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9aa3af;
|
||||
background: #f3f5f7;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.product-image-dialog {
|
||||
background: #f6f7f9;
|
||||
}
|
||||
|
||||
.product-image-dialog-body {
|
||||
height: calc(100vh - 74px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.product-image-carousel {
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #d9dde3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.product-image-large {
|
||||
width: 100%;
|
||||
height: calc(100vh - 130px);
|
||||
}
|
||||
|
||||
.product-image-empty {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7a8390;
|
||||
background: #fff;
|
||||
border: 1px solid #d9dde3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user