Files
bssapp/ui/src/pages/OrderPriceList.vue
2026-06-19 00:22:44 +03:00

1761 lines
56 KiB
Vue

<template>
<q-page class="q-pa-xs pricing-page order-price-list-page">
<teleport to="body">
<div
v-if="pageBusy"
class="page-busy-overlay"
@click.stop
@mousedown.stop
@mouseup.stop
@touchstart.stop
@wheel.stop
>
<q-spinner-gears size="56px" color="primary" />
<div class="page-busy-label">Yukleniyor...</div>
</div>
</teleport>
<div class="top-bar row items-center justify-between q-mb-xs">
<div class="text-subtitle1 text-weight-bold">Fiyat Listesi</div>
<div class="top-actions">
<div class="row items-center q-gutter-xs top-actions-row top-actions-row--filters">
<q-select
v-model="topUrunIlkGrubu"
dense
outlined
clearable
emit-value
map-options
use-input
:options="topUrunIlkGrubuOptions"
:loading="Boolean(serverFilterLoading.urunIlkGrubu)"
:disable="pageBusy"
label="Urun Ilk Grubu"
style="min-width: 220px"
@filter="onTopFilterSearchUrunIlkGrubu"
@update:model-value="onTopUrunIlkGrubuChange"
/>
<q-select
v-model="topUrunAnaGrubu"
dense
outlined
clearable
emit-value
map-options
use-input
:options="topUrunAnaGrubuOptions"
:loading="Boolean(serverFilterLoading.urunAnaGrubu)"
:disable="pageBusy"
label="Urun Ana Grubu"
style="min-width: 240px"
@filter="onTopFilterSearchUrunAnaGrubu"
@update:model-value="onTopUrunAnaGrubuChange"
/>
<q-btn
color="primary"
icon="filter_alt"
label="Listeyi Getir"
:disable="pageBusy || !canFetch"
:loading="loading"
@click="reloadData({ page: 1 })"
/>
<q-btn
flat
color="grey-7"
icon="restart_alt"
label="Secimleri Sifirla"
:disable="pageBusy"
@click="resetSelections"
/>
</div>
<div class="row items-center q-gutter-xs top-actions-row top-actions-row--actions">
<div class="toolbar-group">
<q-btn-dropdown dense color="secondary" outline icon="view_module" label="Fiyat Secimi" :auto-close="false" :disable="pageBusy">
<q-list dense class="currency-menu-list">
<q-item clickable @click="selectAllPrices">
<q-item-section>Tumunu Sec</q-item-section>
</q-item>
<q-item clickable @click="clearAllPrices">
<q-item-section>Tumunu Temizle</q-item-section>
</q-item>
<q-separator />
<q-item v-for="option in priceOptions" :key="option.value" clickable @click="togglePriceOption(option.value)">
<q-item-section avatar>
<q-checkbox
dense
:model-value="selectedPriceSet.has(option.value)"
:disable="pageBusy"
@click.stop
@update:model-value="() => togglePriceOption(option.value)"
/>
</q-item-section>
<q-item-section>{{ option.label }}</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
dense
flat
color="grey-8"
icon="view_sidebar"
:label="leftDetailsExpanded ? 'Detaylari Gizle' : 'Detaylari Goster'"
:disable="pageBusy"
@click="leftDetailsExpanded = !leftDetailsExpanded"
/>
</div>
<div class="toolbar-group">
<q-btn-dropdown dense color="primary" outline icon="download" label="Cikti Al" :disable="pageBusy || filteredRows.length === 0">
<q-list dense style="min-width: 220px;">
<q-item clickable @click="exportVisibleExcel">
<q-item-section avatar><q-icon name="grid_on" /></q-item-section>
<q-item-section>Excel'e Aktar</q-item-section>
</q-item>
<q-item clickable @click="printVisibleRows">
<q-item-section avatar><q-icon name="picture_as_pdf" /></q-item-section>
<q-item-section>PDF / Yazdir</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<q-space />
<div class="toolbar-group toolbar-group--paging">
<q-pagination
v-model="currentPage"
color="primary"
:max="Math.max(1, totalPages || 1)"
:max-pages="8"
boundary-links
direction-links
:disable="pageBusy"
@update:model-value="onPageChange"
/>
<div class="text-caption text-grey-8">
Sayfa {{ currentPage }} / {{ Math.max(1, totalPages || 1) }} - {{ filteredRows.length }} satir
</div>
</div>
</div>
</div>
</div>
<div class="table-wrap" :style="{ '--sticky-scroll-comp': `${stickyScrollComp}px` }">
<div v-if="showGuidanceOverlay" class="empty-overlay">
<div class="empty-overlay-inner">
<div class="text-subtitle1 text-weight-bold">Liste Icin Filtre Secin</div>
<div class="text-body2 q-mt-xs">
Urun Ilk Grubu, Urun Ana Grubu veya Urun Kodu secip <b>LISTEYI GETIR</b>'e basin.
</div>
</div>
</div>
<div
ref="topScrollRef"
class="top-x-scroll"
@scroll.passive="onTopScroll"
>
<div
class="top-x-scroll-inner"
:style="{ width: `${tableMinWidth}px` }"
/>
</div>
<q-table
ref="mainTableRef"
class="pane-table pricing-table order-price-list-table"
flat
dense
row-key="rowKey"
:rows="filteredRows"
:columns="visibleColumns"
:loading="loading"
:rows-per-page-options="[0]"
:pagination="{ rowsPerPage: 0 }"
hide-bottom
:table-style="tableStyle"
>
<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)"
>
<div class="header-with-filter">
<span>{{ col.label }}</span>
<q-btn
v-if="col.name === 'productCode'"
flat
dense
round
size="8px"
icon="filter_alt"
:color="selectedProductCodes.length > 0 ? 'primary' : 'grey-7'"
:disable="pageBusy"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="selectedProductCodes.length > 0" color="primary" floating rounded>
{{ selectedProductCodes.length }}
</q-badge>
<q-menu
class="product-code-filter-menu"
anchor="bottom left"
self="top left"
@before-show="onProductCodeMenuShow"
>
<div class="q-pa-sm filter-menu-panel">
<q-input
v-model="filterSearch.productCode"
dense
outlined
debounce="250"
placeholder="Urun kodu ara"
:disable="pageBusy"
@update:model-value="onProductCodeSearchText"
>
<template #prepend>
<q-icon name="search" />
</template>
</q-input>
<div class="row items-center justify-between q-mt-xs">
<q-btn flat dense size="sm" label="Tumunu Sec" :disable="pageBusy || productCodeOptions.length === 0" @click="selectAllProductCodeOptions" />
<q-btn flat dense size="sm" label="Temizle" :disable="pageBusy || selectedProductCodes.length === 0" @click="clearProductCodeOptions" />
</div>
<q-separator class="q-my-xs" />
<q-scroll-area style="height: 260px; width: 320px;">
<q-list dense>
<q-item
v-for="option in productCodeOptions"
:key="option.value"
clickable
:disable="pageBusy"
@click="toggleProductCodeValue(option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
:model-value="selectedProductCodeSet.has(option.value)"
:disable="pageBusy"
@click.stop
@update:model-value="() => toggleProductCodeValue(option.value)"
/>
</q-item-section>
<q-item-section>{{ option.label }}</q-item-section>
</q-item>
</q-list>
</q-scroll-area>
<q-separator class="q-my-xs" />
<div class="row items-center justify-end q-gutter-xs">
<q-btn v-close-popup dense flat label="Kapat" />
<q-btn v-close-popup dense color="primary" label="Uygula" :disable="pageBusy" @click="reloadData({ page: 1 })" />
</div>
</div>
</q-menu>
</q-btn>
<q-btn
v-else-if="col.name === 'campaignLabel'"
flat
dense
round
size="8px"
icon="filter_alt"
:color="selectedCampaignLabels.length > 0 ? 'primary' : 'grey-7'"
:disable="pageBusy"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="selectedCampaignLabels.length > 0" color="primary" floating rounded>
{{ selectedCampaignLabels.length }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="campaignFilterSearch"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Kampanya ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tumunu Sec" :disable="pageBusy || filteredCampaignOptions.length === 0" @click="selectAllCampaignOptions" />
<q-btn flat dense size="sm" label="Temizle" :disable="pageBusy || selectedCampaignLabels.length === 0" @click="clearCampaignOptions" />
</div>
<q-virtual-scroll
v-if="filteredCampaignOptions.length > 0"
class="excel-filter-options"
:items="filteredCampaignOptions"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`campaign-${option.value}`"
dense
clickable
:disable="pageBusy"
class="excel-filter-option"
@click="toggleCampaignValue(option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="selectedCampaignLabelSet.has(option.value)"
:disable="pageBusy"
@update:model-value="() => toggleCampaignValue(option.value)"
@click.stop
/>
</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>
</q-menu>
</q-btn>
<span v-else class="header-filter-ghost"></span>
</div>
</q-th>
</q-tr>
</template>
<template #body-cell="props">
<q-td
:props="props"
:class="[props.col.classes, { 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }]"
:style="getBodyCellStyle(props.col)"
>
{{ props.value }}
</q-td>
</template>
<template #body-cell-image="props">
<q-td
:props="props"
:class="['image-cell', props.col.classes, { 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }]"
:style="getBodyCellStyle(props.col)"
>
<q-img
v-if="props.row.imageUrl"
:src="props.row.imageUrl"
class="product-thumb cursor-pointer"
fit="cover"
no-spinner
@click.stop="openProductCard(props.row)"
/>
<div v-else class="product-thumb-placeholder cursor-pointer" @click.stop="openProductCard(props.row)">
<q-icon name="image_not_supported" size="24px" color="grey-6" />
</div>
</q-td>
</template>
<template #body-cell-campaignLabel="props">
<q-td
:props="props"
:class="[props.col.classes, { 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }]"
:style="getBodyCellStyle(props.col)"
>
<q-badge v-if="props.row.campaignLabel" color="primary" outline :label="props.row.campaignLabel" class="campaign-badge" />
</q-td>
</template>
<template #body-cell-campaignRate="props">
<q-td
:props="props"
:class="[props.col.classes, { 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }]"
:style="getBodyCellStyle(props.col)"
>
{{ props.row.campaignRate ? formatPrice(props.row.campaignRate) : '' }}
</q-td>
</template>
<template v-for="name in priceColumnNames" #[`body-cell-${name}`]="props" :key="name">
<q-td
:props="props"
:class="['text-right', props.col.classes, { 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }]"
:style="getBodyCellStyle(props.col)"
>
{{ formatPrice(props.row[name]) }}
</q-td>
</template>
</q-table>
</div>
<q-dialog v-model="productCardDialog" maximized @hide="onProductCardDialogHide">
<q-card class="product-card-dialog">
<q-card-section class="row items-center q-pb-sm">
<div class="text-h6">Urun Karti</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-separator />
<q-card-section class="q-pt-md">
<div class="product-card-content">
<div class="product-card-images">
<q-carousel
v-if="productCardImages.length"
v-model="productCardSlide"
animated
swipeable
navigation
arrows
height="100%"
class="product-card-carousel rounded-borders"
>
<q-carousel-slide
v-for="(img, idx) in productCardImages"
:key="'img-' + idx"
:name="idx"
class="column no-wrap flex-center"
>
<div class="dialog-image-stage cursor-pointer" @click="openProductImageFullscreen(img)">
<q-img :src="img" fit="contain" class="dialog-image" />
</div>
</q-carousel-slide>
</q-carousel>
<div v-else class="dialog-image-empty">
<q-icon name="image_not_supported" size="36px" color="grey-6" />
</div>
</div>
<div class="product-card-fields">
<div class="field-row field-row-head"><span class="k">Urun</span><span class="v">{{ productCardData.productCode || '-' }} / {{ productCardData.variantCodes || '-' }}</span></div>
<div class="field-row"><span class="k">Urun Kodu</span><span class="v">{{ productCardData.productCode || '-' }}</span></div>
<div class="field-row"><span class="k">Varyant</span><span class="v">{{ productCardData.variantCodes || '-' }}</span></div>
<div class="field-row"><span class="k">Marka</span><span class="v">{{ productCardData.marka || '-' }}</span></div>
<div class="field-row"><span class="k">Marka Grubu</span><span class="v">{{ productCardData.brandGroupSelection || '-' }}</span></div>
<div class="field-row"><span class="k">Kategori</span><span class="v">{{ productCardData.kategori || '-' }}</span></div>
<div class="field-row"><span class="k">Urun Ilk Grubu</span><span class="v">{{ productCardData.urunIlkGrubu || '-' }}</span></div>
<div class="field-row"><span class="k">Urun Ana Grubu</span><span class="v">{{ productCardData.urunAnaGrubu || '-' }}</span></div>
<div class="field-row"><span class="k">Urun Alt Grubu</span><span class="v">{{ productCardData.urunAltGrubu || '-' }}</span></div>
<div class="field-row"><span class="k">Icerik</span><span class="v">{{ productCardData.icerik || '-' }}</span></div>
<div class="field-row"><span class="k">Karisim</span><span class="v">{{ productCardData.karisim || '-' }}</span></div>
<div class="field-row"><span class="k">Kampanya</span><span class="v">{{ productCardData.campaignLabel || '-' }}</span></div>
<div class="field-row"><span class="k">Stok</span><span class="v">{{ formatStock(productCardData.stockQty || 0) }}</span></div>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="productImageFullscreenDialog" maximized>
<q-card class="image-fullscreen-dialog">
<q-card-section class="row items-center q-pb-sm">
<div class="text-h6">Urun Fotografi</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-separator />
<q-card-section class="image-fullscreen-body">
<q-carousel
v-if="fullscreenImages.length"
v-model="productImageFullscreenSlide"
animated
swipeable
navigation
arrows
height="calc(100vh - 120px)"
class="image-fullscreen-carousel"
>
<q-carousel-slide
v-for="(img, idx) in fullscreenImages"
:key="'full-img-' + idx"
:name="idx"
class="column no-wrap flex-center"
>
<div class="image-fullscreen-stage">
<q-img :src="img" fit="contain" class="image-fullscreen-img" />
</div>
</q-carousel-slide>
</q-carousel>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { Notify } from 'quasar'
import api from 'src/services/api'
const PAGE_LIMIT = 250
const GUIDANCE_MSG = 'Liste icin filtre secin.'
const priceOptions = ['USD', 'EUR', 'TRY'].flatMap((cur) => [1, 2, 3, 4, 5, 6].map((lv) => ({
label: `${cur} ${lv}`,
value: `${cur.toLowerCase()}${lv}`
})))
const campaignPairs = priceOptions.map((x) => ({ base: x.value, derived: `${x.value}Campaign` }))
const priceColumnNames = campaignPairs.flatMap((p) => [p.base, p.derived])
const topUrunIlkGrubu = ref(null)
const topUrunAnaGrubu = ref(null)
const selectedProductCodes = ref([])
const selectedCampaignLabels = ref([])
const campaignFilterSearch = ref('')
const selectedPriceOptions = ref(['usd5', 'try5'])
const leftDetailsExpanded = ref(true)
const rows = ref([])
const loading = ref(false)
const renderPending = ref(false)
const error = ref(GUIDANCE_MSG)
const currentPage = ref(1)
const totalPages = ref(1)
const totalCount = ref(0)
const serverFilterOptionMap = ref({})
const serverFilterLoading = ref({})
const serverFilterLastQuery = ref({})
const filterSearch = ref({ productCode: '', urunIlkGrubu: '', urunAnaGrubu: '' })
const imageCache = new Map()
const imageListCache = new Map()
const variantCodeCollator = new Intl.Collator('tr', { numeric: true, sensitivity: 'base' })
const mainTableRef = ref(null)
const topScrollRef = ref(null)
let syncingScroll = false
const productCardDialog = ref(false)
const productCardData = ref({})
const productCardImages = ref([])
const productCardSlide = ref(0)
const productImageFullscreenDialog = ref(false)
const productImageFullscreenSlide = ref(0)
const fullscreenImages = computed(() => productCardImages.value || [])
const selectedPriceSet = computed(() => new Set(selectedPriceOptions.value || []))
const selectedProductCodeSet = computed(() => new Set(selectedProductCodes.value || []))
const selectedCampaignLabelSet = computed(() => new Set(selectedCampaignLabels.value || []))
const pageBusy = computed(() => loading.value || renderPending.value)
const canFetch = computed(() => Boolean(topUrunIlkGrubu.value || topUrunAnaGrubu.value || selectedProductCodes.value.length > 0))
const showGuidanceOverlay = computed(() => !loading.value && rows.value.length === 0 && error.value === GUIDANCE_MSG)
const topUrunIlkGrubuOptions = computed(() => serverFilterOptionMap.value.urunIlkGrubu || [])
const topUrunAnaGrubuOptions = computed(() => serverFilterOptionMap.value.urunAnaGrubu || [])
const productCodeOptions = computed(() => serverFilterOptionMap.value.productCode || [])
const campaignOptions = computed(() => {
const uniq = new Set()
for (const row of rows.value || []) {
const val = toText(row?.campaignLabel)
if (val) uniq.add(val)
}
return Array.from(uniq)
.sort((a, b) => a.localeCompare(b, 'tr'))
.map((value) => ({ label: value, value }))
})
const filteredCampaignOptions = computed(() => {
const q = toText(campaignFilterSearch.value).toLocaleLowerCase('tr')
const list = campaignOptions.value
return q ? list.filter((x) => x.label.toLocaleLowerCase('tr').includes(q)) : list
})
function toText (value) {
return String(value ?? '').trim()
}
function toNumber (value) {
const n = Number(String(value ?? '0').replace(/\./g, '').replace(',', '.'))
return Number.isFinite(n) ? n : 0
}
function round2 (value) {
const n = Number(value)
return Number.isFinite(n) ? Number(n.toFixed(2)) : null
}
function formatPrice (value) {
const n = Number(value)
if (!Number.isFinite(n) || n === 0) return ''
return n.toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function formatStock (value) {
const n = Number(value)
if (!Number.isFinite(n)) return ''
return n.toLocaleString('tr-TR', { maximumFractionDigits: 2 })
}
function mapProductRow (raw, index) {
const row = {
id: index + 1,
productCode: toText(raw?.ProductCode),
stockQty: toNumber(raw?.StockQty),
stockEntryDate: toText(raw?.StockEntryDate),
lastPricingDate: toText(raw?.LastPricingDate),
askiliYan: toText(raw?.AskiliYan),
kategori: toText(raw?.Kategori),
urunIlkGrubu: toText(raw?.UrunIlkGrubu),
urunAnaGrubu: toText(raw?.UrunAnaGrubu),
urunAltGrubu: toText(raw?.UrunAltGrubu),
icerik: toText(raw?.Icerik),
karisim: toText(raw?.Karisim),
marka: toText(raw?.Marka),
brandGroupSelection: toText(raw?.BrandGroupSec)
}
for (const p of priceOptions) row[p.value] = toNumber(raw?.[p.value.toUpperCase()])
return row
}
function applyCampaignPrices (row) {
const rate = Number(row?.campaignRate || 0)
for (const p of campaignPairs) {
const base = Number(row?.[p.base] || 0)
row[p.derived] = rate > 0 && base > 0 ? round2(base * (1 - rate / 100)) : null
}
}
function buildCampaignLabel (variant) {
const code = toText(variant?.campaign_code)
const title = toText(variant?.campaign_title)
if (code && title) return `${code} - ${title}`
return code || title
}
function buildRows (products, variants) {
const byCode = new Map()
for (const v of variants || []) {
const code = toText(v?.product_code)
if (!code) continue
if (!byCode.has(code)) byCode.set(code, [])
byCode.get(code).push(v)
}
const out = []
for (const p of products) {
const list = byCode.get(p.productCode) || []
if (list.length === 0) {
const row = {
...p,
rowKey: `${p.productCode}|0|0`,
imageUrl: '',
variantCodes: '',
variantStocks: formatStock(p.stockQty),
campaignLabel: '',
campaignRate: null,
lastCampaignDate: ''
}
applyCampaignPrices(row)
out.push(row)
continue
}
list.sort((a, b) => variantCodeCollator.compare(toText(a?.variant_code), toText(b?.variant_code)))
for (const v of list) {
const d1 = Number(v?.dim1 || 0)
const d3 = v?.dim3 == null ? 0 : Number(v?.dim3 || 0)
const row = {
...p,
rowKey: `${p.productCode}|${d1}|${d3}`,
imageUrl: '',
dim1: d1,
dim3: d3,
variantCodes: toText(v?.variant_code),
variantStocks: formatStock(v?.stock_qty),
stockQty: Number(v?.stock_qty ?? 0),
campaignLabel: buildCampaignLabel(v),
campaignRate: Number(v?.discount_rate || 0) || null,
lastCampaignDate: toText(v?.campaign_last_dttm)
}
applyCampaignPrices(row)
out.push(row)
}
}
return out
}
async function fetchServerFilterOptions (field, q = '') {
const query = toText(q)
const last = toText(serverFilterLastQuery.value[field])
const cached = Array.isArray(serverFilterOptionMap.value[field]) && serverFilterOptionMap.value[field].length > 0
if (cached && last === query) return
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: true }
serverFilterLastQuery.value = { ...serverFilterLastQuery.value, [field]: query }
try {
const params = { field, q: query, limit: field === 'productCode' ? 200 : 160 }
if (field === 'urunAnaGrubu' && topUrunIlkGrubu.value) params.urun_ilk_grubu = topUrunIlkGrubu.value
const res = await api.get('/order/price-list/options', { params })
const items = Array.isArray(res?.data?.items) ? res.data.items : []
serverFilterOptionMap.value = {
...serverFilterOptionMap.value,
[field]: items.map((x) => ({
label: toText(x?.label ?? x?.value),
value: toText(x?.value ?? x?.label)
})).filter((x) => x.value)
}
} finally {
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: false }
}
}
function onTopFilterSearchUrunIlkGrubu (val, update) {
update(() => {
filterSearch.value.urunIlkGrubu = toText(val)
void fetchServerFilterOptions('urunIlkGrubu', val)
})
}
function onTopFilterSearchUrunAnaGrubu (val, update) {
update(() => {
filterSearch.value.urunAnaGrubu = toText(val)
void fetchServerFilterOptions('urunAnaGrubu', val)
})
}
function onProductCodeMenuShow () {
void fetchServerFilterOptions('productCode', filterSearch.value.productCode)
}
function onProductCodeSearchText (val) {
void fetchServerFilterOptions('productCode', val)
}
function toggleProductCodeValue (value) {
const v = toText(value)
if (!v) return
const set = new Set(selectedProductCodes.value || [])
if (set.has(v)) set.delete(v)
else set.add(v)
selectedProductCodes.value = Array.from(set).sort((a, b) => a.localeCompare(b, 'tr'))
}
function selectAllProductCodeOptions () {
const set = new Set(selectedProductCodes.value || [])
for (const option of productCodeOptions.value) {
const v = toText(option.value)
if (v) set.add(v)
}
selectedProductCodes.value = Array.from(set).sort((a, b) => a.localeCompare(b, 'tr'))
}
function clearProductCodeOptions () {
selectedProductCodes.value = []
}
function toggleCampaignValue (value) {
const v = toText(value)
if (!v) return
const set = new Set(selectedCampaignLabels.value || [])
if (set.has(v)) set.delete(v)
else set.add(v)
selectedCampaignLabels.value = Array.from(set).sort((a, b) => a.localeCompare(b, 'tr'))
}
function selectAllCampaignOptions () {
const set = new Set(selectedCampaignLabels.value || [])
for (const option of filteredCampaignOptions.value) {
const v = toText(option.value)
if (v) set.add(v)
}
selectedCampaignLabels.value = Array.from(set).sort((a, b) => a.localeCompare(b, 'tr'))
}
function clearCampaignOptions () {
selectedCampaignLabels.value = []
}
function onTopUrunIlkGrubuChange () {
topUrunAnaGrubu.value = null
void fetchServerFilterOptions('urunAnaGrubu', '')
}
function onTopUrunAnaGrubuChange () {}
function requestParams (page) {
const params = {
page,
limit: Math.min(500, Math.max(PAGE_LIMIT, selectedProductCodes.value.length || 0)),
include_total: 1
}
if (topUrunIlkGrubu.value) params.urun_ilk_grubu = topUrunIlkGrubu.value
if (topUrunAnaGrubu.value) params.urun_ana_grubu = topUrunAnaGrubu.value
if (selectedProductCodes.value.length > 0) params.product_code = selectedProductCodes.value.join(',')
return params
}
async function reloadData ({ page = 1 } = {}) {
if (!canFetch.value) {
rows.value = []
error.value = GUIDANCE_MSG
return
}
loading.value = true
renderPending.value = true
try {
const productRes = await api.request({
method: 'GET',
url: '/order/price-list/products',
params: requestParams(page),
timeout: 180000
})
const products = (Array.isArray(productRes?.data) ? productRes.data : []).map(mapProductRow)
totalCount.value = Number(productRes?.headers?.['x-total-count'] || products.length || 0)
totalPages.value = Math.max(1, Number(productRes?.headers?.['x-total-pages'] || 1))
currentPage.value = Math.max(1, Number(productRes?.headers?.['x-page'] || page))
const codes = products.map((x) => x.productCode).filter(Boolean)
let variants = []
if (codes.length > 0) {
const variantRes = await api.request({
method: 'GET',
url: '/order/price-list/variant-rows',
params: { product_code: codes.join(',') },
timeout: 180000
})
variants = Array.isArray(variantRes?.data) ? variantRes.data : []
}
rows.value = buildRows(products, variants)
error.value = ''
void loadImagesForRows(rows.value)
await nextTick()
} catch (err) {
rows.value = []
error.value = err?.response?.data || err?.message || 'Fiyat listesi alinamadi'
Notify.create({ type: 'negative', message: error.value })
} finally {
loading.value = false
setTimeout(() => { renderPending.value = false }, 120)
}
}
async function loadImagesForRows (list) {
const targets = []
const seen = new Set()
for (const row of list) {
const key = `${row.productCode}|${row.dim1 || 0}|${row.dim3 || 0}`
if (!row.productCode || seen.has(key)) continue
seen.add(key)
targets.push({ row, key })
}
const concurrency = 12
let cursor = 0
let loaded = 0
const workers = Array.from({ length: Math.min(concurrency, targets.length) }, async () => {
for (;;) {
const target = targets[cursor]
cursor += 1
if (!target) return
const { row, key } = target
if (imageCache.has(key)) {
row.imageUrl = imageCache.get(key)
continue
}
try {
const res = await api.get('/product-images', {
params: {
code: row.productCode,
dim1_id: row.dim1 || '',
dim3_id: row.dim3 || ''
},
timeout: 15000
})
const first = Array.isArray(res?.data) ? res.data[0] : null
const url = resolveProductImageUrl(first)
imageCache.set(key, url)
row.imageUrl = url
imageListCache.set(key, Array.isArray(res?.data) ? res.data : [])
} catch {
imageCache.set(key, '')
}
loaded += 1
if (loaded % 12 === 0) rows.value = [...rows.value]
}
})
await Promise.all(workers)
rows.value = [...rows.value]
}
function normalizeUploadsPath (storagePath) {
const raw = toText(storagePath)
if (!raw) return ''
const normalized = raw.replace(/\\/g, '/')
const idx = normalized.toLowerCase().indexOf('/uploads/')
if (idx >= 0) return normalized.slice(idx)
if (normalized.toLowerCase().startsWith('uploads/')) return `/${normalized}`
return ''
}
function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return ''
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
const thumbUrl = toText(item.thumb_url || item.thumbUrl)
if (thumbUrl) return thumbUrl
const fullUrl = toText(item.full_url || item.fullUrl)
if (fullUrl) return fullUrl
const contentUrl = toText(item.content_url || item.ContentURL)
if (contentUrl) return contentUrl.startsWith('/api/') ? contentUrl : contentUrl
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage)
if (uploadsPath) return uploadsPath
const fileName = toText(item.file_name || item.FileName)
return fileName ? `/uploads/image/${fileName}` : ''
}
async function fetchImageListForRow (row) {
const key = `${row.productCode}|${row.dim1 || 0}|${row.dim3 || 0}`
if (imageListCache.has(key)) return imageListCache.get(key) || []
const res = await api.get('/product-images', {
params: {
code: row.productCode,
dim1_id: row.dim1 || '',
dim3_id: row.dim3 || ''
},
timeout: 15000
})
const list = Array.isArray(res?.data) ? res.data : []
imageListCache.set(key, list)
return list
}
async function openProductCard (row) {
if (!row) return
productCardData.value = { ...row }
productCardDialog.value = true
productCardSlide.value = 0
try {
const list = await fetchImageListForRow(row)
const images = list.map(resolveProductImageUrl).filter(Boolean)
if (row.imageUrl && !images.includes(row.imageUrl)) images.unshift(row.imageUrl)
productCardImages.value = Array.from(new Set(images))
} catch {
productCardImages.value = row.imageUrl ? [row.imageUrl] : []
}
}
function openProductImageFullscreen (src) {
const value = toText(src)
if (!value) return
const idx = Math.max(0, fullscreenImages.value.findIndex((x) => toText(x) === value))
productImageFullscreenSlide.value = idx
productImageFullscreenDialog.value = true
}
function onProductCardDialogHide () {
productImageFullscreenDialog.value = false
}
function resetSelections () {
topUrunIlkGrubu.value = null
topUrunAnaGrubu.value = null
selectedProductCodes.value = []
rows.value = []
error.value = GUIDANCE_MSG
currentPage.value = 1
totalPages.value = 1
totalCount.value = 0
}
function onPageChange (page) {
void reloadData({ page })
}
function togglePriceOption (value) {
const set = new Set(selectedPriceOptions.value || [])
if (set.has(value)) set.delete(value)
else set.add(value)
selectedPriceOptions.value = priceOptions.map((x) => x.value).filter((x) => set.has(x))
}
function selectAllPrices () {
selectedPriceOptions.value = priceOptions.map((x) => x.value)
}
function clearAllPrices () {
selectedPriceOptions.value = []
}
function col (name, label, field, width, extra = {}) {
return {
name,
label,
field,
align: extra.align || 'left',
sortable: !!extra.sortable,
style: `width:${width}px;min-width:${width}px;max-width:${width}px;`,
headerStyle: `width:${width}px;min-width:${width}px;max-width:${width}px;`,
classes: extra.classes || '',
headerClasses: extra.headerClasses || extra.classes || ''
}
}
const allColumns = [
col('image', '', 'imageUrl', 108, { align: 'center', classes: 'image-col sticky-col' }),
col('brandGroupSelection', 'MARKA GRUBU', 'brandGroupSelection', 86, { classes: 'ps-col sticky-col' }),
col('marka', 'MARKA', 'marka', 72, { sortable: true, classes: 'ps-col sticky-col' }),
col('productCode', 'URUN KODU', 'productCode', 112, { sortable: true, classes: 'ps-col product-code-col sticky-col' }),
col('variantCodes', 'VARYANT', 'variantCodes', 82, { align: 'center', classes: 'ps-col variant-col sticky-col center-col' }),
col('variantStocks', 'STOK', 'stockQty', 64, { align: 'center', sortable: true, classes: 'ps-col variant-stock-col sticky-col center-col' }),
col('campaignLabel', 'KAMPANYA', 'campaignLabel', 118, { align: 'center', classes: 'ps-col campaign-col sticky-col center-col' }),
col('campaignRate', 'IND %', 'campaignRate', 56, { align: 'center', classes: 'ps-col campaign-rate-col sticky-col center-col' }),
col('askiliYan', 'ASKILI YAN', 'askiliYan', 58, { sortable: true, classes: 'ps-col center-col' }),
col('kategori', 'KATEGORI', 'kategori', 72, { sortable: true, classes: 'ps-col center-col' }),
col('urunIlkGrubu', 'URUN ILK GRUBU', 'urunIlkGrubu', 72, { sortable: true, classes: 'ps-col center-col' }),
col('urunAnaGrubu', 'URUN ANA GRUBU', 'urunAnaGrubu', 84, { sortable: true, classes: 'ps-col center-col' }),
col('urunAltGrubu', 'URUN ALT GRUBU', 'urunAltGrubu', 84, { sortable: true, classes: 'ps-col center-col' }),
col('icerik', 'ICERIK', 'icerik', 92, { sortable: true, classes: 'ps-col' }),
col('karisim', 'KARISIM', 'karisim', 88, { sortable: true, classes: 'ps-col karisim-wrap-col' }),
...campaignPairs.flatMap((p) => [
col(p.base, p.base.toUpperCase().replace(/([A-Z]+)(\d)/, '$1 $2'), p.base, 78, { align: 'right', classes: `${p.base.slice(0, 3)}-col` }),
col(p.derived, `${p.base.toUpperCase().replace(/([A-Z]+)(\d)/, '$1 $2')} KMP`, p.derived, 88, { align: 'right', classes: `${p.base.slice(0, 3)}-col campaign-price-col` })
])
]
const compactHiddenColumnNames = new Set([
'variantStocks',
'campaignLabel',
'campaignRate',
'askiliYan',
'kategori',
'urunIlkGrubu',
'urunAnaGrubu',
'urunAltGrubu',
'icerik',
'karisim'
])
const visibleColumns = computed(() => allColumns.filter((c) => {
if (/^(usd|eur|try)[1-6]$/.test(c.name)) return selectedPriceSet.value.has(c.name)
if (/^(usd|eur|try)[1-6]Campaign$/.test(c.name)) return selectedPriceSet.value.has(c.name.replace(/Campaign$/, ''))
if (!leftDetailsExpanded.value && compactHiddenColumnNames.has(c.name)) return false
return true
}))
const filteredRows = computed(() => {
const campaignSet = selectedCampaignLabelSet.value
if (campaignSet.size === 0) return rows.value || []
return (rows.value || []).filter((row) => campaignSet.has(toText(row?.campaignLabel)))
})
const tableMinWidth = computed(() => visibleColumns.value.reduce((sum, c) => sum + extractWidth(c.style), 0))
const tableStyle = computed(() => ({
width: `${tableMinWidth.value}px`,
minWidth: `${tableMinWidth.value}px`,
tableLayout: 'fixed'
}))
const stickyColumnNames = computed(() => {
const visible = new Set(visibleColumns.value.map((x) => x.name))
const expanded = [
'image',
'brandGroupSelection',
'marka',
'productCode',
'variantCodes',
'variantStocks',
'campaignLabel',
'campaignRate',
'askiliYan',
'kategori',
'urunIlkGrubu',
'urunAnaGrubu',
'urunAltGrubu',
'icerik',
'karisim'
]
const compact = ['image', 'brandGroupSelection', 'marka', 'productCode', 'variantCodes']
return (leftDetailsExpanded.value ? expanded : compact).filter((x) => visible.has(x))
})
const stickyBoundaryColumnName = computed(() => {
const list = stickyColumnNames.value
return list.length ? list[list.length - 1] : ''
})
const stickyColumnNameSet = computed(() => new Set(stickyColumnNames.value))
const stickyLeftMap = computed(() => {
const map = {}
let left = 0
for (const name of stickyColumnNames.value) {
const colDef = allColumns.find((x) => x.name === name)
if (!colDef) continue
map[name] = left
left += extractWidth(colDef.style)
}
return map
})
const stickyScrollComp = computed(() => {
const boundaryName = stickyBoundaryColumnName.value
const boundaryCol = allColumns.find((x) => x.name === boundaryName)
return ((stickyLeftMap.value[boundaryName] || 0) + extractWidth(boundaryCol?.style)) * 1.2
})
function isStickyCol (name) {
return stickyColumnNameSet.value.has(name)
}
function isStickyBoundary (name) {
return name === stickyBoundaryColumnName.value
}
function getHeaderCellStyle (col) {
const base = col.headerStyle || col.style || ''
if (!isStickyCol(col.name)) return base
return `${base};left:${stickyLeftMap.value[col.name] || 0}px;`
}
function getBodyCellStyle (col) {
const base = col.style || ''
if (!isStickyCol(col.name)) return base
return `${base};left:${stickyLeftMap.value[col.name] || 0}px;`
}
function extractWidth (style) {
const m = String(style || '').match(/width:(\d+)px/)
return m ? Number(m[1]) : 80
}
function exportCell (row, col) {
if (col.name === 'image') return row.imageUrl || ''
if (priceColumnNames.includes(col.name)) return formatPrice(row[col.field])
if (col.name === 'variantStocks') return formatStock(row.stockQty)
if (col.name === 'campaignRate') return row.campaignRate ? formatPrice(row.campaignRate) : ''
return toText(row[col.field])
}
function exportVisibleExcel () {
const cols = visibleColumns.value.filter((c) => c.name !== 'image')
const body = filteredRows.value.map((row) => `<tr>${cols.map((c) => {
return `<td>${escapeHtml(exportCell(row, c))}</td>`
}).join('')}</tr>`).join('')
const html = `<!doctype html><html><head><meta charset="utf-8"></head><body><table border="1"><thead><tr>${cols.map((c) => `<th>${escapeHtml(c.label)}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table></body></html>`
const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `fiyat_listesi_${new Date().toISOString().slice(0, 10)}.xls`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
void notifyExportTaken('excel')
}
function printVisibleRows () {
const cols = visibleColumns.value
const body = filteredRows.value.map((row) => `<tr>${cols.map((c) => {
if (c.name === 'image' && row.imageUrl) return `<td><img src="${row.imageUrl}" class="thumb"></td>`
return `<td class="${priceColumnNames.includes(c.name) ? 'num' : ''}">${escapeHtml(exportCell(row, c))}</td>`
}).join('')}</tr>`).join('')
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Fiyat Listesi</title><style>
@page { size: A3 landscape; margin: 8mm; }
body { font-family: Arial, sans-serif; font-size: 8px; }
h1 { font-size: 16px; margin: 0 0 8px; }
table { border-collapse: collapse; width: 100%; }
th { background: #957116; color: #fff; }
th, td { border: 1px solid #ccc; padding: 3px; vertical-align: middle; }
.num { text-align: right; }
.thumb { width: 100px; height: 100px; object-fit: cover; }
</style></head><body><h1>Fiyat Listesi</h1><table><thead><tr>${cols.map((c) => `<th>${escapeHtml(c.label || 'Gorsel')}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table><script>window.onload=function(){window.print()}<\/script></body></html>`
const win = window.open('', '_blank')
if (!win) return
win.document.open()
win.document.write(html)
win.document.close()
void notifyExportTaken('pdf')
}
async function notifyExportTaken (format) {
try {
await api.post('/order/price-list/export-notify', {
format,
row_count: filteredRows.value.length,
price_fields: [...selectedPriceOptions.value],
product_codes: [...selectedProductCodes.value],
campaign_labels: [...selectedCampaignLabels.value],
first_groups: Array.from(new Set(filteredRows.value.map((row) => toText(row.urunIlkGrubu)).filter(Boolean))).sort((a, b) => a.localeCompare(b, 'tr')),
urun_ilk_grubu: topUrunIlkGrubu.value || '',
urun_ana_grubu: topUrunAnaGrubu.value || ''
}, { timeout: 30000 })
} catch (err) {
console.warn('[order-price-list][ui] export notify failed', err?.response?.data || err?.message || err)
}
}
function getTableMiddleEl () {
return mainTableRef.value?.$el?.querySelector?.('.q-table__middle') || null
}
function onTopScroll () {
if (syncingScroll) return
const middle = getTableMiddleEl()
const top = topScrollRef.value
if (!middle || !top) return
syncingScroll = true
middle.scrollLeft = top.scrollLeft
requestAnimationFrame(() => { syncingScroll = false })
}
function bindTableScrollSync () {
const middle = getTableMiddleEl()
if (!middle || middle.__orderPriceListScrollBound) return
middle.__orderPriceListScrollBound = true
middle.addEventListener('scroll', () => {
if (syncingScroll) return
const top = topScrollRef.value
if (!top) return
syncingScroll = true
top.scrollLeft = middle.scrollLeft
requestAnimationFrame(() => { syncingScroll = false })
}, { passive: true })
}
function escapeHtml (value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
watch(selectedProductCodes, (list) => {
const clean = Array.isArray(list) ? [...new Set(list.map(toText).filter(Boolean))] : []
if (clean.join('\u0001') !== (Array.isArray(list) ? list.join('\u0001') : '')) {
selectedProductCodes.value = clean
}
})
watch([tableMinWidth, rows], async () => {
await nextTick()
bindTableScrollSync()
})
onMounted(() => {
void fetchServerFilterOptions('urunIlkGrubu', '')
void fetchServerFilterOptions('urunAnaGrubu', '')
void fetchServerFilterOptions('productCode', '')
void nextTick(bindTableScrollSync)
})
</script>
<style scoped>
.order-price-list-page {
position: relative;
height: calc(100vh - 58px);
display: flex;
flex-direction: column;
--pricing-row-height: 108px;
--pricing-header-height: 88px;
--pricing-table-height: calc(100vh - 156px);
}
.top-actions {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
flex: 1 1 auto;
min-width: 0;
}
.top-actions-row {
width: 100%;
justify-content: flex-start;
}
.top-bar {
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
}
.top-actions-row--filters,
.top-actions-row--actions {
justify-content: flex-start;
}
.toolbar-group {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 4px;
background: #fff;
}
.toolbar-group--paging {
gap: 10px;
}
.toolbar-group :deep(.q-btn) {
min-height: 32px;
white-space: nowrap;
}
.toolbar-group :deep(.q-btn__content) {
gap: 6px;
padding: 0;
}
.toolbar-group :deep(.q-btn__wrapper) {
padding: 4px 10px;
}
.table-wrap {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 4px;
display: flex;
flex-direction: column;
}
.product-thumb {
width: 100px;
height: 100px;
border-radius: 4px;
background: #f4f4f4;
}
.product-thumb-placeholder {
width: 100px;
height: 100px;
border-radius: 4px;
background: #f4f4f4;
border: 1px dashed rgba(0, 0, 0, 0.16);
display: flex;
align-items: center;
justify-content: center;
}
.image-cell {
padding: 4px !important;
}
.top-x-scroll {
flex: 0 0 14px;
height: 14px;
overflow-x: auto;
overflow-y: hidden;
background: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.top-x-scroll-inner {
height: 1px;
}
.pane-table {
height: 100%;
width: 100%;
}
.pricing-table :deep(.q-table__middle) {
height: calc(var(--pricing-table-height) - 14px);
min-height: calc(var(--pricing-table-height) - 14px);
max-height: calc(var(--pricing-table-height) - 14px);
overflow: auto !important;
scrollbar-gutter: stable both-edges;
overscroll-behavior: contain;
}
.pricing-table :deep(.q-table) {
width: max-content;
}
.pricing-table :deep(td),
.pricing-table :deep(.q-table tbody tr) {
height: var(--pricing-row-height) !important;
min-height: var(--pricing-row-height) !important;
max-height: var(--pricing-row-height) !important;
line-height: 1.25;
padding: 0 !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
}
.pricing-table :deep(td) {
overflow: hidden !important;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle !important;
}
.pricing-table :deep(th),
.pricing-table :deep(.q-table thead tr),
.pricing-table :deep(.q-table thead tr.header-row-fixed),
.pricing-table :deep(.q-table thead th),
.pricing-table :deep(.q-table thead tr.header-row-fixed > th) {
height: var(--pricing-header-height) !important;
min-height: var(--pricing-header-height) !important;
max-height: var(--pricing-header-height) !important;
}
.pricing-table :deep(th) {
padding-top: 0;
padding-bottom: 0;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
overflow: hidden;
text-align: center;
font-size: 10px;
font-weight: 800;
line-height: 1.15;
}
.pricing-table :deep(.q-table thead th) {
position: sticky;
top: 0;
z-index: 30;
background: #fff;
vertical-align: middle !important;
}
.pricing-table :deep(.sticky-col) {
position: sticky !important;
background-clip: padding-box;
}
.pricing-table :deep(thead .sticky-col) {
z-index: 35 !important;
}
.pricing-table :deep(tbody .sticky-col) {
z-index: 12 !important;
background: #fff !important;
}
.pricing-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);
}
.pricing-table :deep(tbody td:not(.sticky-col)) {
position: relative;
z-index: 1 !important;
}
.pricing-table :deep(tbody td.sticky-col)::after,
.pricing-table :deep(thead th.sticky-col)::after {
content: '';
position: absolute;
inset: 0;
background: inherit;
z-index: -1;
}
.pricing-table :deep(th.ps-col),
.pricing-table :deep(td.ps-col) {
background: #fff;
color: var(--q-primary);
font-weight: 700;
}
.pricing-table :deep(td.ps-col),
.pricing-table :deep(td.usd-col),
.pricing-table :deep(td.eur-col),
.pricing-table :deep(td.try-col) {
vertical-align: middle !important;
}
.pricing-table :deep(td.center-col) {
text-align: center !important;
}
.pricing-table :deep(td.center-col .q-badge) {
margin-left: auto;
margin-right: auto;
}
.pricing-table :deep(td.karisim-wrap-col) {
white-space: normal !important;
text-overflow: clip !important;
word-break: break-word;
overflow-wrap: anywhere;
vertical-align: top !important;
text-align: left !important;
font-size: 9px;
line-height: 1.15;
padding: 6px 4px !important;
align-content: flex-start;
}
.pricing-table :deep(td.karisim-wrap-col .q-td__content),
.pricing-table :deep(td.karisim-wrap-col > div) {
align-items: flex-start !important;
justify-content: flex-start !important;
white-space: normal !important;
overflow: hidden;
}
.order-price-list-table :deep(.campaign-price-col) {
background: #f6fbf7;
}
.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;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
font-weight: 800;
line-height: 1.12;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
.campaign-badge {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.header-filter-btn {
width: 20px;
height: 20px;
min-width: 20px;
justify-self: end;
}
.header-filter-ghost {
opacity: 0;
pointer-events: none;
}
.excel-filter-menu,
.filter-menu-panel {
min-width: 230px;
padding: 8px;
}
.excel-filter-select :deep(.q-field__control) {
min-height: 30px;
}
.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;
}
.page-busy-overlay {
position: fixed;
inset: 0;
z-index: 30000;
background: rgba(255, 255, 255, 0.68);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
pointer-events: all;
}
.page-busy-label {
font-weight: 600;
color: #4c4c4c;
}
.empty-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.72);
}
.empty-overlay-inner {
border: 1px solid #ddd;
background: #fff;
border-radius: 6px;
padding: 16px 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.product-card-dialog {
--pc-media-h: calc(100vh - 180px);
--pc-media-w: min(74vw, 1220px);
background: #f9f8f5;
height: 100vh;
display: flex;
flex-direction: column;
}
:deep(.product-card-dialog > .q-card__section:last-child) {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.product-card-content {
display: grid;
grid-template-columns: minmax(360px, 420px) minmax(760px, 1fr);
gap: 14px;
align-items: stretch;
justify-content: start;
height: 100%;
}
.product-card-images {
grid-column: 2;
grid-row: 1;
height: var(--pc-media-h);
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
}
.product-card-carousel {
width: var(--pc-media-w);
height: 100%;
max-width: 100%;
background: linear-gradient(180deg, #f6f1e6 0%, #efe6d3 100%);
border: 1px solid #e4dac7;
}
.dialog-image {
width: 100%;
height: 100%;
}
.dialog-image-stage {
width: var(--pc-media-w);
max-width: 100%;
height: 100%;
overflow: hidden;
border-radius: 10px;
background: linear-gradient(180deg, #f6f1e6 0%, #efe6d3 100%);
display: flex;
align-items: center;
justify-content: center;
}
.dialog-image-empty {
width: var(--pc-media-w);
max-width: 100%;
height: var(--pc-media-h);
border: 1px dashed #c5b28d;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: #faf6ee;
}
.product-card-fields {
grid-column: 1;
grid-row: 1;
border: 1px solid #e4dac7;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #fdfaf4 100%);
padding: 12px;
height: var(--pc-media-h);
overflow: auto;
}
.field-row {
display: grid;
grid-template-columns: 150px 1fr;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #efe5d5;
font-size: 13px;
}
.field-row.field-row-head {
background: #f8f3e9;
border: 1px solid #e6dccb;
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 8px;
}
.field-row:last-child {
border-bottom: none;
}
.field-row .k {
color: #6b5a33;
font-weight: 700;
}
.field-row .v {
color: #1f1f1f;
word-break: break-word;
}
.image-fullscreen-dialog {
background: #f4f0e2;
}
.image-fullscreen-body {
height: calc(100vh - 72px);
display: flex;
align-items: center;
justify-content: center;
}
.image-fullscreen-carousel {
width: min(98vw, 1500px);
}
.image-fullscreen-stage {
width: min(96vw, 1400px);
height: calc(100vh - 120px);
border-radius: 10px;
background: linear-gradient(180deg, #f1e7d3 0%, #e9dcc4 100%);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.image-fullscreen-img {
width: 100%;
height: 100%;
}
.q-btn,
.q-icon,
.cursor-pointer {
cursor: pointer !important;
}
@media (max-width: 1024px) {
.product-card-content {
grid-template-columns: 1fr;
}
.product-card-images,
.product-card-fields {
grid-column: 1;
grid-row: auto;
}
}
</style>