@@ -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: ' 90 G Satış ', field: row => formatNumber(row.sales_qty_90d, 0), align: ' right ', sortable: true },
{ name: ' sales _usd _90d ', label: ' 90 G 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: ' 90 G 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: ' 90 G Satış ', field: row => formatNumber(row.sales_qty_90d, 0), align: ' right ', sortable: true },
{ name: ' sales _usd _90d ', label: ' 90 G 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: ' 90 G Adet ', field: row => formatNumber(row.sales_qty_90d, 0), align: ' right ', sortable: true },
{ name: ' sales _usd _90d ', label: ' 90 G 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: ' 365 G Adet ', field: row => formatNumber(row.sales_qty_365d, 0), align: ' right ', sortable: true },
{ name: ' sales _usd _365d ', label: ' 365 G 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: ' 90 G Adet ', field: row => formatNumber(row.sales_qty_90d, 0), align: ' right ', sortable: true },
{ name: ' sales _usd _90d ', label: ' 90 G 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: ' 365 G Adet ', field: row => formatNumber(row.sales_qty_365d, 0), align: ' right ', sortable: true },
{ name: ' sales _usd _365d ', label: ' 365 G 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 : 78 px ;
}
. metric - value {
font - size : 20 px ;
font - weight : 700 ;
line - height : 1.25 ;
margin - top : 4 px ;
}
. performance - table {
max - height : calc ( 100 vh - 260 px ) ;
}
. performance - table : deep ( . q - table _ _middle ) {
max - height : calc ( 100 vh - 320 px ) ;
}
. performance - table : deep ( th ) {
position : sticky ;
top : 0 ;
z - index : 1 ;
background : # fff ;
}
. product - image - cell {
width : 72 px ;
min - width : 72 px ;
}
. product - thumb - button {
width : 56 px ;
height : 72 px ;
border : 1 px solid # d9dde3 ;
border - radius : 6 px ;
background : # fff ;
padding : 2 px ;
cursor : pointer ;
}
. product - thumb ,
. product - thumb - placeholder {
width : 50 px ;
height : 66 px ;
}
. product - thumb - placeholder {
display : flex ;
align - items : center ;
justify - content : center ;
color : # 9 aa3af ;
background : # f3f5f7 ;
border - radius : 4 px ;
}
. product - image - dialog {
background : # f6f7f9 ;
}
. product - image - dialog - body {
height : calc ( 100 vh - 74 px ) ;
padding : 12 px ;
}
. product - image - carousel {
height : 100 % ;
background : # fff ;
border : 1 px solid # d9dde3 ;
border - radius : 8 px ;
}
. product - image - large {
width : 100 % ;
height : calc ( 100 vh - 130 px ) ;
}
. product - image - empty {
height : 100 % ;
display : flex ;
flex - direction : column ;
align - items : center ;
justify - content : center ;
color : # 7 a8390 ;
background : # fff ;
border : 1 px solid # d9dde3 ;
border - radius : 8 px ;
}
< / style >