Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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} }
|
||||
|
||||
@@ -368,6 +368,16 @@ const menuItems = [
|
||||
to: '/app/pricing/wholesale-campaigns',
|
||||
permission: 'pricing:view'
|
||||
},
|
||||
{
|
||||
label: 'Ürün Seri Eşleşmeleri',
|
||||
to: '/app/pricing/product-series-mappings',
|
||||
permission: 'pricing:view'
|
||||
},
|
||||
{
|
||||
label: 'Ürün Seri Tanımlamaları',
|
||||
to: '/app/pricing/product-series-definitions',
|
||||
permission: 'pricing:view'
|
||||
},
|
||||
{
|
||||
label: 'Marka Sınıflandırma',
|
||||
to: '/app/pricing/brand-classification',
|
||||
|
||||
195
ui/src/pages/ProductSeriesDefinitions.vue
Normal file
195
ui/src/pages/ProductSeriesDefinitions.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="row items-center justify-between q-mb-md">
|
||||
<div>
|
||||
<div class="text-h6">Urun Seri Tanimlamalari</div>
|
||||
<div class="text-caption text-grey-7">Seri kodu ve beden seri basliklari burada yonetilir.</div>
|
||||
</div>
|
||||
<div class="row q-gutter-sm">
|
||||
<q-btn color="secondary" outline icon="refresh" label="Yenile" :loading="loading" @click="reload" />
|
||||
<q-btn color="primary" icon="add" label="Yeni Seri" :disable="!canUpdate" @click="newRow" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
flat
|
||||
bordered
|
||||
row-key="id"
|
||||
:rows="rows"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 0 }"
|
||||
hide-bottom
|
||||
class="bg-white"
|
||||
>
|
||||
<template #body-cell-is_active="props">
|
||||
<q-td :props="props">
|
||||
<q-toggle v-model="props.row.is_active" dense :disable="!canUpdate || saving" @update:model-value="() => markDirty(props.row)" />
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-actions="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
<q-btn dense flat round icon="edit" color="primary" :disable="!canUpdate" @click="editRow(props.row)" />
|
||||
<q-btn dense flat round icon="delete" color="negative" :disable="!canUpdate" @click="removeRow(props.row)" />
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<q-dialog v-model="dialogOpen" persistent>
|
||||
<q-card style="min-width: 520px">
|
||||
<q-card-section class="row items-center justify-between">
|
||||
<div class="text-subtitle1">{{ editing.id ? 'Seri Duzenle' : 'Yeni Seri' }}</div>
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section class="q-gutter-md">
|
||||
<q-input v-model="editing.code" dense outlined label="Seri Kodu" autofocus />
|
||||
<q-input v-model="editing.title" dense outlined label="Seri Basligi" />
|
||||
<q-input v-model="editing.parent_filter" dense outlined label="Parent Filter" />
|
||||
<q-input v-model.number="editing.sort_order" dense outlined type="number" label="Sira" />
|
||||
<q-input v-model="editing.notes" dense outlined type="textarea" label="Not" />
|
||||
<q-toggle v-model="editing.is_active" label="Aktif" />
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Vazgec" v-close-popup />
|
||||
<q-btn color="primary" icon="save" label="Kaydet" :loading="saving" @click="saveDialog" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Dialog, Notify } from 'quasar'
|
||||
import api from 'src/services/api'
|
||||
import { usePermissionStore } from 'stores/permissionStore'
|
||||
|
||||
const perm = usePermissionStore()
|
||||
const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const rows = ref([])
|
||||
const dialogOpen = ref(false)
|
||||
const editing = ref(emptyRow())
|
||||
|
||||
const columns = [
|
||||
{ name: 'code', label: 'Seri Kodu', field: 'code', align: 'left', sortable: true },
|
||||
{ name: 'title', label: 'Seri Basligi', field: 'title', align: 'left', sortable: true },
|
||||
{ name: 'parent_filter', label: 'Parent Filter', field: 'parent_filter', align: 'left', sortable: true },
|
||||
{ name: 'sort_order', label: 'Sira', field: 'sort_order', align: 'right', sortable: true },
|
||||
{ name: 'is_active', label: 'Aktif', field: 'is_active', align: 'center', sortable: true },
|
||||
{ name: 'actions', label: '', field: 'actions', align: 'right' }
|
||||
]
|
||||
|
||||
function emptyRow () {
|
||||
return {
|
||||
id: 0,
|
||||
code: '',
|
||||
title: '',
|
||||
is_active: true,
|
||||
parent_filter: '',
|
||||
sort_order: 0,
|
||||
notes: ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRow (row) {
|
||||
return {
|
||||
id: Number(row.id || 0),
|
||||
code: String(row.code || '').trim(),
|
||||
title: String(row.title || '').trim(),
|
||||
is_active: row.is_active !== false,
|
||||
parent_filter: String(row.parent_filter || '').trim(),
|
||||
sort_order: Number(row.sort_order || 0),
|
||||
notes: String(row.notes || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
async function reload () {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/pricing/product-series/definitions')
|
||||
rows.value = (Array.isArray(res.data) ? res.data : []).map(normalizeRow)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Seri tanimlari alinamadi' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function newRow () {
|
||||
editing.value = emptyRow()
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function editRow (row) {
|
||||
editing.value = { ...normalizeRow(row) }
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function markDirty (row) {
|
||||
saveInline(row)
|
||||
}
|
||||
|
||||
async function saveInline (row) {
|
||||
if (!canUpdate.value || !row.id) return
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/pricing/product-series/definitions/${row.id}`, normalizeRow(row))
|
||||
Notify.create({ type: 'positive', message: 'Kaydedildi' })
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Seri tanimi kaydedilemedi' })
|
||||
await reload()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDialog () {
|
||||
const payload = normalizeRow(editing.value)
|
||||
if (!payload.code) {
|
||||
Notify.create({ type: 'warning', message: 'Seri kodu zorunludur' })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (payload.id) {
|
||||
await api.put(`/pricing/product-series/definitions/${payload.id}`, payload)
|
||||
} else {
|
||||
await api.post('/pricing/product-series/definitions', payload)
|
||||
}
|
||||
dialogOpen.value = false
|
||||
await reload()
|
||||
Notify.create({ type: 'positive', message: 'Kaydedildi' })
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Seri tanimi kaydedilemedi' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeRow (row) {
|
||||
Dialog.create({
|
||||
title: 'Seri pasife alinsin mi?',
|
||||
message: `${row.code} - ${row.title}`,
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.delete(`/pricing/product-series/definitions/${row.id}`)
|
||||
await reload()
|
||||
Notify.create({ type: 'positive', message: 'Pasife alindi' })
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Seri tanimi silinemedi' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
</script>
|
||||
939
ui/src/pages/ProductSeriesMappings.vue
Normal file
939
ui/src/pages/ProductSeriesMappings.vue
Normal file
@@ -0,0 +1,939 @@
|
||||
<template>
|
||||
<q-page
|
||||
class="order-page product-series-page q-pa-md"
|
||||
:style="{
|
||||
'--grid-header-h': showGridHeader ? `${schemaRows.length * 28}px` : '0px',
|
||||
'--beden-count': 16
|
||||
}"
|
||||
>
|
||||
<div class="sticky-stack">
|
||||
<div class="save-toolbar row items-center q-gutter-sm">
|
||||
<div>
|
||||
<div class="text-subtitle2 text-weight-bold">Urun Seri Eslesmeleri</div>
|
||||
<div class="text-caption text-grey-8">
|
||||
Stogu olan tum varyantlar beden bazinda listelenir; seri secenekleri satir bazinda kaydedilir.
|
||||
</div>
|
||||
</div>
|
||||
<q-space />
|
||||
<q-btn color="primary" outline icon="grid_on" label="Excel" :disable="!displayRows.length" @click="exportVisibleExcel" />
|
||||
<q-btn color="secondary" outline icon="refresh" label="Yenile" :loading="loading" @click="reload" />
|
||||
<q-btn color="primary" icon="save" label="Kaydet" :disable="!canUpdate || dirtyRows.length === 0" :loading="saving" @click="saveDirty" />
|
||||
</div>
|
||||
|
||||
<div v-if="showGridHeader" class="order-grid-header series-grid-header">
|
||||
<div class="col-fixed model">
|
||||
<div class="filter-head">
|
||||
<span>MODEL</span>
|
||||
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('model') ? 'primary' : 'grey-8'">
|
||||
<q-badge v-if="activeFilterCount('model')" floating color="primary">{{ activeFilterCount('model') }}</q-badge>
|
||||
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||
<div class="q-pa-sm">
|
||||
<q-input v-model="filterSearch.model" dense outlined clearable placeholder="Ara" autofocus />
|
||||
<div class="row q-gutter-xs q-mt-sm">
|
||||
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('model')" />
|
||||
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('model')" />
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<q-option-group
|
||||
v-model="columnFilters.model"
|
||||
:options="filteredFilterOptions('model')"
|
||||
type="checkbox"
|
||||
dense
|
||||
class="column-filter-options"
|
||||
/>
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-fixed desc-col">
|
||||
<div class="filter-head">
|
||||
<span>DESC</span>
|
||||
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('desc') ? 'primary' : 'grey-8'">
|
||||
<q-badge v-if="activeFilterCount('desc')" floating color="primary">{{ activeFilterCount('desc') }}</q-badge>
|
||||
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||
<div class="q-pa-sm">
|
||||
<q-input v-model="filterSearch.desc" dense outlined clearable placeholder="Ara" autofocus />
|
||||
<div class="row q-gutter-xs q-mt-sm">
|
||||
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('desc')" />
|
||||
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('desc')" />
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<q-option-group
|
||||
v-model="columnFilters.desc"
|
||||
:options="filteredFilterOptions('desc')"
|
||||
type="checkbox"
|
||||
dense
|
||||
class="column-filter-options"
|
||||
/>
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-fixed renk">
|
||||
<div class="filter-head">
|
||||
<span>RENK</span>
|
||||
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('renk') ? 'primary' : 'grey-8'">
|
||||
<q-badge v-if="activeFilterCount('renk')" floating color="primary">{{ activeFilterCount('renk') }}</q-badge>
|
||||
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||
<div class="q-pa-sm">
|
||||
<q-input v-model="filterSearch.renk" dense outlined clearable placeholder="Ara" autofocus />
|
||||
<div class="row q-gutter-xs q-mt-sm">
|
||||
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('renk')" />
|
||||
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('renk')" />
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<q-option-group
|
||||
v-model="columnFilters.renk"
|
||||
:options="filteredFilterOptions('renk')"
|
||||
type="checkbox"
|
||||
dense
|
||||
class="column-filter-options"
|
||||
/>
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-fixed ana">
|
||||
<div class="filter-head">
|
||||
<span>URUN ANA GRUBU</span>
|
||||
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('ana') ? 'primary' : 'grey-8'">
|
||||
<q-badge v-if="activeFilterCount('ana')" floating color="primary">{{ activeFilterCount('ana') }}</q-badge>
|
||||
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||
<div class="q-pa-sm">
|
||||
<q-input v-model="filterSearch.ana" dense outlined clearable placeholder="Ara" autofocus />
|
||||
<div class="row q-gutter-xs q-mt-sm">
|
||||
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('ana')" />
|
||||
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('ana')" />
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<q-option-group
|
||||
v-model="columnFilters.ana"
|
||||
:options="filteredFilterOptions('ana')"
|
||||
type="checkbox"
|
||||
dense
|
||||
class="column-filter-options"
|
||||
/>
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-fixed alt">
|
||||
<div class="filter-head">
|
||||
<span>URUN ALT GRUBU</span>
|
||||
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('alt') ? 'primary' : 'grey-8'">
|
||||
<q-badge v-if="activeFilterCount('alt')" floating color="primary">{{ activeFilterCount('alt') }}</q-badge>
|
||||
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||
<div class="q-pa-sm">
|
||||
<q-input v-model="filterSearch.alt" dense outlined clearable placeholder="Ara" autofocus />
|
||||
<div class="row q-gutter-xs q-mt-sm">
|
||||
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('alt')" />
|
||||
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('alt')" />
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<q-option-group
|
||||
v-model="columnFilters.alt"
|
||||
:options="filteredFilterOptions('alt')"
|
||||
type="checkbox"
|
||||
dense
|
||||
class="column-filter-options"
|
||||
/>
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-fixed marka">
|
||||
<div class="filter-head">
|
||||
<span>MARKA</span>
|
||||
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('marka') ? 'primary' : 'grey-8'">
|
||||
<q-badge v-if="activeFilterCount('marka')" floating color="primary">{{ activeFilterCount('marka') }}</q-badge>
|
||||
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||
<div class="q-pa-sm">
|
||||
<q-input v-model="filterSearch.marka" dense outlined clearable placeholder="Ara" autofocus />
|
||||
<div class="row q-gutter-xs q-mt-sm">
|
||||
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('marka')" />
|
||||
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('marka')" />
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<q-option-group
|
||||
v-model="columnFilters.marka"
|
||||
:options="filteredFilterOptions('marka')"
|
||||
type="checkbox"
|
||||
dense
|
||||
class="column-filter-options"
|
||||
/>
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="beden-block">
|
||||
<div
|
||||
v-for="grp in schemaRows"
|
||||
:key="'series-hdr-' + grp.key"
|
||||
class="grp-row"
|
||||
>
|
||||
<div class="grp-title">{{ grp.title }}</div>
|
||||
<div class="grp-body">
|
||||
<div v-for="v in paddedSchemaValues(grp)" :key="'hdr-' + grp.key + '-' + v.key" class="grp-cell hdr" :class="{ ghost: v.ghost }">
|
||||
{{ v.ghost ? '' : v.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="total-header-cell">TOPLAM</div>
|
||||
<div class="series-header-cell">SERI</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-banner v-if="errorMessage" class="bg-red-1 text-negative q-my-sm rounded-borders" dense>
|
||||
{{ errorMessage }}
|
||||
</q-banner>
|
||||
|
||||
<q-banner v-else-if="!loading && !displayRows.length" class="bg-blue-1 text-primary q-my-sm rounded-borders" dense>
|
||||
Filtrelere uygun stoklu urun bulunamadi.
|
||||
</q-banner>
|
||||
|
||||
<q-inner-loading :showing="loading">
|
||||
<q-spinner color="primary" size="42px" />
|
||||
</q-inner-loading>
|
||||
|
||||
<div class="order-scroll-y series-scroll">
|
||||
<div v-if="displayRows.length" class="order-grid-body series-grid-body">
|
||||
<div
|
||||
v-for="row in displayRows"
|
||||
:key="row.row_key"
|
||||
class="series-flat-row"
|
||||
:class="{ dirty: row._dirty, warning: !row.mapping_ready }"
|
||||
>
|
||||
<div class="sub-col model">{{ row.product_code || '-' }}</div>
|
||||
<div class="sub-col desc">{{ row.product_description || '-' }}</div>
|
||||
<div class="sub-col renk">
|
||||
<div class="renk-kodu">{{ variantCode(row) }}</div>
|
||||
<div class="renk-aciklama">{{ row.color_title || '-' }}</div>
|
||||
</div>
|
||||
<div class="sub-col ana">{{ row.urun_ana_grubu || '-' }}</div>
|
||||
<div class="sub-col alt">{{ row.urun_alt_grubu || '-' }}</div>
|
||||
<div class="sub-col marka">{{ row.marka || '-' }}</div>
|
||||
|
||||
<div class="flat-size-cells">
|
||||
<div v-for="sz in rowSizeCells(row)" :key="`${row.row_key}-${sz.key}`" class="beden-cell" :class="{ ghost: sz.ghost }">
|
||||
{{ formatQty(row._mapped_size_qty?.[sz.value]) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="total-cell">{{ formatQty(row.total_qty) }}</div>
|
||||
|
||||
<div class="series-select-cell" @click.stop>
|
||||
<q-select
|
||||
v-model="row._series_ids"
|
||||
dense
|
||||
outlined
|
||||
multiple
|
||||
emit-value
|
||||
map-options
|
||||
use-chips
|
||||
:options="seriesOptions"
|
||||
option-value="id"
|
||||
option-label="label"
|
||||
:disable="!canUpdate || saving || !canEditSeriesRow(row)"
|
||||
@update:model-value="() => markDirty(row)"
|
||||
>
|
||||
<template #selected-item="scope">
|
||||
<q-chip dense removable @remove="scope.removeAtIndex(scope.index)" class="series-chip">
|
||||
{{ formatSeries(scope.opt) }}
|
||||
</q-chip>
|
||||
</template>
|
||||
</q-select>
|
||||
<div class="series-meta">
|
||||
<q-badge v-if="row._dirty" color="orange-8">Degisti</q-badge>
|
||||
<q-badge v-else color="grey-6">Kayitli</q-badge>
|
||||
<span v-if="row.mapping_warning" class="mapping-warning">{{ row.mapping_warning }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import api from 'src/services/api'
|
||||
import { usePermissionStore } from 'stores/permissionStore'
|
||||
import {
|
||||
detectBedenGroup,
|
||||
normalizeBedenLabel,
|
||||
schemaByKey as fallbackSchemaByKey,
|
||||
useOrderEntryStore
|
||||
} from 'src/stores/orderentryStore'
|
||||
|
||||
const perm = usePermissionStore()
|
||||
const orderStore = useOrderEntryStore()
|
||||
const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const rows = ref([])
|
||||
const definitions = ref([])
|
||||
const errorMessage = ref('')
|
||||
const columnFilters = ref({
|
||||
model: [],
|
||||
desc: [],
|
||||
renk: [],
|
||||
ana: [],
|
||||
alt: [],
|
||||
marka: []
|
||||
})
|
||||
const filterSearch = ref({
|
||||
model: '',
|
||||
desc: '',
|
||||
renk: '',
|
||||
ana: '',
|
||||
alt: '',
|
||||
marka: ''
|
||||
})
|
||||
|
||||
const showGridHeader = computed(() => !loading.value && productGroups.value.length > 0)
|
||||
|
||||
const seriesOptions = computed(() => definitions.value.map(item => ({
|
||||
...item,
|
||||
label: formatSeries(item)
|
||||
})))
|
||||
|
||||
const dirtyRows = computed(() => rows.value.filter(row => row._dirty))
|
||||
|
||||
const displayRows = computed(() => {
|
||||
const list = rows.value.filter(rowPassesFilters)
|
||||
const productTotals = productTotalQtyMap(list)
|
||||
return list.sort((a, b) => {
|
||||
const groupDiff = Number(productTotals.get(b.product_code) || 0) - Number(productTotals.get(a.product_code) || 0)
|
||||
if (groupDiff !== 0) return groupDiff
|
||||
const code = String(a.product_code || '').localeCompare(String(b.product_code || ''), 'tr')
|
||||
if (code !== 0) return code
|
||||
const color = String(a.color_code || '').localeCompare(String(b.color_code || ''), 'tr')
|
||||
if (color !== 0) return color
|
||||
const dim3 = String(a.dim3_code || '').localeCompare(String(b.dim3_code || ''), 'tr')
|
||||
if (dim3 !== 0) return dim3
|
||||
return Number(b.total_qty || 0) - Number(a.total_qty || 0)
|
||||
})
|
||||
})
|
||||
|
||||
function productTotalQtyMap (list) {
|
||||
const totals = new Map()
|
||||
for (const row of list || []) {
|
||||
const code = String(row?.product_code || '').trim()
|
||||
if (!code) continue
|
||||
totals.set(code, Number(totals.get(code) || 0) + Number(row?.total_qty || 0))
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
const productGroups = computed(() => displayRows.value)
|
||||
|
||||
const filterOptions = computed(() => ({
|
||||
model: uniqueOptions(rows.value.map(row => row.product_code)),
|
||||
desc: uniqueOptions(rows.value.map(row => row.product_description)),
|
||||
renk: uniqueOptions(rows.value.map(row => variantCode(row))),
|
||||
ana: uniqueOptions(rows.value.map(row => row.urun_ana_grubu)),
|
||||
alt: uniqueOptions(rows.value.map(row => row.urun_alt_grubu)),
|
||||
marka: uniqueOptions(rows.value.map(row => row.marka))
|
||||
}))
|
||||
|
||||
const schemaRows = computed(() => {
|
||||
const map = getSchemaMap()
|
||||
const preferred = ['tak', 'ayk', 'ayk_garson', 'yas', 'pan', 'gom', 'aksbir']
|
||||
const ordered = preferred
|
||||
.map(key => map?.[key])
|
||||
.filter(Boolean)
|
||||
const extras = Object.values(map || {})
|
||||
.filter(grp => grp?.key && !preferred.includes(grp.key))
|
||||
return [...ordered, ...extras]
|
||||
})
|
||||
|
||||
function uniqueOptions (values) {
|
||||
return [...new Set(values.map(v => String(v || '').trim()).filter(Boolean))]
|
||||
.sort((a, b) => a.localeCompare(b, 'tr'))
|
||||
.map(value => ({ label: value, value }))
|
||||
}
|
||||
|
||||
function filteredFilterOptions (key) {
|
||||
const needle = normalizeFilterText(filterSearch.value[key])
|
||||
const opts = filterOptions.value[key] || []
|
||||
if (!needle) return opts
|
||||
return opts.filter(opt => normalizeFilterText(opt.label).includes(needle))
|
||||
}
|
||||
|
||||
function activeFilterCount (key) {
|
||||
return Array.isArray(columnFilters.value[key]) ? columnFilters.value[key].length : 0
|
||||
}
|
||||
|
||||
function selectAllFilter (key) {
|
||||
columnFilters.value[key] = filteredFilterOptions(key).map(opt => opt.value)
|
||||
}
|
||||
|
||||
function clearFilter (key) {
|
||||
columnFilters.value[key] = []
|
||||
}
|
||||
|
||||
function rowPassesFilters (row) {
|
||||
return filterMatch('model', row.product_code) &&
|
||||
filterMatch('desc', row.product_description) &&
|
||||
filterMatch('renk', variantCode(row)) &&
|
||||
filterMatch('ana', row.urun_ana_grubu) &&
|
||||
filterMatch('alt', row.urun_alt_grubu) &&
|
||||
filterMatch('marka', row.marka)
|
||||
}
|
||||
|
||||
function filterMatch (key, value) {
|
||||
const selected = columnFilters.value[key] || []
|
||||
if (!selected.length) return true
|
||||
return selected.includes(String(value || '').trim())
|
||||
}
|
||||
|
||||
function normalizeFilterText (value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLocaleLowerCase('tr-TR')
|
||||
}
|
||||
|
||||
function sameIDs (a, b) {
|
||||
const aa = [...(a || [])].map(Number).sort((x, y) => x - y).join(',')
|
||||
const bb = [...(b || [])].map(Number).sort((x, y) => x - y).join(',')
|
||||
return aa === bb
|
||||
}
|
||||
|
||||
function normalizeRow (row) {
|
||||
const ids = Array.isArray(row.series_ids) ? row.series_ids.map(Number).filter(Boolean) : []
|
||||
const mappedInfo = mapRowSizesToSchema(row)
|
||||
return {
|
||||
...row,
|
||||
series_ids: ids,
|
||||
_series_ids: [...ids],
|
||||
_grp_key: mappedInfo.grpKey,
|
||||
_schema: mappedInfo.schema,
|
||||
_mapped_size_qty: mappedInfo.mapped,
|
||||
_dirty: false
|
||||
}
|
||||
}
|
||||
|
||||
function markDirty (row) {
|
||||
row._dirty = !sameIDs(row.series_ids, row._series_ids)
|
||||
}
|
||||
|
||||
function canEditSeriesRow (row) {
|
||||
return !!String(row?.product_code || '').trim() && !!String(row?.color_code || '').trim()
|
||||
}
|
||||
|
||||
async function reload () {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
if (!orderStore.schemaMap || !Object.keys(orderStore.schemaMap).length) {
|
||||
orderStore.initSchemaMap()
|
||||
}
|
||||
await orderStore.ensureProductSizeMatchRules()
|
||||
const res = await api.get('/pricing/product-series/mappings', { timeout: 180000 })
|
||||
rows.value = (res.data?.rows || []).map(normalizeRow)
|
||||
definitions.value = res.data?.definitions || []
|
||||
} catch (err) {
|
||||
errorMessage.value = err?.response?.data || err?.message || 'Urun seri eslesmeleri alinamadi'
|
||||
Notify.create({ type: 'negative', message: errorMessage.value })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function paddedSchemaValues (grp) {
|
||||
const values = Array.isArray(grp?.values) ? grp.values.map(v => normalizeBedenLabel(v)) : []
|
||||
const out = values.slice(0, 16).map((value, index) => ({ key: `${value}-${index}`, value, ghost: false, toString: () => value }))
|
||||
while (out.length < 16) {
|
||||
const index = out.length
|
||||
out.push({ key: `ghost-${index}`, value: `__ghost_${index}`, ghost: true, toString: () => '' })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function rowSizeCells (row) {
|
||||
const schema = row?._schema || getSchemaMap()?.[row?._grp_key] || fallbackSchemaByKey.tak
|
||||
return paddedSchemaValues(schema)
|
||||
}
|
||||
|
||||
async function saveDirty () {
|
||||
const dirty = dirtyRows.value
|
||||
if (dirty.length === 0) return
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/pricing/product-series/mappings/save', {
|
||||
items: dirty.map(row => ({
|
||||
product_code: row.product_code,
|
||||
color_code: row.color_code,
|
||||
dim3_code: row.dim3_code,
|
||||
series_ids: row._series_ids || []
|
||||
}))
|
||||
}, { timeout: 180000 })
|
||||
for (const row of dirty) {
|
||||
row.series_ids = [...(row._series_ids || [])]
|
||||
row._dirty = false
|
||||
}
|
||||
Notify.create({ type: 'positive', message: `Kaydedildi: ${dirty.length} satir` })
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Seri eslesmeleri kaydedilemedi' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getSchemaMap () {
|
||||
return Object.keys(orderStore.schemaMap || {}).length
|
||||
? orderStore.schemaMap
|
||||
: fallbackSchemaByKey
|
||||
}
|
||||
|
||||
function normalizedSchemaLabelMap (schema) {
|
||||
const out = new Map()
|
||||
for (const label of schema?.values || []) {
|
||||
const normalized = normalizeBedenLabel(label)
|
||||
if (!out.has(normalized)) out.set(normalized, label)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function detectRowGroupKey (row, rawSizeLabels) {
|
||||
const fallback = detectRowGroupKeyFallback(row, rawSizeLabels)
|
||||
if (fallback) return fallback
|
||||
return detectBedenGroup(
|
||||
rawSizeLabels,
|
||||
row.urun_ana_grubu || '',
|
||||
row.kategori || '',
|
||||
row.kategori || '',
|
||||
row.urun_alt_grubu || ''
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeMatchText (value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLocaleUpperCase('tr-TR')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
}
|
||||
|
||||
function detectRowGroupKeyFallback (row, rawSizeLabels) {
|
||||
const ana = normalizeMatchText(row?.urun_ana_grubu)
|
||||
const alt = normalizeMatchText(row?.urun_alt_grubu)
|
||||
const marka = normalizeMatchText(row?.marka)
|
||||
const text = `${ana} ${alt} ${marka}`
|
||||
const sizes = (rawSizeLabels || []).map(v => normalizeBedenLabel(v))
|
||||
const hasLetterSize = sizes.some(v => ['XS', 'S', 'M', 'L', 'XL', '2XL', '3XL', '4XL', '5XL', '6XL', '7XL'].includes(v))
|
||||
|
||||
if (['KRAVAT', 'PAPYON', 'KEMER', 'CORAP', 'FULAR', 'MENDIL', 'KASKOL', 'ASKI', 'AKSESUAR'].some(key => text.includes(key))) return 'aksbir'
|
||||
if (text.includes('AYAKKABI')) return text.includes('GARSON') ? 'ayk_garson' : 'ayk'
|
||||
if (text.includes('PANTOLON')) return 'pan'
|
||||
if (text.includes('GOMLEK') || hasLetterSize) return 'gom'
|
||||
if (text.includes('TAKIM') || text.includes('DAMATLIK') || text.includes('CEKET') || text.includes('KABAN') || text.includes('MONT') || text.includes('YELEK')) return 'tak'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function mapRowSizesToSchema (row) {
|
||||
const sizeEntries = Object.entries(row?.size_qty || {})
|
||||
const rawSizeLabels = sizeEntries.map(([rawSize]) => normalizeBedenLabel(rawSize))
|
||||
const schemaMap = getSchemaMap()
|
||||
const grpKey = detectRowGroupKey(row, rawSizeLabels)
|
||||
const schema = grpKey && schemaMap?.[grpKey] ? schemaMap[grpKey] : null
|
||||
const schemaLabelMap = normalizedSchemaLabelMap(schema)
|
||||
const mapped = {}
|
||||
|
||||
for (const [rawSize, rawQty] of sizeEntries) {
|
||||
const normalized = normalizeBedenLabel(rawSize)
|
||||
const target = normalizeBedenLabel(schemaLabelMap.get(normalized) || normalized)
|
||||
mapped[target] = Number(mapped[target] || 0) + Number(rawQty || 0)
|
||||
}
|
||||
|
||||
if (grpKey === 'aksbir' && !Object.keys(mapped).length && Number(row?.total_qty || 0) > 0) {
|
||||
mapped[' '] = Number(row.total_qty || 0)
|
||||
}
|
||||
|
||||
if (grpKey !== 'aksbir' && Object.keys(mapped).some(k => String(k).trim() !== '')) {
|
||||
delete mapped[' ']
|
||||
}
|
||||
|
||||
return { grpKey, schema, mapped }
|
||||
}
|
||||
|
||||
function formatQty (value) {
|
||||
const n = Number(value || 0)
|
||||
if (!Number.isFinite(n) || n === 0) return ''
|
||||
return n.toLocaleString('tr-TR', { maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function formatSeries (item) {
|
||||
const code = String(item?.code || '').trim()
|
||||
const title = String(item?.title || '').trim()
|
||||
return title ? `${code}/${title}` : code
|
||||
}
|
||||
|
||||
function variantCode (row) {
|
||||
const c = String(row?.color_code || '').trim()
|
||||
const d = String(row?.dim3_code || '').trim()
|
||||
return d ? `${c}-${d}` : (c || '-')
|
||||
}
|
||||
|
||||
function exportFileStamp () {
|
||||
const d = new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function escapeHtml (value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function exportVisibleExcel () {
|
||||
const groups = schemaRows.value
|
||||
const rowSpan = Math.max(groups.length, 1)
|
||||
const leftHeaders = ['MODEL', 'DESC', 'RENK', 'URUN ANA GRUBU', 'URUN ALT GRUBU', 'MARKA']
|
||||
const headerRows = (groups.length ? groups : [{ key: 'tak', title: 'TAKIM ELBISE', values: [] }]).map((grp, index) => {
|
||||
const left = index === 0
|
||||
? leftHeaders.map(h => `<th rowspan="${rowSpan}">${escapeHtml(h)}</th>`).join('')
|
||||
: ''
|
||||
const right = index === 0
|
||||
? `<th rowspan="${rowSpan}">${escapeHtml('TOPLAM')}</th><th rowspan="${rowSpan}">${escapeHtml('SERI')}</th>`
|
||||
: ''
|
||||
const sizeHeaders = paddedSchemaValues(grp)
|
||||
.map(cell => `<th>${escapeHtml(cell.ghost ? '' : cell.value)}</th>`)
|
||||
.join('')
|
||||
return `<tr>${left}<th>${escapeHtml(grp.title || '')}</th>${sizeHeaders}${right}</tr>`
|
||||
}).join('')
|
||||
const rowsHtml = displayRows.value.map(row => {
|
||||
const sizeCells = rowSizeCells(row).map(cell => {
|
||||
const value = cell.ghost ? '' : formatQty(row._mapped_size_qty?.[cell.value])
|
||||
return `<td style="text-align:center;">${escapeHtml(value)}</td>`
|
||||
}).join('')
|
||||
const seriesText = (row._series_ids || [])
|
||||
.map(id => seriesOptions.value.find(opt => Number(opt.id) === Number(id)))
|
||||
.filter(Boolean)
|
||||
.map(formatSeries)
|
||||
.join(', ')
|
||||
return `<tr>
|
||||
<td>${escapeHtml(row.product_code || '')}</td>
|
||||
<td>${escapeHtml(row.product_description || '')}</td>
|
||||
<td>${escapeHtml(variantCode(row))}</td>
|
||||
<td>${escapeHtml(row.urun_ana_grubu || '')}</td>
|
||||
<td>${escapeHtml(row.urun_alt_grubu || '')}</td>
|
||||
<td>${escapeHtml(row.marka || '')}</td>
|
||||
<td>${escapeHtml(row._schema?.title || '')}</td>
|
||||
${sizeCells}
|
||||
<td style="text-align:right;">${escapeHtml(formatQty(row.total_qty))}</td>
|
||||
<td>${escapeHtml(seriesText)}</td>
|
||||
</tr>`
|
||||
}).join('')
|
||||
const html = `<!doctype html><html xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="utf-8"><style>th{background:#fff8d1;font-weight:700;text-align:center;}td,th{border:1px solid #999;padding:4px;mso-number-format:'\\@';}.group-title{font-weight:700;}</style></head><body><table border="1"><thead>${headerRows}</thead><tbody>${rowsHtml}</tbody></table></body></html>`
|
||||
const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `urun_seri_eslesmeleri_${exportFileStamp()}.xls`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await orderStore.ensureProductSizeMatchRules()
|
||||
await reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-series-page {
|
||||
--filter-h: 0px;
|
||||
--psq-sticky-offset: 12px;
|
||||
--grp-title-w: 90px;
|
||||
--col-desc: var(--col-aciklama);
|
||||
--col-marka-series: var(--col-marka, 90px);
|
||||
--psq-header-h: var(--grid-header-h);
|
||||
--series-total-w: 76px;
|
||||
--series-col-w: minmax(220px, 1fr);
|
||||
background: #f5f1da;
|
||||
}
|
||||
|
||||
.series-scroll {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.series-grid-header,
|
||||
.series-flat-row {
|
||||
grid-template-columns:
|
||||
var(--col-model)
|
||||
var(--col-desc)
|
||||
var(--col-renk)
|
||||
var(--col-ana)
|
||||
var(--col-alt)
|
||||
var(--col-marka-series)
|
||||
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w) * var(--beden-count)))
|
||||
var(--series-total-w)
|
||||
var(--series-col-w) !important;
|
||||
width: 100%;
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.series-grid-header {
|
||||
top: calc(var(--header-h) + var(--filter-h) + var(--save-h) + var(--psq-sticky-offset)) !important;
|
||||
}
|
||||
|
||||
.series-grid-header .col-fixed,
|
||||
.total-header-cell,
|
||||
.series-header-cell {
|
||||
writing-mode: horizontal-tb !important;
|
||||
transform: none !important;
|
||||
height: var(--psq-header-h) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px !important;
|
||||
line-height: 1 !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 4px !important;
|
||||
}
|
||||
|
||||
.filter-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-head span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.column-filter-menu {
|
||||
width: 280px;
|
||||
max-width: 86vw;
|
||||
}
|
||||
|
||||
.column-filter-options {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.series-grid-header .beden-block {
|
||||
height: var(--psq-header-h) !important;
|
||||
}
|
||||
|
||||
.series-grid-header .grp-row {
|
||||
height: var(--beden-h) !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.series-grid-header .grp-title {
|
||||
width: var(--grp-title-w) !important;
|
||||
text-align: center !important;
|
||||
padding-right: 0 !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.series-grid-header .grp-cell.hdr {
|
||||
height: var(--beden-h) !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.series-grid-header .grp-cell.hdr.ghost {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.total-header-cell,
|
||||
.series-header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff8d1;
|
||||
border-left: 1px solid #d4c79f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.total-header-cell {
|
||||
grid-column: 8 / 9;
|
||||
}
|
||||
|
||||
.series-header-cell {
|
||||
grid-column: 9 / 10;
|
||||
}
|
||||
|
||||
.series-flat-row {
|
||||
display: grid;
|
||||
min-height: 72px;
|
||||
align-items: center;
|
||||
background: #fff9c4 !important;
|
||||
border-top: 1px solid #d4c79f !important;
|
||||
border-bottom: 1px solid #d4c79f !important;
|
||||
}
|
||||
|
||||
.series-flat-row.dirty {
|
||||
background: #fff0cf !important;
|
||||
}
|
||||
|
||||
.series-flat-row.warning {
|
||||
box-shadow: inset 3px 0 0 #f59e0b;
|
||||
}
|
||||
|
||||
.series-flat-row .sub-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #111;
|
||||
min-width: 0;
|
||||
border-right: 1px solid #d4c79f;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.series-flat-row .sub-col.model { grid-column: 1; }
|
||||
.series-flat-row .sub-col.desc { grid-column: 2; }
|
||||
.series-flat-row .sub-col.renk { grid-column: 3; }
|
||||
.series-flat-row .sub-col.ana { grid-column: 4; }
|
||||
.series-flat-row .sub-col.alt { grid-column: 5; }
|
||||
.series-flat-row .sub-col.marka { grid-column: 6; }
|
||||
|
||||
.series-flat-row .sub-col.model,
|
||||
.series-flat-row .sub-col.renk,
|
||||
.series-flat-row .sub-col.ana,
|
||||
.series-flat-row .sub-col.alt,
|
||||
.series-flat-row .sub-col.marka {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.series-flat-row .sub-col.desc {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.series-flat-row .sub-col.renk {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.series-flat-row .sub-col.renk .renk-kodu {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.series-flat-row .sub-col.renk .renk-aciklama {
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.flat-size-cells {
|
||||
grid-column: 7;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: var(--beden-w);
|
||||
justify-content: start;
|
||||
align-items: stretch;
|
||||
width: calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w) * var(--beden-count)));
|
||||
padding-left: calc(var(--grp-title-w) + var(--grp-title-gap));
|
||||
margin-left: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flat-size-cells::before,
|
||||
.flat-size-cells::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.flat-size-cells .beden-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--beden-w);
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #d4c79f;
|
||||
border-right: none;
|
||||
background: #fffef6;
|
||||
color: #1f1f1f;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.flat-size-cells .beden-cell:last-child {
|
||||
border-right: 1px solid #d4c79f;
|
||||
}
|
||||
|
||||
.flat-size-cells .beden-cell.ghost {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.total-cell {
|
||||
grid-column: 8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 10px;
|
||||
border-left: 1px solid #d4c79f;
|
||||
border-right: 1px solid #d4c79f;
|
||||
color: var(--q-primary, #1976d2);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.series-select-cell {
|
||||
grid-column: 9 / 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-left: 1px solid #d4c79f;
|
||||
background: #fffdf0;
|
||||
}
|
||||
|
||||
.series-select-cell :deep(.q-field__control) {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.series-chip {
|
||||
max-width: 170px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.series-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 18px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mapping-warning {
|
||||
color: #b45309;
|
||||
}
|
||||
</style>
|
||||
@@ -419,6 +419,18 @@ const routes = [
|
||||
component: () => import('pages/WholesaleCampaigns.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/product-series-mappings',
|
||||
name: 'product-series-mappings',
|
||||
component: () => import('pages/ProductSeriesMappings.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/product-series-definitions',
|
||||
name: 'product-series-definitions',
|
||||
component: () => import('pages/ProductSeriesDefinitions.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'costing/production-product-costing',
|
||||
name: 'production-product-costing',
|
||||
|
||||
Reference in New Issue
Block a user