Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -325,37 +325,55 @@ const menuItems = [
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Fiyatlandırma/Maliyetlendirme',
|
||||
icon: 'request_quote',
|
||||
|
||||
children: [
|
||||
{
|
||||
label: 'Ürün Fiyatlandırma',
|
||||
to: '/app/pricing/product-pricing',
|
||||
permission: 'order:view'
|
||||
},
|
||||
{
|
||||
label: "Üretim'den Ürün Maliyetlendirme",
|
||||
to: '/app/pricing/production-product-costing',
|
||||
permission: 'order:view'
|
||||
},
|
||||
{
|
||||
label: 'Maliyet Parça Eşleştirme',
|
||||
to: '/app/pricing/production-product-costing/maliyet-parca-eslestirme',
|
||||
permission: 'order:view'
|
||||
},
|
||||
{
|
||||
label: 'Maliyet Varsayilan Miktarlar',
|
||||
to: '/app/pricing/production-product-costing/default-quantities',
|
||||
permission: 'order:view'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Sistem',
|
||||
icon: 'settings',
|
||||
{
|
||||
label: 'Ürün Maliyetlendirme',
|
||||
icon: 'request_quote',
|
||||
|
||||
children: [
|
||||
{
|
||||
label: "Üretim'den Ürün Maliyetlendirme",
|
||||
to: '/app/costing/production-product-costing',
|
||||
permission: 'costing:view'
|
||||
},
|
||||
{
|
||||
label: 'Maliyet Parça Eşleştirme',
|
||||
to: '/app/costing/production-product-costing/maliyet-parca-eslestirme',
|
||||
permission: 'costing:view'
|
||||
},
|
||||
{
|
||||
label: 'Maliyet Varsayilan Miktarlar',
|
||||
to: '/app/costing/production-product-costing/default-quantities',
|
||||
permission: 'costing:view'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Ürün Fiyatlandırma',
|
||||
icon: 'sell',
|
||||
|
||||
children: [
|
||||
{
|
||||
label: 'Fiyatlandırma',
|
||||
to: '/app/pricing/product-pricing',
|
||||
permission: 'pricing:view'
|
||||
},
|
||||
{
|
||||
label: 'Marka Sınıflandırma',
|
||||
to: '/app/pricing/brand-classification',
|
||||
permission: 'pricing:view'
|
||||
},
|
||||
{
|
||||
label: 'Fiyat Çarpan Kuralları',
|
||||
to: '/app/pricing/pricing-rules',
|
||||
permission: 'pricing:view'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Sistem',
|
||||
icon: 'settings',
|
||||
|
||||
children: [
|
||||
|
||||
|
||||
214
ui/src/pages/BrandClassification.vue
Normal file
214
ui/src/pages/BrandClassification.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="row items-center q-col-gutter-sm q-mb-md">
|
||||
<div class="col-12 col-md">
|
||||
<div class="text-h6">Marka Sınıflandırma</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
Kaynak: BAGGI_V3 `cdItemAttribute` (ItemTypeCode=1, AttributeTypeCode=10)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-auto">
|
||||
<q-input
|
||||
v-model="search"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
placeholder="Marka ara (kod / ad)"
|
||||
@keyup.enter="reload"
|
||||
style="min-width: 260px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-auto row items-center q-gutter-sm">
|
||||
<q-btn
|
||||
color="secondary"
|
||||
outline
|
||||
:loading="loading"
|
||||
label="Yenile"
|
||||
@click="reload"
|
||||
/>
|
||||
<q-btn
|
||||
color="primary"
|
||||
unelevated
|
||||
icon="save"
|
||||
:disable="!canUpdate || selectedDirtyCount === 0 || saving"
|
||||
:loading="saving"
|
||||
label="Kaydet (Seçili)"
|
||||
@click="saveSelected"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
flat
|
||||
bordered
|
||||
:rows="rows"
|
||||
:columns="columns"
|
||||
row-key="brand_code"
|
||||
selection="multiple"
|
||||
v-model:selected="selected"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 0 }"
|
||||
hide-bottom
|
||||
class="mk-table"
|
||||
>
|
||||
<template #body-cell-group="props">
|
||||
<q-td :props="props">
|
||||
<q-select
|
||||
v-model="props.row._group_id"
|
||||
dense
|
||||
outlined
|
||||
emit-value
|
||||
map-options
|
||||
option-value="id"
|
||||
option-label="title"
|
||||
:options="groupOptionsWithNone"
|
||||
:disable="!canUpdate || saving"
|
||||
style="min-width: 160px"
|
||||
@update:model-value="markDirty(props.row)"
|
||||
>
|
||||
<template #option="scope">
|
||||
<q-item v-bind="scope.itemProps">
|
||||
<q-item-section>
|
||||
<div class="text-body2">{{ scope.opt.title }}</div>
|
||||
<div v-if="scope.opt.description" class="text-caption text-grey-7">
|
||||
{{ scope.opt.description }}
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-actions="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
<span class="text-caption text-grey-7" v-if="props.row._dirty">Değişti</span>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</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'
|
||||
|
||||
const perm = usePermissionStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const search = ref('')
|
||||
const groups = ref([])
|
||||
const rows = ref([])
|
||||
const selected = ref([])
|
||||
|
||||
const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
|
||||
|
||||
const groupOptionsWithNone = computed(() => {
|
||||
const base = Array.isArray(groups.value) ? groups.value : []
|
||||
return [{ id: 0, title: '(Seçiniz)', code: '' }, ...base]
|
||||
})
|
||||
|
||||
const selectedDirtyCount = computed(() => {
|
||||
const list = Array.isArray(selected.value) ? selected.value : []
|
||||
return list.filter(r => r?._dirty).length
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ name: 'brand_code', label: 'Marka Kodu', field: 'brand_code', align: 'left', sortable: true },
|
||||
{ name: 'brand_name', label: 'Marka Adı', field: 'brand_name', align: 'left', sortable: true },
|
||||
{ name: 'group', label: 'Grup', field: 'group_name', align: 'left' },
|
||||
{ name: 'actions', label: '', field: 'actions', align: 'right' }
|
||||
]
|
||||
|
||||
function normalizeRow (r) {
|
||||
return {
|
||||
brand_code: String(r?.brand_code ?? '').trim(),
|
||||
brand_name: String(r?.brand_name ?? '').trim(),
|
||||
group_id: Number(r?.group_id ?? 0) || 0,
|
||||
group_name: String(r?.group_name ?? '').trim(),
|
||||
_group_id: Number(r?.group_id ?? 0) || 0,
|
||||
_dirty: false
|
||||
}
|
||||
}
|
||||
|
||||
function markDirty (row) {
|
||||
row._dirty = true
|
||||
}
|
||||
|
||||
async function loadLookups () {
|
||||
const res = await api.request({
|
||||
method: 'GET',
|
||||
url: '/pricing/brand-classification/lookups',
|
||||
timeout: 180000
|
||||
})
|
||||
groups.value = Array.isArray(res?.data?.groups) ? res.data.groups : []
|
||||
}
|
||||
|
||||
async function reload () {
|
||||
loading.value = true
|
||||
try {
|
||||
await loadLookups()
|
||||
const res = await api.request({
|
||||
method: 'GET',
|
||||
url: '/pricing/brand-classification/brands',
|
||||
params: search.value ? { q: search.value } : {},
|
||||
timeout: 180000
|
||||
})
|
||||
const data = Array.isArray(res?.data) ? res.data : []
|
||||
rows.value = data.map(normalizeRow)
|
||||
selected.value = []
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Liste alınamadı' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelected () {
|
||||
const list = Array.isArray(selected.value) ? selected.value : []
|
||||
const dirty = list.filter(r => r && r._dirty)
|
||||
if (dirty.length === 0) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
items: dirty.map(r => ({
|
||||
brand_code: r.brand_code,
|
||||
group_id: Number(r._group_id || 0)
|
||||
}))
|
||||
}
|
||||
await api.request({
|
||||
method: 'POST',
|
||||
url: '/pricing/brand-classification/brands/group-bulk',
|
||||
data: payload,
|
||||
timeout: 180000
|
||||
})
|
||||
for (const r of dirty) {
|
||||
r.group_id = Number(r._group_id || 0)
|
||||
const selOpt = groupOptionsWithNone.value.find(x => Number(x.id) === r.group_id)
|
||||
r.group_name = selOpt ? String(selOpt.title || '') : ''
|
||||
r._dirty = false
|
||||
}
|
||||
Notify.create({ type: 'positive', message: `Kaydedildi: ${dirty.length} satır` })
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kaydedilemedi' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Initial: ensure data exists. If mk_brands is empty, user can run sync.
|
||||
await reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mk-table :deep(th) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
892
ui/src/pages/PricingRules.vue
Normal file
892
ui/src/pages/PricingRules.vue
Normal file
@@ -0,0 +1,892 @@
|
||||
<template>
|
||||
<q-page class="q-pa-xs pricing-rules-page">
|
||||
<div class="top-bar row items-center justify-between q-mb-xs">
|
||||
<div>
|
||||
<div class="text-subtitle1 text-weight-bold">Fiyat Carpani Kurallari</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
MSSQL urun kombinasyonlari ve bu kombinasyonlara bagli para birimi bazli fiyat kurallari.
|
||||
</div>
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
color="primary"
|
||||
icon="refresh"
|
||||
label="Yenile"
|
||||
:loading="loading"
|
||||
@click="loadRows"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="action-bar row items-center justify-between q-mb-xs">
|
||||
<div class="text-caption text-grey-8">
|
||||
{{ filteredRows.length }} / {{ rows.length }} kombinasyon gosteriliyor. Degistirilen satirlar otomatik secilir.
|
||||
</div>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-btn
|
||||
flat
|
||||
color="primary"
|
||||
icon="filter_alt_off"
|
||||
label="Filtreleri Temizle"
|
||||
:disable="!hasAnyFilter"
|
||||
@click="clearAllFilters"
|
||||
/>
|
||||
<q-btn
|
||||
color="primary"
|
||||
unelevated
|
||||
icon="save"
|
||||
:disable="!canUpdate || selectedDirtyCount === 0 || saving"
|
||||
:loading="saving"
|
||||
:label="`Kaydet (${selectedDirtyCount})`"
|
||||
@click="saveSelected"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" :style="{ '--sticky-scroll-comp': `${stickyScrollComp}px` }">
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
row-key="_row_key"
|
||||
:rows="filteredRows"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:rows-per-page-options="[0]"
|
||||
v-model:pagination="tablePagination"
|
||||
binary-state-sort
|
||||
hide-bottom
|
||||
:table-style="tableStyle"
|
||||
class="pane-table rules-table"
|
||||
>
|
||||
<template #no-data>
|
||||
<div class="full-width row flex-center q-pa-lg text-grey-7">
|
||||
Parametre cache'i henuz dolmadi veya aktif filtrelerle eslesen kayit bulunamadi.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #header="props">
|
||||
<q-tr :props="props" class="header-row-fixed">
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
:class="[col.headerClasses, { 'sticky-col': isStickyCol(col.name), 'sticky-boundary': isStickyBoundary(col.name) }]"
|
||||
:style="getHeaderCellStyle(col)"
|
||||
>
|
||||
<q-checkbox
|
||||
v-if="col.name === 'select'"
|
||||
dense
|
||||
:model-value="allSelectedVisible"
|
||||
:indeterminate="someSelectedVisible && !allSelectedVisible"
|
||||
@update:model-value="toggleSelectAllVisible"
|
||||
/>
|
||||
<div v-else class="header-with-filter">
|
||||
<span>{{ col.label }}</span>
|
||||
<q-btn
|
||||
v-if="isHeaderFilterField(col.field)"
|
||||
dense
|
||||
flat
|
||||
round
|
||||
size="8px"
|
||||
icon="filter_alt"
|
||||
:color="hasFilter(col.field) ? 'primary' : 'grey-7'"
|
||||
class="header-filter-btn"
|
||||
@click.stop
|
||||
>
|
||||
<q-badge
|
||||
v-if="hasFilter(col.field)"
|
||||
floating
|
||||
rounded
|
||||
color="primary"
|
||||
:label="getFilterBadgeValue(col.field)"
|
||||
/>
|
||||
<q-menu anchor="bottom right" self="top right" @click.stop>
|
||||
<div v-if="isMultiSelectFilterField(col.field)" class="excel-filter-menu">
|
||||
<q-input
|
||||
v-model="columnFilterSearch[col.field]"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
debounce="150"
|
||||
placeholder="Ara"
|
||||
/>
|
||||
<div class="excel-filter-actions row items-center justify-between q-mt-xs">
|
||||
<q-btn flat dense size="sm" color="primary" label="Tumunu Sec" @click="selectAllColumnFilterOptions(col.field)" />
|
||||
<q-btn flat dense size="sm" color="grey-8" label="Temizle" @click="clearColumnFilter(col.field)" />
|
||||
</div>
|
||||
<q-virtual-scroll
|
||||
v-if="getFilterOptionsForField(col.field).length > 0"
|
||||
class="excel-filter-options"
|
||||
:items="getFilterOptionsForField(col.field)"
|
||||
:virtual-scroll-item-size="32"
|
||||
separator
|
||||
>
|
||||
<template #default="{ item: option }">
|
||||
<q-item
|
||||
dense
|
||||
clickable
|
||||
class="excel-filter-option"
|
||||
@click="toggleColumnFilterValue(col.field, option.value)"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-checkbox
|
||||
dense
|
||||
:model-value="isColumnFilterValueSelected(col.field, option.value)"
|
||||
@click.stop
|
||||
@update:model-value="toggleColumnFilterValue(col.field, option.value)"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ option.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-virtual-scroll>
|
||||
<div v-else class="excel-filter-empty">Sonuc yok</div>
|
||||
</div>
|
||||
<div v-else-if="isNumberRangeFilterField(col.field)" class="excel-filter-menu">
|
||||
<div class="range-filter-grid">
|
||||
<q-input
|
||||
dense
|
||||
outlined
|
||||
type="number"
|
||||
label="Minimum"
|
||||
:model-value="numberRangeFilters[col.field]?.min"
|
||||
@update:model-value="(value) => updateNumberRangeFilter(col.field, 'min', value)"
|
||||
/>
|
||||
<q-input
|
||||
dense
|
||||
outlined
|
||||
type="number"
|
||||
label="Maksimum"
|
||||
:model-value="numberRangeFilters[col.field]?.max"
|
||||
@update:model-value="(value) => updateNumberRangeFilter(col.field, 'max', value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="excel-filter-actions row items-center justify-end q-mt-xs">
|
||||
<q-btn flat dense size="sm" color="grey-8" label="Temizle" @click="clearRangeFilter(col.field)" />
|
||||
</div>
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<span v-else class="header-filter-ghost" />
|
||||
</div>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
||||
<template #body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
:class="[col.classes, { 'sticky-col': isStickyCol(col.name), 'sticky-boundary': isStickyBoundary(col.name) }]"
|
||||
:style="getBodyCellStyle(col)"
|
||||
>
|
||||
<q-checkbox
|
||||
v-if="col.name === 'select'"
|
||||
dense
|
||||
:model-value="isRowSelected(props.row)"
|
||||
@update:model-value="(value) => setRowSelected(props.row, value)"
|
||||
/>
|
||||
<q-badge v-else-if="col.name === 'has_rule'" :color="props.row.has_rule ? 'positive' : 'grey-6'">
|
||||
{{ props.row.has_rule ? 'Tanimli' : 'Yeni' }}
|
||||
</q-badge>
|
||||
<q-toggle
|
||||
v-else-if="col.name === 'is_active'"
|
||||
v-model="props.row.is_active"
|
||||
dense
|
||||
@update:model-value="() => markDirty(props.row)"
|
||||
/>
|
||||
<input
|
||||
v-else-if="numericFields.has(col.name)"
|
||||
class="native-cell-input text-right"
|
||||
inputmode="decimal"
|
||||
:value="props.row[col.field]"
|
||||
@input="(event) => updateNumber(props.row, col.field, event.target.value)"
|
||||
>
|
||||
<span v-else class="cell-text" :title="String(col.value ?? '')">
|
||||
{{ col.value }}
|
||||
</span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { 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 selected = ref([])
|
||||
const tablePagination = ref({ rowsPerPage: 0, sortBy: 'urun_ilk_grubu', descending: false })
|
||||
let emptyRetryTimer = null
|
||||
|
||||
const numericFields = new Set([
|
||||
'try_base', 'try1', 'try2', 'try3', 'try4', 'try5', 'try6', 'try_step',
|
||||
'usd_base', 'usd1', 'usd2', 'usd3', 'usd4', 'usd5', 'usd6', 'usd_step',
|
||||
'eur_base', 'eur1', 'eur2', 'eur3', 'eur4', 'eur5', 'eur6', 'eur_step'
|
||||
])
|
||||
|
||||
const multiFilterFields = [
|
||||
'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu', 'urun_ana_grubu',
|
||||
'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group'
|
||||
]
|
||||
const multiSelectFilterFieldSet = new Set(multiFilterFields)
|
||||
const numberRangeFilterFieldSet = new Set(numericFields)
|
||||
const headerFilterFieldSet = new Set([...multiFilterFields, ...numericFields])
|
||||
|
||||
const columnFilters = ref(Object.fromEntries(multiFilterFields.map(field => [field, []])))
|
||||
const columnFilterSearch = ref(Object.fromEntries(multiFilterFields.map(field => [field, ''])))
|
||||
const numberRangeFilters = ref(Object.fromEntries([...numericFields].map(field => [field, { min: '', max: '' }])))
|
||||
|
||||
function col (name, label, field, width, extra = {}) {
|
||||
const size = `width:${width}px;min-width:${width}px;max-width:${width}px;`
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
field,
|
||||
sortable: name !== 'select',
|
||||
align: 'left',
|
||||
style: size,
|
||||
headerStyle: size,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
col('select', '', 'select', 34, { sortable: false, classes: 'selection-col', headerClasses: 'selection-col' }),
|
||||
col('has_rule', 'DURUM', 'has_rule', 62, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('is_active', 'AKTIF', 'is_active', 48, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('askili_yan', 'ASKILI YAN', 'askili_yan', 86, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('kategori', 'KATEGORI', 'kategori', 92, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('urun_ilk_grubu', 'URUN ILK GRUBU', 'urun_ilk_grubu', 100, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('urun_ana_grubu', 'URUN ANA GRUBU', 'urun_ana_grubu', 110, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('urun_alt_grubu', 'URUN ALT GRUBU', 'urun_alt_grubu', 110, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('icerik', 'ICERIK', 'icerik', 90, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('marka', 'MARKA', 'marka', 100, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('brand_code', 'BRAND CODE', 'brand_code', 78, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
col('brand_group', 'MARKA GRUBU', 'brand_group', 88, { classes: 'ps-col', headerClasses: 'ps-col' }),
|
||||
|
||||
col('try_step', 'TRY YUVARLAMA', 'try_step', 84, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try_base', 'TRY TABAN', 'try_base', 70, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try1', 'TRY 1', 'try1', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try2', 'TRY 2', 'try2', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try3', 'TRY 3', 'try3', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try4', 'TRY 4', 'try4', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try5', 'TRY 5', 'try5', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
col('try6', 'TRY 6', 'try6', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
|
||||
|
||||
col('usd_step', 'USD YUVARLAMA', 'usd_step', 84, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd_base', 'USD TABAN', 'usd_base', 70, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd1', 'USD 1', 'usd1', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd2', 'USD 2', 'usd2', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd3', 'USD 3', 'usd3', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd4', 'USD 4', 'usd4', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd5', 'USD 5', 'usd5', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
col('usd6', 'USD 6', 'usd6', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
|
||||
|
||||
col('eur_step', 'EUR YUVARLAMA', 'eur_step', 84, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur_base', 'EUR TABAN', 'eur_base', 70, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur1', 'EUR 1', 'eur1', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur2', 'EUR 2', 'eur2', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur3', 'EUR 3', 'eur3', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur4', 'EUR 4', 'eur4', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur5', 'EUR 5', 'eur5', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
|
||||
col('eur6', 'EUR 6', 'eur6', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' })
|
||||
]
|
||||
|
||||
const stickyColumnNames = [
|
||||
'select', 'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu',
|
||||
'urun_ana_grubu', 'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group'
|
||||
]
|
||||
const stickyBoundaryColumnName = 'brand_group'
|
||||
const stickyColumnNameSet = new Set(stickyColumnNames)
|
||||
|
||||
const stickyLeftMap = computed(() => {
|
||||
const map = {}
|
||||
let left = 0
|
||||
for (const colName of stickyColumnNames) {
|
||||
const column = columns.find(item => item.name === colName)
|
||||
if (!column) continue
|
||||
map[colName] = left
|
||||
left += extractWidth(column.style)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const stickyScrollComp = computed(() => {
|
||||
const boundary = columns.find(item => item.name === stickyBoundaryColumnName)
|
||||
return (stickyLeftMap.value[stickyBoundaryColumnName] || 0) + extractWidth(boundary?.style)
|
||||
})
|
||||
|
||||
const tableMinWidth = computed(() => columns.reduce((sum, column) => sum + extractWidth(column.style), 0))
|
||||
const tableStyle = computed(() => ({
|
||||
width: `${tableMinWidth.value}px`,
|
||||
minWidth: `${tableMinWidth.value}px`,
|
||||
tableLayout: 'fixed'
|
||||
}))
|
||||
|
||||
function filterDisplayValue (row, field) {
|
||||
if (field === 'has_rule') return row?.has_rule ? 'Tanimli' : 'Yeni'
|
||||
if (field === 'is_active') return row?.is_active ? 'Aktif' : 'Pasif'
|
||||
return String(row?.[field] ?? '').trim()
|
||||
}
|
||||
|
||||
const multiFilterOptionMap = computed(() => {
|
||||
const map = {}
|
||||
for (const field of multiFilterFields) {
|
||||
const uniq = new Set()
|
||||
for (const row of rows.value) {
|
||||
const value = filterDisplayValue(row, field)
|
||||
if (value) uniq.add(value)
|
||||
}
|
||||
map[field] = [...uniq]
|
||||
.sort((a, b) => a.localeCompare(b, 'tr'))
|
||||
.map(value => ({ label: value, value }))
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const filteredFilterOptionMap = computed(() => {
|
||||
const map = {}
|
||||
for (const field of multiFilterFields) {
|
||||
const search = String(columnFilterSearch.value[field] || '').trim().toLocaleLowerCase('tr')
|
||||
const options = multiFilterOptionMap.value[field] || []
|
||||
map[field] = search
|
||||
? options.filter(option => option.label.toLocaleLowerCase('tr').includes(search))
|
||||
: options
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
return rows.value.filter(row => {
|
||||
for (const field of multiFilterFields) {
|
||||
const selectedValues = columnFilters.value[field] || []
|
||||
if (selectedValues.length > 0 && !selectedValues.includes(filterDisplayValue(row, field))) return false
|
||||
}
|
||||
for (const field of numericFields) {
|
||||
const value = Number(row?.[field] ?? 0)
|
||||
const min = parseNullableNumber(numberRangeFilters.value[field]?.min)
|
||||
const max = parseNullableNumber(numberRangeFilters.value[field]?.max)
|
||||
if (min !== null && value < min) return false
|
||||
if (max !== null && value > max) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const visibleRowKeys = computed(() => filteredRows.value.map(row => row._row_key))
|
||||
const selectedVisibleCount = computed(() => visibleRowKeys.value.filter(key => selected.value.some(row => row._row_key === key)).length)
|
||||
const allSelectedVisible = computed(() => visibleRowKeys.value.length > 0 && selectedVisibleCount.value === visibleRowKeys.value.length)
|
||||
const someSelectedVisible = computed(() => selectedVisibleCount.value > 0)
|
||||
const selectedDirtyCount = computed(() => selected.value.filter(row => row?._dirty).length)
|
||||
const hasAnyFilter = computed(() => {
|
||||
return [...headerFilterFieldSet].some(field => hasFilter(field))
|
||||
})
|
||||
|
||||
function finiteNumber (value, fallback = 0) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
function parseNullableNumber (value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') return null
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function normalizeWorksheetRow (source) {
|
||||
const rule = source?.rule || {}
|
||||
const row = {
|
||||
pricing_parameter_id: Number(source?.pricing_parameter_id || 0),
|
||||
_row_key: String(source?.scope_key || source?.pricing_parameter_id || ''),
|
||||
has_rule: Boolean(source?.has_rule),
|
||||
id: String(rule?.id || ''),
|
||||
is_active: rule?.is_active !== false,
|
||||
askili_yan: String(source?.askili_yan || ''),
|
||||
kategori: String(source?.kategori || ''),
|
||||
urun_ilk_grubu: String(source?.urun_ilk_grubu || ''),
|
||||
urun_ana_grubu: String(source?.urun_ana_grubu || ''),
|
||||
urun_alt_grubu: String(source?.urun_alt_grubu || ''),
|
||||
icerik: String(source?.icerik || ''),
|
||||
marka: String(source?.marka || ''),
|
||||
brand_code: String(source?.brand_code || ''),
|
||||
brand_group: String(source?.brand_group || ''),
|
||||
_dirty: false
|
||||
}
|
||||
for (const key of numericFields) {
|
||||
row[key] = row.has_rule ? finiteNumber(rule?.[key], 0) : ''
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function extractWidth (style) {
|
||||
const match = String(style || '').match(/width:(\d+)px/)
|
||||
return match ? Number(match[1]) : 0
|
||||
}
|
||||
|
||||
function isStickyCol (colName) {
|
||||
return stickyColumnNameSet.has(colName)
|
||||
}
|
||||
|
||||
function isStickyBoundary (colName) {
|
||||
return colName === stickyBoundaryColumnName
|
||||
}
|
||||
|
||||
function getHeaderCellStyle (column) {
|
||||
if (!isStickyCol(column.name)) return undefined
|
||||
return { left: `${stickyLeftMap.value[column.name] || 0}px`, zIndex: 35 }
|
||||
}
|
||||
|
||||
function getBodyCellStyle (column) {
|
||||
if (!isStickyCol(column.name)) return undefined
|
||||
return { left: `${stickyLeftMap.value[column.name] || 0}px`, zIndex: 12 }
|
||||
}
|
||||
|
||||
function isRowSelected (row) {
|
||||
return selected.value.some(item => item._row_key === row._row_key)
|
||||
}
|
||||
|
||||
function setRowSelected (row, value) {
|
||||
if (value) {
|
||||
if (!isRowSelected(row)) selected.value = [...selected.value, row]
|
||||
return
|
||||
}
|
||||
selected.value = selected.value.filter(item => item._row_key !== row._row_key)
|
||||
}
|
||||
|
||||
function toggleSelectAllVisible (value) {
|
||||
const keys = new Set(visibleRowKeys.value)
|
||||
const remaining = selected.value.filter(row => !keys.has(row._row_key))
|
||||
selected.value = value ? [...remaining, ...filteredRows.value] : remaining
|
||||
}
|
||||
|
||||
function selectDirtyRow (row) {
|
||||
setRowSelected(row, true)
|
||||
}
|
||||
|
||||
function markDirty (row) {
|
||||
row._dirty = true
|
||||
selectDirtyRow(row)
|
||||
}
|
||||
|
||||
function updateNumber (row, field, value) {
|
||||
row[field] = String(value ?? '').trim() === '' ? '' : finiteNumber(value, 0)
|
||||
markDirty(row)
|
||||
}
|
||||
|
||||
function isHeaderFilterField (field) {
|
||||
return headerFilterFieldSet.has(field)
|
||||
}
|
||||
|
||||
function isMultiSelectFilterField (field) {
|
||||
return multiSelectFilterFieldSet.has(field)
|
||||
}
|
||||
|
||||
function isNumberRangeFilterField (field) {
|
||||
return numberRangeFilterFieldSet.has(field)
|
||||
}
|
||||
|
||||
function hasFilter (field) {
|
||||
if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length > 0
|
||||
if (isNumberRangeFilterField(field)) {
|
||||
const filter = numberRangeFilters.value[field]
|
||||
return String(filter?.min || '').trim() !== '' || String(filter?.max || '').trim() !== ''
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getFilterBadgeValue (field) {
|
||||
if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length
|
||||
if (isNumberRangeFilterField(field)) {
|
||||
const filter = numberRangeFilters.value[field]
|
||||
return [filter?.min, filter?.max].filter(value => String(value || '').trim() !== '').length
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function getFilterOptionsForField (field) {
|
||||
return filteredFilterOptionMap.value[field] || []
|
||||
}
|
||||
|
||||
function isColumnFilterValueSelected (field, value) {
|
||||
return (columnFilters.value[field] || []).includes(value)
|
||||
}
|
||||
|
||||
function toggleColumnFilterValue (field, value) {
|
||||
const current = new Set(columnFilters.value[field] || [])
|
||||
if (current.has(value)) current.delete(value)
|
||||
else current.add(value)
|
||||
columnFilters.value = { ...columnFilters.value, [field]: [...current] }
|
||||
}
|
||||
|
||||
function selectAllColumnFilterOptions (field) {
|
||||
columnFilters.value = {
|
||||
...columnFilters.value,
|
||||
[field]: getFilterOptionsForField(field).map(option => option.value)
|
||||
}
|
||||
}
|
||||
|
||||
function clearColumnFilter (field) {
|
||||
columnFilters.value = { ...columnFilters.value, [field]: [] }
|
||||
}
|
||||
|
||||
function updateNumberRangeFilter (field, side, value) {
|
||||
numberRangeFilters.value = {
|
||||
...numberRangeFilters.value,
|
||||
[field]: { ...numberRangeFilters.value[field], [side]: value }
|
||||
}
|
||||
}
|
||||
|
||||
function clearRangeFilter (field) {
|
||||
numberRangeFilters.value = {
|
||||
...numberRangeFilters.value,
|
||||
[field]: { min: '', max: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllFilters () {
|
||||
columnFilters.value = Object.fromEntries(multiFilterFields.map(field => [field, []]))
|
||||
columnFilterSearch.value = Object.fromEntries(multiFilterFields.map(field => [field, '']))
|
||||
numberRangeFilters.value = Object.fromEntries([...numericFields].map(field => [field, { min: '', max: '' }]))
|
||||
}
|
||||
|
||||
async function loadRows () {
|
||||
if (emptyRetryTimer) {
|
||||
clearTimeout(emptyRetryTimer)
|
||||
emptyRetryTimer = null
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request({
|
||||
method: 'GET',
|
||||
url: '/pricing/pricing-rules/parameters',
|
||||
timeout: 180000
|
||||
})
|
||||
rows.value = (Array.isArray(res?.data) ? res.data : []).map(normalizeWorksheetRow)
|
||||
selected.value = []
|
||||
if (rows.value.length === 0) {
|
||||
emptyRetryTimer = setTimeout(loadRows, 10000)
|
||||
}
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kural kombinasyonlari alinamadi' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelected () {
|
||||
const dirty = selected.value.filter(row => row?._dirty)
|
||||
if (dirty.length === 0) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
items: dirty.map(row => {
|
||||
const item = {
|
||||
id: row.id,
|
||||
pricing_parameter_id: row.pricing_parameter_id,
|
||||
is_active: Boolean(row.is_active)
|
||||
}
|
||||
for (const key of numericFields) item[key] = finiteNumber(row[key], 0)
|
||||
return item
|
||||
})
|
||||
}
|
||||
await api.request({
|
||||
method: 'POST',
|
||||
url: '/pricing/pricing-rules/bulk-save',
|
||||
data: payload,
|
||||
timeout: 180000
|
||||
})
|
||||
Notify.create({ type: 'positive', message: `Kaydedildi: ${dirty.length}` })
|
||||
await loadRows()
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kurallar kaydedilemedi' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRows)
|
||||
onBeforeUnmount(() => {
|
||||
if (emptyRetryTimer) clearTimeout(emptyRetryTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pricing-rules-page {
|
||||
--rules-row-height: 31px;
|
||||
--rules-header-height: 72px;
|
||||
--rules-table-height: calc(100vh - 210px);
|
||||
|
||||
min-width: 0;
|
||||
height: calc(100vh - 120px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-bar,
|
||||
.action-bar {
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pane-table {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rules-table :deep(.q-table__middle) {
|
||||
height: var(--rules-table-height);
|
||||
min-height: var(--rules-table-height);
|
||||
max-height: var(--rules-table-height);
|
||||
overflow: auto !important;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.rules-table :deep(.q-table) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
table-layout: fixed;
|
||||
font-size: 11px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin-right: var(--sticky-scroll-comp, 0px);
|
||||
}
|
||||
|
||||
.rules-table :deep(.q-table__container) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.rules-table :deep(th),
|
||||
.rules-table :deep(td) {
|
||||
box-sizing: border-box;
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.rules-table :deep(td),
|
||||
.rules-table :deep(.q-table tbody tr) {
|
||||
height: var(--rules-row-height) !important;
|
||||
min-height: var(--rules-row-height) !important;
|
||||
max-height: var(--rules-row-height) !important;
|
||||
line-height: var(--rules-row-height);
|
||||
padding: 0 !important;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.rules-table :deep(th),
|
||||
.rules-table :deep(.q-table thead tr),
|
||||
.rules-table :deep(.q-table thead tr.header-row-fixed),
|
||||
.rules-table :deep(.q-table thead th),
|
||||
.rules-table :deep(.q-table thead tr.header-row-fixed > th) {
|
||||
height: var(--rules-header-height) !important;
|
||||
min-height: var(--rules-header-height) !important;
|
||||
max-height: var(--rules-header-height) !important;
|
||||
}
|
||||
|
||||
.rules-table :deep(th) {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
white-space: nowrap;
|
||||
word-break: normal;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.rules-table :deep(.q-table thead th) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: #fff;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.rules-table :deep(.sticky-col) {
|
||||
position: sticky !important;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.rules-table :deep(thead .sticky-col) {
|
||||
z-index: 35 !important;
|
||||
}
|
||||
|
||||
.rules-table :deep(tbody .sticky-col) {
|
||||
z-index: 12 !important;
|
||||
}
|
||||
|
||||
.rules-table :deep(.sticky-boundary) {
|
||||
border-right: 2px solid rgba(25, 118, 210, 0.18) !important;
|
||||
box-shadow: 8px 0 12px -10px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.header-with-filter {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 20px;
|
||||
align-items: center;
|
||||
column-gap: 4px;
|
||||
height: 100%;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-with-filter > span {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.header-filter-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.header-filter-ghost {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.excel-filter-menu {
|
||||
min-width: 230px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.range-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.excel-filter-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.excel-filter-options {
|
||||
max-height: 220px;
|
||||
margin-top: 8px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.excel-filter-option {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.excel-filter-empty {
|
||||
padding: 10px 8px;
|
||||
color: #607d8b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rules-table :deep(th.ps-col),
|
||||
.rules-table :deep(td.ps-col) {
|
||||
background: #fff;
|
||||
color: var(--q-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rules-table :deep(th.selection-col),
|
||||
.rules-table :deep(td.selection-col) {
|
||||
background: #fff;
|
||||
color: var(--q-primary);
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.rules-table :deep(.selection-col .q-checkbox__inner) {
|
||||
color: var(--q-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.rules-table :deep(th.usd-col),
|
||||
.rules-table :deep(td.usd-col) {
|
||||
background: #ecf9f0;
|
||||
color: #178a3e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rules-table :deep(th.eur-col),
|
||||
.rules-table :deep(td.eur-col) {
|
||||
background: #fdeeee;
|
||||
color: #c62828;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rules-table :deep(th.try-col),
|
||||
.rules-table :deep(td.try-col) {
|
||||
background: #edf4ff;
|
||||
color: #1e63c6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cell-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.native-cell-input {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
box-sizing: border-box;
|
||||
padding: 1px 3px;
|
||||
border: 1px solid #cfd8dc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.native-cell-input:focus {
|
||||
outline: none;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
</style>
|
||||
@@ -2,58 +2,106 @@
|
||||
<q-page class="q-pa-xs pricing-page">
|
||||
<div class="top-bar row items-center justify-between q-mb-xs">
|
||||
<div class="text-subtitle1 text-weight-bold">Urun Fiyatlandirma</div>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-btn-dropdown color="secondary" outline icon="view_module" label="Doviz Gorunumu" :auto-close="false">
|
||||
<q-list dense class="currency-menu-list">
|
||||
<q-item clickable @click="selectAllCurrencies">
|
||||
<q-item-section>Tumunu Sec</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable @click="clearAllCurrencies">
|
||||
<q-item-section>Tumunu Temizle</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item v-for="option in currencyOptions" :key="option.value" clickable @click="toggleCurrencyRow(option.value)">
|
||||
<q-item-section avatar>
|
||||
<q-checkbox
|
||||
:model-value="isCurrencySelected(option.value)"
|
||||
dense
|
||||
@update:model-value="(val) => toggleCurrency(option.value, val)"
|
||||
@click.stop
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>{{ option.label }}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
<q-btn
|
||||
flat
|
||||
:color="showSelectedOnly ? 'primary' : 'grey-7'"
|
||||
:icon="showSelectedOnly ? 'checklist_rtl' : 'list_alt'"
|
||||
:label="showSelectedOnly ? `Secililer (${selectedRowCount})` : 'Secili Olanlari Getir'"
|
||||
:disable="!showSelectedOnly && selectedRowCount === 0"
|
||||
@click="toggleShowSelectedOnly"
|
||||
/>
|
||||
<q-btn flat color="grey-7" icon="restart_alt" label="Filtreleri Sifirla" @click="resetAll" />
|
||||
<q-btn color="primary" icon="refresh" label="Veriyi Yenile" :loading="store.loading" @click="reloadData" />
|
||||
<q-btn
|
||||
color="primary"
|
||||
outline
|
||||
icon="edit_note"
|
||||
label="Secili Olanlari Toplu Degistir"
|
||||
:disable="selectedRowCount === 0"
|
||||
@click="bulkDialogOpen = true"
|
||||
/>
|
||||
<q-pagination
|
||||
v-model="currentPage"
|
||||
color="primary"
|
||||
:max="Math.max(1, store.totalPages || 1)"
|
||||
:max-pages="8"
|
||||
boundary-links
|
||||
direction-links
|
||||
@update:model-value="onPageChange"
|
||||
/>
|
||||
<div class="text-caption text-grey-8">
|
||||
Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu
|
||||
<div class="top-actions">
|
||||
<div class="row items-center q-gutter-xs top-actions-row">
|
||||
<q-select
|
||||
v-model="topUrunIlkGrubu"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
emit-value
|
||||
map-options
|
||||
:options="topUrunIlkGrubuOptions"
|
||||
:loading="Boolean(serverFilterLoading.urunIlkGrubu)"
|
||||
label="Urun Ilk Grubu"
|
||||
style="min-width: 220px"
|
||||
@filter="onTopFilterSearchUrunIlkGrubu"
|
||||
@update:model-value="onTopUrunIlkGrubuChange"
|
||||
/>
|
||||
<q-select
|
||||
v-model="topUrunAnaGrubu"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
multiple
|
||||
use-chips
|
||||
emit-value
|
||||
map-options
|
||||
:options="topUrunAnaGrubuOptions"
|
||||
:loading="Boolean(serverFilterLoading.urunAnaGrubu)"
|
||||
label="Urun Ana Grubu (max 3)"
|
||||
style="min-width: 260px"
|
||||
@filter="onTopFilterSearchUrunAnaGrubu"
|
||||
@update:model-value="onTopUrunAnaGrubuChange"
|
||||
/>
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="filter_alt"
|
||||
label="Gruplari Getir"
|
||||
:disable="!canFetchByGroup"
|
||||
:loading="store.loading"
|
||||
@click="reloadData({ page: 1 })"
|
||||
/>
|
||||
<q-btn
|
||||
flat
|
||||
color="grey-7"
|
||||
icon="restart_alt"
|
||||
label="Secimleri Sifirla"
|
||||
@click="resetGroupSelections"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row items-center q-gutter-xs top-actions-row">
|
||||
<q-btn-dropdown color="secondary" outline icon="view_module" label="Doviz Gorunumu" :auto-close="false">
|
||||
<q-list dense class="currency-menu-list">
|
||||
<q-item clickable @click="selectAllCurrencies">
|
||||
<q-item-section>Tumunu Sec</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable @click="clearAllCurrencies">
|
||||
<q-item-section>Tumunu Temizle</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item v-for="option in currencyOptions" :key="option.value" clickable @click="toggleCurrencyRow(option.value)">
|
||||
<q-item-section avatar>
|
||||
<q-checkbox
|
||||
:model-value="isCurrencySelected(option.value)"
|
||||
dense
|
||||
@update:model-value="(val) => toggleCurrency(option.value, val)"
|
||||
@click.stop
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>{{ option.label }}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
<q-btn
|
||||
flat
|
||||
:color="showSelectedOnly ? 'primary' : 'grey-7'"
|
||||
:icon="showSelectedOnly ? 'checklist_rtl' : 'list_alt'"
|
||||
:label="showSelectedOnly ? `Secililer (${selectedRowCount})` : 'Secili Olanlari Getir'"
|
||||
:disable="!showSelectedOnly && selectedRowCount === 0"
|
||||
@click="toggleShowSelectedOnly"
|
||||
/>
|
||||
<q-btn
|
||||
color="primary"
|
||||
outline
|
||||
icon="edit_note"
|
||||
label="Secili Olanlari Toplu Degistir"
|
||||
:disable="selectedRowCount === 0"
|
||||
@click="bulkDialogOpen = true"
|
||||
/>
|
||||
<q-pagination
|
||||
v-model="currentPage"
|
||||
color="primary"
|
||||
:max="Math.max(1, store.totalPages || 1)"
|
||||
:max-pages="8"
|
||||
boundary-links
|
||||
direction-links
|
||||
@update:model-value="onPageChange"
|
||||
/>
|
||||
<div class="text-caption text-grey-8">
|
||||
Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,7 +154,12 @@
|
||||
<q-badge v-if="hasFilter(col.field)" color="primary" floating rounded>
|
||||
{{ getFilterBadgeValue(col.field) }}
|
||||
</q-badge>
|
||||
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
|
||||
<q-menu
|
||||
anchor="bottom right"
|
||||
self="top right"
|
||||
:offset="[0, 4]"
|
||||
@before-show="() => onFilterMenuBeforeShow(col.field)"
|
||||
>
|
||||
<div v-if="isMultiSelectFilterField(col.field)" class="excel-filter-menu">
|
||||
<q-input
|
||||
v-model="columnFilterSearch[col.field]"
|
||||
@@ -333,6 +386,24 @@
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-lastCostingDate="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="[
|
||||
{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) },
|
||||
{ 'cell-danger': needsCosting(props.row) }
|
||||
]"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span :class="['date-cell-text', { 'text-white': needsCosting(props.row) }]">
|
||||
{{ formatDateDisplay(props.value) }}
|
||||
</span>
|
||||
<q-tooltip v-if="needsCosting(props.row)" anchor="top middle" self="bottom middle" :offset="[0, 6]">
|
||||
Stok girisinden sonra maliyetlendirme yapilmamis. Urun Ilk Grubu: {{ props.row.urunIlkGrubu || '-' }}
|
||||
</q-tooltip>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-lastPricingDate="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
@@ -351,16 +422,9 @@
|
||||
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<select
|
||||
class="native-cell-select"
|
||||
:value="props.row.brandGroupSelection"
|
||||
@change="(e) => onBrandGroupSelectionChange(props.row, e.target.value)"
|
||||
>
|
||||
<option value="">Seciniz</option>
|
||||
<option v-for="opt in brandGroupOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="cell-text" :title="props.row.brandGroupSelection || ''">
|
||||
{{ props.row.brandGroupSelection || '' }}
|
||||
</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
@@ -428,9 +492,10 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useProductPricingStore } from 'src/stores/ProductPricingStore'
|
||||
import api from 'src/services/api'
|
||||
|
||||
const store = useProductPricingStore()
|
||||
const PAGE_LIMIT = 500
|
||||
const PAGE_LIMIT = 250
|
||||
const currentPage = ref(1)
|
||||
let reloadTimer = null
|
||||
|
||||
@@ -440,11 +505,7 @@ const multipliers = [1, 1.03, 1.06, 1.09, 1.12, 1.15]
|
||||
const rowHeight = 31
|
||||
const headerHeight = 72
|
||||
|
||||
const brandGroupOptions = [
|
||||
{ label: 'MARKA GRUBU A', value: 'MARKA GRUBU A' },
|
||||
{ label: 'MARKA GRUBU B', value: 'MARKA GRUBU B' },
|
||||
{ label: 'MARKA GRUBU C', value: 'MARKA GRUBU C' }
|
||||
]
|
||||
// Marka grubu artik Marka Siniflandirma modulunden (mk_brandgrp) gelir ve listede sadece goruntulenir.
|
||||
|
||||
const currencyOptions = [
|
||||
{ label: 'USD', value: 'USD' },
|
||||
@@ -454,7 +515,7 @@ const currencyOptions = [
|
||||
|
||||
const multiFilterColumns = [
|
||||
{ field: 'productCode', label: 'Urun Kodu' },
|
||||
{ field: 'brandGroupSelection', label: 'Marka Grubu Secimi' },
|
||||
{ field: 'brandGroupSelection', label: 'Marka Grubu' },
|
||||
{ field: 'marka', label: 'Marka' },
|
||||
{ field: 'askiliYan', label: 'Askili Yan' },
|
||||
{ field: 'kategori', label: 'Kategori' },
|
||||
@@ -466,7 +527,6 @@ const multiFilterColumns = [
|
||||
]
|
||||
const serverBackedMultiFilterFields = new Set([
|
||||
'productCode',
|
||||
'brandGroupSelection',
|
||||
'marka',
|
||||
'askiliYan',
|
||||
'kategori',
|
||||
@@ -526,6 +586,130 @@ const columnFilterSearch = ref({
|
||||
icerik: '',
|
||||
karisim: ''
|
||||
})
|
||||
|
||||
const serverFilterOptionMap = ref({})
|
||||
const serverFilterLoading = ref({})
|
||||
const serverFilterLastQuery = ref({})
|
||||
const serverFilterTimers = {}
|
||||
|
||||
const topUrunIlkGrubu = ref(null)
|
||||
const topUrunAnaGrubu = ref([])
|
||||
|
||||
const topUrunIlkGrubuOptions = computed(() => serverFilterOptionMap.value.urunIlkGrubu || [])
|
||||
const topUrunAnaGrubuOptions = computed(() => serverFilterOptionMap.value.urunAnaGrubu || [])
|
||||
const canFetchByGroup = computed(() => {
|
||||
return Boolean(String(topUrunIlkGrubu.value || '').trim()) || (topUrunAnaGrubu.value?.length || 0) > 0
|
||||
})
|
||||
|
||||
async function fetchServerFilterOptions (field, { force = false } = {}) {
|
||||
if (!serverBackedMultiFilterFields.has(field)) return
|
||||
const q = String(columnFilterSearch.value[field] || '').trim()
|
||||
const lastQ = String(serverFilterLastQuery.value[field] || '')
|
||||
const hasCached = Array.isArray(serverFilterOptionMap.value[field]) && serverFilterOptionMap.value[field].length > 0
|
||||
if (!force && hasCached && q === lastQ) return
|
||||
if (serverFilterLoading.value[field]) return
|
||||
|
||||
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: true }
|
||||
serverFilterLastQuery.value = { ...serverFilterLastQuery.value, [field]: q }
|
||||
try {
|
||||
const params = { field, q, limit: 160 }
|
||||
// Cascade scope for Urun Ana Grubu options.
|
||||
if (field === 'urunAnaGrubu') {
|
||||
const ilk = String(topUrunIlkGrubu.value || '').trim()
|
||||
if (ilk) params.urun_ilk_grubu = [ilk]
|
||||
}
|
||||
const res = await api.get('/pricing/products/options', { params })
|
||||
const items = Array.isArray(res?.data?.items) ? res.data.items : []
|
||||
serverFilterOptionMap.value = {
|
||||
...serverFilterOptionMap.value,
|
||||
[field]: items.map((x) => ({
|
||||
label: String(x?.label ?? x?.value ?? '').trim(),
|
||||
value: String(x?.value ?? x?.label ?? '').trim()
|
||||
})).filter((x) => x.value)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[product-pricing][ui] filter options error', {
|
||||
field,
|
||||
q,
|
||||
message: String(err?.message || err || 'options failed')
|
||||
})
|
||||
serverFilterOptionMap.value = { ...serverFilterOptionMap.value, [field]: [] }
|
||||
} finally {
|
||||
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: false }
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleServerFilterOptionsFetch (field) {
|
||||
if (!serverBackedMultiFilterFields.has(field)) return
|
||||
if (serverFilterTimers[field]) clearTimeout(serverFilterTimers[field])
|
||||
serverFilterTimers[field] = setTimeout(() => {
|
||||
serverFilterTimers[field] = null
|
||||
void fetchServerFilterOptions(field)
|
||||
}, 220)
|
||||
}
|
||||
|
||||
function onFilterMenuBeforeShow (field) {
|
||||
if (!serverBackedMultiFilterFields.has(field)) return
|
||||
void fetchServerFilterOptions(field)
|
||||
}
|
||||
|
||||
function onTopFilterSearchUrunIlkGrubu (val, update) {
|
||||
update(() => {
|
||||
columnFilterSearch.value = { ...columnFilterSearch.value, urunIlkGrubu: String(val || '') }
|
||||
scheduleServerFilterOptionsFetch('urunIlkGrubu')
|
||||
})
|
||||
}
|
||||
|
||||
function onTopFilterSearchUrunAnaGrubu (val, update) {
|
||||
update(() => {
|
||||
columnFilterSearch.value = { ...columnFilterSearch.value, urunAnaGrubu: String(val || '') }
|
||||
scheduleServerFilterOptionsFetch('urunAnaGrubu')
|
||||
})
|
||||
}
|
||||
|
||||
function applyTopGroupFiltersToColumnFilters () {
|
||||
// Enforce max 3 selection for Urun Ana Grubu.
|
||||
const nextAna = Array.isArray(topUrunAnaGrubu.value) ? topUrunAnaGrubu.value.slice(0, 3) : []
|
||||
if (nextAna.length !== (topUrunAnaGrubu.value || []).length) topUrunAnaGrubu.value = nextAna
|
||||
const ilk = String(topUrunIlkGrubu.value || '').trim()
|
||||
|
||||
columnFilters.value = {
|
||||
...columnFilters.value,
|
||||
urunIlkGrubu: ilk ? [ilk] : [],
|
||||
urunAnaGrubu: nextAna
|
||||
}
|
||||
}
|
||||
|
||||
function onTopUrunIlkGrubuChange () {
|
||||
// Cascade: when Ilk Grubu changes, clear Ana Grubu selection and refetch options scoped by Ilk Grubu.
|
||||
topUrunAnaGrubu.value = []
|
||||
applyTopGroupFiltersToColumnFilters()
|
||||
void fetchServerFilterOptions('urunAnaGrubu', { force: true })
|
||||
}
|
||||
|
||||
function onTopUrunAnaGrubuChange () {
|
||||
applyTopGroupFiltersToColumnFilters()
|
||||
}
|
||||
|
||||
function resetGroupSelections () {
|
||||
topUrunIlkGrubu.value = null
|
||||
topUrunAnaGrubu.value = []
|
||||
applyTopGroupFiltersToColumnFilters()
|
||||
// Keep other local filters cleared too, so page is "clean render".
|
||||
store.rows = []
|
||||
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
|
||||
store.totalCount = 0
|
||||
store.totalPages = 1
|
||||
store.page = 1
|
||||
store.hasMore = false
|
||||
}
|
||||
|
||||
for (const field of Array.from(serverBackedMultiFilterFields)) {
|
||||
watch(
|
||||
() => columnFilterSearch.value[field],
|
||||
() => { scheduleServerFilterOptionsFetch(field) }
|
||||
)
|
||||
}
|
||||
const numberRangeFilters = ref({
|
||||
stockQty: { min: '', max: '' }
|
||||
})
|
||||
@@ -608,6 +792,7 @@ const allColumns = [
|
||||
col('calcAction', 'HESAPLA', 'calcAction', 72, { align: 'center', classes: 'ps-col' }),
|
||||
col('stockQty', 'STOK ADET', 'stockQty', 72, { align: 'right', sortable: true, classes: 'ps-col stock-col' }),
|
||||
col('stockEntryDate', 'STOK GIRIS TARIHI', 'stockEntryDate', 92, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
|
||||
col('lastCostingDate', 'SON MALIYETLENDIRME', 'lastCostingDate', 110, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
|
||||
col('lastPricingDate', 'SON FIYATLANDIRMA TARIHI', 'lastPricingDate', 108, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
|
||||
col('askiliYan', 'ASKILI YAN', 'askiliYan', 54, { sortable: true, classes: 'ps-col' }),
|
||||
col('kategori', 'KATEGORI', 'kategori', 54, { sortable: true, classes: 'ps-col' }),
|
||||
@@ -880,6 +1065,9 @@ function clearRangeFilter (field) {
|
||||
|
||||
function getFilterOptionsForField (field) {
|
||||
if (isValueSelectFilterField(field)) return filteredValueFilterOptionMap.value[field] || []
|
||||
if (serverBackedMultiFilterFields.has(field)) {
|
||||
return serverFilterOptionMap.value[field] || []
|
||||
}
|
||||
return filteredFilterOptionMap.value[field] || []
|
||||
}
|
||||
|
||||
@@ -1025,6 +1213,14 @@ function needsRepricing (row) {
|
||||
return lastPricingDate < stockEntryDate
|
||||
}
|
||||
|
||||
function needsCosting (row) {
|
||||
const stockEntryDate = String(row?.stockEntryDate || '').trim()
|
||||
const lastCostingDate = String(row?.lastCostingDate || '').trim()
|
||||
if (!stockEntryDate) return false
|
||||
if (!lastCostingDate) return true
|
||||
return lastCostingDate < stockEntryDate
|
||||
}
|
||||
|
||||
function recalcByBasePrice (row) {
|
||||
row.basePriceTry = round2((row.basePriceUsd * usdToTry) + row.expenseForBasePrice)
|
||||
multipliers.forEach((multiplier, index) => {
|
||||
@@ -1047,7 +1243,7 @@ function calculateRow (row) {
|
||||
}
|
||||
|
||||
function onBrandGroupSelectionChange (row, val) {
|
||||
store.updateBrandGroupSelection(row, val)
|
||||
// no-op (read-only)
|
||||
}
|
||||
|
||||
function isRowSelected (rowKey) {
|
||||
@@ -1150,12 +1346,20 @@ function clearAllCurrencies () {
|
||||
}
|
||||
|
||||
function onPaginationChange (next) {
|
||||
const prevSortBy = tablePagination.value.sortBy
|
||||
const prevDesc = tablePagination.value.descending
|
||||
tablePagination.value = {
|
||||
...tablePagination.value,
|
||||
...(next || {}),
|
||||
page: 1,
|
||||
rowsPerPage: 0
|
||||
}
|
||||
const nextSortBy = tablePagination.value.sortBy
|
||||
const nextDesc = tablePagination.value.descending
|
||||
if (nextSortBy !== prevSortBy || nextDesc !== prevDesc) {
|
||||
currentPage.value = 1
|
||||
void reloadData({ page: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
function buildServerFilters () {
|
||||
@@ -1181,17 +1385,41 @@ function scheduleReload () {
|
||||
}, 180)
|
||||
}
|
||||
|
||||
async function fetchChunk ({ page = 1 } = {}) {
|
||||
const result = await store.fetchRows({
|
||||
limit: PAGE_LIMIT,
|
||||
page,
|
||||
append: false,
|
||||
silent: false,
|
||||
filters: buildServerFilters()
|
||||
})
|
||||
currentPage.value = Number(result?.page) || page
|
||||
return Number(result?.fetched) || 0
|
||||
}
|
||||
async function fetchChunk ({ page = 1 } = {}) {
|
||||
const filters = buildServerFilters()
|
||||
const hasAnyFilter = Object.values(filters).some((v) => Array.isArray(v) && v.length > 0)
|
||||
const hasPrimaryFilter = (filters.urun_ilk_grubu?.length || 0) > 0 || (filters.urun_ana_grubu?.length || 0) > 0
|
||||
if (!hasAnyFilter) {
|
||||
// This endpoint is expensive without filters; require the user to scope down first.
|
||||
store.rows = []
|
||||
store.error = 'Liste cok buyuk. Lutfen en az bir filtre secin (or: Urun Ilk Grubu / Urun Ana Grubu / Urun Kodu).'
|
||||
store.totalCount = 0
|
||||
store.totalPages = 1
|
||||
store.page = 1
|
||||
store.hasMore = false
|
||||
return 0
|
||||
}
|
||||
if (!hasPrimaryFilter) {
|
||||
store.rows = []
|
||||
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
|
||||
store.totalCount = 0
|
||||
store.totalPages = 1
|
||||
store.page = 1
|
||||
store.hasMore = false
|
||||
return 0
|
||||
}
|
||||
const result = await store.fetchRows({
|
||||
limit: PAGE_LIMIT,
|
||||
page,
|
||||
append: false,
|
||||
silent: false,
|
||||
filters,
|
||||
sortBy: tablePagination.value.sortBy,
|
||||
descending: tablePagination.value.descending
|
||||
})
|
||||
currentPage.value = Number(result?.page) || page
|
||||
return Number(result?.fetched) || 0
|
||||
}
|
||||
|
||||
async function reloadData ({ page = 1 } = {}) {
|
||||
const startedAt = Date.now()
|
||||
@@ -1211,9 +1439,10 @@ async function reloadData ({ page = 1 } = {}) {
|
||||
row_count: Array.isArray(store.rows) ? store.rows.length : 0,
|
||||
has_error: Boolean(store.error)
|
||||
})
|
||||
selectedMap.value = {}
|
||||
}
|
||||
|
||||
// Full "fetch all pages" is intentionally avoided; keep server-side paging for performance.
|
||||
|
||||
function onPageChange (page) {
|
||||
const p = Number(page) > 0 ? Number(page) : 1
|
||||
if (store.loading) return
|
||||
@@ -1223,7 +1452,16 @@ function onPageChange (page) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await reloadData({ page: currentPage.value })
|
||||
// Prefetch a couple of common filters so the first open is not empty.
|
||||
void fetchServerFilterOptions('urunIlkGrubu')
|
||||
void fetchServerFilterOptions('urunAnaGrubu')
|
||||
// Do not auto-fetch listing on mount; user must scope by group first.
|
||||
store.rows = []
|
||||
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
|
||||
store.totalCount = 0
|
||||
store.totalPages = 1
|
||||
store.page = 1
|
||||
store.hasMore = false
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -1233,11 +1471,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[columnFilters],
|
||||
() => { scheduleReload() },
|
||||
{ deep: true }
|
||||
)
|
||||
// NOTE: Listing fetch is intentionally manual via "Gruplari Getir" for performance.
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1256,6 +1490,18 @@ watch(
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.top-actions-row {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1489,6 +1735,10 @@ watch(
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.cell-danger {
|
||||
background: #c62828 !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(th.selection-col),
|
||||
.pricing-table :deep(td.selection-col) {
|
||||
background: #fff;
|
||||
|
||||
@@ -124,9 +124,21 @@
|
||||
:rows-per-page-options="[0]"
|
||||
hide-bottom
|
||||
>
|
||||
<template #body-cell="props">
|
||||
<q-td :props="props" :class="props.col.classes">
|
||||
<template v-if="props.col.name === 'open'">
|
||||
<template #body="props">
|
||||
<q-tr
|
||||
:id="rowDomId(props.row)"
|
||||
:props="props"
|
||||
class="rdp-data-row"
|
||||
@mouseenter="showMembersPopup(props.row)"
|
||||
@mouseleave="scheduleHideMembersPopup"
|
||||
>
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
:class="col.classes"
|
||||
>
|
||||
<template v-if="col.name === 'open'">
|
||||
<div class="text-center">
|
||||
<q-btn
|
||||
icon="open_in_new"
|
||||
@@ -141,10 +153,10 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isPermissionColumn(props.col.name)">
|
||||
<template v-else-if="isPermissionColumn(col.name)">
|
||||
<div class="text-center">
|
||||
<q-checkbox
|
||||
:model-value="Boolean(props.value)"
|
||||
:model-value="Boolean(col.value)"
|
||||
disable
|
||||
dense
|
||||
/>
|
||||
@@ -152,12 +164,43 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
{{ props.value }}
|
||||
{{ col.value }}
|
||||
</template>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<q-menu
|
||||
v-model="membersPopupOpen"
|
||||
:target="membersPopupTarget"
|
||||
no-parent-event
|
||||
anchor="center right"
|
||||
self="center left"
|
||||
:offset="[8, 0]"
|
||||
class="rdp-members-popup"
|
||||
@mouseenter="cancelHideMembersPopup"
|
||||
@mouseleave="scheduleHideMembersPopup"
|
||||
>
|
||||
<div class="rdp-members-popup__header">
|
||||
<div class="text-weight-bold">{{ hoveredRow?.role_title }}</div>
|
||||
<div class="text-caption text-grey-7">{{ hoveredRow?.department_title }}</div>
|
||||
</div>
|
||||
<q-separator />
|
||||
<q-list v-if="hoveredMembers.length" dense class="rdp-members-popup__list">
|
||||
<q-item v-for="member in hoveredMembers" :key="member.id">
|
||||
<q-item-section avatar class="rdp-member-id">{{ member.id }}</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ member.full_name || member.username }}</q-item-label>
|
||||
<q-item-label caption>{{ member.username }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div v-else class="q-pa-sm text-caption text-grey-7">
|
||||
Bu grupta aktif kullanici bulunmuyor.
|
||||
</div>
|
||||
</q-menu>
|
||||
|
||||
<q-banner v-if="store.error" class="bg-red text-white q-mt-sm">
|
||||
Hata: {{ store.error }}
|
||||
</q-banner>
|
||||
@@ -171,7 +214,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { usePermission } from 'src/composables/usePermission'
|
||||
@@ -187,6 +230,10 @@ const selectedModules = ref([])
|
||||
const selectedActionsByModule = ref({})
|
||||
const activeModuleCode = ref('')
|
||||
const allowEmptySelection = ref(false)
|
||||
const membersPopupOpen = ref(false)
|
||||
const membersPopupTarget = ref(false)
|
||||
const hoveredRow = ref(null)
|
||||
let membersPopupHideTimer = null
|
||||
|
||||
const actionLabelMap = {
|
||||
update: 'Güncelleme',
|
||||
@@ -428,6 +475,37 @@ const tableRows = computed(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
const hoveredMembers = computed(() => hoveredRow.value?.members || [])
|
||||
|
||||
function rowDomId (row) {
|
||||
const roleID = String(row?.role_id || '0').replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
const departmentCode = String(row?.department_code || '').replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
return `rdp-row-${roleID}-${departmentCode}`
|
||||
}
|
||||
|
||||
function cancelHideMembersPopup () {
|
||||
if (!membersPopupHideTimer) return
|
||||
clearTimeout(membersPopupHideTimer)
|
||||
membersPopupHideTimer = null
|
||||
}
|
||||
|
||||
function showMembersPopup (row) {
|
||||
cancelHideMembersPopup()
|
||||
hoveredRow.value = row
|
||||
membersPopupTarget.value = `#${rowDomId(row)}`
|
||||
membersPopupOpen.value = true
|
||||
}
|
||||
|
||||
function scheduleHideMembersPopup () {
|
||||
cancelHideMembersPopup()
|
||||
membersPopupHideTimer = setTimeout(() => {
|
||||
membersPopupOpen.value = false
|
||||
hoveredRow.value = null
|
||||
membersPopupTarget.value = false
|
||||
membersPopupHideTimer = null
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function isPermissionColumn (name) {
|
||||
return String(name || '').startsWith('perm_')
|
||||
}
|
||||
@@ -472,6 +550,10 @@ onMounted(async () => {
|
||||
syncSelections()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelHideMembersPopup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -571,6 +653,30 @@ onMounted(async () => {
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.rdp-data-row {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rdp-members-popup {
|
||||
min-width: 280px;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.rdp-members-popup__header {
|
||||
padding: 10px 12px 8px;
|
||||
}
|
||||
|
||||
.rdp-members-popup__list {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rdp-member-id {
|
||||
min-width: 48px;
|
||||
color: #1976d2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rdp-table :deep(.q-checkbox__inner) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,63 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="lookupsLoaded && roleId && deptCode"
|
||||
class="group-members-toolbar"
|
||||
>
|
||||
<div class="group-members-toolbar__members">
|
||||
<div class="text-caption text-weight-bold">
|
||||
Grup Kullanicilari ({{ members.length }})
|
||||
</div>
|
||||
<div v-if="membersLoading" class="q-ml-sm">
|
||||
<q-spinner color="primary" size="18px" />
|
||||
</div>
|
||||
<div v-else-if="members.length" class="group-members-toolbar__chips">
|
||||
<q-chip
|
||||
v-for="member in members"
|
||||
:key="member.id"
|
||||
dense
|
||||
square
|
||||
color="blue-1"
|
||||
text-color="primary"
|
||||
>
|
||||
{{ member.id }} - {{ member.full_name || member.username }}
|
||||
<q-tooltip>{{ member.username }}</q-tooltip>
|
||||
</q-chip>
|
||||
</div>
|
||||
<div v-else class="text-caption text-grey-7 q-ml-sm">
|
||||
Bu grupta aktif kullanici bulunmuyor.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group-members-toolbar__add">
|
||||
<q-select
|
||||
v-model="memberUserId"
|
||||
:options="filteredUserOptions"
|
||||
option-value="id"
|
||||
option-label="title"
|
||||
emit-value
|
||||
map-options
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
use-input
|
||||
input-debounce="150"
|
||||
label="Kullanici ekle"
|
||||
class="group-members-toolbar__select"
|
||||
@filter="filterUsers"
|
||||
/>
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="person_add"
|
||||
label="Ekle"
|
||||
:disable="!memberUserId || addingMember"
|
||||
:loading="addingMember"
|
||||
@click="addMember"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -184,7 +241,7 @@
|
||||
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Notify } from 'quasar'
|
||||
import api from 'src/services/api'
|
||||
@@ -201,13 +258,19 @@ const router = useRouter()
|
||||
|
||||
const roles = ref([])
|
||||
const departments = ref([])
|
||||
const users = ref([])
|
||||
const filteredUserOptions = ref([])
|
||||
const members = ref([])
|
||||
|
||||
const roleId = ref(null)
|
||||
const deptCode = ref(null)
|
||||
const memberUserId = ref(null)
|
||||
|
||||
const rows = ref([])
|
||||
|
||||
const loading = ref(false)
|
||||
const membersLoading = ref(false)
|
||||
const addingMember = ref(false)
|
||||
const dirty = ref(false)
|
||||
const lookupsLoaded = ref(false)
|
||||
|
||||
@@ -274,15 +337,18 @@ function applyRouteSelection () {
|
||||
|
||||
async function loadLookups () {
|
||||
|
||||
const [r, d, m] = await Promise.all([
|
||||
const [r, d, m, u] = await Promise.all([
|
||||
api.get('/lookups/roles-perm'),
|
||||
api.get('/lookups/departments-perm'),
|
||||
api.get('/lookups/modules')
|
||||
api.get('/lookups/modules'),
|
||||
api.get('/lookups/users-perm')
|
||||
])
|
||||
|
||||
roles.value = r.data || []
|
||||
departments.value = d.data || []
|
||||
modules.value = m.data || []
|
||||
users.value = u.data || []
|
||||
filteredUserOptions.value = [...users.value]
|
||||
|
||||
lookupsLoaded.value = true
|
||||
}
|
||||
@@ -312,7 +378,10 @@ function initMatrix () {
|
||||
|
||||
async function loadMatrix () {
|
||||
|
||||
if (!roleId.value || !deptCode.value) return
|
||||
if (!roleId.value || !deptCode.value) {
|
||||
members.value = []
|
||||
return
|
||||
}
|
||||
if (matrixLoading) return
|
||||
|
||||
matrixLoading = true
|
||||
@@ -326,9 +395,10 @@ async function loadMatrix () {
|
||||
|
||||
initMatrix()
|
||||
|
||||
const res = await api.get(
|
||||
`/roles/${roleId.value}/departments/${deptCode.value}/permissions`
|
||||
)
|
||||
const [res] = await Promise.all([
|
||||
api.get(`/roles/${roleId.value}/departments/${deptCode.value}/permissions`),
|
||||
loadMembers()
|
||||
])
|
||||
|
||||
const list = Array.isArray(res.data) ? res.data : []
|
||||
|
||||
@@ -384,6 +454,70 @@ async function loadMatrix () {
|
||||
}
|
||||
}
|
||||
|
||||
const availableUserOptions = computed(() => {
|
||||
const memberIDs = new Set(members.value.map(member => Number(member.id)))
|
||||
return users.value.filter(user => !memberIDs.has(Number(user.id)))
|
||||
})
|
||||
|
||||
function filterUsers (value, update) {
|
||||
update(() => {
|
||||
const needle = String(value || '').trim().toLocaleLowerCase('tr')
|
||||
filteredUserOptions.value = needle
|
||||
? availableUserOptions.value.filter(user => String(user.title || '').toLocaleLowerCase('tr').includes(needle))
|
||||
: [...availableUserOptions.value]
|
||||
})
|
||||
}
|
||||
|
||||
async function loadMembers () {
|
||||
if (!roleId.value || !deptCode.value) {
|
||||
members.value = []
|
||||
return
|
||||
}
|
||||
membersLoading.value = true
|
||||
try {
|
||||
const res = await api.get(
|
||||
`/roles/${roleId.value}/departments/${encodeURIComponent(deptCode.value)}/members`
|
||||
)
|
||||
members.value = Array.isArray(res.data) ? res.data : []
|
||||
filteredUserOptions.value = [...availableUserOptions.value]
|
||||
} catch (err) {
|
||||
console.error('GROUP MEMBERS LOAD ERROR:', err)
|
||||
members.value = []
|
||||
Notify.create({
|
||||
type: 'negative',
|
||||
message: 'Grup kullanicilari yuklenemedi'
|
||||
})
|
||||
} finally {
|
||||
membersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember () {
|
||||
if (!roleId.value || !deptCode.value || !memberUserId.value) return
|
||||
addingMember.value = true
|
||||
try {
|
||||
const res = await api.post(
|
||||
`/roles/${roleId.value}/departments/${encodeURIComponent(deptCode.value)}/members`,
|
||||
{ user_id: Number(memberUserId.value) }
|
||||
)
|
||||
members.value = Array.isArray(res.data) ? res.data : []
|
||||
memberUserId.value = null
|
||||
filteredUserOptions.value = [...availableUserOptions.value]
|
||||
Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Kullanici gruba eklendi'
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('GROUP MEMBER ADD ERROR:', err)
|
||||
Notify.create({
|
||||
type: 'negative',
|
||||
message: 'Kullanici gruba eklenemedi'
|
||||
})
|
||||
} finally {
|
||||
addingMember.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ================= SAVE ================= */
|
||||
|
||||
@@ -482,3 +616,53 @@ watch(
|
||||
)
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.group-members-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.group-members-toolbar__members {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.group-members-toolbar__chips {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.group-members-toolbar__add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.group-members-toolbar__select {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.group-members-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.group-members-toolbar__select {
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -340,55 +340,96 @@ const routes = [
|
||||
meta: { permission: 'order:view' }
|
||||
},
|
||||
|
||||
/* ================= PRICING ================= */
|
||||
{
|
||||
path: 'pricing/product-pricing',
|
||||
name: 'product-pricing',
|
||||
component: () => import('pages/ProductPricing.vue'),
|
||||
meta: { permission: 'order:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing',
|
||||
name: 'production-product-costing',
|
||||
component: () => import('pages/ProductionProductCosting.vue'),
|
||||
meta: { permission: 'order:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/has-cost',
|
||||
name: 'production-product-costing-has-cost',
|
||||
component: () => import('pages/ProductionProductCostingHasCost.vue'),
|
||||
meta: { permission: 'order:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/has-cost/history',
|
||||
name: 'production-product-costing-has-cost-history',
|
||||
component: () => import('pages/ProductionProductCostingHasCostHistory.vue'),
|
||||
meta: { permission: 'order:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/has-cost/detail',
|
||||
name: 'production-product-costing-has-cost-detail',
|
||||
component: () => import('pages/ProductionProductCostingHasCostDetail.vue'),
|
||||
meta: { permission: 'order:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/no-cost',
|
||||
name: 'production-product-costing-no-cost',
|
||||
component: () => import('pages/ProductionProductCostingNoCost.vue'),
|
||||
meta: { permission: 'order:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/maliyet-parca-eslestirme',
|
||||
name: 'production-product-costing-maliyet-parca-eslestirme',
|
||||
component: () => import('pages/ProductionProductCostingMTBolumMapping.vue'),
|
||||
meta: { permission: 'order:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/default-quantities',
|
||||
name: 'production-product-costing-default-quantities',
|
||||
component: () => import('pages/ProductionProductCostingDefaultQuantities.vue'),
|
||||
meta: { permission: 'order:view' }
|
||||
},
|
||||
/* ================= PRICING ================= */
|
||||
// Backward-compatible redirects (old "pricing/production-product-costing" URLs).
|
||||
{
|
||||
path: 'pricing/production-product-costing',
|
||||
redirect: { name: 'production-product-costing' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/has-cost',
|
||||
redirect: { name: 'production-product-costing-has-cost' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/has-cost/history',
|
||||
redirect: { name: 'production-product-costing-has-cost-history' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/has-cost/detail',
|
||||
redirect: { name: 'production-product-costing-has-cost-detail' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/no-cost',
|
||||
redirect: { name: 'production-product-costing-no-cost' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/maliyet-parca-eslestirme',
|
||||
redirect: { name: 'production-product-costing-maliyet-parca-eslestirme' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/production-product-costing/default-quantities',
|
||||
redirect: { name: 'production-product-costing-default-quantities' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/product-pricing',
|
||||
name: 'product-pricing',
|
||||
component: () => import('pages/ProductPricing.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/brand-classification',
|
||||
name: 'brand-classification',
|
||||
component: () => import('pages/BrandClassification.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/pricing-rules',
|
||||
name: 'pricing-rules',
|
||||
component: () => import('pages/PricingRules.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'costing/production-product-costing',
|
||||
name: 'production-product-costing',
|
||||
component: () => import('pages/ProductionProductCosting.vue'),
|
||||
meta: { permission: 'costing:view' }
|
||||
},
|
||||
{
|
||||
path: 'costing/production-product-costing/has-cost',
|
||||
name: 'production-product-costing-has-cost',
|
||||
component: () => import('pages/ProductionProductCostingHasCost.vue'),
|
||||
meta: { permission: 'costing:view' }
|
||||
},
|
||||
{
|
||||
path: 'costing/production-product-costing/has-cost/history',
|
||||
name: 'production-product-costing-has-cost-history',
|
||||
component: () => import('pages/ProductionProductCostingHasCostHistory.vue'),
|
||||
meta: { permission: 'costing:view' }
|
||||
},
|
||||
{
|
||||
path: 'costing/production-product-costing/has-cost/detail',
|
||||
name: 'production-product-costing-has-cost-detail',
|
||||
component: () => import('pages/ProductionProductCostingHasCostDetail.vue'),
|
||||
meta: { permission: 'costing:view' }
|
||||
},
|
||||
{
|
||||
path: 'costing/production-product-costing/no-cost',
|
||||
name: 'production-product-costing-no-cost',
|
||||
component: () => import('pages/ProductionProductCostingNoCost.vue'),
|
||||
meta: { permission: 'costing:view' }
|
||||
},
|
||||
{
|
||||
path: 'costing/production-product-costing/maliyet-parca-eslestirme',
|
||||
name: 'production-product-costing-maliyet-parca-eslestirme',
|
||||
component: () => import('pages/ProductionProductCostingMTBolumMapping.vue'),
|
||||
meta: { permission: 'costing:view' }
|
||||
},
|
||||
{
|
||||
path: 'costing/production-product-costing/default-quantities',
|
||||
name: 'production-product-costing-default-quantities',
|
||||
component: () => import('pages/ProductionProductCostingDefaultQuantities.vue'),
|
||||
meta: { permission: 'costing:view' }
|
||||
},
|
||||
|
||||
|
||||
/* ================= PASSWORD ================= */
|
||||
|
||||
@@ -42,6 +42,7 @@ function mapRow (raw, index, baseIndex = 0) {
|
||||
productCode: toText(raw?.ProductCode),
|
||||
stockQty: toNumber(raw?.StockQty),
|
||||
stockEntryDate: toText(raw?.StockEntryDate),
|
||||
lastCostingDate: toText(raw?.LastCostingDate),
|
||||
lastPricingDate: toText(raw?.LastPricingDate),
|
||||
askiliYan: toText(raw?.AskiliYan),
|
||||
kategori: toText(raw?.Kategori),
|
||||
@@ -54,26 +55,26 @@ function mapRow (raw, index, baseIndex = 0) {
|
||||
brandGroupSelection: toText(raw?.BrandGroupSec),
|
||||
costPrice: toNumber(raw?.CostPrice),
|
||||
expenseForBasePrice: 0,
|
||||
basePriceUsd: 0,
|
||||
basePriceTry: 0,
|
||||
usd1: 0,
|
||||
usd2: 0,
|
||||
usd3: 0,
|
||||
usd4: 0,
|
||||
usd5: 0,
|
||||
usd6: 0,
|
||||
eur1: 0,
|
||||
eur2: 0,
|
||||
eur3: 0,
|
||||
eur4: 0,
|
||||
eur5: 0,
|
||||
eur6: 0,
|
||||
try1: 0,
|
||||
try2: 0,
|
||||
try3: 0,
|
||||
try4: 0,
|
||||
try5: 0,
|
||||
try6: 0
|
||||
basePriceUsd: toNumber(raw?.BasePriceUsd),
|
||||
basePriceTry: toNumber(raw?.BasePriceTry),
|
||||
usd1: toNumber(raw?.USD1),
|
||||
usd2: toNumber(raw?.USD2),
|
||||
usd3: toNumber(raw?.USD3),
|
||||
usd4: toNumber(raw?.USD4),
|
||||
usd5: toNumber(raw?.USD5),
|
||||
usd6: toNumber(raw?.USD6),
|
||||
eur1: toNumber(raw?.EUR1),
|
||||
eur2: toNumber(raw?.EUR2),
|
||||
eur3: toNumber(raw?.EUR3),
|
||||
eur4: toNumber(raw?.EUR4),
|
||||
eur5: toNumber(raw?.EUR5),
|
||||
eur6: toNumber(raw?.EUR6),
|
||||
try1: toNumber(raw?.TRY1),
|
||||
try2: toNumber(raw?.TRY2),
|
||||
try3: toNumber(raw?.TRY3),
|
||||
try4: toNumber(raw?.TRY4),
|
||||
try5: toNumber(raw?.TRY5),
|
||||
try6: toNumber(raw?.TRY6)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,11 +96,18 @@ function normalizeFilters (filters = {}) {
|
||||
return out
|
||||
}
|
||||
|
||||
function hasPrimaryFilter (filters = {}) {
|
||||
return (Array.isArray(filters.urun_ilk_grubu) && filters.urun_ilk_grubu.length > 0) ||
|
||||
(Array.isArray(filters.urun_ana_grubu) && filters.urun_ana_grubu.length > 0)
|
||||
}
|
||||
|
||||
function makeCacheKey (limit, page, filters) {
|
||||
return JSON.stringify({
|
||||
limit: Number(limit) || 500,
|
||||
page: Number(page) || 1,
|
||||
filters: normalizeFilters(filters)
|
||||
filters: normalizeFilters(filters),
|
||||
sortBy: toText(filters?.__sortBy),
|
||||
descending: Boolean(filters?.__descending)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,6 +153,8 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
||||
const limit = Number(options?.limit) > 0 ? Number(options.limit) : 500
|
||||
const page = Number(options?.page) > 0 ? Number(options.page) : 1
|
||||
const filters = normalizeFilters(options?.filters || {})
|
||||
const sortBy = toText(options?.sortBy)
|
||||
const descending = Boolean(options?.descending)
|
||||
const key = makeCacheKey(limit, page, filters)
|
||||
if (this.pageCache[key]) return
|
||||
if (this.prefetchInFlight[key]) {
|
||||
@@ -153,7 +163,12 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
||||
}
|
||||
const run = async () => {
|
||||
try {
|
||||
const params = { limit, page }
|
||||
const includeTotal = hasPrimaryFilter(filters) ? 1 : 0
|
||||
const params = { limit, page, include_total: includeTotal }
|
||||
if (sortBy) {
|
||||
params.sort_by = sortBy
|
||||
params.desc = descending ? 1 : 0
|
||||
}
|
||||
for (const k of Object.keys(filters)) {
|
||||
if (k === 'q') {
|
||||
params.q = filters.q
|
||||
@@ -169,11 +184,14 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
||||
params,
|
||||
timeout: 180000
|
||||
})
|
||||
const totalCount = Number(res?.headers?.['x-total-count'] || 0)
|
||||
const totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 1))
|
||||
const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page))
|
||||
const data = Array.isArray(res?.data) ? res.data : []
|
||||
const mapped = data.map((x, i) => mapRow(x, i, 0))
|
||||
const totalCount = Number(res?.headers?.['x-total-count'] || 0)
|
||||
let totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 0))
|
||||
const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page))
|
||||
const data = Array.isArray(res?.data) ? res.data : []
|
||||
const mapped = data.map((x, i) => mapRow(x, i, 0))
|
||||
if (!Number.isFinite(totalPages) || totalPages <= 0) {
|
||||
totalPages = mapped.length >= limit ? currentPage + 1 : currentPage
|
||||
}
|
||||
this.cachePut(key, {
|
||||
rows: mapped,
|
||||
totalCount: Number.isFinite(totalCount) ? totalCount : 0,
|
||||
@@ -202,6 +220,8 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
||||
const append = Boolean(options?.append)
|
||||
const baseIndex = append ? this.rows.length : 0
|
||||
const filters = normalizeFilters(options?.filters || {})
|
||||
const sortBy = toText(options?.sortBy)
|
||||
const descending = Boolean(options?.descending)
|
||||
const cacheKey = makeCacheKey(limit, page, filters)
|
||||
const startedAt = Date.now()
|
||||
console.info('[product-pricing][frontend] request:start', {
|
||||
@@ -237,7 +257,12 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
||||
}
|
||||
}
|
||||
|
||||
const params = { limit, page }
|
||||
const includeTotal = hasPrimaryFilter(filters) ? 1 : 0
|
||||
const params = { limit, page, include_total: includeTotal }
|
||||
if (sortBy) {
|
||||
params.sort_by = sortBy
|
||||
params.desc = descending ? 1 : 0
|
||||
}
|
||||
for (const key of Object.keys(filters)) {
|
||||
if (key === 'q') {
|
||||
params.q = filters.q
|
||||
@@ -253,11 +278,15 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
||||
timeout: 180000
|
||||
})
|
||||
const traceId = res?.headers?.['x-trace-id'] || null
|
||||
const totalCount = Number(res?.headers?.['x-total-count'] || 0)
|
||||
const totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 1))
|
||||
const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page))
|
||||
const data = Array.isArray(res?.data) ? res.data : []
|
||||
const totalCount = Number(res?.headers?.['x-total-count'] || 0)
|
||||
let totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 0))
|
||||
const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page))
|
||||
const data = Array.isArray(res?.data) ? res.data : []
|
||||
const mapped = data.map((x, i) => mapRow(x, i, baseIndex))
|
||||
if (!Number.isFinite(totalPages) || totalPages <= 0) {
|
||||
// When server skips count, infer "hasMore" from page size.
|
||||
totalPages = mapped.length >= limit ? currentPage + 1 : currentPage
|
||||
}
|
||||
const payload = {
|
||||
rows: mapped,
|
||||
totalCount: Number.isFinite(totalCount) ? totalCount : 0,
|
||||
@@ -265,7 +294,15 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
||||
page: Number.isFinite(currentPage) ? currentPage : page
|
||||
}
|
||||
this.cachePut(cacheKey, payload)
|
||||
this.applyPageResult(payload, page)
|
||||
if (append) {
|
||||
this.rows = [...cloneRows(this.rows || []), ...mapped.map((r) => ({ ...r }))]
|
||||
this.totalCount = Number.isFinite(payload.totalCount) ? payload.totalCount : this.totalCount
|
||||
this.totalPages = Math.max(1, Number(payload.totalPages || this.totalPages || 1))
|
||||
this.page = Math.max(1, Number(payload.page || page))
|
||||
this.hasMore = this.page < this.totalPages
|
||||
} else {
|
||||
this.applyPageResult(payload, page)
|
||||
}
|
||||
|
||||
// Background prefetch for next page to reduce perceived wait on page change.
|
||||
if (this.page < this.totalPages) {
|
||||
@@ -311,6 +348,8 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
||||
}
|
||||
},
|
||||
|
||||
// fetchAllByGroups removed: keep paging server-side.
|
||||
|
||||
updateCell (row, field, val) {
|
||||
if (!row || !field) return
|
||||
row[field] = toNumber(val)
|
||||
|
||||
@@ -94,7 +94,14 @@ export const useRoleDeptPermissionListStore = defineStore('roleDeptPermissionLis
|
||||
role_title: r.role_title || '',
|
||||
department_code: r.department_code || '',
|
||||
department_title: r.department_title || '',
|
||||
module_flags: flags
|
||||
module_flags: flags,
|
||||
members: Array.isArray(r.members)
|
||||
? r.members.map((member) => ({
|
||||
id: Number(member?.id || 0),
|
||||
full_name: String(member?.full_name || ''),
|
||||
username: String(member?.username || '')
|
||||
})).filter((member) => member.id > 0)
|
||||
: []
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user