ui: add B2B olmayan stok (orphans) page

This commit is contained in:
M_Kececi
2026-07-02 15:33:49 +03:00
parent 810c0e5eff
commit cbb853d433
19 changed files with 3365 additions and 395 deletions
-75
View File
@@ -1,75 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { Quasar } from 'quasar'
import { markRaw } from 'vue'
import RootComponent from 'app/src/App.vue'
import createStore from 'app/src/stores/index'
import createRouter from 'app/src/router/index'
export default async function (createAppFn, quasarUserOptions) {
// Create the app instance.
// Here we inject into it the Quasar UI, the router & possibly the store.
const app = createAppFn(RootComponent)
app.use(Quasar, quasarUserOptions)
const store = typeof createStore === 'function'
? await createStore({})
: createStore
app.use(store)
const router = markRaw(
typeof createRouter === 'function'
? await createRouter({store})
: createRouter
)
// make router instance available in store
store.use(({ store }) => { store.router = router })
// Expose the app, the router and the store.
// Note that we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return {
app,
store,
router
}
}
-158
View File
@@ -1,158 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { createApp } from 'vue'
import '@quasar/extras/roboto-font/roboto-font.css'
import '@quasar/extras/material-icons/material-icons.css'
// We load Quasar stylesheet file
import 'quasar/dist/quasar.sass'
import 'src/css/app.css'
import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js'
const publicPath = `/`
async function start ({
app,
router
, store
}, bootFiles) {
let hasRedirected = false
const getRedirectUrl = url => {
try { return router.resolve(url).href }
catch (err) {}
return Object(url) === url
? null
: url
}
const redirect = url => {
hasRedirected = true
if (typeof url === 'string' && /^https?:\/\//.test(url)) {
window.location.href = url
return
}
const href = getRedirectUrl(url)
// continue if we didn't fail to resolve the url
if (href !== null) {
window.location.href = href
window.location.reload()
}
}
const urlPath = window.location.href.replace(window.location.origin, '')
for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) {
try {
await bootFiles[i]({
app,
router,
store,
ssrContext: null,
redirect,
urlPath,
publicPath
})
}
catch (err) {
if (err && err.url) {
redirect(err.url)
return
}
console.error('[Quasar] boot error:', err)
return
}
}
if (hasRedirected === true) return
app.use(router)
app.mount('#q-app')
}
createQuasarApp(createApp, quasarUserOptions)
.then(app => {
// eventually remove this when Cordova/Capacitor/Electron support becomes old
const [ method, mapFn ] = Promise.allSettled !== void 0
? [
'allSettled',
bootFiles => bootFiles.map(result => {
if (result.status === 'rejected') {
console.error('[Quasar] boot error:', result.reason)
return
}
return result.value.default
})
]
: [
'all',
bootFiles => bootFiles.map(entry => entry.default)
]
return Promise[ method ]([
import(/* webpackMode: "eager" */ 'boot/dayjs'),
import(/* webpackMode: "eager" */ 'boot/locale'),
import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard')
]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
start(app, boot)
})
})
-116
View File
@@ -1,116 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import App from 'app/src/App.vue'
let appPrefetch = typeof App.preFetch === 'function'
? App.preFetch
: (
// Class components return the component options (and the preFetch hook) inside __c property
App.__c !== void 0 && typeof App.__c.preFetch === 'function'
? App.__c.preFetch
: false
)
function getMatchedComponents (to, router) {
const route = to
? (to.matched ? to : router.resolve(to).route)
: router.currentRoute.value
if (!route) { return [] }
const matched = route.matched.filter(m => m.components !== void 0)
if (matched.length === 0) { return [] }
return Array.prototype.concat.apply([], matched.map(m => {
return Object.keys(m.components).map(key => {
const comp = m.components[key]
return {
path: m.path,
c: comp
}
})
}))
}
export function addPreFetchHooks ({ router, store, publicPath }) {
// Add router hook for handling preFetch.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
router.beforeResolve((to, from, next) => {
const
urlPath = window.location.href.replace(window.location.origin, ''),
matched = getMatchedComponents(to, router),
prevMatched = getMatchedComponents(from, router)
let diffed = false
const preFetchList = matched
.filter((m, i) => {
return diffed || (diffed = (
!prevMatched[i] ||
prevMatched[i].c !== m.c ||
m.path.indexOf('/:') > -1 // does it has params?
))
})
.filter(m => m.c !== void 0 && (
typeof m.c.preFetch === 'function'
// Class components return the component options (and the preFetch hook) inside __c property
|| (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function')
))
.map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch)
if (appPrefetch !== false) {
preFetchList.unshift(appPrefetch)
appPrefetch = false
}
if (preFetchList.length === 0) {
return next()
}
let hasRedirected = false
const redirect = url => {
hasRedirected = true
next(url)
}
const proceed = () => {
if (hasRedirected === false) { next() }
}
preFetchList.reduce(
(promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({
store,
currentRoute: to,
previousRoute: from,
redirect,
urlPath,
publicPath
})),
Promise.resolve()
)
.then(proceed)
.catch(e => {
console.error(e)
proceed()
})
})
}
@@ -1,23 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import lang from 'quasar/lang/tr.js'
import {Loading,Dialog,Notify} from 'quasar'
export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} }
+5
View File
@@ -365,6 +365,11 @@ const menuItems = [
to: '/app/pricing/product-pricing',
permission: 'pricing:view'
},
{
label: 'Ürün Performans ve Karlılık Analizi',
to: '/app/pricing/product-performance-profitability',
permission: 'pricing:view'
},
{
label: 'Toptan Kampanya Yönetimi',
to: '/app/pricing/wholesale-campaigns',
@@ -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>
+80 -23
View File
@@ -87,26 +87,52 @@
<q-menu class="npc-filter-menu" fit>
<div class="npc-filter-menu-content">
<div class="text-caption text-weight-bold q-mb-sm">{{ props.col.label }}</div>
<q-input
v-model="getColumnFilter(props.col.name).text"
dense
outlined
clearable
label="Icerir"
/>
<q-select
v-model="getColumnFilter(props.col.name).selected"
class="q-mt-sm"
dense
outlined
multiple
use-chips
use-input
emit-value
map-options
:options="getColumnDistinctOptions(props.col.name)"
label="Deger Sec"
/>
<template v-if="isNumericColumn(props.col.name)">
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-input
v-model="getColumnFilter(props.col.name).min"
dense
outlined
clearable
inputmode="decimal"
label="Min"
/>
</div>
<div class="col-6">
<q-input
v-model="getColumnFilter(props.col.name).max"
dense
outlined
clearable
inputmode="decimal"
label="Max"
/>
</div>
</div>
</template>
<template v-else>
<q-input
v-model="getColumnFilter(props.col.name).text"
dense
outlined
clearable
label="Icerir"
/>
<q-select
v-model="getColumnFilter(props.col.name).selected"
class="q-mt-sm"
dense
outlined
multiple
use-chips
use-input
emit-value
map-options
:options="getColumnDistinctOptions(props.col.name)"
label="Deger Sec"
/>
</template>
<div class="row justify-end q-gutter-sm q-mt-sm">
<q-btn dense flat color="grey-7" label="Temizle" @click="clearColumnFilter(props.col.name)" />
</div>
@@ -241,12 +267,29 @@ function getColumnFilter (name) {
if (!columnFilters[name]) {
columnFilters[name] = {
text: '',
selected: []
selected: [],
min: '',
max: ''
}
}
return columnFilters[name]
}
function isNumericColumn (name) {
return name === 'lMMiktar_G'
}
function parseNumberFilter (value) {
const raw = String(value ?? '').trim()
if (!raw) return null
const normalized = raw
.replace(/\s/g, '')
.replace(/\./g, '')
.replace(',', '.')
const n = Number(normalized)
return Number.isFinite(n) ? n : null
}
function formatDateTR (value) {
const s = String(value || '').trim()
if (!s) return ''
@@ -264,10 +307,19 @@ const rows = computed(() => {
const cf = getColumnFilter(col.name)
const text = String(cf.text || '').trim().toLowerCase()
const selected = Array.isArray(cf.selected) ? cf.selected : []
const min = parseNumberFilter(cf.min)
const max = parseNumberFilter(cf.max)
if (!text && selected.length === 0) continue
if (!text && selected.length === 0 && min === null && max === null) continue
result = result.filter((row) => {
if (isNumericColumn(col.name)) {
const numericValue = Number(row?.[col.name] || 0)
if (min !== null && numericValue < min) return false
if (max !== null && numericValue > max) return false
return true
}
const value = getColumnComparableValue(row, col.name)
const valueLC = value.toLowerCase()
@@ -310,13 +362,18 @@ function getColumnDistinctOptions (colName) {
function isColumnFilterActive (name) {
const cf = getColumnFilter(name)
return !!String(cf.text || '').trim() || (Array.isArray(cf.selected) && cf.selected.length > 0)
return !!String(cf.text || '').trim() ||
(Array.isArray(cf.selected) && cf.selected.length > 0) ||
parseNumberFilter(cf.min) !== null ||
parseNumberFilter(cf.max) !== null
}
function clearColumnFilter (name) {
const cf = getColumnFilter(name)
cf.text = ''
cf.selected = []
cf.min = ''
cf.max = ''
}
function clearAllColumnFilters () {
+6
View File
@@ -394,6 +394,12 @@ const routes = [
component: () => import('pages/ProductPricing.vue'),
meta: { permission: 'pricing:view' }
},
{
path: 'pricing/product-performance-profitability',
name: 'product-performance-profitability',
component: () => import('pages/ProductPerformanceProfitability.vue'),
meta: { permission: 'pricing:view' }
},
{
path: 'pricing/brand-classification',
name: 'brand-classification',