ui: add B2B olmayan stok (orphans) page

This commit is contained in:
M_Kececi
2026-07-02 16:08:27 +03:00
parent cbb853d433
commit 1bf51b9e82
10 changed files with 1126 additions and 73 deletions
+75
View File
@@ -0,0 +1,75 @@
/* 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
@@ -0,0 +1,158 @@
/* 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
@@ -0,0 +1,116 @@
/* 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()
})
})
}
@@ -0,0 +1,23 @@
/* 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} }
+429 -35
View File
@@ -85,48 +85,83 @@
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="rows"
:rows="productTableRows"
: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
<template #header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
<q-tr class="table-filter-row">
<q-th v-for="col in props.cols" :key="`filter-${col.name}`">
<q-input
v-if="isColumnFilterable(col.name)"
v-model="columnFilters[col.name]"
dense
borderless
clearable
debounce="250"
class="header-filter"
placeholder="Filtre"
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</q-td>
</q-th>
</q-tr>
</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 #body="props">
<q-tr v-if="props.row.__group" :props="props" class="group-row">
<q-td :colspan="columns.length" :style="{ paddingLeft: `${8 + props.row.level * 18}px` }">
<q-btn
flat
dense
round
size="sm"
:icon="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ props.row.label }}</span>
<span class="group-meta">
{{ formatNumber(props.row.count, 0) }} satır ·
Stok {{ formatNumber(props.row.stock_qty, 0) }} ·
90G {{ formatNumber(props.row.sales_qty_90d, 0) }} ·
180G {{ formatNumber(props.row.sales_qty_180d, 0) }} ·
Piyasa {{ formatNumber(props.row.market_count_90d, 0) }} ·
Müşteri {{ formatNumber(props.row.customer_count_90d, 0) }} ·
Ort.Satış {{ formatMoney(props.row.avg_price_usd_90d, 'USD') }} ·
Taban {{ formatMoney(props.row.avg_base_price_usd, 'USD') }} ·
Çıplak {{ formatMoney(props.row.avg_cost_price_usd, 'USD') }} ·
Marj {{ formatPercent(props.row.avg_gross_margin_90d) }}
</span>
</q-td>
</q-tr>
<q-tr v-else :props="props" class="cursor-pointer" @click="openPerformanceDialog(props.row)">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<template v-if="col.name === 'image'">
<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>
</template>
<q-badge v-else-if="col.name === 'performance_bucket'" :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
<span v-else>{{ formatProductCell(props.row, col.name) }}</span>
</q-td>
</q-tr>
</template>
</q-table>
@@ -307,6 +342,60 @@
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="performanceDialog" maximized>
<q-card class="product-performance-dialog">
<q-card-section class="row items-center q-pb-sm">
<div>
<div class="text-h6">{{ performanceDialogTitle }}</div>
<div class="text-caption text-grey-7">{{ performanceDialogSubtitle }}</div>
</div>
<q-space />
<q-btn v-close-popup flat round dense icon="close" />
</q-card-section>
<q-separator />
<q-card-section class="q-pa-sm">
<div class="row q-col-gutter-sm q-mb-sm">
<div v-for="card in performanceCards" :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>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-table
flat
bordered
dense
title="Beden Stok"
row-key="size_code"
:rows="detailStockSizes"
:columns="detailStockColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 50 }"
/>
</div>
<div class="col-12 col-md-8">
<q-table
flat
bordered
dense
title="Satış / Müşteri Özeti"
row-key="detail_key"
:rows="detailSalesRows"
:columns="detailSalesColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 50 }"
/>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
@@ -336,6 +425,13 @@ const imageDialogUrls = ref([])
const imageDialogTitle = ref('')
const imageDialogSubtitle = ref('')
const imageSlide = ref(0)
const performanceDialog = ref(false)
const performanceDialogRow = ref(null)
const performanceDialogTitle = ref('')
const performanceDialogSubtitle = ref('')
const detailLoading = ref(false)
const detailSalesRows = ref([])
const detailStockSizes = ref([])
const filters = reactive({
q: '',
@@ -344,6 +440,8 @@ const filters = reactive({
seri: '',
bucket: ''
})
const columnFilters = reactive({})
const expandedGroups = ref({})
const pagination = ref({
page: 1,
@@ -368,6 +466,23 @@ const customerBreakdownOptions = [
{ label: 'Piyasa > Ülke > Müşteri', value: 'market_country_customer' }
]
const detailStockColumns = [
{ name: 'size_code', label: 'Beden', field: 'size_code', align: 'left', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }
]
const detailSalesColumns = [
{ name: 'sales_date', label: 'Tarih', field: 'sales_date', align: 'left', sortable: true },
{ name: 'ref_number', label: 'Ref', field: 'ref_number', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ name: 'country', label: 'Ülke', field: 'country', 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: 'sales_qty', label: 'Adet', field: row => formatNumber(row.sales_qty, 0), align: 'right', sortable: true },
{ name: 'sales_usd', label: 'USD', field: row => formatMoney(row.sales_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd, 'USD'), align: 'right', sortable: true }
]
const columns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
@@ -379,12 +494,26 @@ const columns = [
{ 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: 'market_key', label: 'Piyasa', field: 'market_key', align: 'left', sortable: true },
{ 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_qty_180d', label: '180G Satış', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
{ name: 'sales_usd_180d', label: '180G USD', field: 'sales_usd_180d', 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: 'stock_days_180d', label: '180G Stok Gün', field: row => formatNumber(row.stock_days_180d, 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: 'avg_price_usd_180d', label: '180G Ort. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' },
{ name: 'base_price_usd', label: 'Ort. USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'Ort. USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_90d', label: '90G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_90d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_90d', label: '90G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_90d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_180d', label: '180G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_180d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_180d', label: '180G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_90d', label: 'Marj', field: 'gross_margin_90d', align: 'right', sortable: true },
{ name: 'gross_margin_180d', label: '180G Marj', field: 'gross_margin_180d', align: 'right', sortable: true },
{ name: 'market_count_90d', label: '90G Piyasa', field: row => formatNumber(row.market_count_90d, 0), align: 'right', sortable: true },
{ name: 'customer_count_90d', label: '90G Müşteri', field: row => formatNumber(row.customer_count_90d, 0), 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' },
@@ -465,6 +594,8 @@ const summaryCards = computed(() => [
{ 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: 'markets', label: '90G Piyasa', value: formatNumber(summary.value.market_count_90d, 0) },
{ key: 'customers', label: '90G Müşteri', value: formatNumber(summary.value.customer_count_90d, 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') }
])
@@ -481,6 +612,203 @@ const idleRows = computed(() => rows.value
))
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0)))
const performanceCards = computed(() => {
const row = performanceDialogRow.value || {}
return [
{ key: 'score', label: 'Skor', value: formatNumber(row.performance_score, 1) },
{ key: 'stock', label: 'Stok', value: formatNumber(row.stock_qty, 0) },
{ key: 'sales90', label: '90G Satış', value: formatNumber(row.sales_qty_90d, 0) },
{ key: 'sales180', label: '180G Satış', value: formatNumber(row.sales_qty_180d, 0) },
{ key: 'markets', label: '90G Piyasa', value: formatNumber(row.market_count_90d, 0) },
{ key: 'customers', label: '90G Müşteri', value: formatNumber(row.customer_count_90d, 0) },
{ key: 'avg', label: 'Ort. USD Satış', value: formatMoney(row.avg_price_usd_90d, 'USD') },
{ key: 'base', label: 'Taban Maliyet', value: formatMoney(row.base_price_usd, 'USD') },
{ key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(row.cost_price_usd, 'USD') },
{ key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(row.unit_profit_base_90d, 'USD') },
{ key: 'profitCost', label: 'P.Başı Çıplak K/Z', value: formatMoney(row.unit_profit_cost_90d, 'USD') },
{ key: 'margin', label: '90G Marj', value: formatPercent(row.gross_margin_90d) },
{ key: 'bucket', label: 'Durum', value: bucketLabel(row.performance_bucket) }
]
})
const filterableColumnNames = new Set([
'product_code',
'color_code',
'yaka_kodu',
'item_description',
'kategori',
'urun_ilk_grubu',
'askili_yan',
'urun_ana_grubu',
'urun_alt_grubu',
'market_key',
'performance_bucket',
'recommendation'
])
const groupLevels = [
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu', fallback: 'seri' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün Kodu' },
{ key: 'color_code', label: 'Renk' }
]
const filteredProductRows = computed(() => rows.value.filter(row => {
return columns.every(col => {
if (!isColumnFilterable(col.name)) return true
const needle = String(columnFilters[col.name] || '').trim().toLocaleLowerCase('tr-TR')
if (!needle) return true
return String(rawProductCellValue(row, col.name) || '').toLocaleLowerCase('tr-TR').includes(needle)
})
}))
const productTableRows = computed(() => {
const out = []
appendGroupRows(out, filteredProductRows.value, 0, [])
return out
})
function appendGroupRows (out, sourceRows, level, parentKeys) {
if (level >= groupLevels.length) {
out.push(...sourceRows)
return
}
const groupDef = groupLevels[level]
const grouped = new Map()
for (const row of sourceRows) {
const value = normalizeGroupValue(rawProductCellValue(row, groupDef.key) || row[groupDef.fallback])
if (!grouped.has(value)) grouped.set(value, [])
grouped.get(value).push(row)
}
Array.from(grouped.entries())
.sort((a, b) => a[0].localeCompare(b[0], 'tr'))
.forEach(([value, groupRows]) => {
const key = [...parentKeys, `${groupDef.key}:${value}`].join('|')
out.push(makeGroupRow(key, level, `${groupDef.label}: ${value}`, groupRows))
if (isGroupExpanded(key)) {
appendGroupRows(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`])
}
})
}
function makeGroupRow (key, level, label, groupRows) {
return {
__group: true,
row_key: `group|${key}`,
key,
level,
label,
count: groupRows.length,
stock_qty: sumRows(groupRows, 'stock_qty'),
sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'),
sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'),
sales_usd_90d: sumRows(groupRows, 'sales_usd_90d'),
sales_usd_180d: sumRows(groupRows, 'sales_usd_180d'),
market_count_90d: distinctCount(groupRows, 'market_key'),
customer_count_90d: sumRows(groupRows, 'customer_count_90d'),
avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'),
avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'),
avg_base_price_usd: averageRows(groupRows, 'base_price_usd'),
avg_cost_price_usd: averageRows(groupRows, 'cost_price_usd'),
avg_gross_margin_90d: averageRows(groupRows, 'gross_margin_90d')
}
}
function sumRows (sourceRows, field) {
return sourceRows.reduce((sum, row) => sum + Number(row[field] || 0), 0)
}
function distinctCount (sourceRows, field) {
const values = new Set()
for (const row of sourceRows) {
const value = String(row[field] || '').trim()
if (value && value !== '-') values.add(value)
}
return values.size
}
function averageRows (sourceRows, field) {
const values = sourceRows.map(row => Number(row[field] || 0)).filter(value => Number.isFinite(value) && value !== 0)
if (!values.length) return 0
return values.reduce((sum, value) => sum + value, 0) / values.length
}
function weightedAverage (sourceRows, amountField, qtyField) {
const qty = sumRows(sourceRows, qtyField)
if (!qty) return 0
return sumRows(sourceRows, amountField) / qty
}
function normalizeGroupValue (value) {
const text = String(value || '').trim()
return text || '-'
}
function isGroupExpanded (key) {
return expandedGroups.value[key] === true
}
function toggleGroup (key) {
expandedGroups.value = {
...expandedGroups.value,
[key]: !isGroupExpanded(key)
}
}
function isColumnFilterable (name) {
return filterableColumnNames.has(name)
}
function rawProductCellValue (row, name) {
switch (name) {
case 'urun_ilk_grubu': return row.urun_ilk_grubu || row.yas_grubu || ''
case 'urun_ana_grubu': return row.urun_ana_grubu || row.seri || ''
case 'performance_bucket': return bucketLabel(row.performance_bucket)
default: return row[name]
}
}
function formatProductCell (row, name) {
switch (name) {
case 'stock_qty':
case 'sales_qty_90d':
case 'sales_qty_180d':
case 'market_count_90d':
case 'customer_count_90d':
return formatNumber(row[name], 0)
case 'stock_days_90d':
case 'stock_days_180d':
case 'sales_index_90d':
case 'performance_score':
return formatNumber(row[name], name === 'performance_score' ? 1 : 2)
case 'sales_usd_90d':
case 'sales_usd_180d':
case 'avg_price_usd_90d':
case 'avg_price_usd_180d':
case 'base_price_usd':
case 'cost_price_usd':
case 'unit_profit_base_90d':
case 'unit_profit_cost_90d':
case 'unit_profit_base_180d':
case 'unit_profit_cost_180d':
return formatMoney(row[name], 'USD')
case 'gross_margin_90d':
case 'gross_margin_180d':
return formatPercent(row[name])
case 'urun_ilk_grubu':
return row.urun_ilk_grubu || row.yas_grubu || ''
case 'urun_ana_grubu':
return row.urun_ana_grubu || row.seri || ''
default:
return rawProductCellValue(row, name) || ''
}
}
function normalizeRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
@@ -595,6 +923,41 @@ async function openProductImageDialog (row) {
imageDialog.value = true
}
async function openPerformanceDialog (row) {
performanceDialogRow.value = row
performanceDialogTitle.value = String(row?.product_code || '-')
performanceDialogSubtitle.value = [
row?.item_description,
row?.color_code ? `Renk: ${row.color_code}` : '',
row?.yaka_kodu ? `Yaka: ${row.yaka_kodu}` : '',
row?.market_key ? `Piyasa: ${row.market_key}` : ''
].filter(Boolean).join(' | ')
performanceDialog.value = true
detailLoading.value = true
detailSalesRows.value = []
detailStockSizes.value = []
try {
const params = {
product_code: row?.product_code || '',
color_code: row?.color_code || '',
yaka_kodu: row?.yaka_kodu || ''
}
const [salesResp, stockResp] = await Promise.all([
api.get('/pricing/product-performance/sales-details', { params: { ...params, limit: 200 }, timeout: 60000 }),
api.get('/pricing/product-performance/stock-sizes', { params, timeout: 60000 })
])
detailSalesRows.value = (Array.isArray(salesResp?.data) ? salesResp.data : []).map((item, idx) => ({
...item,
detail_key: `${item.sales_date}|${item.ref_number}|${item.customer_code}|${idx}`
}))
detailStockSizes.value = Array.isArray(stockResp?.data) ? stockResp.data : []
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün detay analizi alınamadı' })
} finally {
detailLoading.value = false
}
}
async function reload () {
loading.value = true
try {
@@ -608,7 +971,9 @@ async function reload () {
product_code: filters.product_code || undefined,
kategori: filters.kategori || undefined,
seri: filters.seri || undefined,
bucket: filters.bucket || undefined
bucket: filters.bucket || undefined,
sort_by: pagination.value.sortBy || undefined,
descending: pagination.value.descending ? 'true' : 'false'
},
timeout: 60000
}),
@@ -709,6 +1074,35 @@ onMounted(reload)
background: #fff;
}
.performance-table :deep(thead tr:nth-child(2) th) {
top: 40px;
z-index: 2;
}
.table-filter-row :deep(.q-field__control) {
min-height: 28px;
height: 28px;
}
.header-filter {
min-width: 96px;
}
.group-row td {
background: #eef2f6;
font-weight: 600;
}
.group-title {
margin-left: 4px;
}
.group-meta {
margin-left: 12px;
color: #5f6b7a;
font-weight: 500;
}
.product-image-cell {
width: 72px;
min-width: 72px;