Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-03-11 17:53:30 +03:00
parent aba71341b9
commit 6ff8747411
8 changed files with 1373 additions and 129 deletions

View File

@@ -181,21 +181,29 @@
</div>
<div class="sub-image level2-image">
<q-card flat bordered class="product-image-card">
<q-card flat bordered class="product-image-card cursor-pointer" @click.stop="openProductCard(grp1, grp2)">
<q-card-section class="q-pa-xs product-image-wrap">
<q-img
v-if="getProductImageUrl(grp1.productCode, grp2.colorCode)"
:src="getProductImageUrl(grp1.productCode, grp2.colorCode)"
fit="cover"
v-if="getProductImageUrl(grp1.productCode, grp2.colorCode, grp2.secondColor)"
:src="getProductImageUrl(grp1.productCode, grp2.colorCode, grp2.secondColor)"
fit="contain"
class="product-image"
loading="lazy"
@error="onProductImageError(grp1.productCode, grp2.colorCode)"
@error="onProductImageError(grp1.productCode, grp2.colorCode, grp2.secondColor)"
/>
<div v-else class="product-image-placeholder">
<q-icon name="image_not_supported" size="22px" color="grey-6" />
</div>
</q-card-section>
</q-card>
<q-btn
dense
flat
color="primary"
label="Urun Detayi Gor"
class="detail-open-btn"
@click.stop="openProductCard(grp1, grp2)"
/>
</div>
</div>
@@ -231,6 +239,99 @@
</template>
</div>
</div>
<q-dialog v-model="productCardDialog" maximized>
<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-stock">
<div class="text-subtitle1 text-weight-bold">
{{ productCardData.productCode || '-' }} / {{ productCardData.colorCode || '-' }}{{ productCardData.secondColor ? '-' + productCardData.secondColor : '' }}
</div>
<div class="text-caption">Toplam Stok: {{ formatNumber(productCardData.totalQty || 0) }}</div>
<div class="stock-size-grid q-mt-sm">
<div v-for="sz in sizeLabels" :key="'dlg-sz-' + sz" class="stock-size-chip">
<span class="label">{{ sz }}</span>
<span class="value">{{ Number(productCardData.sizeTotals?.[sz] || 0) > 0 ? formatNumber(productCardData.sizeTotals[sz]) : '-' }}</span>
</div>
</div>
</div>
<q-separator class="q-my-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="560px"
class="product-card-carousel rounded-borders bg-grey-2"
>
<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"><span class="k">Urun Kodu</span><span class="v">{{ productCardData.productCode || '-' }}</span></div>
<div class="field-row"><span class="k">Urun Renk</span><span class="v">{{ productCardData.colorCode || '-' }}</span></div>
<div class="field-row"><span class="k">Urun 2.Renk</span><span class="v">{{ productCardData.secondColor || '-' }}</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 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">Urun Icerigi</span><span class="v">{{ productCardData.urunIcerigi || '-' }}</span></div>
<div class="field-row"><span class="k">Fit</span><span class="v">{{ productCardData.fit || '-' }}</span></div>
<div class="field-row"><span class="k">Drop</span><span class="v">{{ productCardData.drop || '-' }}</span></div>
<div class="field-row"><span class="k">Kumas</span><span class="v">{{ productCardData.kumas || '-' }}</span></div>
<div class="field-row"><span class="k">Karisim</span><span class="v">{{ productCardData.karisim || '-' }}</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">
<div class="image-fullscreen-stage cursor-pointer" @click="toggleFullscreenImageZoom">
<q-img
:src="productImageFullscreenSrc"
fit="contain"
class="image-fullscreen-img"
:style="fullscreenImageStyle"
/>
</div>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
<q-page v-else class="q-pa-md flex flex-center">
@@ -284,6 +385,7 @@ const filters = ref({
})
const optionLists = ref({})
const filteredOptionLists = ref({})
const filterOptionsCache = ref({})
const rawRows = ref([])
const productImageCache = ref({})
const productImageLoading = ref({})
@@ -293,8 +395,19 @@ const productImageFallbackByKey = ref({})
const productImageContentLoading = ref({})
const productImageBlobUrls = ref([])
const productImageListBlockedUntil = ref(0)
const productCardDialog = ref(false)
const productCardData = ref({})
const productCardImages = ref([])
const productCardSlide = ref(0)
const productImageFullscreenDialog = ref(false)
const productImageFullscreenSrc = ref('')
const productImageFullscreenZoom = ref(1)
const IMAGE_LIST_CONCURRENCY = 8
const FILTER_OPTIONS_CACHE_TTL_MS = 60 * 1000
const FILTER_OPTIONS_DEBOUNCE_MS = 250
let imageListActiveRequests = 0
let filterOptionsDebounceTimer = null
let filterOptionsRequestSeq = 0
const imageListWaitQueue = []
const activeSchema = ref(storeSchemaByKey.tak)
const activeGrpKey = ref('tak')
@@ -324,6 +437,11 @@ const allDetailsExpanded = computed(() => {
const gridHeaderHeight = computed(() =>
showGridHeader.value ? '56px' : '0px'
)
const fullscreenImageStyle = computed(() => ({
transform: `scale(${productImageFullscreenZoom.value})`,
transformOrigin: 'center center',
transition: 'transform 0.15s ease-out'
}))
function emptySizeTotals() {
const map = {}
@@ -347,8 +465,20 @@ function sortByTotalQtyDesc(a, b) {
return String(a?.key || '').localeCompare(String(b?.key || ''), 'tr', { sensitivity: 'base' })
}
function buildImageKey(code, color) {
return `${String(code || '').trim().toUpperCase()}::${String(color || '').trim().toUpperCase()}`
function buildImageKey(code, color, secondColor = '') {
return `${String(code || '').trim().toUpperCase()}::${String(color || '').trim().toUpperCase()}::${String(secondColor || '').trim().toUpperCase()}`
}
function imageNameMatches(fileName, color, secondColor) {
const text = String(fileName || '').toUpperCase()
if (!text) return false
const tokens = text.replace(/[^A-Z0-9_]+/g, ' ').trim().split(/\s+/).filter(Boolean)
if (!tokens.length) return false
const colorTrim = String(color || '').trim().toUpperCase()
const secondTrim = String(secondColor || '').trim().toUpperCase()
if (colorTrim && !tokens.includes(colorTrim)) return false
if (secondTrim && !tokens.includes(secondTrim)) return false
return true
}
function normalizeUploadsPath(storagePath) {
@@ -403,17 +533,17 @@ function resolveProductImageUrl(item) {
return { contentUrl, publicUrl }
}
function getProductImageUrl(code, color) {
const key = buildImageKey(code, color)
function getProductImageUrl(code, color, secondColor = '') {
const key = buildImageKey(code, color, secondColor)
const existing = productImageCache.value[key]
if (existing !== undefined) return existing || ''
void ensureProductImage(code, color)
void ensureProductImage(code, color, secondColor)
return ''
}
async function onProductImageError(code, color) {
const key = buildImageKey(code, color)
async function onProductImageError(code, color, secondColor = '') {
const key = buildImageKey(code, color, secondColor)
const fallback = String(productImageFallbackByKey.value[key] || '')
if (fallback && !productImageContentLoading.value[key]) {
productImageContentLoading.value[key] = true
@@ -438,10 +568,12 @@ async function onProductImageError(code, color) {
productImageCache.value[key] = ''
}
async function ensureProductImage(code, color) {
const key = buildImageKey(code, color)
async function ensureProductImage(code, color, secondColor = '') {
const key = buildImageKey(code, color, secondColor)
const codeTrim = String(code || '').trim().toUpperCase()
const colorTrim = String(color || '').trim().toUpperCase()
const secondTrim = String(secondColor || '').trim().toUpperCase()
const listKey = buildImageKey(codeTrim, colorTrim, secondTrim)
if (!codeTrim) {
productImageCache.value[key] = ''
return ''
@@ -455,44 +587,44 @@ async function ensureProductImage(code, color) {
productImageLoading.value[key] = true
try {
if (!productImageListByCode.value[codeTrim]) {
if (!productImageListLoading.value[codeTrim]) {
productImageListLoading.value[codeTrim] = true
if (!productImageListByCode.value[listKey]) {
if (!productImageListLoading.value[listKey]) {
productImageListLoading.value[listKey] = true
try {
if (imageListActiveRequests >= IMAGE_LIST_CONCURRENCY) {
await new Promise((resolve) => imageListWaitQueue.push(resolve))
}
imageListActiveRequests++
const res = await api.get('/product-images', { params: { code: codeTrim } })
productImageListByCode.value[codeTrim] = Array.isArray(res?.data) ? res.data : []
const params = { code: codeTrim, dim1: colorTrim, dim3: secondTrim }
const res = await api.get('/product-images', { params })
productImageListByCode.value[listKey] = Array.isArray(res?.data) ? res.data : []
} catch (err) {
productImageListByCode.value[codeTrim] = []
productImageListByCode.value[listKey] = []
const status = Number(err?.response?.status || 0)
if (status >= 500 || status === 403 || status === 0) {
// Backend dengesizken istek firtinasini kisaca kes.
productImageListBlockedUntil.value = Date.now() + 30 * 1000
}
console.warn('[ProductStockByAttributes] product image list fetch failed', { code: codeTrim, err })
console.warn('[ProductStockByAttributes] product image list fetch failed', { code: codeTrim, color: colorTrim, secondColor: secondTrim, err })
} finally {
imageListActiveRequests = Math.max(0, imageListActiveRequests - 1)
const nextInQueue = imageListWaitQueue.shift()
if (nextInQueue) nextInQueue()
delete productImageListLoading.value[codeTrim]
delete productImageListLoading.value[listKey]
}
} else {
// Ayni code icin baska bir istek zaten calisiyorsa tamamlanmasini bekle.
while (productImageListLoading.value[codeTrim]) {
while (productImageListLoading.value[listKey]) {
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
}
const list = productImageListByCode.value[codeTrim] || []
const list = productImageListByCode.value[listKey] || []
let first = null
if (colorTrim) {
const needle = `-${colorTrim.toLowerCase()}-`
if (colorTrim || secondTrim) {
first = list.find((item) =>
String(item?.file_name || item?.FileName || '').toLowerCase().includes(needle)
imageNameMatches(String(item?.file_name || item?.FileName || ''), colorTrim, secondTrim)
) || null
}
if (!first) first = list[0] || null
@@ -625,6 +757,11 @@ const level1Groups = computed(() => {
const depoAdi = String(item.Depo_Adi || '').trim()
const urunAnaGrubu = String(item.URUN_ANA_GRUBU || '').trim()
const urunAltGrubu = String(item.URUN_ALT_GRUBU || '').trim()
const urunIcerigi = String(item.URUN_ICERIGI || item.KISA_KAR || '').trim()
const fit = String(item.BIRINCI_PARCA_FIT || '').trim()
const drop = String(item.DR || '').trim()
const kumas = String(item.BIRINCI_PARCA_KUMAS || '').trim()
const karisim = String(item.BIRINCI_PARCA_KARISIM || '').trim()
const aciklama = String(item.Madde_Aciklamasi || '').trim()
const beden = normalizeSize(item.Beden || '')
const qty = parseNumber(item.Kullanilabilir_Envanter)
@@ -654,6 +791,11 @@ const level1Groups = computed(() => {
secondColor,
urunAnaGrubu,
urunAltGrubu,
urunIcerigi,
fit,
drop,
kumas,
karisim,
aciklama,
sizeTotals: emptySizeTotals(),
totalQty: 0,
@@ -714,6 +856,11 @@ function buildFilterParams() {
return out
}
function buildFilterCacheKey(params) {
const keys = Object.keys(params || {}).sort()
return keys.map((k) => `${k}=${String(params[k] || '').trim()}`).join('&')
}
function isFilterDisabled(key) {
if (key === 'kategori') return false
if (key === 'urun_ana_grubu') {
@@ -747,7 +894,12 @@ function onFilterValueChange(changedKey) {
filters.value.beden = ''
}
void loadFilterOptions()
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer)
}
filterOptionsDebounceTimer = setTimeout(() => {
void loadFilterOptions()
}, FILTER_OPTIONS_DEBOUNCE_MS)
}
function filterOptions(field, val, update) {
@@ -768,12 +920,26 @@ function filterOptions(field, val, update) {
})
}
async function loadFilterOptions() {
async function loadFilterOptions(force = false) {
const params = buildFilterParams()
const cacheKey = buildFilterCacheKey(params)
const now = Date.now()
if (!force) {
const cached = filterOptionsCache.value[cacheKey]
if (cached && Number(cached.expiresAt || 0) > now && cached.payload) {
optionLists.value = cached.payload.optionLists
filteredOptionLists.value = cached.payload.filteredOptionLists
return
}
}
const reqSeq = ++filterOptionsRequestSeq
loadingFilterOptions.value = true
try {
const res = await api.get('/product-stock-attribute-options', {
params: buildFilterParams()
params
})
if (reqSeq !== filterOptionsRequestSeq) return
const payload = res?.data && typeof res.data === 'object' ? res.data : {}
const next = {}
const nextFiltered = {}
@@ -795,11 +961,21 @@ async function loadFilterOptions() {
optionLists.value = next
filteredOptionLists.value = nextFiltered
filterOptionsCache.value[cacheKey] = {
expiresAt: now + FILTER_OPTIONS_CACHE_TTL_MS,
payload: {
optionLists: next,
filteredOptionLists: nextFiltered
}
}
} catch (err) {
if (reqSeq !== filterOptionsRequestSeq) return
errorMessage.value = 'Urun ozellik secenekleri alinamadi.'
console.error('loadFilterOptions error:', err)
} finally {
loadingFilterOptions.value = false
if (reqSeq === filterOptionsRequestSeq) {
loadingFilterOptions.value = false
}
}
}
@@ -871,10 +1047,65 @@ async function fetchStockByAttributes() {
function onLevel2Click(productCode, grp2) {
toggleOpen(grp2.key)
if (isOpen(grp2.key)) {
void ensureProductImage(productCode, grp2.colorCode)
void ensureProductImage(productCode, grp2.colorCode, grp2.secondColor)
}
}
async function openProductCard(grp1, grp2) {
const productCode = String(grp1?.productCode || '').trim()
const colorCode = String(grp2?.colorCode || '').trim()
const secondColor = String(grp2?.secondColor || '').trim()
const listKey = buildImageKey(productCode, colorCode, secondColor)
await ensureProductImage(productCode, colorCode, secondColor)
const list = Array.isArray(productImageListByCode.value[listKey]) ? productImageListByCode.value[listKey] : []
const images = list
.map((item) => {
const resolved = resolveProductImageUrl(item)
return resolved.contentUrl || resolved.publicUrl || ''
})
.filter((x) => String(x || '').trim() !== '')
if (!images.length) {
const single = getProductImageUrl(productCode, colorCode, secondColor)
if (single) images.push(single)
}
productCardImages.value = images
productCardSlide.value = 0
productCardData.value = {
productCode,
colorCode,
secondColor,
kategori: String(filters.value?.kategori || '').trim(),
urunAnaGrubu: String(grp2?.urunAnaGrubu || '').trim(),
urunAltGrubu: String(grp2?.urunAltGrubu || '').trim(),
urunIcerigi: String(grp2?.urunIcerigi || '').trim(),
fit: String(grp2?.fit || '').trim(),
drop: String(grp2?.drop || '').trim(),
kumas: String(grp2?.kumas || '').trim(),
karisim: String(grp2?.karisim || '').trim(),
sizeTotals: grp2?.sizeTotals || {},
totalQty: Number(grp2?.totalQty || 0)
}
productCardDialog.value = true
}
function openProductImageFullscreen(src) {
const value = String(src || '').trim()
if (!value) return
productImageFullscreenSrc.value = value
productImageFullscreenZoom.value = 1
productImageFullscreenDialog.value = true
}
function toggleFullscreenImageZoom() {
const current = Number(productImageFullscreenZoom.value || 1)
if (current < 1.5) productImageFullscreenZoom.value = 1.8
else if (current < 2.3) productImageFullscreenZoom.value = 2.6
else productImageFullscreenZoom.value = 1
}
function resetForm() {
filters.value = {
kategori: '',
@@ -898,14 +1129,26 @@ function resetForm() {
productImageFallbackByKey.value = {}
productImageContentLoading.value = {}
productImageListBlockedUntil.value = 0
void loadFilterOptions()
productCardDialog.value = false
productCardData.value = {}
productCardImages.value = []
productCardSlide.value = 0
productImageFullscreenDialog.value = false
productImageFullscreenSrc.value = ''
productImageFullscreenZoom.value = 1
filterOptionsCache.value = {}
void loadFilterOptions(true)
}
onMounted(() => {
loadFilterOptions()
void loadFilterOptions(true)
})
onUnmounted(() => {
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer)
filterOptionsDebounceTimer = null
}
for (const url of productImageBlobUrls.value) {
try { URL.revokeObjectURL(url) } catch {}
}
@@ -920,7 +1163,7 @@ onUnmounted(() => {
--grp-title-w: 44px;
--psq-header-h: 56px;
--psq-col-adet: calc(var(--col-adet) + var(--beden-w));
--psq-col-img: 126px;
--psq-col-img: 190px;
--psq-l1-lift: 42px;
}
@@ -1003,8 +1246,8 @@ onUnmounted(() => {
}
.order-sub-header.level-2 {
min-height: 82px !important;
height: 82px !important;
min-height: 252px !important;
height: 252px !important;
background: #fff9c4 !important;
border-top: 1px solid #d4c79f !important;
border-bottom: 1px solid #d4c79f !important;
@@ -1250,16 +1493,18 @@ onUnmounted(() => {
.order-sub-header.level-2 .sub-image.level2-image {
grid-column: 8 / 9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-left: 1px solid #d4c79f;
padding: 0 6px;
gap: 8px;
padding: 8px 10px;
background: #fffef7;
}
.product-image-card {
width: 108px;
height: 66px;
width: 162px;
height: 216px;
border-radius: 8px;
}
@@ -1272,6 +1517,7 @@ onUnmounted(() => {
width: 100%;
height: 100%;
border-radius: 6px;
background: #fff;
}
.product-image-placeholder {
@@ -1284,6 +1530,178 @@ onUnmounted(() => {
border-radius: 6px;
}
.detail-open-btn {
margin-top: 4px;
font-size: 11px;
}
.product-card-dialog {
background: #fffef9;
}
.product-card-stock {
background: #f8f5e7;
border: 1px solid #e2d9b6;
border-radius: 10px;
padding: 12px;
}
.stock-size-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
gap: 8px;
}
.stock-size-chip {
border: 1px solid #d8cca6;
border-radius: 8px;
background: #fff;
padding: 6px 8px;
display: flex;
justify-content: space-between;
font-size: 12px;
}
.stock-size-chip .label {
font-weight: 700;
}
.product-card-content {
display: grid;
grid-template-columns: minmax(360px, 1fr) 420px;
gap: 12px;
align-items: stretch;
justify-content: start;
}
.product-card-images {
grid-column: 2;
grid-row: 1;
min-height: 560px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
.product-card-carousel {
width: 420px;
max-width: 100%;
}
.dialog-image {
width: 100%;
height: 100%;
}
.dialog-image-stage {
width: 420px;
max-width: 100%;
height: 560px;
overflow: hidden;
border-radius: 8px;
background: #f7f4e9;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-image-empty {
width: 420px;
max-width: 100%;
height: 560px;
border: 1px dashed #cabf9a;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: #faf7ee;
}
.image-fullscreen-dialog {
background: #f4f0e2;
}
.image-fullscreen-body {
height: calc(100vh - 72px);
display: flex;
align-items: center;
justify-content: center;
}
.image-fullscreen-stage {
width: min(96vw, 1400px);
height: calc(100vh - 120px);
border-radius: 10px;
background: #efe7cc;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.image-fullscreen-img {
width: 100%;
height: 100%;
}
.product-card-fields {
grid-column: 1;
grid-row: 1;
border: 1px solid #e2d9b6;
border-radius: 10px;
background: #fff;
padding: 10px;
height: 560px;
overflow: auto;
}
.field-row {
display: grid;
grid-template-columns: 150px 1fr;
gap: 8px;
padding: 7px 0;
border-bottom: 1px solid #f0ead7;
font-size: 13px;
}
.field-row:last-child {
border-bottom: none;
}
.field-row .k {
color: #5a4f2c;
font-weight: 700;
}
.field-row .v {
color: #1f1f1f;
word-break: break-word;
}
.q-btn,
.q-icon,
.product-image-card,
.cursor-pointer {
cursor: pointer !important;
}
@media (max-width: 1024px) {
.product-card-content {
grid-template-columns: 1fr;
}
.product-card-images,
.product-card-fields {
grid-column: auto;
grid-row: auto;
}
.product-card-fields {
height: auto;
}
}
.order-sub-header.level-2 .sub-right .top-total {
grid-column: 1 / 2;
grid-row: 1;