Files
bssapp/ui/src/pages/PricingRules.vue
2026-06-17 21:57:02 +03:00

1697 lines
56 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
<q-btn
flat
color="primary"
icon="refresh"
label="Yenile"
:loading="loading"
@click="refreshRows"
/>
</div>
<div class="action-bar row items-center justify-between q-mb-xs">
<div class="text-caption text-grey-8">
Satir: {{ filteredRows.length }} / {{ rows.length }} | Degisen: {{ selectedDirtyCount }} | Kopya: {{ copySelectedCount }} | Secili: {{ selectedCount }}
</div>
<div class="row items-center q-gutter-sm">
<div class="row items-center q-gutter-xs action-legend">
<q-chip dense square color="orange-1" text-color="deep-orange-8" icon="content_copy">
Kopya secimi
</q-chip>
<q-chip dense square color="light-green-1" text-color="green-9" icon="task_alt">
Kaydetme secimi
</q-chip>
</div>
<q-btn
flat
color="primary"
icon="filter_alt_off"
label="Filtreleri Temizle"
:disable="!hasAnyFilter"
@click="clearAllFilters"
/>
<q-btn
color="secondary"
unelevated
icon="content_copy"
label="Kopyala"
:disable="!canCopySelected"
@click="copySelectedToSelected"
/>
<q-btn
color="primary"
flat
icon="download"
label="Sayfayi Excel'e Aktar"
:disable="filteredRows.length === 0"
@click="exportCurrentView"
/>
<q-btn
color="primary"
outline
icon="download_for_offline"
label="Tum Filtreyi Excel'e Aktar"
:disable="rows.length === 0"
@click="exportAllFiltered"
/>
<q-btn
color="primary"
outline
icon="upload_file"
label="Verileri CSV'den Yukle"
:disable="loading || rows.length === 0"
@click="openImportDialog"
/>
<q-btn
color="primary"
unelevated
icon="save"
:disable="!canUpdate || selectedDirtyCount === 0 || saving"
:loading="saving"
:label="`Kaydet (${selectedDirtyCount})`"
@click="saveSelected"
/>
<input
ref="fileInputRef"
type="file"
accept=".csv,text/csv"
class="hidden-file-input"
@change="onImportFileChange"
>
</div>
</div>
<q-banner
v-if="csvImportStatus"
dense
class="q-mb-xs"
:class="csvImportStatus.type === 'warning' ? 'bg-amber-2 text-amber-10' : 'bg-green-1 text-green-10'"
>
{{ csvImportStatus.message }}
</q-banner>
<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
size="24px"
class="rule-select-checkbox"
color="positive"
:model-value="allSelectedVisible"
:indeterminate="someSelectedVisible && !allSelectedVisible"
@update:model-value="toggleSelectAllVisible"
/>
<div v-else-if="col.name === 'copy_select'" class="selection-header-copy">
<q-icon name="content_copy" size="16px" />
<span>Kopya</span>
</div>
<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)"
>
<div
v-if="col.name === 'copy_select'"
class="row items-center no-wrap justify-center copy-cell-wrap"
>
<q-checkbox
dense
size="24px"
class="rule-select-checkbox"
color="secondary"
:model-value="isCopySelected(props.row)"
@update:model-value="(value) => toggleCopySelected(props.row, value)"
/>
<q-badge
v-if="copyRoleLabel(props.row)"
color="deep-orange"
class="q-ml-xs"
>
{{ copyRoleLabel(props.row) }}
</q-badge>
</div>
<q-checkbox
v-else-if="col.name === 'select'"
dense
size="24px"
class="rule-select-checkbox"
color="positive"
: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)"
/>
<q-toggle
v-else-if="col.name === 'calc_enabled'"
v-model="props.row.calc_enabled"
dense
@update:model-value="() => markDirty(props.row)"
/>
<q-toggle
v-else-if="col.name === 'publish_postgres'"
v-model="props.row.publish_postgres"
dense
@update:model-value="() => markDirty(props.row)"
/>
<q-toggle
v-else-if="col.name === 'publish_nebim'"
v-model="props.row.publish_nebim"
dense
@update:model-value="() => markDirty(props.row)"
/>
<q-select
v-else-if="retailModeFields.has(col.name)"
dense
outlined
emit-value
map-options
:options="retailModeOptions.map(value => ({ label: value, value }))"
:model-value="props.row[col.field]"
@update:model-value="(value) => updateRetailMode(props.row, col.field, value)"
/>
<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-inner-loading :showing="saving" label="Kaydediliyor..." />
</q-page>
</template>
<script setup>
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { Dialog, Notify } from 'quasar'
import api, { download } 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 fileInputRef = ref(null)
const selectedKeyMap = ref({})
const copySelectedKeys = ref([])
const tablePagination = ref({ rowsPerPage: 0, sortBy: 'urun_ilk_grubu', descending: false })
const csvImportStatus = ref(null) // { type: 'positive'|'warning', message: string, at: string }
let emptyRetryTimer = null
const numericFields = new Set([
'try_base', 'try1', 'try2', 'try3', 'try4', 'try5', 'try6', 'try_wholesale_step', 'try_retail_step',
'usd_base', 'usd1', 'usd2', 'usd3', 'usd4', 'usd5', 'usd6', 'usd_wholesale_step', 'usd_retail_step',
'eur_base', 'eur1', 'eur2', 'eur3', 'eur4', 'eur5', 'eur6', 'eur_wholesale_step', 'eur_retail_step'
])
const retailModeFields = new Set(['try_retail_mode', 'usd_retail_mode', 'eur_retail_mode'])
const retailModeOptions = ['STEP', 'END_99', 'END_49', 'BAND_99', 'BAND_49']
const importKeyFieldLabels = [
['askili_yan', 'ASKILI YAN'],
['kategori', 'KATEGORI'],
['urun_ilk_grubu', 'URUN ILK GRUBU'],
['urun_ana_grubu', 'URUN ANA GRUBU'],
['urun_alt_grubu', 'URUN ALT GRUBU'],
['icerik', 'ICERIK'],
['marka', 'MARKA'],
['brand_code', 'BRAND CODE'],
['brand_group', 'MARKA GRUBU']
]
const importFieldMap = {
AKTIF: 'is_active',
'HESAP AKTIF': 'calc_enabled',
'PG YAYIN': 'publish_postgres',
'NEBIM YAYIN': 'publish_nebim',
'TRY TOPTAN YUVARLAMA': 'try_wholesale_step',
'TRY PERAKENDE MODU': 'try_retail_mode',
'TRY PERAKENDE DEGERI': 'try_retail_step',
'TRY PERAKENDE YUVARLAMA': 'try_retail_step',
'TRY YUVARLAMA': 'try_wholesale_step',
'TRY TABAN': 'try_base',
'TRY 1': 'try1',
'TRY 2': 'try2',
'TRY 3': 'try3',
'TRY 4': 'try4',
'TRY 5': 'try5',
'TRY 6': 'try6',
'USD TOPTAN YUVARLAMA': 'usd_wholesale_step',
'USD PERAKENDE MODU': 'usd_retail_mode',
'USD PERAKENDE DEGERI': 'usd_retail_step',
'USD PERAKENDE YUVARLAMA': 'usd_retail_step',
'USD YUVARLAMA': 'usd_wholesale_step',
'USD TABAN': 'usd_base',
'USD 1': 'usd1',
'USD 2': 'usd2',
'USD 3': 'usd3',
'USD 4': 'usd4',
'USD 5': 'usd5',
'USD 6': 'usd6',
'EUR TOPTAN YUVARLAMA': 'eur_wholesale_step',
'EUR PERAKENDE MODU': 'eur_retail_mode',
'EUR PERAKENDE DEGERI': 'eur_retail_step',
'EUR PERAKENDE YUVARLAMA': 'eur_retail_step',
'EUR YUVARLAMA': 'eur_wholesale_step',
'EUR TABAN': 'eur_base',
'EUR 1': 'eur1',
'EUR 2': 'eur2',
'EUR 3': 'eur3',
'EUR 4': 'eur4',
'EUR 5': 'eur5',
'EUR 6': 'eur6'
}
const multiFilterFields = [
'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu', 'urun_ana_grubu',
'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group',
'try_retail_mode', 'usd_retail_mode', 'eur_retail_mode'
]
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('copy_select', 'KOPYA', 'copy_select', 68, { sortable: false, classes: 'copy-selection-col', headerClasses: 'copy-selection-col' }),
col('select', 'KAYDET', 'select', 58, { sortable: false, classes: 'save-selection-col', headerClasses: 'save-selection-col' }),
col('has_rule', 'DURUM', 'has_rule', 54, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('is_active', 'AKTIF', 'is_active', 42, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('askili_yan', 'ASKILI YAN', 'askili_yan', 72, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('kategori', 'KATEGORI', 'kategori', 76, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('urun_ilk_grubu', 'URUN ILK GRUBU', 'urun_ilk_grubu', 84, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('urun_ana_grubu', 'URUN ANA GRUBU', 'urun_ana_grubu', 90, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('urun_alt_grubu', 'URUN ALT GRUBU', 'urun_alt_grubu', 90, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('icerik', 'ICERIK', 'icerik', 72, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('marka', 'MARKA', 'marka', 80, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('brand_code', 'BRAND CODE', 'brand_code', 68, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('brand_group', 'MARKA GRUBU', 'brand_group', 76, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('anchor_mode', 'ANCHOR MODE', 'anchor_mode', 66, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('calc_enabled', 'HESAP AKTIF', 'calc_enabled', 66, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('publish_postgres', 'PG YAYIN', 'publish_postgres', 62, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('publish_nebim', 'NEBIM YAYIN', 'publish_nebim', 66, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('try_wholesale_step', 'TRY TOPTAN YUVARLAMA', 'try_wholesale_step', 76, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try_retail_mode', 'TRY PERAKENDE MODU', 'try_retail_mode', 76, { classes: 'try-col', headerClasses: 'try-col' }),
col('try_retail_step', 'TRY PERAKENDE DEGERI', 'try_retail_step', 78, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try_base', 'TRY TABAN', 'try_base', 58, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try1', 'TRY 1', 'try1', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try2', 'TRY 2', 'try2', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try3', 'TRY 3', 'try3', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try4', 'TRY 4', 'try4', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try5', 'TRY 5', 'try5', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try6', 'TRY 6', 'try6', 52, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('usd_wholesale_step', 'USD TOPTAN YUVARLAMA', 'usd_wholesale_step', 76, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd_retail_mode', 'USD PERAKENDE MODU', 'usd_retail_mode', 76, { classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd_retail_step', 'USD PERAKENDE DEGERI', 'usd_retail_step', 78, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd_base', 'USD TABAN', 'usd_base', 58, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd1', 'USD 1', 'usd1', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd2', 'USD 2', 'usd2', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd3', 'USD 3', 'usd3', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd4', 'USD 4', 'usd4', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd5', 'USD 5', 'usd5', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd6', 'USD 6', 'usd6', 52, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('eur_wholesale_step', 'EUR TOPTAN YUVARLAMA', 'eur_wholesale_step', 76, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur_retail_mode', 'EUR PERAKENDE MODU', 'eur_retail_mode', 76, { classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur_retail_step', 'EUR PERAKENDE DEGERI', 'eur_retail_step', 78, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur_base', 'EUR TABAN', 'eur_base', 58, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur1', 'EUR 1', 'eur1', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur2', 'EUR 2', 'eur2', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur3', 'EUR 3', 'eur3', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur4', 'EUR 4', 'eur4', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur5', 'EUR 5', 'eur5', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur6', 'EUR 6', 'eur6', 52, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' })
]
const stickyColumnNames = [
'copy_select', 'select', 'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu',
'urun_ana_grubu', 'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group',
'anchor_mode', 'calc_enabled', 'publish_postgres', 'publish_nebim'
]
const stickyBoundaryColumnName = 'publish_nebim'
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'
if (retailModeFields.has(field)) return String(row?.[field] || 'STEP').trim() || 'STEP'
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 exportableColumns = computed(() => columns.filter(col => col.name !== 'copy_select' && col.name !== 'select'))
const exportedRows = computed(() => {
const list = [...filteredRows.value]
const sortBy = String(tablePagination.value?.sortBy || '').trim()
const descending = Boolean(tablePagination.value?.descending)
if (!sortBy) return list
list.sort((a, b) => {
const av = exportSortValue(a, sortBy)
const bv = exportSortValue(b, sortBy)
if (typeof av === 'number' && typeof bv === 'number') return av - bv
return String(av ?? '').localeCompare(String(bv ?? ''), 'tr', { numeric: true, sensitivity: 'base' })
})
return descending ? list.reverse() : list
})
const copySelectedKeySet = computed(() => new Set(copySelectedKeys.value))
const visibleRowKeys = computed(() => filteredRows.value.map(row => row._row_key))
const selectedVisibleCount = computed(() => visibleRowKeys.value.reduce((count, key) => count + (selectedKeyMap.value?.[key] ? 1 : 0), 0))
const allSelectedVisible = computed(() => visibleRowKeys.value.length > 0 && selectedVisibleCount.value === visibleRowKeys.value.length)
const someSelectedVisible = computed(() => selectedVisibleCount.value > 0)
const selectedDirtyCount = computed(() => rows.value.reduce((count, row) => count + (selectedKeyMap.value?.[row._row_key] && row?._dirty ? 1 : 0), 0))
const selectedCount = computed(() => Object.keys(selectedKeyMap.value || {}).length)
const copySelectedCount = computed(() => copySelectedKeys.value.length)
const canCopySelected = computed(() => copySelectedCount.value >= 2)
const hasUnsavedLocalChanges = computed(() => rows.value.some(row => Boolean(row?._dirty)))
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 || ''),
anchor_mode: String(rule?.anchor_mode || 'USD'),
calc_enabled: rule?.calc_enabled !== false,
publish_postgres: rule?.publish_postgres !== false,
publish_nebim: rule?.publish_nebim !== false,
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 || ''),
try_retail_mode: String(rule?.try_retail_mode || 'STEP'),
usd_retail_mode: String(rule?.usd_retail_mode || 'STEP'),
eur_retail_mode: String(rule?.eur_retail_mode || 'STEP'),
_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 !!selectedKeyMap.value?.[row._row_key]
}
function clearSelections () {
selectedKeyMap.value = {}
copySelectedKeys.value = []
}
function isCopySelected (row) {
return copySelectedKeySet.value.has(row._row_key)
}
function setRowSelected (row, value) {
const key = row?._row_key
if (!key) return
const next = { ...(selectedKeyMap.value || {}) }
if (value) {
next[key] = true
} else {
delete next[key]
}
selectedKeyMap.value = next
}
function toggleCopySelected (row, value) {
const key = row?._row_key
if (!key) return
const next = [...copySelectedKeys.value]
const idx = next.indexOf(key)
if (value) {
if (idx === -1) next.push(key)
} else if (idx >= 0) {
next.splice(idx, 1)
}
copySelectedKeys.value = next
}
function copyRoleLabel (row) {
const key = row?._row_key
if (!key) return ''
const idx = copySelectedKeys.value.indexOf(key)
if (idx === 0) return 'Kaynak'
if (idx > 0) return 'Hedef'
return ''
}
function toggleSelectAllVisible (value) {
const next = { ...(selectedKeyMap.value || {}) }
for (const key of visibleRowKeys.value) {
if (value) next[key] = true
else delete next[key]
}
selectedKeyMap.value = next
}
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 updateRetailMode (row, field, value) {
row[field] = retailModeOptions.includes(String(value || '').trim()) ? String(value).trim() : 'STEP'
markDirty(row)
}
function exportSortValue (row, field) {
if (field === 'has_rule') return row?.has_rule ? 1 : 0
if (field === 'is_active') return row?.is_active ? 1 : 0
if (field === 'calc_enabled') return row?.calc_enabled ? 1 : 0
if (field === 'publish_postgres') return row?.publish_postgres ? 1 : 0
if (field === 'publish_nebim') return row?.publish_nebim ? 1 : 0
if (numericFields.has(field)) return finiteNumber(row?.[field], 0)
return String(row?.[field] ?? '')
}
function exportCellValue (row, field) {
if (field === 'has_rule') return row?.has_rule ? 'Tanimli' : 'Yeni'
if (field === 'is_active') return row?.is_active ? 'Aktif' : 'Pasif'
if (field === 'calc_enabled') return row?.calc_enabled ? 'Aktif' : 'Pasif'
if (field === 'publish_postgres') return row?.publish_postgres ? 'Evet' : 'Hayir'
if (field === 'publish_nebim') return row?.publish_nebim ? 'Evet' : 'Hayir'
// Excel often coerces numeric-looking codes/names; wrap to keep as text when opened/edited in Excel.
if (field === 'brand_code' || field === 'marka') {
const text = String(row?.[field] ?? '').trim()
if (!text) return ''
return `="${text.replaceAll('"', '""')}"`
}
if (retailModeFields.has(field)) return String(row?.[field] || 'STEP').trim() || 'STEP'
if (numericFields.has(field)) {
const value = row?.[field]
if (value === '' || value === null || value === undefined) return '0'
return String(finiteNumber(value, 0))
}
return String(row?.[field] ?? '').trim()
}
function csvSafe (value) {
let text = String(value ?? '').replaceAll('\r', ' ').replaceAll('\n', ' ').trim()
if (text.includes(';') || text.includes('"')) {
text = `"${text.replaceAll('"', '""')}"`
}
return text
}
function exportCurrentView () {
const cols = exportableColumns.value
const list = exportedRows.value
if (cols.length === 0 || list.length === 0) return
const lines = [cols.map(col => csvSafe(col.label)).join(';')]
for (const row of list) {
lines.push(cols.map(col => csvSafe(exportCellValue(row, col.field))).join(';'))
}
const bom = '\uFEFF'
const blob = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `pricing_rules_${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
async function exportAllFiltered () {
try {
const params = {
askili_yan: (columnFilters.value.askili_yan || []).join(','),
kategori: (columnFilters.value.kategori || []).join(','),
urun_ilk_grubu: (columnFilters.value.urun_ilk_grubu || []).join(','),
urun_ana_grubu: (columnFilters.value.urun_ana_grubu || []).join(','),
urun_alt_grubu: (columnFilters.value.urun_alt_grubu || []).join(','),
icerik: (columnFilters.value.icerik || []).join(','),
marka: (columnFilters.value.marka || []).join(','),
brand_code: (columnFilters.value.brand_code || []).join(','),
brand_group: (columnFilters.value.brand_group || []).join(','),
sort_by: tablePagination.value?.sortBy || '',
desc: tablePagination.value?.descending ? 1 : 0
}
for (const field of numericFields) {
const min = String(numberRangeFilters.value[field]?.min || '').trim()
const max = String(numberRangeFilters.value[field]?.max || '').trim()
if (min) params[`${field}_min`] = min
if (max) params[`${field}_max`] = max
}
const blob = await download('/pricing/pricing-rules/export-all', params)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `pricing_rules_all_${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch (err) {
Notify.create({ type: 'negative', message: err?.message || 'Tum filtre export alinamadi' })
}
}
function openImportDialog () {
fileInputRef.value?.click?.()
}
function parseCsvRows (text) {
const clean = String(text || '').replace(/^\uFEFF/, '')
const rows = []
let cell = ''
let row = []
let inQuotes = false
for (let i = 0; i < clean.length; i++) {
const ch = clean[i]
if (inQuotes) {
if (ch === '"') {
if (clean[i + 1] === '"') {
cell += '"'
i += 1
} else {
inQuotes = false
}
} else {
cell += ch
}
continue
}
if (ch === '"') {
inQuotes = true
continue
}
if (ch === ';') {
row.push(cell)
cell = ''
continue
}
if (ch === '\n') {
row.push(cell)
rows.push(row)
row = []
cell = ''
continue
}
if (ch !== '\r') {
cell += ch
}
}
if (cell !== '' || row.length > 0) {
row.push(cell)
rows.push(row)
}
return rows
}
function normalizeImportText (value) {
let text = String(value ?? '').trim()
if (text.startsWith("'")) text = text.slice(1).trim()
const formulaMatch = text.match(/^=\s*"([\s\S]*)"$/)
if (formulaMatch) text = formulaMatch[1]
return text.toLocaleUpperCase('tr')
}
function buildImportRowKeyFromObject (row) {
return importKeyFieldLabels
.map(([field]) => normalizeImportText(row?.[field] ?? ''))
.join('|')
}
function parseImportedNumber (value) {
const text = String(value ?? '').trim().replace(/\s/g, '')
if (!text) return 0
const lastComma = text.lastIndexOf(',')
const lastDot = text.lastIndexOf('.')
let normalized = text
if (lastComma >= 0 && lastDot >= 0) {
if (lastComma > lastDot) normalized = text.replace(/\./g, '').replace(',', '.')
else normalized = text.replace(/,/g, '')
} else if (lastComma >= 0) {
normalized = text.replace(/\./g, '').replace(',', '.')
} else {
normalized = text.replace(/,/g, '')
}
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : 0
}
function parseImportedBoolean (value) {
const normalized = normalizeImportText(value)
if (!normalized) return null
if (['AKTIF', 'TRUE', '1', 'EVET', 'YES'].includes(normalized)) return true
if (['PASIF', 'FALSE', '0', 'HAYIR', 'NO'].includes(normalized)) return false
return null
}
async function onImportFileChange (event) {
const input = event?.target
const file = input?.files?.[0]
if (!file) return
try {
const startedAt = Date.now()
console.info('[pricing-rules][ui] csv-import:start', {
at: new Date(startedAt).toISOString(),
name: file?.name || '',
size: file?.size || 0
})
const text = await file.text()
const matrix = parseCsvRows(text).filter(row => row.some(cell => String(cell || '').trim() !== ''))
if (matrix.length < 2) {
Notify.create({ type: 'negative', message: 'CSV bos veya gecersiz' })
csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: 'CSV bos veya gecersiz.' }
return
}
const headers = matrix[0].map(cell => String(cell || '').trim())
const keyHeaderIndexes = {}
for (const [, label] of importKeyFieldLabels) {
keyHeaderIndexes[label] = headers.indexOf(label)
}
const missingKeyHeaders = importKeyFieldLabels
.map(([, label]) => label)
.filter(label => keyHeaderIndexes[label] < 0)
if (missingKeyHeaders.length > 0) {
Notify.create({ type: 'negative', message: `Eksik kolon: ${missingKeyHeaders.join(', ')}` })
return
}
const rowMap = new Map(rows.value.map(row => [buildImportRowKeyFromObject(row), row]))
let matched = 0
let updated = 0
let skipped = 0
const updatedRowKeys = []
for (let i = 1; i < matrix.length; i++) {
const csvRow = matrix[i]
const identity = {}
for (const [field, label] of importKeyFieldLabels) {
identity[field] = String(csvRow[keyHeaderIndexes[label]] ?? '').trim().replace(/^'/, '').replace(/^=\s*"([\s\S]*)"$/, '$1')
}
const target = rowMap.get(buildImportRowKeyFromObject(identity))
if (!target) {
skipped++
continue
}
matched++
let rowChanged = false
for (const [headerLabel, field] of Object.entries(importFieldMap)) {
const idx = headers.indexOf(headerLabel)
if (idx < 0) continue
const rawValue = csvRow[idx] ?? ''
if (field === 'is_active') {
const next = parseImportedBoolean(rawValue)
if (next !== null && Boolean(target.is_active) !== next) {
target.is_active = next
rowChanged = true
}
continue
}
if (field === 'calc_enabled' || field === 'publish_postgres' || field === 'publish_nebim') {
const next = parseImportedBoolean(rawValue)
if (next !== null && Boolean(target[field]) !== next) {
target[field] = next
rowChanged = true
}
continue
}
if (retailModeFields.has(field)) {
const next = retailModeOptions.includes(normalizeImportText(rawValue)) ? normalizeImportText(rawValue) : 'STEP'
if (String(target[field] || 'STEP') !== next) {
target[field] = next
rowChanged = true
}
continue
}
const next = parseImportedNumber(rawValue)
if (Number(target[field] ?? 0) !== next) {
target[field] = next
rowChanged = true
}
}
if (rowChanged) {
markDirty(target)
updatedRowKeys.push(String(target._row_key || '').trim())
updated++
}
}
if (matched === 0) {
Notify.create({ type: 'warning', message: 'CSV icindeki satirlar ekrandaki kayitlarla eslesmedi' })
csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: 'CSV icindeki satirlar ekrandaki kayitlarla eslesmedi.' }
return
}
// Ensure: CSV'den degisen satirlar hem dirty hem de "Kaydet" secimi (checkbox) olarak isaretlensin.
// Bazı render edge-case'lerinde sadece sayac artip checkbox guncellenmiyor gibi gorunebiliyor;
// burada selection map'i explicit guncelleyip senkronu garanti ediyoruz.
if (updatedRowKeys.length > 0) {
const next = { ...(selectedKeyMap.value || {}) }
for (const key of updatedRowKeys) {
if (!key) continue
next[key] = true
}
selectedKeyMap.value = next
}
const summary = `CSV yuklendi. Islenen: ${matrix.length - 1}, eslesen: ${matched}, guncellenen: ${updated}, atlanan: ${skipped}`
if (updated === 0) {
Notify.create({ type: 'warning', message: summary })
csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: summary }
} else {
Notify.create({ type: 'positive', message: summary })
csvImportStatus.value = { type: 'positive', at: new Date().toISOString(), message: summary }
}
console.info('[pricing-rules][ui] csv-import:done', {
duration_ms: Date.now() - startedAt,
processed: matrix.length - 1,
matched,
updated,
skipped
})
} catch (err) {
Notify.create({ type: 'negative', message: err?.message || 'CSV okunamadi' })
csvImportStatus.value = { type: 'warning', at: new Date().toISOString(), message: err?.message || 'CSV okunamadi' }
} finally {
if (input) input.value = ''
}
}
function copySelectedToSelected () {
const keys = [...copySelectedKeys.value]
if (keys.length < 2) return
const sourceKey = keys[0]
const source = rows.value.find(row => row._row_key === sourceKey)
if (!source) return
for (let i = 1; i < keys.length; i++) {
const target = rows.value.find(row => row._row_key === keys[i])
if (!target) continue
target.is_active = Boolean(source.is_active)
target.calc_enabled = Boolean(source.calc_enabled)
target.publish_postgres = Boolean(source.publish_postgres)
target.publish_nebim = Boolean(source.publish_nebim)
target.try_retail_mode = String(source.try_retail_mode || 'STEP')
target.usd_retail_mode = String(source.usd_retail_mode || 'STEP')
target.eur_retail_mode = String(source.eur_retail_mode || 'STEP')
for (const field of numericFields) {
target[field] = source[field]
}
markDirty(target)
}
copySelectedKeys.value = []
Notify.create({ type: 'positive', message: 'Kopyalama tamamlandi' })
}
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 refreshRows () {
if (hasUnsavedLocalChanges.value) {
const confirmed = await new Promise(resolve => {
Dialog.create({
title: 'Degisiklikler silinecek',
message: 'Kaydedilmemis tum degisiklikler silinecek. Devam etmek istiyor musunuz?',
cancel: true,
persistent: true
})
.onOk(() => resolve(true))
.onCancel(() => resolve(false))
.onDismiss(() => resolve(false))
})
if (!confirmed) return
}
clearAllFilters()
clearSelections()
await loadRows()
}
async function loadRows () {
if (emptyRetryTimer) {
clearTimeout(emptyRetryTimer)
emptyRetryTimer = null
}
loading.value = true
let ok = false
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)
clearSelections()
if (rows.value.length === 0) {
emptyRetryTimer = setTimeout(loadRows, 10000)
}
ok = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kural kombinasyonlari alinamadi' })
} finally {
loading.value = false
}
return ok
}
async function saveSelected () {
const dirty = rows.value.filter(row => selectedKeyMap.value?.[row._row_key] && row?._dirty)
if (dirty.length === 0) return
saving.value = true
try {
const startedAt = Date.now()
console.info('[pricing-rules][ui] saveSelected:start', {
at: new Date(startedAt).toISOString(),
dirty_count: dirty.length
})
const buildPayload = (list) => {
return {
items: list.map(row => {
const item = {
id: row.id,
pricing_parameter_id: row.pricing_parameter_id,
calc_enabled: Boolean(row.calc_enabled),
publish_postgres: Boolean(row.publish_postgres),
publish_nebim: Boolean(row.publish_nebim),
is_active: Boolean(row.is_active),
try_retail_mode: String(row.try_retail_mode || 'STEP'),
usd_retail_mode: String(row.usd_retail_mode || 'STEP'),
eur_retail_mode: String(row.eur_retail_mode || 'STEP')
}
for (const key of numericFields) item[key] = finiteNumber(row[key], 0)
return item
})
}
}
const makeTraceId = () => `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
const isTimeoutLikeError = (e) => {
const status = e?.response?.status || null
// With axios timeout disabled for bulk-save, treat only real upstream/proxy timeouts as retry-able.
return status === 504
}
let savedTotal = 0
const failedKeys = []
const postBulkSave = async (list) => {
const traceId = makeTraceId()
const payload = buildPayload(list)
await api.request({
method: 'POST',
url: '/pricing/pricing-rules/bulk-save',
data: payload,
// Disable axios timeout here: backend may legitimately run for several minutes on the first write after a full truncate/import.
// Any upstream/proxy timeout will surface as 504 anyway.
timeout: 0,
headers: { 'X-Trace-ID': traceId }
})
return traceId
}
// Prefer single request (fast path). Fallback to bisection only on proxy/timeout errors.
try {
const traceId = await postBulkSave(dirty)
savedTotal = dirty.length
console.info('[pricing-rules][ui] saveSelected:one-shot:done', { trace_id: traceId, total: dirty.length })
} catch (e) {
if (!isTimeoutLikeError(e)) throw e
const initialChunkSize = 50
const queue = []
for (let offset = 0; offset < dirty.length; offset += initialChunkSize) {
queue.push(dirty.slice(offset, offset + initialChunkSize))
}
while (queue.length > 0) {
const batch = queue.shift()
if (!batch || batch.length === 0) continue
console.info('[pricing-rules][ui] saveSelected:batch:start', {
batch_size: batch.length,
saved_total: savedTotal,
total: dirty.length
})
try {
const traceId = await postBulkSave(batch)
savedTotal += batch.length
Notify.create({ type: 'positive', message: `Kaydedildi: ${savedTotal} / ${dirty.length}` })
console.info('[pricing-rules][ui] saveSelected:batch:done', {
trace_id: traceId,
batch_size: batch.length,
saved_total: savedTotal,
total: dirty.length
})
} catch (err2) {
if (isTimeoutLikeError(err2) && batch.length > 1) {
const mid = Math.ceil(batch.length / 2)
queue.unshift(batch.slice(mid))
queue.unshift(batch.slice(0, mid))
continue
}
throw err2
}
}
}
const reloaded = await loadRows()
if (!reloaded) {
Notify.create({
type: 'warning',
message: 'Kaydetme tamamlandi, ancak liste yenilenemedi. Sayfayi yenileyip (F5) kontrol edin.'
})
return
}
Notify.create({ type: 'positive', message: `Kaydedildi: ${dirty.length}` })
csvImportStatus.value = null
console.info('[pricing-rules][ui] saveSelected:done', {
duration_ms: Date.now() - startedAt,
dirty_count: dirty.length,
reloaded: true
})
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kurallar kaydedilemedi' })
console.error('[pricing-rules][ui] saveSelected:error', {
status: err?.response?.status || null,
message: err?.response?.data || err?.message || 'Kurallar kaydedilemedi'
})
} finally {
saving.value = false
}
}
function resetTransientState () {
rows.value = []
clearSelections()
}
onMounted(refreshRows)
onActivated(refreshRows)
onBeforeUnmount(() => {
if (emptyRetryTimer) clearTimeout(emptyRetryTimer)
resetTransientState()
})
</script>
<style scoped>
.pricing-rules-page {
--rules-row-height: 27px;
--rules-header-height: 58px;
--rules-table-height: calc(100vh - 210px);
position: relative;
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: 10px;
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 2px;
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: 9px;
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;
}
.selection-header-copy {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 9px;
font-weight: 800;
color: #bf5b04;
}
.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.copy-selection-col),
.rules-table :deep(td.copy-selection-col) {
background: #fff3e8;
color: #bf5b04;
padding-left: 0 !important;
padding-right: 0 !important;
text-align: center !important;
}
.rules-table :deep(th.save-selection-col),
.rules-table :deep(td.save-selection-col) {
background: #eef9ef;
color: #1b7f3a;
padding-left: 0 !important;
padding-right: 0 !important;
text-align: center !important;
}
.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: 14px;
}
.copy-cell-wrap {
min-height: 100%;
}
.rules-table :deep(.rule-select-checkbox) {
display: inline-flex;
align-items: center;
justify-content: center;
}
.rules-table :deep(.rule-select-checkbox .q-checkbox__inner) {
font-size: 20px;
}
.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 3px;
font-size: 10px;
}
.native-cell-input {
width: 100%;
height: 20px;
box-sizing: border-box;
padding: 1px 2px;
border: 1px solid #cfd8dc;
border-radius: 4px;
background: #fff;
font-size: 10px;
margin: 0;
}
.native-cell-input:focus {
outline: none;
border-color: #1976d2;
}
.action-legend :deep(.q-chip) {
font-size: 10px;
font-weight: 700;
}
.hidden-file-input {
display: none;
}
</style>