Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-06-02 16:14:54 +03:00
parent 5f3e975b6d
commit b4e87cfd47
25 changed files with 4918 additions and 287 deletions

View File

@@ -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;