ui: add B2B olmayan stok (orphans) page
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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} }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user