Files
bssapp/ui/src/pages/ProductPerformanceProfitability.vue
T

7087 lines
293 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="product-performance-page q-pa-sm">
<q-inner-loading :showing="pageBusy">
<q-spinner-gears size="52px" color="primary" />
</q-inner-loading>
<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">{{ loadingMessage }}</div>
<div v-if="loadingElapsedLabel" class="page-busy-time">{{ loadingElapsedLabel }}</div>
</div>
</teleport>
<div class="performance-top-bar row items-center justify-between q-col-gutter-xs q-mb-xs">
<div class="col-12 col-md">
<div class="performance-page-title">Ürün Performans ve Karlılık Analizi</div>
<div class="performance-page-subtitle text-grey-7">
Ürün, renk, yaka, piyasa ve müşteri segmenti bazında satış, stok dönüşü ve karlılık KPI'ları.
</div>
</div>
<div class="performance-top-actions col-12 col-md-auto row items-center q-gutter-xs">
<q-option-group
v-model="selectedPeriods"
:options="periodOptions"
type="checkbox"
inline
dense
:disable="pageBusy"
class="period-selector bg-white q-px-xs q-py-none"
/>
<q-btn
dense
outline
color="primary"
icon="view_column"
:label="detailColumnsHidden ? 'Detay Kolonları Göster' : 'Detay Kolonları Gizle'"
:disable="pageBusy"
@click="detailColumnsHidden = !detailColumnsHidden"
/>
<q-btn-dropdown
v-model="detailLevelMenuOpen"
split
dense
outline
color="primary"
:icon="allProductGroupsExpanded ? 'unfold_less' : 'unfold_more'"
:label="allProductGroupsExpanded ? 'Detayları Kapat' : 'Tüm Detayları '"
:disable="pageBusy"
@click="toggleAllProductGroups"
>
<div class="detail-level-menu">
<q-option-group
v-model="activeSelectedExpandLevelKeys"
:options="expandLevelOptions"
type="checkbox"
dense
/>
<q-separator class="q-my-sm" />
<div class="row items-center justify-end q-gutter-xs">
<q-btn dense flat color="grey-7" label="Kapat" :disable="pageBusy" @click.stop="collapseAllProductGroups" />
<q-btn dense color="primary" label="Seçileni Aç" :disable="pageBusy" @click.stop="expandSelectedProductGroups" />
</div>
</div>
</q-btn-dropdown>
<q-btn dense outline color="positive" icon="grid_on" label="Excel" :loading="excelExportLoading" :disable="pageBusy || excelExportLoading" @click="exportProductPerformanceExcel" />
<q-btn dense outline color="secondary" icon="refresh" label="Yenile" :loading="loading" :disable="pageBusy" @click="reload" />
</div>
</div>
<div class="performance-summary-row row q-col-gutter-xs q-mb-xs">
<div v-for="card in summaryCards" :key="card.key" class="col-6 col-md-2">
<q-card flat bordered class="metric-card">
<q-card-section>
<div class="text-caption text-grey-7">{{ card.label }}</div>
<div class="metric-value">{{ card.value }}</div>
</q-card-section>
</q-card>
</div>
</div>
<div class="performance-tab-grid q-mb-xs">
<button
v-for="tab in performanceTabs"
:key="tab.name"
type="button"
:class="['performance-tab-button', { active: activeTab === tab.name }]"
:disabled="pageBusy"
@click="switchPerformanceTab(tab.name)"
>
<q-icon :name="tab.icon" size="16px" />
<span>{{ tab.label }}</span>
</button>
</div>
<div ref="topScrollbarRef" class="performance-top-scrollbar q-mb-xs">
<div ref="topScrollbarInnerRef" class="performance-top-scrollbar-inner"></div>
</div>
<q-table
v-if="activeTab === 'general'"
flat
bordered
row-key="row_key"
:class="[
'performance-table',
'sticky-dim-table',
detailColumnsHidden ? 'sticky-dim-4' : 'sticky-dim-7',
'bg-white'
]"
:rows="filteredGeneralRows"
:columns="visibleGeneralColumns"
:loading="activeTableLoading"
v-model:pagination="tablePagination.general"
virtual-scroll
:virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
<div class="header-with-filter">
<span class="header-label" :title="props.col.label">{{ props.col.label }}</span>
<q-btn
v-if="isColumnFilterable(props.col.name)"
dense
flat
round
size="8px"
icon="filter_alt"
:color="hasColumnFilter('general', props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="hasColumnFilter('general', props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue('general', props.col.name) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[filterKey('general', props.col.name)]"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions('general', props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter('general', props.col.name)" />
</div>
<q-virtual-scroll
v-if="columnFilterOptions('general', props.col.name).length > 0"
class="excel-filter-options"
:items="columnFilterOptions('general', props.col.name)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`${props.col.name}-${option.value}`"
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue('general', props.col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected('general', props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue('general', props.col.name, 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">Sonuç yok</div>
</div>
</q-menu>
</q-btn>
<q-btn v-else dense flat round size="8px" icon="filter_alt" class="header-filter-btn header-filter-ghost" tabindex="-1" />
</div>
</q-th>
</template>
<template #body-cell-image="props">
<q-td :props="props" class="product-image-cell">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</q-td>
</template>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'order_product_customers'"
flat
bordered
row-key="row_key"
class="performance-table sticky-dim-table sticky-dim-8 bg-white"
:rows="displayOrderProductCustomerTableRows"
:columns="orderProductCustomerColumns"
:loading="activeTableLoading"
v-model:pagination="tablePagination.order_product_customers"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
<div class="header-with-filter">
<span class="header-label" :title="props.col.label">{{ props.col.label }}</span>
<q-btn
v-if="isColumnFilterable(props.col.name)"
dense
flat
round
size="8px"
icon="filter_alt"
:color="hasColumnFilter('order_product_customers', props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="hasColumnFilter('order_product_customers', props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue('order_product_customers', props.col.name) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[filterKey('order_product_customers', props.col.name)]"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions('order_product_customers', props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter('order_product_customers', props.col.name)" />
</div>
<q-virtual-scroll
v-if="columnFilterOptions('order_product_customers', props.col.name).length > 0"
class="excel-filter-options"
:items="columnFilterOptions('order_product_customers', props.col.name)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`${props.col.name}-${option.value}`"
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue('order_product_customers', props.col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected('order_product_customers', props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue('order_product_customers', props.col.name, 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">Sonuç yok</div>
</div>
</q-menu>
</q-btn>
<q-btn v-else dense flat round size="8px" icon="filter_alt" class="header-filter-btn header-filter-ghost" tabindex="-1" />
</div>
</q-th>
</template>
<template #body-cell-image="props">
<q-td :props="props" class="product-image-cell">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</q-td>
</template>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
<template #body-cell-net_stock_after_order="props">
<q-td :props="props" class="text-right" :class="Number(props.row.net_stock_after_order || 0) < 0 ? 'text-negative text-weight-bold' : ''">
{{ formatNumber(props.row.net_stock_after_order, 0) }}
</q-td>
</template>
<template #body-cell-expected_profit_cost_usd="props">
<q-td :props="props" class="text-right" :class="Number(props.row.expected_profit_cost_usd || 0) < 0 ? 'text-negative text-weight-bold' : 'text-positive'">
{{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
</q-td>
</template>
<template #body="props">
<q-tr
v-if="props.row.__group"
:props="props"
:class="groupRowClasses(props.row)"
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<template v-if="col.name === 'image' && groupShowsImage(props.row)">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</template>
<div
v-else-if="col.name === groupLabelColumnName(props.row)"
class="group-cell-label"
:style="{ paddingLeft: groupIndentPx(props.row) }"
>
<q-btn
flat
dense
round
size="sm"
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
:disable="!groupCanExpand(props.row)"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
</q-tr>
<q-tr v-else :props="props" class="cursor-pointer" @click="openPerformanceDialog(props.row)">
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<template v-if="col.name === 'image'">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</template>
<q-badge v-else-if="col.name === 'performance_bucket'" :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
<span v-else>{{ formatTableCell(props.row, col) }}</span>
</q-td>
</q-tr>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'order_market_details'"
flat
bordered
row-key="row_key"
class="performance-table sticky-dim-table sticky-dim-10 bg-white"
:rows="displayOrderMarketDetailTableRows"
:columns="orderMarketDetailColumns"
:loading="activeTableLoading"
v-model:pagination="tablePagination.order_market_details"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
<div class="header-with-filter">
<span class="header-label" :title="props.col.label">{{ props.col.label }}</span>
<q-btn
v-if="isColumnFilterable(props.col.name)"
dense
flat
round
size="8px"
icon="filter_alt"
:color="hasColumnFilter('order_market_details', props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="hasColumnFilter('order_market_details', props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue('order_market_details', props.col.name) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[filterKey('order_market_details', props.col.name)]"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions('order_market_details', props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter('order_market_details', props.col.name)" />
</div>
<q-virtual-scroll
v-if="columnFilterOptions('order_market_details', props.col.name).length > 0"
class="excel-filter-options"
:items="columnFilterOptions('order_market_details', props.col.name)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`${props.col.name}-${option.value}`"
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue('order_market_details', props.col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected('order_market_details', props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue('order_market_details', props.col.name, 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">Sonuç yok</div>
</div>
</q-menu>
</q-btn>
<q-btn v-else dense flat round size="8px" icon="filter_alt" class="header-filter-btn header-filter-ghost" tabindex="-1" />
</div>
</q-th>
</template>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
<template #body-cell-net_stock_after_order="props">
<q-td :props="props" class="text-right" :class="Number(props.row.net_stock_after_order || 0) < 0 ? 'text-negative text-weight-bold' : ''">
{{ formatNumber(props.row.net_stock_after_order, 0) }}
</q-td>
</template>
<template #body-cell-expected_profit_cost_usd="props">
<q-td :props="props" class="text-right" :class="Number(props.row.expected_profit_cost_usd || 0) < 0 ? 'text-negative text-weight-bold' : 'text-positive'">
{{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
</q-td>
</template>
<template #body="props">
<q-tr
v-if="props.row.__group"
:props="props"
:class="groupRowClasses(props.row)"
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<div
v-if="col.name === groupLabelColumnName(props.row)"
class="group-cell-label"
:style="{ paddingLeft: groupIndentPx(props.row) }"
>
<q-btn
flat
dense
round
size="sm"
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
:disable="!groupCanExpand(props.row)"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
</q-tr>
<q-tr v-else :props="props" class="cursor-pointer" @click="openPerformanceDialog(props.row)">
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<q-badge v-if="col.name === 'performance_bucket'" :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
<span v-else>{{ formatTableCell(props.row, col) }}</span>
</q-td>
</q-tr>
</template>
</q-table>
<q-table
v-else-if="isProductKpiTab"
flat
bordered
row-key="row_key"
:class="[
'performance-table',
'product-breakdown-table',
'bg-white',
{ 'compact-detail-columns': detailColumnsHidden },
activeTab === 'product_detail' ? 'product-detail-table' : 'product-summary-table'
]"
:rows="displayProductKpiTableRows"
:columns="activeProductColumns"
:loading="activeTableLoading"
v-model:pagination="activeProductPagination"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
<div class="header-with-filter">
<span class="header-label" :title="props.col.label">{{ props.col.label }}</span>
<q-btn
v-if="isColumnFilterable(props.col.name)"
dense
flat
round
size="8px"
icon="filter_alt"
:color="hasColumnFilter(activeProductTableKey, props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="hasColumnFilter(activeProductTableKey, props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue(activeProductTableKey, props.col.name) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[filterKey(activeProductTableKey, props.col.name)]"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions(activeProductTableKey, props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter(activeProductTableKey, props.col.name)" />
</div>
<q-virtual-scroll
v-if="columnFilterOptions(activeProductTableKey, props.col.name).length > 0"
class="excel-filter-options"
:items="columnFilterOptions(activeProductTableKey, props.col.name)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`${props.col.name}-${option.value}`"
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue(activeProductTableKey, props.col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected(activeProductTableKey, props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue(activeProductTableKey, props.col.name, 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">Sonuç yok</div>
</div>
</q-menu>
</q-btn>
<q-btn v-else dense flat round size="8px" icon="filter_alt" class="header-filter-btn header-filter-ghost" tabindex="-1" />
</div>
</q-th>
</template>
<template #body="props">
<q-tr
v-if="props.row.__group"
:props="props"
:class="groupRowClasses(props.row)"
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<template v-if="col.name === 'image' && groupShowsImage(props.row)">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</template>
<div
v-else-if="col.name === groupLabelColumnName(props.row)"
class="group-cell-label"
:style="{ paddingLeft: groupIndentPx(props.row) }"
>
<q-btn
flat
dense
round
size="sm"
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
:disable="!groupCanExpand(props.row)"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col.name) }}</span>
</q-td>
</q-tr>
<q-tr v-else :props="props" class="cursor-pointer" @click="openPerformanceDialog(props.row)">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<template v-if="col.name === 'image'">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</template>
<q-badge v-else-if="col.name === 'performance_bucket'" :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
<span v-else>{{ formatProductCell(props.row, col.name) }}</span>
</q-td>
</q-tr>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'idle'"
flat
bordered
row-key="row_key"
class="performance-table sticky-dim-table sticky-dim-11 bg-white"
:rows="displayIdleTableRows"
:columns="idleColumns"
:loading="activeTableLoading"
v-model:pagination="tablePagination.idle"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
<div class="header-with-filter">
<span class="header-label" :title="props.col.label">{{ props.col.label }}</span>
<q-btn
v-if="isColumnFilterable(props.col.name)"
dense
flat
round
size="8px"
icon="filter_alt"
:color="hasColumnFilter('idle', props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="hasColumnFilter('idle', props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue('idle', props.col.name) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[filterKey('idle', props.col.name)]"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions('idle', props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter('idle', props.col.name)" />
</div>
<q-virtual-scroll
v-if="columnFilterOptions('idle', props.col.name).length > 0"
class="excel-filter-options"
:items="columnFilterOptions('idle', props.col.name)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`${props.col.name}-${option.value}`"
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue('idle', props.col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected('idle', props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue('idle', props.col.name, 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">Sonuç yok</div>
</div>
</q-menu>
</q-btn>
<q-btn v-else dense flat round size="8px" icon="filter_alt" class="header-filter-btn header-filter-ghost" tabindex="-1" />
</div>
</q-th>
</template>
<template #body-cell-image="props">
<q-td :props="props" class="product-image-cell">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</q-td>
</template>
<template #body-cell-idle_cost_usd="props">
<q-td :props="props" class="text-right text-weight-bold">
{{ formatMoney(props.row.idle_cost_usd, 'USD') }}
</q-td>
</template>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
<template #body="props">
<q-tr
v-if="props.row.__group"
:props="props"
:class="groupRowClasses(props.row)"
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<template v-if="col.name === 'image' && groupShowsImage(props.row)">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</template>
<div
v-else-if="col.name === groupLabelColumnName(props.row)"
class="group-cell-label"
:style="{ paddingLeft: groupIndentPx(props.row) }"
>
<q-btn
flat
dense
round
size="sm"
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
:disable="!groupCanExpand(props.row)"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
</q-tr>
<q-tr v-else :props="props" class="cursor-pointer" @click="openPerformanceDialog(props.row)">
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<template v-if="col.name === 'image'">
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
<img
v-if="getCachedProductImageUrl(props.row)"
:src="getCachedProductImageUrl(props.row)"
class="product-thumb"
@error="event => markProductImageFailed(props.row, event?.target?.currentSrc)"
loading="lazy"
decoding="async"
alt=""
/>
<div v-else class="product-thumb-placeholder">
<q-icon name="image" size="22px" />
</div>
<q-tooltip>Foto</q-tooltip>
</button>
</template>
<q-badge v-else-if="col.name === 'performance_bucket'" :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
<span v-else>{{ formatTableCell(props.row, col) }}</span>
</q-td>
</q-tr>
</template>
</q-table>
<q-table
v-else-if="activeTab === 'markets'"
flat
bordered
row-key="market_key"
class="performance-table sticky-dim-table sticky-dim-9 bg-white"
:rows="filteredMarketRows"
:columns="marketColumns"
:loading="activeTableLoading"
v-model:pagination="tablePagination.markets"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
<div class="header-with-filter">
<span class="header-label" :title="props.col.label">{{ props.col.label }}</span>
<q-btn
v-if="isColumnFilterable(props.col.name)"
dense
flat
round
size="8px"
icon="filter_alt"
:color="hasColumnFilter('markets', props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="hasColumnFilter('markets', props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue('markets', props.col.name) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[filterKey('markets', props.col.name)]"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions('markets', props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter('markets', props.col.name)" />
</div>
<q-virtual-scroll
v-if="columnFilterOptions('markets', props.col.name).length > 0"
class="excel-filter-options"
:items="columnFilterOptions('markets', props.col.name)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`${props.col.name}-${option.value}`"
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue('markets', props.col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected('markets', props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue('markets', props.col.name, 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">Sonuç yok</div>
</div>
</q-menu>
</q-btn>
<q-btn v-else dense flat round size="8px" icon="filter_alt" class="header-filter-btn header-filter-ghost" tabindex="-1" />
</div>
</q-th>
</template>
<template #body-cell-stock_cost_value_usd="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.stock_cost_value_usd, 'USD') }}
</q-td>
</template>
<template #body-cell-risk_stock_cost_value_usd="props">
<q-td :props="props" class="text-right text-negative text-weight-bold">
{{ formatMoney(props.row.risk_stock_cost_value_usd, 'USD') }}
</q-td>
</template>
<template #body-cell-avg_gross_margin_90d="props">
<q-td :props="props" class="text-right">
{{ formatPercent(props.row.avg_gross_margin_90d) }}
</q-td>
</template>
</q-table>
<q-table
v-else-if="salesBreakdownTabKeys.includes(activeTab)"
flat
bordered
row-key="row_key"
:class="[
'performance-table',
'sticky-dim-table',
salesBreakdownStickyClass,
activeTab === 'sales_color_yaka_market_customer' ? 'sales-color-yaka-table' : '',
'bg-white'
]"
:rows="displayActiveSalesBreakdownTableRows"
:columns="visibleSalesBreakdownColumns"
:loading="activeTableLoading"
v-model:pagination="activeSalesBreakdownPagination"
:sort-method="sortProductGroupedTableRows"
virtual-scroll
:virtual-scroll-item-size="productThumbVirtualItemSize"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
<div class="header-with-filter">
<span class="header-label" :title="props.col.label">{{ props.col.label }}</span>
<q-btn
v-if="isColumnFilterable(props.col.name)"
dense
flat
round
size="8px"
icon="filter_alt"
:color="hasColumnFilter(activeTab, props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="hasColumnFilter(activeTab, props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue(activeTab, props.col.name) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[filterKey(activeTab, props.col.name)]"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions(activeTab, props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter(activeTab, props.col.name)" />
</div>
<q-virtual-scroll
v-if="columnFilterOptions(activeTab, props.col.name).length > 0"
class="excel-filter-options"
:items="columnFilterOptions(activeTab, props.col.name)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`${props.col.name}-${option.value}`"
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue(activeTab, props.col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected(activeTab, props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue(activeTab, props.col.name, 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">Sonuç yok</div>
</div>
</q-menu>
</q-btn>
<q-btn v-else dense flat round size="8px" icon="filter_alt" class="header-filter-btn header-filter-ghost" tabindex="-1" />
</div>
</q-th>
</template>
<template #body-cell-sales_usd_90d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.sales_usd_90d, 'USD') }}
</q-td>
</template>
<template #body-cell-sales_usd_365d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.sales_usd_365d, 'USD') }}
</q-td>
</template>
<template #body-cell-avg_price_usd_90d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.avg_price_usd_90d, 'USD') }}
</q-td>
</template>
<template #body-cell-performance_bucket="props">
<q-td :props="props">
<q-badge :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
</q-td>
</template>
<template #body="props">
<q-tr
v-if="props.row.__group"
:props="props"
:class="groupRowClasses(props.row)"
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<div
v-if="col.name === groupLabelColumnName(props.row)"
class="group-cell-label"
:style="{ paddingLeft: groupIndentPx(props.row) }"
>
<q-btn
flat
dense
round
size="sm"
:icon="groupCanExpand(props.row) ? (isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right') : 'fiber_manual_record'"
:disable="!groupCanExpand(props.row)"
@click.stop="toggleGroup(props.row.key)"
/>
<span class="group-title">{{ groupDisplayLabel(props.row) }}</span>
</div>
<span v-else>{{ formatGroupCell(props.row, col) }}</span>
</q-td>
</q-tr>
<q-tr v-else :props="props" class="cursor-pointer" @click="openPerformanceDialog(props.row)">
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
<q-badge v-if="col.name === 'performance_bucket'" :color="bucketColor(props.row.performance_bucket)">
{{ bucketLabel(props.row.performance_bucket) }}
</q-badge>
<span v-else>{{ formatTableCell(props.row, col) }}</span>
</q-td>
</q-tr>
</template>
</q-table>
<div v-else-if="activeTab === 'customers'">
<q-card flat bordered class="q-mb-sm">
<q-card-section class="row q-col-gutter-sm items-center">
<div class="col-12 col-md-4">
<q-select
v-model="customerBreakdown"
dense
outlined
emit-value
map-options
label="Kırılım"
:options="customerBreakdownOptions"
@update:model-value="reload"
/>
</div>
</q-card-section>
</q-card>
<q-table
flat
bordered
row-key="customer_key"
class="performance-table sticky-dim-table sticky-dim-5 bg-white"
:rows="filteredCustomerRows"
:columns="customerColumns"
:loading="activeTableLoading"
v-model:pagination="tablePagination.customers"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
<div class="header-with-filter">
<span class="header-label" :title="props.col.label">{{ props.col.label }}</span>
<q-btn
v-if="isColumnFilterable(props.col.name)"
dense
flat
round
size="8px"
icon="filter_alt"
:color="hasColumnFilter('customers', props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="hasColumnFilter('customers', props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue('customers', props.col.name) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[filterKey('customers', props.col.name)]"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions('customers', props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter('customers', props.col.name)" />
</div>
<q-virtual-scroll
v-if="columnFilterOptions('customers', props.col.name).length > 0"
class="excel-filter-options"
:items="columnFilterOptions('customers', props.col.name)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`${props.col.name}-${option.value}`"
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue('customers', props.col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected('customers', props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue('customers', props.col.name, 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">Sonuç yok</div>
</div>
</q-menu>
</q-btn>
<q-btn v-else dense flat round size="8px" icon="filter_alt" class="header-filter-btn header-filter-ghost" tabindex="-1" />
</div>
</q-th>
</template>
<template #body-cell-sales_usd_90d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.sales_usd_90d, 'USD') }}
</q-td>
</template>
<template #body-cell-avg_price_usd_90d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.avg_price_usd_90d, 'USD') }}
</q-td>
</template>
<template #body-cell-sales_usd_365d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.sales_usd_365d, 'USD') }}
</q-td>
</template>
</q-table>
</div>
<q-table
v-else
flat
bordered
row-key="country_key"
class="performance-table sticky-dim-table sticky-dim-3 bg-white"
:rows="filteredCountryRows"
:columns="countryColumns"
:loading="activeTableLoading"
v-model:pagination="tablePagination.countries"
virtual-scroll
:virtual-scroll-item-size="64"
:virtual-scroll-slice-size="tableVirtualSliceSize"
>
<template #header-cell="props">
<q-th :props="props" class="filterable-header-cell">
<div class="header-with-filter">
<span class="header-label" :title="props.col.label">{{ props.col.label }}</span>
<q-btn
v-if="isColumnFilterable(props.col.name)"
dense
flat
round
size="8px"
icon="filter_alt"
:color="hasColumnFilter('countries', props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="hasColumnFilter('countries', props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue('countries', props.col.name) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[filterKey('countries', props.col.name)]"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tümünü Seç" @click="selectAllColumnFilterOptions('countries', props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter('countries', props.col.name)" />
</div>
<q-virtual-scroll
v-if="columnFilterOptions('countries', props.col.name).length > 0"
class="excel-filter-options"
:items="columnFilterOptions('countries', props.col.name)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`${props.col.name}-${option.value}`"
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue('countries', props.col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected('countries', props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue('countries', props.col.name, 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">Sonuç yok</div>
</div>
</q-menu>
</q-btn>
<q-btn v-else dense flat round size="8px" icon="filter_alt" class="header-filter-btn header-filter-ghost" tabindex="-1" />
</div>
</q-th>
</template>
<template #body-cell-sales_usd_90d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.sales_usd_90d, 'USD') }}
</q-td>
</template>
<template #body-cell-avg_price_usd_90d="props">
<q-td :props="props" class="text-right">
{{ formatMoney(props.row.avg_price_usd_90d, 'USD') }}
</q-td>
</template>
</q-table>
<q-dialog v-model="imageDialog" maximized>
<q-card class="product-image-dialog">
<q-card-section class="row items-center q-pb-sm">
<div>
<div class="text-h6">{{ imageDialogTitle }}</div>
<div class="text-caption text-grey-7">{{ imageDialogSubtitle }}</div>
</div>
<q-space />
<q-btn v-close-popup flat round dense icon="close" />
</q-card-section>
<q-separator />
<q-card-section class="product-image-dialog-body">
<div class="performance-image-layout">
<aside class="image-product-fields">
<div class="image-section-title">Ürün Bilgileri</div>
<div v-for="item in imageDialogInfoRows" :key="item.key" class="image-field-row">
<span class="k">{{ item.label }}</span>
<span class="v">{{ item.value }}</span>
</div>
<div class="image-product-section">
<div class="image-section-title">Beden Stokları</div>
<q-inner-loading :showing="detailLoading">
<q-spinner size="24px" color="primary" />
</q-inner-loading>
<div v-if="detailStockSizes.length" class="image-size-stock-grid">
<div v-for="item in detailStockSizes" :key="item.size_code" class="image-size-stock-cell">
<span class="image-size-label">{{ item.size_code || '-' }}</span>
<span class="image-size-qty">{{ formatNumber(item.stock_qty, 0) }}</span>
</div>
</div>
<div v-else-if="!detailLoading" class="image-empty-text">Beden stoku bulunamadi</div>
</div>
</aside>
<section class="image-media-panel">
<q-carousel
v-if="imageDialogUrls.length"
v-model="imageSlide"
animated
arrows
navigation
infinite
swipeable
class="product-image-carousel"
>
<q-carousel-slide
v-for="(url, idx) in imageDialogUrls"
:key="`${url}-${idx}`"
:name="idx"
class="column no-wrap flex-center"
>
<q-img :src="url" fit="contain" class="product-image-large" @error="markDialogImageFailed(url)" />
</q-carousel-slide>
</q-carousel>
<div v-else class="product-image-empty">
<q-icon name="image_not_supported" size="48px" />
<div class="text-subtitle2 q-mt-sm">Foto bulunamadi</div>
</div>
</section>
<section class="image-sales-panel">
<div class="row q-col-gutter-xs q-mb-sm">
<div v-for="card in imageDialogPerformanceCards" :key="card.key" class="col-6 col-md-3 col-lg-4">
<q-card flat bordered class="metric-card image-metric-card">
<q-card-section>
<div class="text-caption text-grey-7">{{ card.label }}</div>
<div class="metric-value">{{ card.value }}</div>
</q-card-section>
</q-card>
</div>
</div>
<q-table
flat
bordered
dense
class="dialog-detail-table"
title="Satış / Müşteri Özeti"
row-key="detail_key"
:rows="detailSalesRows"
:columns="detailSalesColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 0, sortBy: 'sales_usd', descending: true }"
/>
</section>
</div>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="performanceDialog" maximized>
<q-card class="product-performance-dialog">
<q-card-section class="row items-center q-pb-sm">
<div>
<div class="text-h6">{{ performanceDialogTitle }}</div>
<div class="text-caption text-grey-7">{{ performanceDialogSubtitle }}</div>
</div>
<q-space />
<q-btn v-close-popup flat round dense icon="close" />
</q-card-section>
<q-separator />
<q-card-section class="q-pa-sm">
<div class="row q-col-gutter-sm q-mb-sm">
<div v-for="card in performanceCards" :key="card.key" class="col-6 col-md-2">
<q-card flat bordered class="metric-card">
<q-card-section>
<div class="text-caption text-grey-7">{{ card.label }}</div>
<div class="metric-value">{{ card.value }}</div>
</q-card-section>
</q-card>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-table
flat
bordered
dense
class="dialog-stock-table"
title="Beden Stok"
row-key="size_code"
:rows="detailStockSizes"
:columns="detailStockColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 0 }"
virtual-scroll
:virtual-scroll-item-size="48"
/>
</div>
<div class="col-12 col-md-8">
<q-table
flat
bordered
dense
class="dialog-detail-table"
title="Satış / Müşteri Özeti"
row-key="detail_key"
:rows="detailSalesRows"
:columns="detailSalesColumns"
:loading="detailLoading"
:pagination="{ rowsPerPage: 0, sortBy: 'sales_usd', descending: true }"
virtual-scroll
:virtual-scroll-item-size="48"
/>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Notify } from 'quasar'
import api, { extractApiErrorDetail } from 'src/services/api'
import { useProductPerformanceStore } from 'src/stores/productPerformanceStore'
const performanceStore = useProductPerformanceStore()
function formatNumber (value, fraction = 2) {
const n = Number(value || 0)
const requested = Number(fraction)
const digits = Number.isFinite(requested) ? Math.max(0, Math.min(4, requested)) : 2
return n.toLocaleString('tr-TR', {
minimumFractionDigits: digits,
maximumFractionDigits: digits
})
}
function formatMoney (value, currency = 'USD') {
return `${formatNumber(value, 2)} ${currency}`
}
function formatPercent (value) {
return `${formatNumber(Number(value || 0) * 100, 2)}%`
}
const loading = ref(false)
const excelExportLoading = ref(false)
const rows = ref([])
const detailMainGroupOptionRows = ref([])
const generalRows = ref([])
const orderAnalysisRows = ref([])
const orderMarketRows = ref([])
const orderCustomerRows = ref([])
const orderProductCustomerRows = ref([])
const orderMarketDetailRows = ref([])
const orderAnalysisLoaded = ref(false)
const orderProductCustomersLoaded = ref(false)
const orderMarketDetailsLoaded = ref(false)
const marketRowsLoaded = ref(false)
const countryRowsLoaded = ref(false)
const customerRowsLoaded = ref(false)
const orderAnalysisMode = ref('product')
const marketRows = ref([])
const countryRows = ref([])
const customerRows = ref([])
const salesBreakdownRows = reactive({
sales_color_yaka_market_customer: [],
sales_product_country_segment_market_customer: [],
sales_market_customer_product: [],
sales_country_segment_market_customer_product: []
})
const summary = ref({})
const activeTab = ref('products')
const customerBreakdown = ref('market_customer')
const selectedPeriods = ref(['90', '180', '360', 'total'])
const selectedDetailMainGroup = ref('')
const imageUrlByKey = ref({})
const imageListByKey = ref({})
const imageFailedKeys = ref(new Set())
const imageLoadingKeys = ref(new Set())
const imageDialog = ref(false)
const imageDialogUrls = ref([])
const imageDialogTitle = ref('')
const imageDialogSubtitle = ref('')
const imageSlide = ref(0)
const imageDialogRow = ref(null)
const performanceDialog = ref(false)
const performanceDialogRow = ref(null)
const performanceDialogTitle = ref('')
const performanceDialogSubtitle = ref('')
const detailLoading = ref(false)
const detailSalesRows = ref([])
const detailStockSizes = ref([])
const backendGroupedRows = ref({})
const backendGroupedFilterOptions = ref({})
const backendGroupedLoading = ref(false)
const imagePrimeLoading = ref(false)
const filterBusy = ref(false)
const bulkExpandLoading = ref(false)
const loadingStartedAt = ref(0)
const loadingNow = ref(0)
const loadingStage = ref('')
const generalRowsLoaded = ref(false)
const detailLevelMenuOpen = ref(false)
const detailColumnsHidden = ref(false)
const topScrollbarRef = ref(null)
const topScrollbarInnerRef = ref(null)
let backendGroupedTimer = null
let imagePrimeActiveRequests = 0
let activeTableMiddleEl = null
let topScrollbarResizeObserver = null
let topScrollbarSyncing = false
let loadingTimer = null
const columnFilters = reactive({})
const columnFilterSearch = reactive({})
const expandedGroups = ref({})
const lastManualExpandedGroupKey = ref('')
const selectedExpandLevelKeysByTab = reactive({
products: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
product_detail: ['urun_alt_grubu'],
sales_color_yaka_market_customer: ['urun_ana_grubu', 'color_yaka'],
idle: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
sales_product_country_segment_market_customer: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
sales_country_segment_market_customer_product: ['country', 'customer_segment', 'market_key'],
order_product_customers: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
order_market_details: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan']
})
const productKpiFetchLimit = 50000
const reportFetchLimit = 50000
const maxBulkExpandKeys = 1000
const maxBackendExpandedKeys = 1000
const tableVirtualSliceSize = 160
const productThumbVirtualItemSize = 236
const maxRenderedGroupedRows = 2500
const maxAutoExpandLevelByTab = {
products: 8,
product_detail: 8,
idle: 8,
sales_color_yaka_market_customer: 8,
sales_product_country_segment_market_customer: 8,
sales_market_customer_product: 8,
sales_country_segment_market_customer_product: 8,
order_product_customers: 8,
order_market_details: 8
}
const backendGroupedFilterFields = new Set([
'kategori',
'askili_yan',
'urun_ilk_grubu',
'urun_ana_grubu',
'urun_alt_grubu',
'product_code',
'color_yaka',
'market_key',
'country',
'customer_segment',
'customer_code',
'customer_name',
'performance_bucket'
])
const productPerformanceExcelExportFilterFields = new Set([
'kategori',
'askili_yan',
'urun_ilk_grubu',
'urun_ana_grubu',
'urun_alt_grubu',
'product_code',
'color_yaka',
'color_code',
'yaka_kodu',
'market_key',
'performance_bucket'
])
const performanceTabs = [
{ name: 'products', icon: 'dashboard', label: 'Genel Özet KPI' },
{ name: 'sales_color_yaka_market_customer', icon: 'palette', label: 'Ürün Ana Grubu > Renk/Yaka > Piyasa > Müşteri' },
{ name: 'idle', icon: 'warning', label: 'Atıl Stok / Maliyet' },
{ name: 'sales_product_country_segment_market_customer', icon: 'account_tree', label: 'Ürün > Ülke > Segment > Piyasa > Müşteri' },
{ name: 'sales_country_segment_market_customer_product', icon: 'public', label: 'Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI' },
{ name: 'order_product_customers', icon: 'assignment_ind', label: 'Ürün > Piyasa > Müşteri Sipariş' },
{ name: 'order_market_details', icon: 'receipt_long', label: 'Piyasa > Müşteri > Ürün Sipariş' }
]
const pageBusy = computed(() => loading.value)
const activeTableLoading = computed(() => loading.value || (backendGroupedSupportedTab(activeTab.value) && backendGroupedLoading.value))
const loadingElapsedLabel = computed(() => {
if (!loadingStartedAt.value || !loadingNow.value) return ''
const seconds = Math.max(0, Math.floor((loadingNow.value - loadingStartedAt.value) / 1000))
const minutes = Math.floor(seconds / 60)
const rest = seconds % 60
return minutes > 0 ? `${minutes}:${String(rest).padStart(2, '0')}` : `${seconds} sn`
})
const loadingMessage = computed(() => loadingStage.value || 'Rapor verileri yükleniyor...')
const tablePagination = reactive({
general: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
products: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
product_detail: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
order_product_customers: { page: 1, rowsPerPage: 0, sortBy: 'order_usd', descending: true },
order_market_details: { page: 1, rowsPerPage: 0, sortBy: 'order_date', descending: true },
idle: { page: 1, rowsPerPage: 0, sortBy: 'idle_cost_usd', descending: true },
markets: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_90d', descending: true },
countries: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_90d', descending: true },
customers: { page: 1, rowsPerPage: 0, sortBy: 'sales_usd_365d', descending: true },
sales_color_yaka_market_customer: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
sales_product_country_segment_market_customer: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
sales_market_customer_product: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true },
sales_country_segment_market_customer_product: { page: 1, rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }
})
const activeProductPagination = computed({
get: () => tablePagination[activeTab.value === 'product_detail' ? 'product_detail' : 'products'],
set: value => {
tablePagination[activeTab.value === 'product_detail' ? 'product_detail' : 'products'] = normalizeTablePagination(value)
}
})
const activeSalesBreakdownPagination = computed({
get: () => tablePagination[activeTab.value] || tablePagination.sales_color_yaka_market_customer,
set: value => {
tablePagination[activeTab.value] = normalizeTablePagination(value)
}
})
const activeBackendGroupedSortSignature = computed(() => {
if (!backendGroupedSupportedTab(activeTab.value)) return ''
const state = tablePagination[activeTab.value] || {}
return `${activeTab.value}|${state.sortBy || ''}|${state.descending !== false ? 1 : 0}`
})
function normalizeTablePagination (value) {
return {
page: Number(value?.page || 1),
rowsPerPage: 0,
sortBy: value?.sortBy || '',
descending: value?.descending === true
}
}
const bucketOptions = [
{ label: 'Yıldız Ürün', value: 'YILDIZ_URUN' },
{ label: 'Stoksuz Talep', value: 'STOKSUZ_TALEP' },
{ label: 'Stok Riski', value: 'STOK_RISKI' },
{ label: 'Fiyat Baskısı', value: 'FIYAT_BASKISI' },
{ label: 'Fiyat Fırsatı', value: 'FIYAT_FIRSATI' },
{ label: 'Maliyet Yok', value: 'MALIYET_YOK' },
{ label: 'Takip', value: 'TAKIP' }
]
const periodOptions = [
{ label: '90G', value: '90' },
{ label: '180G', value: '180' },
{ label: '360G', value: '360' },
{ label: 'Genel', value: 'total' }
]
const customerBreakdownOptions = [
{ label: 'Piyasa > Müşteri', value: 'market_customer' },
{ label: 'Ülke > Müşteri', value: 'country_customer' },
{ label: 'Piyasa > Ülke > Müşteri', value: 'market_country_customer' }
]
const salesBreakdownModes = {
sales_color_yaka_market_customer: 'color_yaka_market_customer',
sales_product_country_segment_market_customer: 'product_country_segment_market_customer',
sales_country_segment_market_customer_product: 'country_segment_market_customer_product'
}
const salesBreakdownTabKeys = Object.keys(salesBreakdownModes)
const detailStockColumns = [
{ name: 'size_code', label: 'Beden', field: 'size_code', align: 'left', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true }
]
const detailSalesColumns = [
{ name: 'sales_date', label: 'Tarih', field: 'sales_date', align: 'left', sortable: true },
{ name: 'ref_number', label: 'Ref', field: 'ref_number', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'sales_qty', label: 'Adet', field: row => formatNumber(row.sales_qty, 2), align: 'right', sortable: true },
{ name: 'sales_usd', label: 'USD', field: row => formatMoney(row.sales_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd', label: 'Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd, 'USD'), align: 'right', sortable: true }
]
const columns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => productFirstGroupValue(row), align: 'left' },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
{ name: 'kategori', label: 'Kategori', field: row => productCategoryValue(row), align: 'left' },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => productMainGroupValue(row), align: 'left' },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' },
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'performance_score_90d', label: '90G Skor', field: row => formatNumber(row.performance_score_90d, 2), align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'performance_score_180d', label: '180G Skor', field: row => formatNumber(row.performance_score_180d, 2), align: 'right', sortable: true },
{ name: 'sales_qty_180d', label: '180G Satış', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true },
{ name: 'performance_score_365d', label: '360G Skor', field: row => formatNumber(row.performance_score_365d, 2), align: 'right', sortable: true },
{ name: 'sales_qty_365d', label: '360G Satış', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
{ name: 'performance_score_total', label: 'Genel Skor', field: row => formatNumber(row.performance_score_total, 2), align: 'right', sortable: true },
{ name: 'sales_qty_total', label: 'Genel Satış', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
{ name: 'sales_usd_180d', label: '180G USD', field: 'sales_usd_180d', align: 'right', sortable: true },
{ name: 'sales_usd_365d', label: '360G USD', field: 'sales_usd_365d', align: 'right', sortable: true },
{ name: 'sales_usd_total', label: 'Genel USD', field: 'sales_usd_total', align: 'right', sortable: true },
{ name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true },
{ name: 'stock_days_180d', label: '180G Stok Gün', field: row => formatNumber(row.stock_days_180d, 1), align: 'right', sortable: true },
{ name: 'stock_days_365d', label: '360G Stok Gün', field: row => formatNumber(row.stock_days_365d, 1), align: 'right', sortable: true },
{ name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_180d', label: '180G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_365d', label: '360G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_total', label: 'Genel Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right' },
{ name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_90d', label: '90G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_90d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_90d', label: '90G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_90d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_180d', label: '180G P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_180d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_180d', label: '180G P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_180d, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_total', label: 'Genel P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_total, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_total', label: 'Genel P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_90d', label: '90G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_180d', label: '180G Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_total', label: 'Genel Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_total', label: 'Genel Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_cost_usd_total', label: 'Genel Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_base_90d', label: '90G Taban Marj', field: row => formatPercent(row.gross_margin_base_90d), align: 'right', sortable: true },
{ name: 'gross_margin_cost_90d', label: '90G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_90d), align: 'right', sortable: true },
{ name: 'gross_margin_base_180d', label: '180G Taban Marj', field: row => formatPercent(row.gross_margin_base_180d), align: 'right', sortable: true },
{ name: 'gross_margin_cost_180d', label: '180G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_180d), align: 'right', sortable: true },
{ name: 'gross_margin_base_total', label: 'Genel Taban Marj', field: row => formatPercent(row.gross_margin_base_total), align: 'right', sortable: true },
{ name: 'gross_margin_cost_total', label: 'Genel Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_total), align: 'right', sortable: true },
{ name: 'market_count_90d', label: '90G Piyasa', field: row => formatNumber(row.market_count_90d, 0), align: 'right', sortable: true },
{ name: 'customer_count_90d', label: '90G Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true },
{ name: 'market_count_total', label: 'Genel Piyasa', field: row => formatNumber(row.market_count_total, 0), align: 'right', sortable: true },
{ name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
{ name: 'sales_index_90d', label: 'Piyasa End.', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
{ name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
const periodColumnRules = [
{ period: '90', match: name => name.includes('_90d') },
{ period: '180', match: name => name.includes('_180d') },
{ period: '360', match: name => name.includes('_365d') },
{ period: 'total', match: name => name.includes('_total') }
]
const periodOrder = {
90: 1,
180: 2,
360: 3,
total: 4
}
const hiddenTableColumns = new Set([
'item_description',
'performance_score',
'gross_profit_usd_total',
'invoice_count_90d',
'invoice_count_180d',
'invoice_count_365d',
'invoice_count_total',
'sales_index_90d',
'sales_index_180d',
'sales_index_365d',
'sales_index_total',
'stock_days_90d',
'stock_days_180d',
'stock_days_365d',
'stock_days_total'
])
const dimensionColumnNames = [
'product_code',
'color_yaka',
'color_code',
'yaka_kodu',
'item_description',
'kategori',
'askili_yan',
'urun_ilk_grubu',
'urun_ana_grubu',
'urun_alt_grubu',
'market_key',
'country',
'customer_segment',
'customer_code',
'customer_name'
]
const productHierarchyColumnOrder = [
'urun_ilk_grubu',
'askili_yan',
'kategori',
'urun_ana_grubu',
'urun_alt_grubu'
]
const metricOrderFragments = [
'customer_score',
'performance_score',
'product_count',
'market_count',
'customer_count',
'invoice_count',
'order_count',
'line_count',
'sales_qty',
'order_qty',
'sales_usd',
'order_usd',
'avg_price',
'avg_order_price',
'sales_index',
'avg_daily_sales',
'stock_days',
'stock_turnover',
'base_price',
'cost_price',
'base_cost',
'cost_amount',
'unit_profit_base',
'unit_profit_cost',
'gross_profit_usd',
'gross_profit_base',
'gross_profit_cost',
'expected_profit_base',
'expected_profit_cost',
'gross_margin_base',
'gross_margin_cost',
'expected_margin_base',
'expected_margin_cost'
]
const selectedPeriodSet = computed(() => new Set(selectedPeriods.value))
function columnPeriod (name) {
return periodColumnRules.find(rule => rule.match(name))?.period || ''
}
function visiblePeriodColumn (col) {
const period = columnPeriod(col.name)
return !period || selectedPeriodSet.value.has(period)
}
function metricBaseName (name) {
return String(name || '')
.replace(/_90d$|_180d$|_365d$|_total$/g, '')
}
function metricOrder (name) {
const base = metricBaseName(name)
const idx = metricOrderFragments.findIndex(fragment => base.includes(fragment))
return idx >= 0 ? idx : 999
}
function isTrailingInfoColumn (name) {
return ['performance_bucket', 'recommendation', 'last_sale_date', 'first_sale_date', 'is_overdue'].includes(name)
}
function nonPeriodColumnOrder (name) {
if (name === 'performance_score') return 2
if (name === 'stock_qty' || name === 'product_count') return 1
if (dimensionColumnNames.includes(name) || name === 'image') return 0
return 3
}
function orderedMetricColumns (sourceColumns) {
const sorted = sourceColumns.filter(col => !hiddenTableColumns.has(col.name)).sort((a, b) => {
const aPeriod = columnPeriod(a.name)
const bPeriod = columnPeriod(b.name)
const aTrailing = isTrailingInfoColumn(a.name)
const bTrailing = isTrailingInfoColumn(b.name)
if (aTrailing !== bTrailing) return aTrailing ? 1 : -1
if (!aPeriod && !bPeriod) {
return nonPeriodColumnOrder(a.name) - nonPeriodColumnOrder(b.name)
}
if (!aPeriod) return -1
if (!bPeriod) return 1
const periodDiff = periodOrder[aPeriod] - periodOrder[bPeriod]
if (periodDiff) return periodDiff
const metricDiff = metricOrder(a.name) - metricOrder(b.name)
return metricDiff || 0
})
return normalizeProductHierarchyColumnOrder(sorted)
}
function normalizeProductHierarchyColumnOrder (sourceColumns) {
const positions = sourceColumns
.map((col, index) => productHierarchyColumnOrder.includes(col.name) ? index : -1)
.filter(index => index >= 0)
if (positions.length < 2) return sourceColumns
const ordered = positions
.map(index => sourceColumns[index])
.sort((a, b) => productHierarchyColumnOrder.indexOf(a.name) - productHierarchyColumnOrder.indexOf(b.name))
const next = [...sourceColumns]
positions.forEach((position, index) => {
next[position] = ordered[index]
})
return next
}
function reorderColumnsInPlace (targetColumns) {
targetColumns.splice(0, targetColumns.length, ...orderedMetricColumns(targetColumns))
return targetColumns
}
const productColumns = computed(() => orderedMetricColumns(columns.filter(visiblePeriodColumn)))
const productDetailColumns = computed(() => orderedMetricColumns(columns
.filter(col => !['urun_ilk_grubu', 'askili_yan', 'kategori'].includes(col.name))
.filter(visiblePeriodColumn)))
const detailColumnNames = new Set([
'item_description',
'kategori',
'askili_yan',
'urun_ilk_grubu',
'urun_ana_grubu',
'urun_alt_grubu',
'period_start',
'period_end',
'country',
'customer_segment',
'customer_code',
'customer_name',
'first_sale_date',
'last_sale_date',
'last_ref_number'
])
function applyDetailColumnVisibility (sourceColumns) {
if (!detailColumnsHidden.value) return sourceColumns
return sourceColumns.filter(col => !detailColumnNames.has(col.name))
}
const visibleProductColumns = computed(() => applyDetailColumnVisibility(productColumns.value))
const visibleProductDetailColumns = computed(() => applyDetailColumnVisibility(productDetailColumns.value))
const activeProductColumns = computed(() => activeTab.value === 'product_detail' ? visibleProductDetailColumns.value : visibleProductColumns.value)
const generalColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => productFirstGroupValue(row), align: 'left', sortable: true },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => productMainGroupValue(row), align: 'left', sortable: true },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
{ name: 'period_start', label: 'Başlangıç', field: 'period_start', align: 'left', sortable: true },
{ name: 'period_end', label: 'Bitiş', field: 'period_end', align: 'left', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'sales_qty_total', label: 'Toplam Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
{ name: 'sales_usd_total', label: 'Toplam Ciro USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'avg_daily_sales_total', label: 'Günlük Ort.', field: row => formatNumber(row.avg_daily_sales_total, 3), align: 'right', sortable: true },
{ name: 'stock_days_total', label: 'Genel Stok Gün', field: row => formatNumber(row.stock_days_total, 1), align: 'right', sortable: true },
{ name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_total', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_total, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_total', label: 'P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_usd_total', label: 'Toplam K/Z', field: row => formatMoney(row.gross_profit_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_total', label: 'Taban Toplam K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_cost_usd_total', label: 'Çıplak Toplam K/Z', field: row => formatMoney(row.gross_profit_cost_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_base_total', label: 'Genel Taban Marj', field: row => formatPercent(row.gross_margin_base_total), align: 'right', sortable: true },
{ name: 'gross_margin_cost_total', label: 'Genel Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_total), align: 'right', sortable: true },
{ name: 'market_count_total', label: 'Toplam Piyasa', field: row => formatNumber(row.market_count_total, 0), align: 'right', sortable: true },
{ name: 'customer_count_total', label: 'Toplam Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
{ name: 'invoice_count_total', label: 'Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true },
{ name: 'sales_index_total', label: 'Genel Endeks', field: row => formatNumber(row.sales_index_total, 2), align: 'right', sortable: true },
{ name: 'performance_score_total', label: 'Genel Skor', field: row => formatNumber(row.performance_score_total ?? row.performance_score, 2), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'first_sale_date', label: 'İlk Satış', field: 'first_sale_date', align: 'left', sortable: true },
{ name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
const visibleGeneralColumns = computed(() => applyDetailColumnVisibility(generalColumns))
const orderAnalysisColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => productFirstGroupValue(row), align: 'left', sortable: true },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => productMainGroupValue(row), align: 'left', sortable: true },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'base_price_usd', label: 'USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'USD Çıplak Maliyeti', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_base_usd', label: 'P.Başı Taban K/Z', field: row => formatMoney(row.unit_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'unit_profit_cost_usd', label: 'P.Başı Çıplak K/Z', field: row => formatMoney(row.unit_profit_cost_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Taban Toplam K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Çıplak Toplam K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Beklenen Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'market_count', label: 'Piyasa', field: row => formatNumber(row.market_count, 0), align: 'right', sortable: true },
{ name: 'customer_count', label: 'Müşteri', field: row => formatNumber(row.customer_count, 0), align: 'right', sortable: true },
{ name: 'order_count', label: 'Sipariş', field: row => formatNumber(row.order_count, 0), align: 'right', sortable: true },
{ name: 'line_count', label: 'Satır', field: row => formatNumber(row.line_count, 0), align: 'right', sortable: true },
{ name: 'earliest_due_date', label: 'İlk Termin', field: 'earliest_due_date', align: 'left', sortable: true },
{ name: 'overdue_qty', label: 'Geciken Adet', field: row => formatNumber(row.overdue_qty, 0), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const orderGroupColumns = [
{ name: 'group_key', label: 'Kırılım', field: row => orderGroupLabel(row), align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'base_cost_usd', label: 'Taban Maliyet USD', field: row => formatMoney(row.base_cost_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_amount_usd', label: 'Çıplak Maliyet USD', field: row => formatMoney(row.cost_amount_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Yakl. Net K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_base', label: 'Brüt Marj', field: row => formatPercent(row.expected_margin_base), align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Yakl. Net Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'market_count', label: 'Piyasa', field: row => formatNumber(row.market_count, 0), align: 'right', sortable: true },
{ name: 'customer_count', label: 'Müşteri', field: row => formatNumber(row.customer_count, 0), align: 'right', sortable: true },
{ name: 'order_count', label: 'Sipariş', field: row => formatNumber(row.order_count, 0), align: 'right', sortable: true },
{ name: 'line_count', label: 'Satır', field: row => formatNumber(row.line_count, 0), align: 'right', sortable: true },
{ name: 'overdue_qty', label: 'Geciken Adet', field: row => formatNumber(row.overdue_qty, 0), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const orderProductCustomerColumns = [
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => productFirstGroupValue(row), align: 'left', sortable: true },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => productMainGroupValue(row), align: 'left', sortable: true },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'order_qty', label: 'Açık Sipariş', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'Sipariş USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'base_price_usd', label: 'Taban USD', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'Çıplak USD', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Yakl. Net K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_base', label: 'Brüt Marj', field: row => formatPercent(row.expected_margin_base), align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Yakl. Net Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'order_count', label: 'Sipariş', field: row => formatNumber(row.order_count, 0), align: 'right', sortable: true },
{ name: 'line_count', label: 'Satır', field: row => formatNumber(row.line_count, 0), align: 'right', sortable: true },
{ name: 'overdue_qty', label: 'Geciken Adet', field: row => formatNumber(row.overdue_qty, 0), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const orderMarketDetailColumns = [
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => productFirstGroupValue(row), align: 'left', sortable: true },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => productMainGroupValue(row), align: 'left', sortable: true },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'order_number', label: 'Sipariş No', field: 'order_number', align: 'left', sortable: true },
{ name: 'order_date', label: 'Sipariş Tarihi', field: 'order_date', align: 'left', sortable: true },
{ name: 'due_date', label: 'Termin', field: 'due_date', align: 'left', sortable: true },
{ name: 'order_qty', label: 'Adet', field: row => formatNumber(row.order_qty, 0), align: 'right', sortable: true },
{ name: 'order_usd', label: 'USD', field: row => formatMoney(row.order_usd, 'USD'), align: 'right', sortable: true },
{ name: 'avg_order_price_usd', label: 'Ort. Sipariş Fiyatı USD', field: row => formatMoney(row.avg_order_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'net_stock_after_order', label: 'Stok - Sipariş', field: 'net_stock_after_order', align: 'right', sortable: true },
{ name: 'expected_profit_base_usd', label: 'Brüt K/Z', field: row => formatMoney(row.expected_profit_base_usd, 'USD'), align: 'right', sortable: true },
{ name: 'expected_profit_cost_usd', label: 'Yakl. Net K/Z', field: 'expected_profit_cost_usd', align: 'right', sortable: true },
{ name: 'expected_margin_base', label: 'Brüt Marj', field: row => formatPercent(row.expected_margin_base), align: 'right', sortable: true },
{ name: 'expected_margin_cost', label: 'Yakl. Net Marj', field: row => formatPercent(row.expected_margin_cost), align: 'right', sortable: true },
{ name: 'is_overdue', label: 'Gecikti', field: row => row.is_overdue ? 'Evet' : 'Hayır', align: 'left', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Yorum', field: 'recommendation', align: 'left' }
]
const idleColumns = [
{ name: 'image', label: 'Foto', field: 'image', align: 'center' },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => productFirstGroupValue(row), align: 'left' },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => productMainGroupValue(row), align: 'left' },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left' },
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left' },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left' },
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left' },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'Maliyet USD', field: row => formatMoney(row.cost_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_usd', align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
const marketColumns = [
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left' },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left' },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => productFirstGroupValue(row), align: 'left' },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => productMainGroupValue(row), align: 'left' },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left' },
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'star_count', label: 'Yıldız', field: row => formatNumber(row.star_count, 0), align: 'right', sortable: true },
{ name: 'stock_risk_count', label: 'Risk', field: row => formatNumber(row.stock_risk_count, 0), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'stock_cost_value_usd', label: 'Stok Maliyeti', field: 'stock_cost_value_usd', align: 'right', sortable: true },
{ name: 'risk_stock_cost_value_usd', label: 'Risk Maliyeti', field: 'risk_stock_cost_value_usd', align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Satış', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: row => formatMoney(row.sales_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'avg_gross_margin_90d', label: 'Ort. Marj', field: 'avg_gross_margin_90d', align: 'right', sortable: true },
{ name: 'avg_stock_days_90d', label: 'Ort. Stok Gün', field: row => formatNumber(row.avg_stock_days_90d, 1), align: 'right', sortable: true }
]
const countryColumns = [
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
{ name: 'customer_segment', label: 'Müşteri Segmenti', field: 'customer_segment', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left' },
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: row => formatMoney(row.sales_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'customer_count_90d', label: 'Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true },
{ name: 'invoice_count_90d', label: 'Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true },
{ name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_365d', label: '360G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true }
]
const customerColumns = [
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
{ name: 'customer_segment', label: 'Segment', field: 'customer_segment', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'product_count', label: 'Ürün', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. Satış Fiyatı USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
{ name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 2), align: 'right', sortable: true },
{ name: 'base_price_usd_90d', label: '90G Taban', field: row => formatMoney(row.base_price_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd_90d', label: '90G Çıplak', field: row => formatMoney(row.cost_price_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_90d', label: '90G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_cost_usd_90d', label: '90G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_90d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_base_90d', label: '90G Taban Marj', field: row => formatPercent(row.gross_margin_base_90d), align: 'right', sortable: true },
{ name: 'gross_margin_cost_90d', label: '90G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_90d), align: 'right', sortable: true },
{ name: 'invoice_count_90d', label: 'Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true },
{ name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_365d', label: '360G USD', field: 'sales_usd_365d', align: 'right', sortable: true },
{ name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true }
]
const salesBreakdownColumns = [
{ name: 'color_code', label: 'Renk', field: 'color_code', align: 'left', sortable: true },
{ name: 'yaka_kodu', label: 'Yaka', field: 'yaka_kodu', align: 'left', sortable: true },
{ name: 'askili_yan', label: 'Askılı/Yan', field: 'askili_yan', align: 'left', sortable: true },
{ name: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', field: row => productFirstGroupValue(row), align: 'left', sortable: true },
{ name: 'urun_ana_grubu', label: 'Ürün Ana Grubu', field: row => productMainGroupValue(row), align: 'left', sortable: true },
{ name: 'urun_alt_grubu', label: 'Ürün Alt Grubu', field: 'urun_alt_grubu', align: 'left', sortable: true },
{ name: 'product_code', label: 'Ürün', field: 'product_code', align: 'left', sortable: true },
{ name: 'item_description', label: 'Açıklama', field: 'item_description', align: 'left', sortable: true },
{ name: 'country', label: 'Ülke', field: 'country', align: 'left', sortable: true },
{ name: 'market_key', label: 'Piyasa', field: row => displayMarketName(row.market_key), align: 'left', sortable: true },
{ name: 'customer_segment', label: 'Segment', field: 'customer_segment', align: 'left', sortable: true },
{ name: 'customer_code', label: 'Müşteri Kodu', field: 'customer_code', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Müşteri', field: 'customer_name', align: 'left', sortable: true },
{ name: 'kategori', label: 'Kategori', field: 'kategori', align: 'left', sortable: true },
{ name: 'product_count', label: 'Ürün Sayısı', field: row => formatNumber(row.product_count, 0), align: 'right', sortable: true },
{ name: 'stock_qty', label: 'Stok', field: row => formatNumber(row.stock_qty, 0), align: 'right', sortable: true },
{ name: 'customer_count_90d', label: '90G Müşteri', field: row => formatNumber(row.customer_count_90d, 0), align: 'right', sortable: true },
{ name: 'invoice_count_90d', label: '90G Fatura', field: row => formatNumber(row.invoice_count_90d, 0), align: 'right', sortable: true },
{ name: 'sales_qty_90d', label: '90G Adet', field: row => formatNumber(row.sales_qty_90d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_90d', label: '90G USD', field: 'sales_usd_90d', align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: '90G Ort. Satış Fiyatı USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'customer_count_180d', label: '180G Müşteri', field: row => formatNumber(row.customer_count_180d, 0), align: 'right', sortable: true },
{ name: 'invoice_count_180d', label: '180G Fatura', field: row => formatNumber(row.invoice_count_180d, 0), align: 'right', sortable: true },
{ name: 'sales_qty_180d', label: '180G Adet', field: row => formatNumber(row.sales_qty_180d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_180d', label: '180G USD', field: row => formatMoney(row.sales_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_180d', label: '180G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_180d', label: '180G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
{ name: 'base_price_usd_180d', label: '180G Taban', field: row => formatMoney(row.base_price_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd_180d', label: '180G Çıplak', field: row => formatMoney(row.cost_price_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_180d', label: '180G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_cost_usd_180d', label: '180G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_base_180d', label: '180G Taban Marj', field: row => formatPercent(row.gross_margin_base_180d), align: 'right', sortable: true },
{ name: 'gross_margin_cost_180d', label: '180G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_180d), align: 'right', sortable: true },
{ name: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 2), align: 'right', sortable: true },
{ name: 'sales_qty_365d', label: '360G Adet', field: row => formatNumber(row.sales_qty_365d, 0), align: 'right', sortable: true },
{ name: 'sales_usd_365d', label: '360G USD', field: row => formatMoney(row.sales_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_365d', label: '360G Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_365d', label: '360G Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
{ name: 'base_price_usd_365d', label: '360G Taban', field: row => formatMoney(row.base_price_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd_365d', label: '360G Çıplak', field: row => formatMoney(row.cost_price_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_365d', label: '360G Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_cost_usd_365d', label: '360G Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_base_365d', label: '360G Taban Marj', field: row => formatPercent(row.gross_margin_base_365d), align: 'right', sortable: true },
{ name: 'gross_margin_cost_365d', label: '360G Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_365d), align: 'right', sortable: true },
{ name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 2), align: 'right', sortable: true },
{ name: 'customer_count_total', label: 'Genel Müşteri', field: row => formatNumber(row.customer_count_total, 0), align: 'right', sortable: true },
{ name: 'invoice_count_total', label: 'Genel Fatura', field: row => formatNumber(row.invoice_count_total, 0), align: 'right', sortable: true },
{ name: 'sales_qty_total', label: 'Genel Adet', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
{ name: 'sales_usd_total', label: 'Genel USD', field: row => formatMoney(row.sales_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'avg_price_usd_total', label: 'Genel Ort. Satış Fiyatı USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_total', label: 'Genel Yıllık Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
{ name: 'base_price_usd_total', label: 'Genel Taban', field: row => formatMoney(row.base_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd_total', label: 'Genel Çıplak', field: row => formatMoney(row.cost_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_base_usd_total', label: 'Genel Taban K/Z', field: row => formatMoney(row.gross_profit_base_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_profit_cost_usd_total', label: 'Genel Çıplak K/Z', field: row => formatMoney(row.gross_profit_cost_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'gross_margin_base_total', label: 'Genel Taban Marj', field: row => formatPercent(row.gross_margin_base_total), align: 'right', sortable: true },
{ name: 'gross_margin_cost_total', label: 'Genel Çıplak Marj', field: row => formatPercent(row.gross_margin_cost_total), align: 'right', sortable: true },
{ name: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 2), align: 'right', sortable: true },
{ name: 'sales_index_90d', label: 'Endeks', field: row => formatNumber(row.sales_index_90d, 2), align: 'right', sortable: true },
{ name: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left', sortable: true },
{ name: 'last_sale_date', label: 'Son Satış', field: 'last_sale_date', align: 'left', sortable: true },
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
]
const productPeriodScoreColumns = [
{ name: 'performance_score_90d', label: '90G Ürün Skor', field: row => formatNumber(row.performance_score_90d, 2), align: 'right', sortable: true },
{ name: 'performance_score_180d', label: '180G Ürün Skor', field: row => formatNumber(row.performance_score_180d, 2), align: 'right', sortable: true },
{ name: 'performance_score_365d', label: '360G Ürün Skor', field: row => formatNumber(row.performance_score_365d, 2), align: 'right', sortable: true },
{ name: 'performance_score_total', label: 'Genel Ürün Skor', field: row => formatNumber(row.performance_score_total, 2), align: 'right', sortable: true }
]
const customerPeriodScoreColumns = [
{ name: 'customer_score_90d', label: '90G Müşteri Skor', field: row => formatNumber(row.customer_score_90d, 2), align: 'right', sortable: true },
{ name: 'customer_score_180d', label: '180G Müşteri Skor', field: row => formatNumber(row.customer_score_180d, 2), align: 'right', sortable: true },
{ name: 'customer_score_365d', label: '360G Müşteri Skor', field: row => formatNumber(row.customer_score_365d, 2), align: 'right', sortable: true },
{ name: 'customer_score_total', label: 'Genel Müşteri Skor', field: row => formatNumber(row.customer_score_total, 2), align: 'right', sortable: true }
]
const metricLabelOverrides = {
stock_qty: 'Toplam Stok',
sales_qty: 'Toplam Adet',
sales_qty_90d: '90G Toplam Adet',
sales_qty_180d: '180G Toplam Adet',
sales_qty_365d: '360G Toplam Adet',
sales_qty_total: 'Genel Toplam Adet',
sales_usd: 'Toplam Ciro USD',
sales_usd_90d: '90G Toplam Ciro USD',
sales_usd_180d: '180G Toplam Ciro USD',
sales_usd_365d: '360G Toplam Ciro USD',
sales_usd_total: 'Genel Toplam Ciro USD',
avg_price_usd: 'Ort. Satış Fiyatı USD',
avg_price_usd_90d: '90G Ort. Satış Fiyatı USD',
avg_price_usd_180d: '180G Ort. Satış Fiyatı USD',
avg_price_usd_365d: '360G Ort. Satış Fiyatı USD',
avg_price_usd_total: 'Genel Ort. Satış Fiyatı USD',
base_price_usd: 'Taban Maliyet USD',
cost_price_usd: 'Çıplak Maliyet USD',
base_price_usd_90d: '90G Taban Maliyet USD',
cost_price_usd_90d: '90G Çıplak Maliyet USD',
base_price_usd_180d: '180G Taban Maliyet USD',
cost_price_usd_180d: '180G Çıplak Maliyet USD',
base_price_usd_365d: '360G Taban Maliyet USD',
cost_price_usd_365d: '360G Çıplak Maliyet USD',
base_price_usd_total: 'Genel Taban Maliyet USD',
cost_price_usd_total: 'Genel Çıplak Maliyet USD',
unit_profit_base_90d: '90G Birim Taban K/Z USD',
unit_profit_cost_90d: '90G Birim Çıplak K/Z USD',
unit_profit_base_180d: '180G Birim Taban K/Z USD',
unit_profit_cost_180d: '180G Birim Çıplak K/Z USD',
unit_profit_base_total: 'Genel Birim Taban K/Z USD',
unit_profit_cost_total: 'Genel Birim Çıplak K/Z USD',
gross_profit_usd_90d: '90G Toplam K/Z USD',
gross_profit_usd_180d: '180G Toplam K/Z USD',
gross_profit_usd_total: 'Genel Toplam K/Z USD',
gross_profit_base_usd_90d: '90G Taban Toplam K/Z USD',
gross_profit_cost_usd_90d: '90G Çıplak Toplam K/Z USD',
gross_profit_base_usd_180d: '180G Taban Toplam K/Z USD',
gross_profit_cost_usd_180d: '180G Çıplak Toplam K/Z USD',
gross_profit_base_usd_365d: '360G Taban Toplam K/Z USD',
gross_profit_cost_usd_365d: '360G Çıplak Toplam K/Z USD',
gross_profit_base_usd_total: 'Genel Taban Toplam K/Z USD',
gross_profit_cost_usd_total: 'Genel Çıplak Toplam K/Z USD',
gross_margin_base_90d: '90G Taban Marj',
gross_margin_cost_90d: '90G Çıplak Marj',
gross_margin_base_180d: '180G Taban Marj',
gross_margin_cost_180d: '180G Çıplak Marj',
gross_margin_base_365d: '360G Taban Marj',
gross_margin_cost_365d: '360G Çıplak Marj',
gross_margin_base_total: 'Genel Taban Marj',
gross_margin_cost_total: 'Genel Çıplak Marj',
performance_score_90d: '90G Ürün Skor',
performance_score_180d: '180G Ürün Skor',
performance_score_365d: '360G Ürün Skor',
performance_score_total: 'Genel Ürün Skor',
customer_score_90d: '90G Müşteri Skor',
customer_score_180d: '180G Müşteri Skor',
customer_score_365d: '360G Müşteri Skor',
customer_score_total: 'Genel Müşteri Skor',
market_count_90d: '90G Tekil Piyasa',
customer_count_90d: '90G Tekil Müşteri',
market_count_total: 'Genel Tekil Piyasa',
customer_count_total: 'Genel Tekil Müşteri',
stock_turnover_90d: '90G Yıllık Stok Devir',
stock_turnover_180d: '180G Yıllık Stok Devir',
stock_turnover_365d: '360G Yıllık Stok Devir',
stock_turnover_total: 'Genel Yıllık Stok Devir',
sales_index_90d: '90G Endeks',
sales_index_total: 'Genel Endeks',
stock_days_90d: '90G Stok Gün',
stock_days_180d: '180G Stok Gün',
stock_days_365d: '360G Stok Gün',
stock_days_total: 'Genel Stok Gün',
avg_daily_sales_total: 'Genel Günlük Ort. Adet'
}
function applyMetricLabelOverrides (...columnGroups) {
for (const group of columnGroups) {
for (const col of group || []) {
if (metricLabelOverrides[col?.name]) col.label = metricLabelOverrides[col.name]
}
}
}
applyMetricLabelOverrides(
detailSalesColumns,
columns,
generalColumns,
orderAnalysisColumns,
orderGroupColumns,
orderProductCustomerColumns,
orderMarketDetailColumns,
idleColumns,
marketColumns,
countryColumns,
customerColumns,
salesBreakdownColumns,
productPeriodScoreColumns,
customerPeriodScoreColumns
)
function ensureColumns (targetColumns, columnsToEnsure) {
const existing = new Set(targetColumns.map(col => col.name))
for (const col of columnsToEnsure) {
if (!existing.has(col.name)) targetColumns.push(col)
}
}
function removeColumnsInPlace (targetColumns, names) {
const removeSet = new Set(names)
const next = targetColumns.filter(col => !removeSet.has(col.name))
targetColumns.splice(0, targetColumns.length, ...next)
return targetColumns
}
function ensureColorYakaColumn (targetColumns) {
const colorIndex = targetColumns.findIndex(col => col.name === 'color_code')
const yakaIndex = targetColumns.findIndex(col => col.name === 'yaka_kodu')
const insertAt = colorIndex >= 0 ? colorIndex : yakaIndex
if (insertAt >= 0 && !targetColumns.some(col => col.name === 'color_yaka')) {
targetColumns.splice(insertAt, 0, {
name: 'color_yaka',
label: 'Renk/Yaka',
field: row => colorYakaValue(row),
align: 'left',
sortable: true,
classes: 'color-yaka-cell',
headerClasses: 'color-yaka-cell'
})
}
removeColumnsInPlace(targetColumns, ['color_code', 'yaka_kodu'])
return targetColumns
}
;[
columns,
generalColumns,
orderAnalysisColumns,
orderGroupColumns,
orderProductCustomerColumns,
orderMarketDetailColumns,
idleColumns,
salesBreakdownColumns
].forEach(ensureColorYakaColumn)
;[
columns,
generalColumns,
orderAnalysisColumns,
orderGroupColumns,
orderProductCustomerColumns,
orderMarketDetailColumns,
idleColumns,
marketColumns,
countryColumns,
salesBreakdownColumns
].forEach(target => ensureColumns(target, productPeriodScoreColumns))
;[
customerColumns,
salesBreakdownColumns
].forEach(target => ensureColumns(target, customerPeriodScoreColumns))
;[
orderAnalysisColumns,
orderGroupColumns,
orderProductCustomerColumns,
orderMarketDetailColumns,
idleColumns,
marketColumns,
countryColumns,
customerColumns,
salesBreakdownColumns
].forEach(target => removeColumnsInPlace(target, ['image']))
;[
columns,
generalColumns,
orderAnalysisColumns,
orderGroupColumns,
orderProductCustomerColumns,
orderMarketDetailColumns,
idleColumns,
marketColumns,
countryColumns,
customerColumns,
salesBreakdownColumns
].forEach(target => {
reorderColumnsInPlace(target)
attachColumnSorts(target)
})
attachColumnSorts(detailStockColumns)
attachColumnSorts(detailSalesColumns)
const visibleSalesBreakdownColumns = computed(() => {
const base = orderedMetricColumns(salesBreakdownColumns.filter(visiblePeriodColumn))
if (activeTab.value !== 'sales_color_yaka_market_customer') return base
const priority = ['performance_bucket', 'recommendation']
const pulled = priority
.map(name => base.find(col => col.name === name))
.filter(Boolean)
const rest = base.filter(col => !priority.includes(col.name))
const insertAfter = Math.max(
rest.findIndex(col => col.name === 'item_description'),
rest.findIndex(col => col.name === 'product_code')
)
if (insertAfter < 0) return [...pulled, ...rest]
return [
...rest.slice(0, insertAfter + 1),
...pulled,
...rest.slice(insertAfter + 1)
]
})
const salesBreakdownStickyClass = computed(() => activeTab.value === 'sales_color_yaka_market_customer' ? 'sticky-dim-9' : 'sticky-dim-9')
const summaryCards = computed(() => [
{ key: 'date', label: 'KPI Tarihi', value: summary.value.kpi_date || '-' },
{ key: 'rows', label: 'Satır', value: formatNumber(summary.value.total_rows, 0) },
{ key: 'star', label: 'Yıldız', value: formatNumber(summary.value.star_count, 0) },
{ key: 'risk', label: 'Stok Riski', value: formatNumber(summary.value.stock_risk, 0) },
{ key: 'markets', label: '90G Piyasa', value: formatNumber(summary.value.market_count_90d, 0) },
{ key: 'customers', label: '90G Müşteri', value: formatNumber(summary.value.customer_count_90d, 0) },
{ key: 'stock_cost', label: 'Stok Maliyeti', value: formatMoney(summary.value.stock_cost_usd, 'USD') },
{ key: 'risk_cost', label: 'Risk Maliyeti', value: formatMoney(summary.value.risk_cost_usd, 'USD') }
])
const idleRows = computed(() => {
const variants = new Map()
for (const row of rows.value) {
const key = `${row.product_code || ''}|${row.color_code || ''}|${row.yaka_kodu || ''}`
const current = variants.get(key)
if (!current) {
variants.set(key, {
...row,
market_key: row.market_key && row.market_key !== 'STOK' ? row.market_key : '',
sales_qty_90d: Number(row.sales_qty_90d || 0),
sales_usd_90d: Number(row.sales_usd_90d || 0),
gross_profit_usd_90d: Number(row.gross_profit_usd_90d || 0),
customer_count_90d: Number(row.customer_count_90d || 0),
market_count_90d: row.market_key && row.market_key !== 'STOK' ? 1 : 0,
has_stock_risk: row.performance_bucket === 'STOK_RISKI'
})
continue
}
current.market_key = distinctTextSummary(current.market_key, row.market_key)
current.sales_qty_90d += Number(row.sales_qty_90d || 0)
current.sales_usd_90d += Number(row.sales_usd_90d || 0)
current.gross_profit_usd_90d += Number(row.gross_profit_usd_90d || 0)
current.customer_count_90d += Number(row.customer_count_90d || 0)
current.market_count_90d += row.market_key && row.market_key !== 'STOK' ? 1 : 0
current.has_stock_risk = current.has_stock_risk || row.performance_bucket === 'STOK_RISKI'
}
return Array.from(variants.values())
.map(row => {
const stockQty = Number(row.stock_qty || 0)
const salesQty90 = Number(row.sales_qty_90d || 0)
const stockDays90 = salesQty90 > 0 ? stockQty / (salesQty90 / 90) : 0
return {
...row,
stock_days_90d: stockDays90,
idle_cost_usd: stockQty * Number(row.cost_price_usd || 0),
performance_bucket: row.has_stock_risk ? 'STOK_RISKI' : row.performance_bucket
}
})
.filter(row => Number(row.stock_qty || 0) > 0 && (
row.performance_bucket === 'STOK_RISKI' ||
Number(row.sales_qty_90d || 0) === 0 ||
Number(row.stock_days_90d || 0) > 180
))
.sort((a, b) => Number(b.idle_cost_usd || 0) - Number(a.idle_cost_usd || 0))
})
const visibleOrderAnalysisRows = computed(() => {
if (orderAnalysisMode.value === 'market') return orderMarketRows.value
if (orderAnalysisMode.value === 'customer') return orderCustomerRows.value
return orderAnalysisRows.value
})
const visibleOrderAnalysisColumns = computed(() => {
if (orderAnalysisMode.value === 'market' || orderAnalysisMode.value === 'customer') return orderGroupColumns
return orderAnalysisColumns
})
function performanceCardsForRow (row) {
const source = row || {}
return [
{ key: 'score90', label: '90G Skor', value: formatNumber(source.performance_score_90d ?? source.performance_score, 2) },
{ key: 'score180', label: '180G Skor', value: formatNumber(source.performance_score_180d, 2) },
{ key: 'score360', label: '360G Skor', value: formatNumber(source.performance_score_365d, 2) },
{ key: 'scoreTotal', label: 'Genel Skor', value: formatNumber(source.performance_score_total ?? source.performance_score, 2) },
{ key: 'stock', label: 'Stok', value: formatNumber(source.stock_qty, 0) },
{ key: 'sales90', label: '90G Satış', value: formatNumber(source.sales_qty_90d, 0) },
{ key: 'sales180', label: '180G Satış', value: formatNumber(source.sales_qty_180d, 0) },
{ key: 'markets', label: '90G Piyasa', value: formatNumber(source.market_count_90d, 0) },
{ key: 'customers', label: '90G Müşteri', value: formatNumber(source.customer_count_90d, 0) },
{ key: 'avg', label: 'Ort. Satış Fiyatı', value: formatMoney(source.avg_price_usd_90d, 'USD') },
{ key: 'base', label: 'Taban Maliyet', value: formatMoney(source.base_price_usd, 'USD') },
{ key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(source.cost_price_usd, 'USD') },
{ key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(source.unit_profit_base_90d, 'USD') },
{ key: 'profitCost', label: 'P.Başı Çıplak K/Z', value: formatMoney(source.unit_profit_cost_90d, 'USD') },
{ key: 'marginBase', label: '90G Taban Marj', value: formatPercent(source.gross_margin_base_90d) },
{ key: 'marginCost', label: '90G Çıplak Marj', value: formatPercent(source.gross_margin_cost_90d) },
{ key: 'bucket', label: 'Durum', value: bucketLabel(source.performance_bucket) }
]
}
const performanceCards = computed(() => performanceCardsForRow(performanceDialogRow.value))
const imageDialogPerformanceCards = computed(() => performanceCardsForRow(imageDialogRow.value))
const imageDialogInfoRows = computed(() => {
const row = imageDialogRow.value || {}
const productCode = row.image_product_code || row.product_code
const colorCode = row.image_color_code || row.color_code
const yakaCode = row.image_yaka_kodu || row.yaka_kodu
return [
{ key: 'product', label: 'Ürün', value: productCode || '-' },
{ key: 'color', label: 'Renk', value: colorCode || '-' },
{ key: 'yaka', label: 'Yaka', value: yakaCode || '-' },
{ key: 'category', label: 'Kategori', value: productCategoryValue(row) || '-' },
{ key: 'hanger', label: 'Askılı/Yan', value: cleanOptionalGroupValue(row.askili_yan) },
{ key: 'first', label: 'İlk Grup', value: productFirstGroupValue(row) || '-' },
{ key: 'main', label: 'Ana Grup', value: productMainGroupValue(row) || '-' },
{ key: 'sub', label: 'Alt Grup', value: row.urun_alt_grubu || '-' },
{ key: 'market', label: 'Piyasa', value: row.market_key || '-' },
{ key: 'stock', label: 'Toplam Stok', value: formatNumber(row.stock_qty, 0) },
{ key: 'base', label: 'Taban Maliyet', value: formatMoney(row.base_price_usd, 'USD') },
{ key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(row.cost_price_usd, 'USD') }
]
})
const groupLevels = [
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'market_key', label: 'Piyasa' }
]
const tabGroupLevels = {
products: groupLevels,
product_detail: [
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'market_key', label: 'Piyasa' }
],
sales_color_yaka_market_customer: [
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' }
],
idle: [
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_yaka', label: 'Renk/Yaka' }
],
sales_product_country_segment_market_customer: [
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'country', label: 'Ülke' },
{ key: 'customer_segment', label: 'Segment' },
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' }
],
sales_market_customer_product: [
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' },
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_yaka', label: 'Renk/Yaka' }
],
sales_country_segment_market_customer_product: [
{ key: 'country', label: 'Ülke' },
{ key: 'customer_segment', label: 'Segment' },
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' },
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_yaka', label: 'Renk/Yaka' }
],
order_product_customers: [
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_yaka', label: 'Renk/Yaka' },
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' }
],
order_market_details: [
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' },
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu' },
{ key: 'askili_yan', label: 'Askılı/Yan' },
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ana_grubu', label: 'Ürün Ana Grubu' },
{ key: 'urun_alt_grubu', label: 'Ürün Alt Grubu' },
{ key: 'product_code', label: 'Ürün' },
{ key: 'color_yaka', label: 'Renk/Yaka' }
]
}
const activeGroupLevels = computed(() => tabGroupLevels[activeTab.value] || groupLevels)
const activeSelectedExpandLevelKeys = computed({
get: () => selectedExpandLevelKeysByTab[activeTab.value] || [],
set: value => {
selectedExpandLevelKeysByTab[activeTab.value] = Array.isArray(value) ? value : []
}
})
const expandLevelOptions = computed(() => activeGroupLevels.value.map(level => ({
label: level.label,
value: level.key
})))
const selectedExpandThroughLevel = computed(() => {
const selected = new Set(activeSelectedExpandLevelKeys.value)
const indexes = activeGroupLevels.value
.map((level, index) => selected.has(level.key) ? index : -1)
.filter(index => index >= 0)
return indexes.length ? Math.max(...indexes) : -1
})
function maxAutoExpandThroughLevelForTab (tabKey = activeTab.value) {
return Number.isFinite(maxAutoExpandLevelByTab[tabKey]) ? maxAutoExpandLevelByTab[tabKey] : 8
}
function maxBackendExpandedKeysForTab (tabKey = activeTab.value) {
if (tabKey === 'product_detail') return 1000
if (tabKey === 'products' || tabKey === 'idle') return 1000
if (salesBreakdownTabKeys.includes(tabKey)) return 1000
return 1000
}
const autoExpandThroughLevel = computed(() => {
const selectedLevel = selectedExpandThroughLevel.value
const lastGroupLevel = Math.max(0, activeGroupLevels.value.length - 2)
const requestedLevel = selectedLevel >= 0 ? selectedLevel : 2
return Math.min(requestedLevel, lastGroupLevel, maxAutoExpandThroughLevelForTab())
})
const fullExpandThroughLevel = computed(() => {
const lastGroupLevel = Math.max(0, activeGroupLevels.value.length - 2)
return Math.min(lastGroupLevel, maxAutoExpandThroughLevelForTab())
})
const filteredProductRows = computed(() => filterRowsForTable('products', rows.value, productColumns.value))
const isProductKpiTab = computed(() => activeTab.value === 'products' || activeTab.value === 'product_detail')
const activeProductTableKey = computed(() => activeTab.value === 'product_detail' ? 'product_detail' : 'products')
const detailProductRows = computed(() => {
const selected = String(selectedDetailMainGroup.value || '').trim()
if (!selected) return rows.value
return rows.value.filter(row => productMainGroupValue(row) === selected)
})
const filteredDetailProductRows = computed(() => filterRowsForTable('product_detail', detailProductRows.value, productDetailColumns.value))
const filteredGeneralRows = computed(() => filterRowsForTable('general', generalRows.value, generalColumns))
const filteredOrderProductCustomerRows = computed(() => filterRowsForTable('order_product_customers', orderProductCustomerRows.value, orderProductCustomerColumns))
const filteredOrderMarketDetailRows = computed(() => filterRowsForTable('order_market_details', orderMarketDetailRows.value, orderMarketDetailColumns))
const filteredIdleRows = computed(() => filterRowsForTable('idle', idleRows.value, idleColumns))
const filteredMarketRows = computed(() => filterRowsForTable('markets', marketRows.value, marketColumns))
const filteredCountryRows = computed(() => filterRowsForTable('countries', countryRows.value, countryColumns))
const filteredCustomerRows = computed(() => filterRowsForTable('customers', customerRows.value, customerColumns))
const filteredActiveSalesBreakdownRows = computed(() => filterRowsForTable(activeTab.value, salesBreakdownRows[activeTab.value] || [], visibleSalesBreakdownColumns.value))
const activeGroupSourceRows = computed(() => {
if (activeTab.value === 'products') return filteredProductRows.value
if (activeTab.value === 'product_detail') return filteredDetailProductRows.value
if (activeTab.value === 'idle') return filteredIdleRows.value
if (activeTab.value === 'order_product_customers') return filteredOrderProductCustomerRows.value
if (activeTab.value === 'order_market_details') return filteredOrderMarketDetailRows.value
if (salesBreakdownTabKeys.includes(activeTab.value)) return filteredActiveSalesBreakdownRows.value
return []
})
const columnFilterOptionMap = computed(() => {
const out = {}
const tableKey = isProductKpiTab.value ? activeProductTableKey.value : activeTab.value
const source = tableFilterSource(tableKey)
if (!source) return out
out[tableKey] = {}
const preparedOptionEntry = backendGroupedSupportedTab(tableKey)
? backendGroupedFilterOptions.value[tableKey]
: null
const preparedOptions = preparedOptionEntry?._key === backendGroupedFilterOptionsKey(tableKey)
? preparedOptionEntry.options || {}
: {}
for (const col of source.columns) {
if (!isColumnFilterable(col.name)) continue
if (backendGroupedSupportedTab(tableKey) && backendGroupedFilterFields.has(col.name)) {
out[tableKey][col.name] = preparedOptions[col.name] || []
continue
}
if (backendGroupedSupportedTab(tableKey) && !backendGroupedFilterFields.has(col.name)) {
out[tableKey][col.name] = []
continue
}
const seen = new Map()
for (const row of source.rows) {
const value = columnFilterValue(row, col)
if (!value) continue
if (!seen.has(value)) {
seen.set(value, {
label: columnFilterLabel(row, col),
value
})
}
}
out[tableKey][col.name] = Array.from(seen.values())
.sort((a, b) => String(a.label).localeCompare(String(b.label), 'tr', { numeric: true }))
}
return out
})
const productTableRows = computed(() => {
if (shouldUseBackendGroupedRows('products')) return []
const out = []
appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels, 'products')
return out
})
const detailProductTableRows = computed(() => shouldUseBackendGroupedRows('product_detail') ? [] : buildGroupedTableRows('product_detail', filteredDetailProductRows.value))
const idleTableRows = computed(() => shouldUseBackendGroupedRows('idle') ? [] : buildGroupedTableRows('idle', filteredIdleRows.value))
const orderProductCustomerTableRows = computed(() => shouldUseBackendGroupedRows('order_product_customers') ? [] : buildGroupedTableRows('order_product_customers', filteredOrderProductCustomerRows.value))
const orderMarketDetailTableRows = computed(() => shouldUseBackendGroupedRows('order_market_details') ? [] : buildGroupedTableRows('order_market_details', filteredOrderMarketDetailRows.value))
const activeSalesBreakdownTableRows = computed(() => shouldUseBackendGroupedRows(activeTab.value) ? [] : buildGroupedTableRows(activeTab.value, filteredActiveSalesBreakdownRows.value))
const displayProductTableRows = computed(() => backendRowsForTab('products', productTableRows.value))
const displayDetailProductTableRows = computed(() => backendRowsForTab('product_detail', detailProductTableRows.value))
const displayProductKpiTableRows = computed(() => activeTab.value === 'product_detail' ? displayDetailProductTableRows.value : displayProductTableRows.value)
const displayIdleTableRows = computed(() => backendRowsForTab('idle', idleTableRows.value))
const displayOrderProductCustomerTableRows = computed(() => backendRowsForTab('order_product_customers', orderProductCustomerTableRows.value))
const displayOrderMarketDetailTableRows = computed(() => backendRowsForTab('order_market_details', orderMarketDetailTableRows.value))
const displayActiveSalesBreakdownTableRows = computed(() => backendRowsForTab(activeTab.value, activeSalesBreakdownTableRows.value))
const activeDisplayedTableRows = computed(() => {
if (isProductKpiTab.value) return displayProductKpiTableRows.value
if (activeTab.value === 'idle') return displayIdleTableRows.value
if (activeTab.value === 'order_product_customers') return displayOrderProductCustomerTableRows.value
if (activeTab.value === 'order_market_details') return displayOrderMarketDetailTableRows.value
if (salesBreakdownTabKeys.includes(activeTab.value)) return displayActiveSalesBreakdownTableRows.value
if (activeTab.value === 'general') return filteredGeneralRows.value
if (activeTab.value === 'markets') return filteredMarketRows.value
if (activeTab.value === 'countries') return filteredCountryRows.value
if (activeTab.value === 'customers') return filteredCustomerRows.value
return []
})
const detailMainGroupOptions = computed(() => {
if (detailMainGroupOptionRows.value.length) {
return [...detailMainGroupOptionRows.value]
.sort((a, b) => Number(b.sales_qty_90d || 0) - Number(a.sales_qty_90d || 0) || a.label.localeCompare(b.label, 'tr'))
}
const grouped = new Map()
for (const row of rows.value) {
const value = productMainGroupValue(row)
if (!value) continue
const current = grouped.get(value) || { value, label: value, stock_qty: 0, sales_qty_90d: 0, _stockSeen: new Set() }
const variantKey = productVariantMetricKey(row)
if (variantKey && !current._stockSeen.has(variantKey)) {
current._stockSeen.add(variantKey)
current.stock_qty += Number(row?.stock_qty || 0)
} else if (!variantKey) {
current.stock_qty += Number(row?.stock_qty || 0)
}
current.sales_qty_90d += Number(row?.sales_qty_90d || 0)
grouped.set(value, current)
}
return Array.from(grouped.values())
.map(({ _stockSeen, ...option }) => option)
.sort((a, b) => Number(b.sales_qty_90d || 0) - Number(a.sales_qty_90d || 0) || a.label.localeCompare(b.label, 'tr'))
})
function filterRowsBySelectedDetailMainGroup (sourceRows) {
const selected = String(selectedDetailMainGroup.value || '').trim()
const rows = Array.isArray(sourceRows) ? sourceRows : []
if (!selected) return []
return rows.filter(row => {
const value = productMainGroupValue(row)
if (value === selected) return true
return Boolean(row?.__group && row.group_field !== 'urun_ana_grubu' && !value)
})
}
function ensureDetailMainGroupSelection () {
const options = detailMainGroupOptions.value
if (!options.length) {
selectedDetailMainGroup.value = ''
return false
}
const preferred = options.find(option => String(option.value || '').toLocaleUpperCase('tr-TR') === 'TAKIM ELBISE')
if (!selectedDetailMainGroup.value || !options.some(option => option.value === selectedDetailMainGroup.value)) {
selectedDetailMainGroup.value = (preferred || options[0]).value
}
return true
}
const productGroupKeys = computed(() => {
const keys = []
collectGroupKeys(keys, filteredProductRows.value, 0, ['tab:products'], groupLevels)
return keys
})
const productAutoExpandKeys = computed(() => {
const keys = []
collectGroupKeys(keys, activeGroupSourceRows.value, 0, [`tab:${activeTab.value}`], activeGroupLevels.value, autoExpandThroughLevel.value)
return keys
})
const allProductGroupsExpanded = computed(() => {
return selectedExpandThroughLevel.value >= fullExpandThroughLevel.value
})
function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels, tableKey = activeTab.value) {
appendGroupRowsAt(out, sourceRows, level, 0, parentKeys, levels, tableKey)
}
function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, levels = groupLevels, tableKey = activeTab.value) {
if (out.length >= maxRenderedGroupedRows) return
if (level >= levels.length) {
return
}
const groupDef = levels[level]
const grouped = new Map()
for (const row of sourceRows) {
const value = normalizeGroupValue(rawProductCellValue(row, groupDef.key))
if (!grouped.has(value)) grouped.set(value, [])
grouped.get(value).push(row)
}
Array.from(grouped.entries())
.sort((a, b) => compareGroupedBucketsForTable(tableKey, a, b, groupDef))
.forEach(([value, groupRows]) => {
if (out.length >= maxRenderedGroupedRows) return
if (shouldSkipGroupValue(groupDef.key, value)) {
appendGroupRowsAt(out, groupRows, level + 1, visualLevel, parentKeys, levels, tableKey)
return
}
const key = [...parentKeys, `${groupDef.key}:${value}`].join('|')
out.push(makeGroupRow(key, visualLevel, groupDef, value, groupRows))
if (isGroupExpanded(key)) {
appendGroupRowsAt(out, groupRows, level + 1, visualLevel + 1, [...parentKeys, `${groupDef.key}:${value}`], levels, tableKey)
}
})
}
function buildGroupedTableRows (tableKey, sourceRows) {
const levels = tabGroupLevels[tableKey] || groupLevels
const out = []
appendGroupRows(out, sourceRows, 0, [`tab:${tableKey}`], levels, tableKey)
return out
}
function backendRowsForTab (tabKey, fallbackRows) {
if (shouldUseBackendGroupedRows(tabKey)) {
const rows = backendGroupedRows.value[tabKey]
return Array.isArray(rows) ? rows : []
}
return tabKey === 'product_detail'
? filterRowsBySelectedDetailMainGroup(fallbackRows)
: fallbackRows
}
function filterSourceRowsForTab (tabKey, fallbackRows) {
return fallbackRows
}
function backendGroupedFilterOptionParams (tabKey) {
const columns = filterColumnsForTableKey(tabKey)
const fields = columns
.map(col => col?.name || '')
.filter(name => backendGroupedFilterFields.has(name))
const params = {
mode: tabKey,
groupLevels: (tabGroupLevels[tabKey] || groupLevels).map(level => level.key),
fields,
limit: 50000
}
if (tabKey === 'product_detail') params.urunAnaGrubu = selectedDetailMainGroup.value
return params
}
function backendGroupedFilterOptionsKey (tabKey, params = backendGroupedFilterOptionParams(tabKey)) {
return JSON.stringify({
tabKey,
mainGroup: params.urunAnaGrubu || '',
groupLevels: params.groupLevels || [],
fields: params.fields || []
})
}
async function ensureBackendGroupedFilterOptions (tabKey = activeTab.value, force = false) {
if (!backendGroupedSupportedTab(tabKey)) return
if (tabKey === 'product_detail' && !selectedDetailMainGroup.value) return
const params = backendGroupedFilterOptionParams(tabKey)
if (!params.fields.length) return
const key = backendGroupedFilterOptionsKey(tabKey, params)
if (!force && backendGroupedFilterOptions.value[tabKey]?._key === key) return
try {
const data = await performanceStore.fetchGroupedFilterOptions(params, { force })
if (backendGroupedFilterOptionsKey(tabKey) !== key) return
backendGroupedFilterOptions.value = {
...backendGroupedFilterOptions.value,
[tabKey]: {
_key: key,
options: normalizeBackendGroupedFilterOptions(data)
}
}
} catch (err) {
console.warn('product performance grouped filter options failed', err)
}
}
function normalizeBackendGroupedFilterOptions (data) {
const out = {}
const source = data && typeof data === 'object' ? data : {}
for (const [field, values] of Object.entries(source)) {
if (!Array.isArray(values)) continue
const seen = new Map()
for (const raw of values) {
const value = normalizeBackendFilterValue(field, raw)
if (!value || seen.has(value)) continue
seen.set(value, {
value,
label: backendFilterOptionLabel(field, value)
})
}
out[field] = Array.from(seen.values())
.sort((a, b) => String(a.label).localeCompare(String(b.label), 'tr', { numeric: true }))
}
return out
}
function normalizeBackendFilterValue (name, value) {
const text = String(value || '').trim()
if (name === 'performance_bucket') {
return bucketOptions.find(option => option.value === text || option.label === text)?.value || text
}
return text
}
function backendFilterOptionLabel (name, value) {
if (name === 'performance_bucket') return bucketLabel(value)
return String(value || '').trim()
}
function backendGroupedFilterState (tabKey) {
const out = {}
const columns = filterColumnsForTableKey(tabKey)
for (const col of columns) {
const name = col?.name || ''
if (!isColumnFilterable(name)) continue
const selected = selectedColumnFilters(tabKey, name)
if (!selected.length) continue
if (!backendGroupedFilterFields.has(name)) {
continue
}
out[name] = selected.map(value => normalizeBackendFilterValue(name, value)).filter(Boolean)
}
return { filters: out, unsupported: false }
}
function shouldUseBackendGroupedRows (tabKey) {
return backendGroupedSupportedTab(tabKey) && !backendGroupedFilterState(tabKey).unsupported
}
function tableFilterSource (tableKey) {
if (backendGroupedSupportedTab(tableKey)) {
return { rows: backendGroupedRows.value[tableKey] || [], columns: filterColumnsForTableKey(tableKey) }
}
if (tableKey === 'general') return { rows: generalRows.value, columns: generalColumns }
if (tableKey === 'markets') return { rows: marketRows.value, columns: marketColumns }
if (tableKey === 'countries') return { rows: countryRows.value, columns: countryColumns }
if (tableKey === 'customers') return { rows: customerRows.value, columns: customerColumns }
return null
}
function columnsForTableKey (tabKey) {
if (tabKey === 'products') return visibleProductColumns.value
if (tabKey === 'product_detail') return visibleProductDetailColumns.value
if (tabKey === 'general') return visibleGeneralColumns.value
if (tabKey === 'order_product_customers') return orderProductCustomerColumns
if (tabKey === 'order_market_details') return orderMarketDetailColumns
if (tabKey === 'idle') return idleColumns
if (tabKey === 'markets') return marketColumns
if (tabKey === 'countries') return countryColumns
if (tabKey === 'customers') return customerColumns
if (salesBreakdownTabKeys.includes(tabKey)) return visibleSalesBreakdownColumns.value
return productColumns.value
}
function filterColumnsForTableKey (tabKey) {
if (tabKey === 'products') return productColumns.value
if (tabKey === 'product_detail') return productDetailColumns.value
if (tabKey === 'general') return generalColumns
if (tabKey === 'order_product_customers') return orderProductCustomerColumns
if (tabKey === 'order_market_details') return orderMarketDetailColumns
if (tabKey === 'idle') return idleColumns
if (tabKey === 'markets') return marketColumns
if (tabKey === 'countries') return countryColumns
if (tabKey === 'customers') return customerColumns
if (salesBreakdownTabKeys.includes(tabKey)) return visibleSalesBreakdownColumns.value
return productColumns.value
}
function collectGroupKeys (out, sourceRows, level, parentKeys, levels = groupLevels, maxLevel = levels.length - 1) {
if (out.length >= maxBulkExpandKeys) return
if (level >= levels.length) return
const groupDef = levels[level]
const grouped = new Map()
for (const row of sourceRows) {
const value = normalizeGroupValue(rawProductCellValue(row, groupDef.key))
if (!grouped.has(value)) grouped.set(value, [])
grouped.get(value).push(row)
}
Array.from(grouped.entries())
.sort((a, b) => a[0].localeCompare(b[0], 'tr'))
.forEach(([value, groupRows]) => {
if (out.length >= maxBulkExpandKeys) return
if (shouldSkipGroupValue(groupDef.key, value)) {
collectGroupKeys(out, groupRows, level + 1, parentKeys, levels, maxLevel)
return
}
const key = [...parentKeys, `${groupDef.key}:${value}`].join('|')
out.push(key)
if (level < maxLevel) {
collectGroupKeys(out, groupRows, level + 1, [...parentKeys, `${groupDef.key}:${value}`], levels, maxLevel)
}
})
}
function sortProductLeafRows (sourceRows) {
return [...sourceRows].sort((a, b) => {
const colorCmp = String(a?.color_code || '').localeCompare(String(b?.color_code || ''), 'tr', { numeric: true })
if (colorCmp !== 0) return colorCmp
const yakaCmp = String(a?.yaka_kodu || '').localeCompare(String(b?.yaka_kodu || ''), 'tr', { numeric: true })
if (yakaCmp !== 0) return yakaCmp
return String(a?.item_description || '').localeCompare(String(b?.item_description || ''), 'tr', { numeric: true })
})
}
function sortLeafRowsForTable (tableKey, sourceRows) {
const { sortBy, descending } = tableSortState(tableKey)
if (!sortBy) return sortProductLeafRows(sourceRows)
return sortFlatTableRows(sourceRows, sortBy, descending)
}
function tableSortState (tableKey) {
const pagination = tablePagination[tableKey] || {}
return {
sortBy: pagination.sortBy || '',
descending: pagination.descending !== false
}
}
function compareGroupedBucketsForTable (tableKey, left, right, groupDef) {
const [leftLabel, leftRows] = left
const [rightLabel, rightRows] = right
const { sortBy, descending } = tableSortState(tableKey)
if (!sortBy || sortBy === groupDef.key) {
return String(leftLabel).localeCompare(String(rightLabel), 'tr', { numeric: true, sensitivity: 'base' })
}
const leftValue = groupedBucketSortValue(leftRows, sortBy)
const rightValue = groupedBucketSortValue(rightRows, sortBy)
const cmp = compareColumnSortValues(leftValue, rightValue)
if (cmp !== 0) return descending ? -cmp : cmp
return String(leftLabel).localeCompare(String(rightLabel), 'tr', { numeric: true, sensitivity: 'base' })
}
function groupedBucketSortValue (sourceRows, sortBy) {
if (!sourceRows?.length) return 0
if (shouldUseDerivedGroupSortValue(sortBy)) {
return sortValueForColumnName(aggregateGroupFields(sourceRows), sortBy)
}
if (sortBy === 'stock_qty') return distinctVariantStockQty(sourceRows)
if (sortBy === 'idle_cost_usd') return distinctVariantStockCost(sourceRows)
if (sortBy === 'base_price_usd' || sortBy === 'cost_price_usd') return weightedAverageProductCost(sourceRows, sortBy)
if (shouldSumField(sortBy)) return sumRows(sourceRows, sortBy)
if (shouldAverageField(sortBy)) return weightedAverageOrAverage(sourceRows, sortBy, weightFieldForMetric(sortBy))
return sortValueForColumnName(sourceRows[0], sortBy)
}
function shouldUseDerivedGroupSortValue (sortBy) {
return /^(performance_score|stock_turnover|stock_days|avg_price_usd|unit_profit|gross_profit|gross_margin|market_count|customer_count|sales_index)/i.test(String(sortBy || ''))
}
function sortProductGroupedTableRows (tableRows, sortBy, descending) {
const source = Array.isArray(tableRows) ? tableRows : []
if (backendGroupedSupportedTab(activeTab.value)) return source
if (!sortBy || !source.some(row => row?.__group)) {
return sortFlatTableRows(source, sortBy, descending)
}
const roots = []
const stack = []
for (const row of source) {
const node = { row, children: [] }
if (row?.__group) {
const level = Math.max(0, Number(row.level || 0))
stack.length = Math.min(stack.length, level)
const parent = level > 0 ? stack[level - 1] : null
if (parent) parent.children.push(node)
else roots.push(node)
stack[level] = node
stack.length = level + 1
} else {
const parent = stack[stack.length - 1]
if (parent) parent.children.push(node)
else roots.push(node)
}
}
const sorted = []
const appendNodes = (nodes) => {
const ordered = sortNodeSiblings(nodes, sortBy, descending)
for (const node of ordered) {
sorted.push(node.row)
if (node.children.length) appendNodes(node.children)
}
}
appendNodes(roots)
return sorted
}
function sortNodeSiblings (nodes, sortBy, descending) {
const source = mergeDuplicateGroupSiblingNodes(nodes)
if (!sortBy) return source
return source.sort((a, b) => {
const cmp = compareColumnSortValues(sortValueForColumnName(a.row, sortBy), sortValueForColumnName(b.row, sortBy))
return descending ? -cmp : cmp
})
}
function mergeDuplicateGroupSiblingNodes (nodes) {
const out = []
const seen = new Map()
for (const node of nodes) {
if (!node?.row?.__group) {
out.push(node)
continue
}
const key = [
Number(node.row.level || 0),
String(node.row.group_field || ''),
String(node.row.group_value || node.row.label || '')
].join('|')
const existing = seen.get(key)
if (existing) {
existing.children.push(...node.children)
continue
}
seen.set(key, node)
out.push(node)
}
return out
}
function sortFlatTableRows (tableRows, sortBy, descending) {
if (!sortBy) return [...tableRows]
return [...tableRows].sort((a, b) => {
const cmp = compareColumnSortValues(sortValueForColumnName(a, sortBy), sortValueForColumnName(b, sortBy))
return descending ? -cmp : cmp
})
}
function sortValueForColumnName (row, columnName) {
const columns = [
...(productColumns.value || []),
...idleColumns,
...orderProductCustomerColumns,
...orderMarketDetailColumns,
...(visibleSalesBreakdownColumns.value || [])
]
const col = columns.find(item => item.name === columnName || item.field === columnName) || { name: columnName, field: columnName }
return sortValueForColumn(row, col)
}
function makeGroupRow (key, level, groupDef, value, groupRows) {
const imageSource = groupRows.find(row => row?.product_code) || {}
const aggregate = aggregateGroupFields(groupRows, groupDef.key)
const colorSummary = groupDef.key === 'color_code' ? value : ''
const yakaSummary = groupDef.key === 'yaka_kodu' ? value : ''
const colorDescriptionSummary = groupDef.key === 'color_code' ? distinctSummary(groupRows, 'color_description') : ''
return {
__group: true,
row_key: `group|${key}`,
key,
level,
group_field: groupDef.key,
group_value: value,
label: colorYakaGroupLabel(groupDef.key, value, colorSummary, yakaSummary),
count: groupRows.length,
image_product_code: imageSource.product_code || '',
image_color_code: imageSource.color_code || '',
image_color_description: imageSource.color_description || '',
image_yaka_kodu: imageSource.yaka_kodu || '',
product_code: groupDef.key === 'product_code' ? value : '',
color_code: colorSummary,
color_description: colorDescriptionSummary,
yaka_kodu: yakaSummary,
item_description: groupDef.key === 'item_description' ? value : '',
kategori: groupDef.key === 'kategori' ? value : '',
urun_ilk_grubu: groupDef.key === 'urun_ilk_grubu' ? value : '',
askili_yan: groupDef.key === 'askili_yan' ? value : '',
urun_ana_grubu: groupDef.key === 'urun_ana_grubu' ? value : '',
urun_alt_grubu: groupDef.key === 'urun_alt_grubu' ? value : '',
market_key: groupDef.key === 'market_key' ? value : '',
country: groupDef.key === 'country' ? value : '',
customer_segment: groupDef.key === 'customer_segment' ? value : '',
customer_code: groupDef.key === 'customer_code' ? value : '',
customer_name: groupDef.key === 'customer_name' ? value : '',
stock_qty: sumRows(groupRows, 'stock_qty'),
sales_qty_90d: sumRows(groupRows, 'sales_qty_90d'),
sales_qty_180d: sumRows(groupRows, 'sales_qty_180d'),
sales_qty_365d: sumRows(groupRows, 'sales_qty_365d'),
sales_qty_total: sumRows(groupRows, 'sales_qty_total'),
sales_usd_90d: sumRows(groupRows, 'sales_usd_90d'),
sales_usd_180d: sumRows(groupRows, 'sales_usd_180d'),
sales_usd_365d: sumRows(groupRows, 'sales_usd_365d'),
sales_usd_total: sumRows(groupRows, 'sales_usd_total'),
gross_profit_usd_90d: sumRows(groupRows, 'gross_profit_usd_90d'),
gross_profit_usd_180d: sumRows(groupRows, 'gross_profit_usd_180d'),
gross_profit_usd_total: sumRows(groupRows, 'gross_profit_usd_total'),
market_count_90d: distinctSpreadCount(groupRows, 'market_count_90d'),
customer_count_90d: distinctSpreadCount(groupRows, 'customer_count_90d'),
market_count_total: distinctSpreadCount(groupRows, 'market_count_total'),
customer_count_total: distinctSpreadCount(groupRows, 'customer_count_total'),
avg_price_usd_90d: weightedAverage(groupRows, 'sales_usd_90d', 'sales_qty_90d'),
avg_price_usd_180d: weightedAverage(groupRows, 'sales_usd_180d', 'sales_qty_180d'),
avg_price_usd_365d: weightedAverage(groupRows, 'sales_usd_365d', 'sales_qty_365d'),
avg_price_usd_total: weightedAverage(groupRows, 'sales_usd_total', 'sales_qty_total'),
base_price_usd: weightedAverageProductCost(groupRows, 'base_price_usd'),
cost_price_usd: weightedAverageProductCost(groupRows, 'cost_price_usd'),
unit_profit_base_90d: weightedAverageOrAverage(groupRows, 'unit_profit_base_90d', 'sales_qty_90d'),
unit_profit_cost_90d: weightedAverageOrAverage(groupRows, 'unit_profit_cost_90d', 'sales_qty_90d'),
unit_profit_base_180d: weightedAverageOrAverage(groupRows, 'unit_profit_base_180d', 'sales_qty_180d'),
unit_profit_cost_180d: weightedAverageOrAverage(groupRows, 'unit_profit_cost_180d', 'sales_qty_180d'),
unit_profit_base_total: weightedAverageOrAverage(groupRows, 'unit_profit_base_total', 'sales_qty_total'),
unit_profit_cost_total: weightedAverageOrAverage(groupRows, 'unit_profit_cost_total', 'sales_qty_total'),
gross_margin_base_90d: marginFromRows(groupRows, 'sales_usd_90d', 'sales_qty_90d', 'base_price_usd'),
gross_margin_cost_90d: marginFromRows(groupRows, 'sales_usd_90d', 'sales_qty_90d', 'cost_price_usd'),
gross_margin_base_180d: marginFromRows(groupRows, 'sales_usd_180d', 'sales_qty_180d', 'base_price_usd'),
gross_margin_cost_180d: marginFromRows(groupRows, 'sales_usd_180d', 'sales_qty_180d', 'cost_price_usd'),
gross_margin_base_total: marginFromRows(groupRows, 'sales_usd_total', 'sales_qty_total', 'base_price_usd'),
gross_margin_cost_total: marginFromRows(groupRows, 'sales_usd_total', 'sales_qty_total', 'cost_price_usd'),
gross_margin_90d: ratio(sumRows(groupRows, 'gross_profit_usd_90d'), sumRows(groupRows, 'sales_usd_90d')),
gross_margin_180d: ratio(sumRows(groupRows, 'gross_profit_usd_180d'), sumRows(groupRows, 'sales_usd_180d')),
stock_days_90d: weightedAverageOrAverage(groupRows, 'stock_days_90d', 'stock_qty'),
stock_days_180d: weightedAverageOrAverage(groupRows, 'stock_days_180d', 'stock_qty'),
stock_days_total: weightedAverageOrAverage(groupRows, 'stock_days_total', 'stock_qty'),
sales_index_90d: averageRows(groupRows, 'sales_index_90d'),
sales_index_total: averageRows(groupRows, 'sales_index_total'),
performance_bucket: dominantValue(groupRows, 'performance_bucket'),
...aggregate,
performance_score: Object.prototype.hasOwnProperty.call(aggregate, 'performance_score')
? aggregate.performance_score
: groupPerformanceScore(aggregate, groupDef.key),
recommendation: dominantValue(groupRows, 'recommendation') || `${formatNumber(groupRows.length, 0)} satır`
}
}
function aggregateGroupFields (sourceRows, groupField = '') {
const out = {}
const fields = new Set()
for (const row of sourceRows) {
Object.keys(row || {}).forEach(key => fields.add(key))
}
for (const field of fields) {
if (field === 'row_key' || field === 'key') continue
if (/^gross_margin(_base|_cost)?(_|$)/i.test(field)) continue
if (field === 'stock_qty' || /^avg_stock_/i.test(field)) {
out[field] = distinctVariantNumber(sourceRows, field)
continue
}
if (field === 'idle_cost_usd') {
out[field] = distinctVariantStockCost(sourceRows)
continue
}
if (field === 'base_price_usd' || field === 'cost_price_usd') {
out[field] = weightedAverageProductCost(sourceRows, field)
continue
}
if (isDistinctSpreadCountField(field)) {
out[field] = distinctSpreadCount(sourceRows, field)
continue
}
if (shouldSumField(field)) {
out[field] = sumRows(sourceRows, field)
} else if (shouldAverageField(field)) {
out[field] = weightedAverageOrAverage(sourceRows, field, weightFieldForMetric(field))
}
}
applyDerivedGroupMetrics(out, sourceRows, groupField)
return out
}
function shouldSumField (field) {
return /^(sales_qty_|sales_usd_|avg_daily_sales_|gross_profit_|invoice_count_|market_count_|customer_count_|product_group_count_|product_count_)/i.test(field) ||
/(_qty|_usd|_count|_value_usd|line_count|invoice_count|order_count|product_count|market_count|customer_count|overdue_qty|stock_qty|net_stock_after_order|idle_cost_usd)$/i.test(field)
}
function shouldAverageField (field) {
if (/^avg_daily_sales_/i.test(field)) return false
return /^(avg_|unit_|base_price|cost_price|gross_margin|expected_margin|sales_index|performance_score|customer_score|stock_days|stock_turnover)/i.test(field) ||
/(_price_usd|_margin|_index|_days)$/i.test(field)
}
function weightFieldForMetric (field) {
if (field.startsWith('stock_turnover')) return `sales_qty_${countPeriodSuffix(field)}`
if (field.includes('_180d')) return 'sales_qty_180d'
if (field.includes('_365d')) return 'sales_qty_365d'
if (field.includes('_total')) return 'sales_qty_total'
if (field.includes('order') || field.includes('expected_')) return 'order_qty'
if (field.includes('stock')) return 'stock_qty'
return 'sales_qty_90d'
}
function applyDerivedGroupMetrics (out, sourceRows, groupField = '') {
Object.assign(out, normalizeProductCostFields(out))
const isGroup = Boolean(String(groupField || '').trim())
const preservedCustomerScores = {}
const preservedProductScores = {}
const preservedStockTurnovers = {}
for (const suffix of ['90d', '180d', '365d', 'total']) {
if (Object.prototype.hasOwnProperty.call(out, `customer_score_${suffix}`)) {
preservedCustomerScores[suffix] = Number(out[`customer_score_${suffix}`] || 0)
}
if (Object.prototype.hasOwnProperty.call(out, `performance_score_${suffix}`)) {
preservedProductScores[suffix] = Number(out[`performance_score_${suffix}`] || 0)
}
if (isGroup && Object.prototype.hasOwnProperty.call(out, `stock_turnover_${suffix}`)) {
preservedStockTurnovers[suffix] = Number(out[`stock_turnover_${suffix}`] || 0)
}
}
if (
Object.prototype.hasOwnProperty.call(out, 'performance_score') &&
!Object.prototype.hasOwnProperty.call(preservedProductScores, '90d')
) {
preservedProductScores['90d'] = Number(out.performance_score || 0)
}
for (const suffix of ['90d', '180d', '365d', 'total']) {
const sales = Number(out[`sales_usd_${suffix}`] || 0)
const qty = Number(out[`sales_qty_${suffix}`] || 0)
const stockQty = Number(out.stock_qty || 0)
const avgStock = Number(out[`avg_stock_${suffix}`] || 0)
const turnoverBase = avgStock > 0 ? avgStock : (isGroup ? 0 : stockQty)
const days = productPerformancePeriodDays(out, suffix)
const avgDaily = days > 0 ? qty / days : Number(out[`avg_daily_sales_${suffix}`] || 0)
if (days > 0) out[`avg_daily_sales_${suffix}`] = avgDaily
if (avgDaily > 0 && turnoverBase > 0) out[`stock_days_${suffix}`] = turnoverBase / avgDaily
else if (qty <= 0 && stockQty > 0) out[`stock_days_${suffix}`] = 9999
else if (!Object.prototype.hasOwnProperty.call(out, `stock_days_${suffix}`)) out[`stock_days_${suffix}`] = 0
out[`stock_turnover_${suffix}`] = annualizedStockTurnover(qty, turnoverBase, days)
if (Object.prototype.hasOwnProperty.call(preservedStockTurnovers, suffix)) {
out[`stock_turnover_${suffix}`] = preservedStockTurnovers[suffix]
}
if (qty > 0) out[`avg_price_usd_${suffix}`] = sales / qty
const { costPrice, basePrice } = periodCostPair(out, suffix)
out[`base_price_usd_${suffix}`] = basePrice
out[`cost_price_usd_${suffix}`] = costPrice
if (qty > 0) {
const avgPrice = sales / qty
out[`unit_profit_base_${suffix}`] = avgPrice - basePrice
out[`unit_profit_cost_${suffix}`] = avgPrice - costPrice
out[`gross_profit_base_usd_${suffix}`] = sales - qty * basePrice
out[`gross_profit_cost_usd_${suffix}`] = sales - qty * costPrice
out[`gross_profit_usd_${suffix}`] = out[`gross_profit_cost_usd_${suffix}`]
} else {
out[`unit_profit_base_${suffix}`] = 0
out[`unit_profit_cost_${suffix}`] = 0
out[`gross_profit_base_usd_${suffix}`] = 0
out[`gross_profit_cost_usd_${suffix}`] = 0
out[`gross_profit_usd_${suffix}`] = 0
}
out[`gross_margin_base_${suffix}`] = sales > 0 ? Number(out[`gross_profit_base_usd_${suffix}`] || 0) / sales : 0
out[`gross_margin_cost_${suffix}`] = sales > 0 ? Number(out[`gross_profit_cost_usd_${suffix}`] || 0) / sales : 0
out[`gross_margin_${suffix}`] = sales > 0 ? Number(out[`gross_profit_usd_${suffix}`] || 0) / sales : 0
}
const orderUsd = Number(out.order_usd || 0)
if (orderUsd > 0) {
if (out.expected_profit_base_usd !== undefined) out.expected_margin_base = Number(out.expected_profit_base_usd || 0) / orderUsd
if (out.expected_profit_cost_usd !== undefined) out.expected_margin_cost = Number(out.expected_profit_cost_usd || 0) / orderUsd
}
const orderQty = Number(out.order_qty || 0)
if (orderQty > 0) out.avg_order_price_usd = Number(out.order_usd || 0) / orderQty
if (out.net_stock_after_order !== undefined || orderQty > 0) {
out.net_stock_after_order = Number(out.stock_qty || 0) - orderQty
}
if (sourceRows.some(row => row.performance_bucket)) {
out.performance_bucket = dominantValue(sourceRows, 'performance_bucket')
}
for (const suffix of ['90d', '180d', '365d', 'total']) {
out[`customer_score_${suffix}`] = Object.prototype.hasOwnProperty.call(preservedCustomerScores, suffix)
? preservedCustomerScores[suffix]
: customerSalesPeriodScore(periodMetricSource(out, suffix))
out[`performance_score_${suffix}`] = Object.prototype.hasOwnProperty.call(preservedProductScores, suffix)
? preservedProductScores[suffix]
: productSalesPeriodScore(productPeriodMetricSource(out, suffix))
}
out.performance_score = Number(out.performance_score_90d || 0)
}
function productPerformancePeriodDays (row, suffix) {
if (suffix === '90d') return 90
if (suffix === '180d') return 180
if (suffix === '365d') return 360
if (suffix !== 'total') return 0
const start = parseProductPerformanceDate(row?.period_start) || new Date(Date.UTC(2022, 0, 1))
const end = parseProductPerformanceDate(row?.period_end) || parseProductPerformanceDate(row?.kpi_date)
if (!start || !end || end < start) return 0
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
}
function parseProductPerformanceDate (value) {
const text = String(value || '').trim().slice(0, 10)
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return null
const [year, month, day] = text.split('-').map(Number)
return new Date(Date.UTC(year, month - 1, day))
}
function groupPerformanceScore (row, groupField = '') {
if (Number(row?.order_qty || 0) > 0 || Number(row?.order_usd || 0) > 0) return orderGroupPerformanceScore(row)
if (['customer_code', 'customer_name'].includes(groupField)) return customerSalesGroupPerformanceScore(row)
return salesGroupPerformanceScore(row)
}
function salesGroupPerformanceScore (row) {
return productSalesPeriodScore(productPeriodMetricSource(row, '90d'))
}
function productSalesPeriodScore (row) {
const suffix = row?.suffix || '90d'
const periodDays = productPerformancePeriodDays(row, suffix)
return productScore100({
suffix,
periodDays,
salesUSD: Number(row?.sales_usd_90d || 0),
salesIndex: Number(row?.sales_index_90d || 0),
margin: Number(row?.gross_margin_cost_90d ?? row?.gross_margin_90d ?? 0),
stockTurnover: Number(row?.stock_turnover_90d || 0),
marketCount: Number(row?.market_count_90d || 0),
customerCount: Number(row?.customer_count_90d || 0)
})
}
function productPeriodMetricSource (row, suffix) {
const salesIndex = Number(row?.[`sales_index_${suffix}`] || 0)
const qty = Number(row?.[`sales_qty_${suffix}`] || 0)
const stockQty = Number(row?.stock_qty || 0)
const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0)
const isGroup = Boolean(row?.__group || String(row?.group_field || '').trim())
const turnoverBase = avgStock > 0 ? avgStock : (isGroup ? 0 : stockQty)
const turnoverKey = `stock_turnover_${suffix}`
const hasTurnover = Object.prototype.hasOwnProperty.call(row || {}, turnoverKey)
return {
suffix,
sales_index_90d: salesIndex,
sales_usd_90d: Number(row?.[`sales_usd_${suffix}`] || 0),
gross_margin_cost_90d: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0),
gross_margin_90d: Number(row?.[`gross_margin_${suffix}`] ?? 0),
sales_qty_90d: qty,
stock_turnover_90d: hasTurnover ? Number(row?.[turnoverKey] || 0) : annualizedStockTurnover(qty, turnoverBase, productPerformancePeriodDays(row, suffix)),
market_count_90d: periodCount(row, 'market_count', suffix),
customer_count_90d: periodCount(row, 'customer_count', suffix),
stock_qty: stockQty
}
}
function customerSalesGroupPerformanceScore (row) {
const periodToScore = {
90: Number(row?.customer_score_90d || 0),
180: Number(row?.customer_score_180d || 0),
360: Number(row?.customer_score_365d || 0),
total: Number(row?.customer_score_total || 0)
}
const selected = selectedPeriods.value.length ? selectedPeriods.value : ['90', '180', '360', 'total']
const scores = selected.map(period => periodToScore[period]).filter(score => Number.isFinite(score) && score > 0)
if (!scores.length) return 0
return scores.reduce((sum, score) => sum + score, 0) / scores.length
}
function customerSalesPeriodScore (row) {
return customerScore100({
suffix: row?.suffix || '90d',
salesUSD: Number(row?.sales_usd || 0),
margin: Number(row?.gross_margin_cost ?? row?.gross_margin ?? 0),
productGroupCount: Number(row?.product_group_count || row?.product_count || 0),
salesQty: Number(row?.sales_qty || 0)
})
}
function periodMetricSource (row, suffix) {
return {
suffix,
sales_usd: Number(row?.[`sales_usd_${suffix}`] || 0),
gross_margin_cost: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0),
gross_margin: Number(row?.[`gross_margin_${suffix}`] ?? 0),
sales_qty: Number(row?.[`sales_qty_${suffix}`] || 0),
product_group_count: periodCount(row, 'product_group_count', suffix),
product_count: Number(row?.product_count || 0)
}
}
function productScore100 ({ suffix = '90d', periodDays = 0, salesUSD = 0, margin = 0, stockTurnover = 0, marketCount = 0, customerCount = 0 } = {}) {
if (Number(salesUSD || 0) <= 0) return 1
const revenue = ratioScore(salesUSD, productRevenueTarget(suffix, periodDays))
return clampScore(
0.30 * marginScore(margin) +
0.20 * ratioScore(stockTurnover, stockTurnoverTarget(suffix)) +
0.20 * revenue +
0.05 * ratioScore(marketCount, marketSpreadTarget(suffix)) +
0.25 * ratioScore(customerCount, customerSpreadTarget(suffix, periodDays))
)
}
function customerScore100 ({ suffix = '90d', salesUSD = 0, margin = 0, productGroupCount = 0, salesQty = 0 } = {}) {
if (Number(salesUSD || 0) <= 0) return 1
return clampScore(
0.35 * ratioScore(salesUSD, customerRevenueTarget(suffix)) +
0.30 * marginScore(margin) +
0.20 * ratioScore(productGroupCount, 8) +
0.15 * ratioScore(salesQty, customerQtyTarget(suffix))
)
}
function ratioScore (value, target) {
const n = Number(value || 0)
const t = Number(target || 0)
if (!Number.isFinite(n) || !Number.isFinite(t) || n <= 0 || t <= 0) return 0
return Math.min(100, Math.max(0, n) * 100 / t)
}
function marginScore (margin) {
return ratioScore(Math.max(0, Number(margin || 0)), 0.45)
}
function clampScore (score) {
const n = Number(score || 0)
if (!Number.isFinite(n) || n <= 0) return 0
return Math.round(Math.min(100, n) * 10000) / 10000
}
function productRevenueTarget (suffix, periodDays = 0) {
if (suffix === '180d') return 50000
if (suffix === '365d') return 100000
if (suffix === 'total') return 50000 * totalPeriodMultiplier(periodDays)
return 25000
}
function customerRevenueTarget (suffix) {
if (suffix === '180d') return 50000
if (suffix === '365d') return 100000
if (suffix === 'total') return 300000
return 25000
}
function customerQtyTarget (suffix) {
if (suffix === '180d') return 1000
if (suffix === '365d') return 2000
if (suffix === 'total') return 6000
return 500
}
const STOCK_TURNOVER_YEAR_DAYS = 360
function annualizedStockTurnover (salesQty, avgStock, periodDays) {
const qty = Number(salesQty || 0)
const stock = Number(avgStock || 0)
const days = Number(periodDays || 0)
if (!Number.isFinite(qty) || !Number.isFinite(stock) || qty <= 0 || stock <= 0) return 0
const raw = qty / stock
return days > 0 ? raw * STOCK_TURNOVER_YEAR_DAYS / days : raw
}
function stockTurnoverTarget () {
return 4
}
function marketSpreadTarget (suffix) {
if (suffix === '90d') return 3
if (suffix === '180d') return 3
if (suffix === '365d') return 6
return 6
}
function customerSpreadTarget (suffix, periodDays = 0) {
if (suffix === '90d') return 8
if (suffix === '180d') return 20
if (suffix === '365d') return 50
return 20 * totalPeriodMultiplier(periodDays)
}
function totalPeriodMultiplier (periodDays = 0) {
let days = Number(periodDays || 0)
if (!Number.isFinite(days) || days <= 0) {
days = productPerformancePeriodDays({ kpi_date: new Date().toISOString().slice(0, 10) }, 'total')
}
return Math.max(1, days / 180)
}
function periodCount (row, prefix, suffix) {
const keyed = Number(row?.[`${prefix}_${suffix}`] || 0)
if (keyed > 0) return keyed
if (suffix === 'total') return Number(row?.[`${prefix}_total`] || 0)
return Number(row?.[`${prefix}_90d`] || row?.[prefix] || 0)
}
function orderGroupPerformanceScore (row) {
const orderUSD = Number(row?.order_usd || 0)
const margin = Number(row?.expected_margin_cost || 0)
const orders = Number(row?.order_count || 0)
const qty = Number(row?.order_qty || 0)
const stockBonus = Number(row?.net_stock_after_order || 0) >= 0 ? 10 : 0
return Math.min(35, orderUSD / 1000) +
Math.min(25, Math.max(0, margin) * 60) +
Math.min(15, orders * 2) +
Math.min(15, qty / 10) +
stockBonus
}
function sumRows (sourceRows, field) {
return sourceRows.reduce((sum, row) => sum + Number(row[field] || 0), 0)
}
function productVariantStockKey (row) {
const productCode = String(row?.product_code || '').trim()
const colorCode = String(row?.color_code || '').trim()
const yakaKodu = String(row?.yaka_kodu || '').trim()
if (!productCode && !colorCode && !yakaKodu) return ''
return `${productCode}|${colorCode}|${yakaKodu}`
}
function distinctVariantStockQty (sourceRows) {
return distinctVariantNumber(sourceRows, 'stock_qty')
}
function distinctVariantNumber (sourceRows, field) {
const seen = new Set()
let total = 0
let keyed = false
for (const row of sourceRows) {
const key = productVariantStockKey(row)
const value = Number(row?.[field] || 0)
if (!key) continue
keyed = true
if (seen.has(key)) continue
seen.add(key)
total += value
}
return keyed ? total : sumRows(sourceRows, field)
}
function distinctVariantStockCost (sourceRows) {
const seen = new Set()
let total = 0
let keyed = false
for (const row of sourceRows) {
const key = productVariantStockKey(row)
if (!key) continue
keyed = true
if (seen.has(key)) continue
seen.add(key)
const stockQty = Number(row?.stock_qty || 0)
const costPrice = Number(row?.cost_price_usd || 0)
total += stockQty * costPrice
}
return keyed ? total : sumRows(sourceRows, 'idle_cost_usd')
}
function distinctCount (sourceRows, field) {
const values = new Set()
for (const row of sourceRows) {
const value = String(row[field] || '').trim()
if (value && value !== '-') values.add(value)
}
return values.size
}
function isDistinctSpreadCountField (field) {
return /^market_count(_|$)/i.test(field) || /^customer_count(_|$)/i.test(field)
}
function countPeriodSuffix (field) {
if (field.includes('_180d')) return '180d'
if (field.includes('_365d')) return '365d'
if (field.includes('_total')) return 'total'
return '90d'
}
function salesUsdFieldForSuffix (suffix) {
return `sales_usd_${suffix}`
}
function distinctSpreadCount (sourceRows, field) {
const suffix = countPeriodSuffix(field)
return /^market_count/i.test(field)
? distinctRevenueMarketCount(sourceRows, suffix, field)
: distinctRevenueCustomerCount(sourceRows, suffix, field)
}
function distinctRevenueMarketCount (sourceRows, suffix, fallbackField) {
const salesField = salesUsdFieldForSuffix(suffix)
const values = new Set()
for (const row of sourceRows) {
if (Number(row?.[salesField] || 0) <= 0) continue
const marketKeys = stringListFromRow(row, `__market_keys_${suffix}`)
for (const key of marketKeys) {
const value = displayMarketName(key)
if (value && value !== 'STOK' && value !== '-') values.add(value)
}
if (marketKeys.length > 0) continue
const value = displayMarketName(row?.market_key)
if (value && value !== 'STOK' && value !== '-') values.add(value)
}
if (values.size > 0) return values.size
return sourceRows.reduce((sum, row) => {
return Number(row?.[salesField] || 0) > 0 ? sum + Number(row?.[fallbackField] || 0) : sum
}, 0)
}
function distinctRevenueCustomerCount (sourceRows, suffix, fallbackField) {
const salesField = salesUsdFieldForSuffix(suffix)
const values = new Set()
for (const row of sourceRows) {
if (Number(row?.[salesField] || 0) <= 0) continue
const customerKeys = stringListFromRow(row, `__customer_keys_${suffix}`)
for (const code of customerKeys) {
if (code && code !== '-') values.add(code)
}
if (customerKeys.length > 0) continue
const code = String(row?.customer_code || '').trim()
if (code && code !== '-') values.add(code)
}
if (values.size > 0) return values.size
return sourceRows.reduce((sum, row) => {
return Number(row?.[salesField] || 0) > 0 ? sum + Number(row?.[fallbackField] || 0) : sum
}, 0)
}
function stringListFromRow (row, field) {
const value = row?.[field]
if (Array.isArray(value)) return value.map(item => String(item || '').trim()).filter(Boolean)
const text = String(value || '').trim()
if (!text) return []
if (text.startsWith('[')) {
try {
const parsed = JSON.parse(text)
return Array.isArray(parsed) ? parsed.map(item => String(item || '').trim()).filter(Boolean) : []
} catch {
return []
}
}
return [text]
}
function distinctSummary (sourceRows, field, fallbackField) {
const values = new Set()
for (const row of sourceRows) {
const value = String(row[field] || (fallbackField ? row[fallbackField] : '') || '').trim()
if (value && value !== '-') values.add(value)
if (values.size > 1) return `${values.size} farklı`
}
return Array.from(values)[0] || ''
}
function distinctTextSummary (current, next) {
const left = String(current || '').trim()
const right = String(next || '').trim()
if (!right || right === 'STOK') return left
if (!left) return right
if (left === right || left === 'Çoklu') return left
return 'Çoklu'
}
function dominantValue (sourceRows, field) {
const counts = new Map()
for (const row of sourceRows) {
const value = String(row[field] || '').trim()
if (!value) continue
counts.set(value, (counts.get(value) || 0) + 1)
}
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || ''
}
function ratio (amount, base) {
const divisor = Number(base || 0)
if (!divisor) return 0
return Number(amount || 0) / divisor
}
function averageRows (sourceRows, field) {
const values = sourceRows.map(row => Number(row[field] || 0)).filter(value => Number.isFinite(value) && value !== 0)
if (!values.length) return 0
return values.reduce((sum, value) => sum + value, 0) / values.length
}
function weightedAverage (sourceRows, amountField, qtyField) {
const qty = sumRows(sourceRows, qtyField)
if (!qty) return 0
return sumRows(sourceRows, amountField) / qty
}
function weightedAverageOrAverage (sourceRows, valueField, qtyField) {
if (/^(performance_score|customer_score)/i.test(valueField)) {
return weightedAverageScore(sourceRows, valueField, scoreSuffixForMetric(valueField))
}
const weightedRows = sourceRows
.map(row => ({
value: Number(row[valueField] || 0),
qty: Number(row[qtyField] || 0)
}))
.filter(row => Number.isFinite(row.value) && Number.isFinite(row.qty) && row.qty > 0)
const qty = weightedRows.reduce((sum, row) => sum + row.qty, 0)
if (qty > 0) {
return weightedRows.reduce((sum, row) => sum + row.value * row.qty, 0) / qty
}
return 0
}
function scoreSuffixForMetric (field) {
if (field.includes('_180d')) return '180d'
if (field.includes('_365d')) return '365d'
if (field.includes('_total')) return 'total'
return '90d'
}
function scoreWeight (row, suffix) {
const salesQty = Number(row?.[`sales_qty_${suffix}`] || 0)
if (salesQty > 0) return salesQty
return 0
}
function weightedAverageScore (sourceRows, valueField, suffix) {
const rows = sourceRows
.filter(row => Object.prototype.hasOwnProperty.call(row || {}, valueField))
.map(row => ({
value: Number(row[valueField] || 0),
weight: scoreWeight(row, suffix)
}))
.filter(row => Number.isFinite(row.value) && Number.isFinite(row.weight))
const weight = rows.reduce((sum, row) => sum + Math.max(0, row.weight), 0)
if (weight > 0) {
return rows.reduce((sum, row) => sum + row.value * Math.max(0, row.weight), 0) / weight
}
return rows.length ? rows.reduce((sum, row) => sum + row.value, 0) / rows.length : 0
}
function weightedAverageProductCost (sourceRows, valueField) {
const salesRows = sourceRows
.map(row => ({
value: Number(row[valueField] || 0),
qty: Number(row.sales_qty_total || 0)
}))
.filter(row => Number.isFinite(row.value) && Number.isFinite(row.qty) && row.value > 0 && row.qty > 0)
const salesQty = salesRows.reduce((sum, row) => sum + row.qty, 0)
if (salesQty > 0) {
return salesRows.reduce((sum, row) => sum + row.value * row.qty, 0) / salesQty
}
const variants = new Map()
for (const row of sourceRows) {
const key = productVariantStockKey(row)
if (!key || variants.has(key)) continue
variants.set(key, row)
}
const averageSource = variants.size > 0 ? Array.from(variants.values()) : sourceRows
const values = averageSource
.map(row => Number(row[valueField] || 0))
.filter(value => Number.isFinite(value) && value > 0)
if (!values.length) return 0
return values.reduce((sum, value) => sum + value, 0) / values.length
}
function marginFromSalesCost (salesUSD, qty, unitCost) {
const sales = Number(salesUSD || 0)
if (!sales) return 0
return (sales - (Number(qty || 0) * Number(unitCost || 0))) / sales
}
function marginFromRows (sourceRows, salesField, qtyField, unitCostField) {
const sales = sumRows(sourceRows, salesField)
if (!sales) return 0
const costAmount = sourceRows.reduce((sum, row) => {
return sum + Number(row[qtyField] || 0) * Number(row[unitCostField] || 0)
}, 0)
return (sales - costAmount) / sales
}
function normalizeGroupValue (value) {
return String(value || '').trim() || '(Boş)'
}
function shouldSkipGroupValue (field, value) {
return false
}
function groupCanExpand (rowOrKey) {
const key = typeof rowOrKey === 'string' ? rowOrKey : rowOrKey?.key
const depth = groupKeyDepth(key)
const tabKey = tabKeyFromGroupKey(key)
const levels = tabGroupLevels[tabKey] || groupLevels
return depth >= 0 && depth < levels.length - 1
}
function isGroupExpanded (key) {
if (!groupCanExpand(key)) return false
const tabKey = tabKeyFromGroupKey(key)
const depth = groupKeyDepth(key)
const level = selectedExpandThroughLevelForTab(tabKey)
return depth >= 0 && level >= depth
}
function groupKeyDepth (key) {
const parts = String(key || '').split('|').filter(Boolean)
return Math.max(-1, parts.length - 2)
}
function tabKeyFromGroupKey (key) {
const first = String(key || '').split('|')[0] || ''
return first.startsWith('tab:') ? first.slice(4) : activeTab.value
}
function selectedExpandThroughLevelForTab (tabKey) {
const levels = tabGroupLevels[tabKey] || groupLevels
const selected = new Set(selectedExpandLevelKeysByTab[tabKey] || [])
const indexes = levels
.map((level, index) => selected.has(level.key) ? index : -1)
.filter(index => index >= 0)
return indexes.length ? Math.max(...indexes) : -1
}
function activeTabPrefix () {
return `tab:${activeTab.value}|`
}
function expandedLevelKeysThrough (level) {
if (level < 0) return []
const keys = []
collectGroupKeys(keys, activeGroupSourceRows.value, 0, [`tab:${activeTab.value}`], activeGroupLevels.value, level)
return keys
}
function replaceActiveTabExpandedGroups (keys) {
const tabPrefix = activeTabPrefix()
const next = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith(tabPrefix))
)
for (const key of keys) next[key] = true
expandedGroups.value = next
}
function setSelectedExpandLevelsThrough (level) {
activeSelectedExpandLevelKeys.value = level < 0
? []
: activeGroupLevels.value.slice(0, level + 1).map(item => item.key)
}
function applyActiveExpansionThroughLevel (level) {
setSelectedExpandLevelsThrough(level)
replaceActiveTabExpandedGroups([])
lastManualExpandedGroupKey.value = ''
scheduleLoadBackendGroupedRows()
}
function toggleGroup (key) {
if (!groupCanExpand(key)) return
const level = groupKeyDepth(key)
applyActiveExpansionThroughLevel(isGroupExpanded(key) ? level - 1 : level)
}
async function toggleAllProductGroups () {
if (allProductGroupsExpanded.value) await collapseAllProductGroups()
else {
detailLevelMenuOpen.value = false
applyActiveExpansionThroughLevel(fullExpandThroughLevel.value)
await nextTick()
}
}
async function collapseAllProductGroups () {
if (bulkExpandLoading.value) return
detailLevelMenuOpen.value = false
applyActiveExpansionThroughLevel(-1)
bulkExpandLoading.value = true
try {
await nextTick()
} finally {
bulkExpandLoading.value = false
}
}
async function expandSelectedProductGroups () {
if (bulkExpandLoading.value) return
detailLevelMenuOpen.value = false
applyActiveExpansionThroughLevel(autoExpandThroughLevel.value)
bulkExpandLoading.value = true
try {
await nextTick()
} finally {
bulkExpandLoading.value = false
}
}
function groupLabelColumnName (row) {
const field = String(row?.group_field || '')
if (field === 'color_code' || field === 'yaka_kodu' || field === 'color_yaka') return 'color_yaka'
return field || 'product_code'
}
function groupDisplayLabel (row) {
const field = String(row?.group_field || '')
if (field === 'color_code' || field === 'yaka_kodu' || field === 'color_yaka') {
return colorYakaValue(row) || row?.label || row?.group_value || ''
}
return row?.label || row?.group_value || ''
}
function groupIndentPx (row) {
return '0px'
}
function hasProductVariantImageKey (row) {
const product = String(row?.image_product_code || row?.product_code || '').trim()
const color = String(row?.image_color_code || row?.color_code || '').trim()
return Boolean(product && color)
}
function canShowProductImage (row) {
return isProductKpiTab.value && hasProductVariantImageKey(row)
}
function groupShowsImage (row) {
return isProductKpiTab.value &&
row?.__group &&
['product_code', 'color_code', 'yaka_kodu', 'color_yaka'].includes(String(row.group_field || '')) &&
hasProductVariantImageKey(row)
}
const groupDimensionFields = new Set(dimensionColumnNames)
function groupRowClasses (row) {
const level = Math.min(Number(row?.level || 0), 5)
const field = String(row?.group_field || 'unknown').replace(/[^a-z0-9_-]/gi, '-').toLowerCase()
return ['group-row', `group-row-level-${level}`, `group-row-field-${field}`]
}
function groupCellClass (colOrName) {
const name = colOrName?.name || colOrName
if (name === 'image') return 'product-image-cell'
if (colOrName?.align === 'right') return 'text-right'
return /(^|_)(score|qty|count|usd|days|turnover|index|margin)$/.test(name) || ['stock_qty', 'net_stock_after_order'].includes(name)
? 'text-right'
: ''
}
function formatGroupCell (row, colOrName) {
const name = colOrName?.name || colOrName
if (name === 'image') return ''
if (name === 'recommendation') return row.recommendation || ''
if (name === 'color_yaka' && ['color_code', 'yaka_kodu', 'color_yaka'].includes(String(row?.group_field || ''))) return colorYakaValue(row)
if (groupDimensionFields.has(name) && name !== row?.group_field) return ''
return formatTableCell(row, colOrName)
}
function withProductMargins (row) {
const next = normalizeProductCostFields(row)
const out = {
...next,
avg_price_usd_365d: Number(next?.sales_qty_365d || 0) > 0 ? Number(next?.sales_usd_365d || 0) / Number(next?.sales_qty_365d || 0) : 0,
stock_turnover_90d: groupedOrExistingStockTurnover(next, '90d', 90),
stock_turnover_180d: groupedOrExistingStockTurnover(next, '180d', 180),
stock_turnover_365d: groupedOrExistingStockTurnover(next, '365d', 360),
stock_turnover_total: groupedOrExistingStockTurnover(next, 'total', productPerformancePeriodDays(next, 'total'))
}
for (const suffix of ['90d', '180d', '365d', 'total']) {
applyPeriodProfitFields(out, suffix)
}
return out
}
function withGeneralMargins (row) {
const out = normalizeProductCostFields(row)
applyPeriodProfitFields(out, 'total')
return {
...out,
stock_turnover_total: groupedOrExistingStockTurnover(out, 'total', productPerformancePeriodDays(out, 'total'))
}
}
function normalizeProductCostFields (row) {
const next = { ...row }
normalizeProductCostPairFields(next, 'cost_price_usd', 'base_price_usd')
for (const suffix of ['90d', '180d', '365d', 'total']) {
normalizeProductCostPairFields(next, `cost_price_usd_${suffix}`, `base_price_usd_${suffix}`)
}
return next
}
function normalizeProductCostPairFields (row, costField, baseField) {
const hasCost = Object.prototype.hasOwnProperty.call(row || {}, costField)
const hasBase = Object.prototype.hasOwnProperty.call(row || {}, baseField)
if (!hasCost && !hasBase) return
let costPrice = Number(row?.[costField] || 0)
let basePrice = Number(row?.[baseField] || 0)
if (costPrice > 0 && basePrice > 0 && costPrice > basePrice) {
const tmp = costPrice
costPrice = basePrice
basePrice = tmp
}
if (hasCost || costPrice > 0) row[costField] = costPrice
if (hasBase || basePrice > 0) row[baseField] = basePrice
}
function periodCostPair (row, suffix) {
const costField = `cost_price_usd_${suffix}`
const baseField = `base_price_usd_${suffix}`
let costPrice = Number(row?.[costField] || 0)
let basePrice = Number(row?.[baseField] || 0)
if (costPrice <= 0) costPrice = Number(row?.cost_price_usd || 0)
if (basePrice <= 0) basePrice = Number(row?.base_price_usd || 0)
if (costPrice > 0 && basePrice > 0 && costPrice > basePrice) {
const tmp = costPrice
costPrice = basePrice
basePrice = tmp
}
return { costPrice, basePrice }
}
function applyPeriodProfitFields (row, suffix) {
const qty = Number(row?.[`sales_qty_${suffix}`] || 0)
const salesUSD = Number(row?.[`sales_usd_${suffix}`] || 0)
const { costPrice, basePrice } = periodCostPair(row, suffix)
row[`base_price_usd_${suffix}`] = basePrice
row[`cost_price_usd_${suffix}`] = costPrice
if (qty > 0) {
row[`avg_price_usd_${suffix}`] = salesUSD / qty
row[`unit_profit_base_${suffix}`] = salesUSD / qty - basePrice
row[`unit_profit_cost_${suffix}`] = salesUSD / qty - costPrice
row[`gross_profit_base_usd_${suffix}`] = salesUSD - qty * basePrice
row[`gross_profit_cost_usd_${suffix}`] = salesUSD - qty * costPrice
row[`gross_profit_usd_${suffix}`] = row[`gross_profit_cost_usd_${suffix}`]
} else {
row[`unit_profit_base_${suffix}`] = 0
row[`unit_profit_cost_${suffix}`] = 0
row[`gross_profit_base_usd_${suffix}`] = 0
row[`gross_profit_cost_usd_${suffix}`] = 0
row[`gross_profit_usd_${suffix}`] = 0
}
row[`gross_margin_base_${suffix}`] = marginFromSalesCost(salesUSD, qty, basePrice)
row[`gross_margin_cost_${suffix}`] = marginFromSalesCost(salesUSD, qty, costPrice)
}
function stockTurnover (salesQty, stockQty, periodDays) {
return annualizedStockTurnover(salesQty, stockQty, periodDays)
}
function existingOrStockTurnover (value, salesQty, stockQty, periodDays) {
const current = Number(value)
return value !== undefined && value !== null && Number.isFinite(current) ? current : stockTurnover(salesQty, stockQty, periodDays)
}
function groupedOrExistingStockTurnover (row, suffix, periodDays) {
const salesQty = Number(row?.[`sales_qty_${suffix}`] || 0)
const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0)
const stockQty = Number(row?.stock_qty || 0)
if (row?.__group) {
const current = Number(row?.[`stock_turnover_${suffix}`])
if (Number.isFinite(current)) return current
if (avgStock > 0) return stockTurnover(salesQty, avgStock, periodDays)
return 0
}
const stockBase = avgStock > 0 ? avgStock : stockQty
return existingOrStockTurnover(row?.[`stock_turnover_${suffix}`], salesQty, stockBase, periodDays)
}
function filterKey (tableKey, name) {
return `${tableKey}:${name}`
}
function isColumnFilterable (name) {
return name !== 'image'
}
function columnFilterOptions (tableKey, name) {
if (backendGroupedSupportedTab(tableKey) && !backendGroupedFilterFields.has(name)) return []
const options = columnFilterOptionMap.value[tableKey]?.[name] || []
const search = String(columnFilterSearch[filterKey(tableKey, name)] || '').trim().toLocaleLowerCase('tr')
if (!search) return options
return options.filter(option => String(option.label || '').toLocaleLowerCase('tr').includes(search))
}
function hasColumnFilter (tableKey, name) {
return selectedColumnFilters(tableKey, name).length > 0
}
function getColumnFilterBadgeValue (tableKey, name) {
return selectedColumnFilters(tableKey, name).length
}
function selectedColumnFilters (tableKey, name) {
const selected = columnFilters[filterKey(tableKey, name)]
return Array.isArray(selected) ? selected : []
}
function hasActiveTableFilters (tableKey) {
const prefix = `${tableKey}:`
return Object.entries(columnFilters).some(([key, value]) => key.startsWith(prefix) && Array.isArray(value) && value.length > 0)
}
function isColumnFilterValueSelected (tableKey, name, value) {
return selectedColumnFilters(tableKey, name).includes(value)
}
function toggleColumnFilterValue (tableKey, name, value) {
runWithFilterBusy(() => {
const key = filterKey(tableKey, name)
const selected = selectedColumnFilters(tableKey, name)
columnFilters[key] = selected.includes(value)
? selected.filter(item => item !== value)
: [...selected, value]
})
}
function clearColumnFilter (tableKey, name) {
runWithFilterBusy(() => {
columnFilters[filterKey(tableKey, name)] = []
})
}
function selectAllColumnFilterOptions (tableKey, name) {
runWithFilterBusy(() => {
columnFilters[filterKey(tableKey, name)] = columnFilterOptions(tableKey, name).map(option => option.value)
})
}
function runWithFilterBusy (apply) {
apply()
syncExpansionAfterFilterChange()
}
function syncExpansionAfterFilterChange () {
if (!backendGroupedSupportedTab(activeTab.value)) return
const level = selectedExpandThroughLevel.value
applyActiveExpansionThroughLevel(level >= 0 ? autoExpandThroughLevel.value : -1)
}
function filterRowsForTable (tableKey, sourceRows, sourceColumns) {
const activeFilters = sourceColumns
.filter(col => isColumnFilterable(col.name))
.map(col => ({
col,
selected: new Set(selectedColumnFilters(tableKey, col.name))
}))
.filter(item => item.selected.size > 0)
if (!activeFilters.length) return sourceRows
return sourceRows.filter(row => activeFilters.every(item => item.selected.has(columnFilterValue(row, item.col))))
}
function columnFilterValue (row, col) {
return String(rawColumnCellValue(row, col) ?? '').trim()
}
function columnFilterLabel (row, col) {
return formattedColumnCellValue(row, col) || columnFilterValue(row, col)
}
function colorYakaValue (row) {
const groupField = String(row?.group_field || '')
const groupValue = String(row?.group_value || row?.label || '').trim()
const color = String(row?.image_color_code || row?.color_code || '').trim()
const colorDescription = String(row?.image_color_description || row?.color_description || '').trim()
const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
const colorLabel = colorDisplayLabel(groupField === 'color_code' ? groupValue || color : color, colorDescription)
if (row?.__group && groupField === 'color_yaka') {
const parts = groupValue.split('/').map(part => part.trim()).filter(Boolean)
const groupColor = parts[0] || ''
const groupYaka = parts.slice(1).join('/')
return [colorDisplayLabel(groupColor, colorDescription), groupYaka].filter(value => value && value !== '-').join('/')
}
if (row?.__group && groupField === 'color_code') return colorLabel
if (row?.__group && groupField === 'yaka_kodu') {
const parentColor = colorLabel && colorLabel !== '-' ? colorLabel : ''
const groupYaka = groupValue && groupValue !== '-' ? groupValue : yaka
return [parentColor, groupYaka].filter(Boolean).join('/')
}
return [colorLabel, yaka].filter(value => value && value !== '-').join('/')
}
function colorDisplayLabel (code, description) {
const colorCode = String(code || '').trim()
const desc = String(description || '').trim()
if (!colorCode) return ''
if (!desc) return colorCode
return `${colorCode}-${desc.toLocaleUpperCase('tr-TR')}`
}
function colorYakaGroupLabel (groupField, groupValue, colorSummary, yakaSummary) {
const field = String(groupField || '')
const value = String(groupValue || '').trim()
if (field === 'color_code') return value
if (field === 'yaka_kodu') {
const color = String(colorSummary || '').trim()
const yaka = value || String(yakaSummary || '').trim()
return [color, yaka].filter(part => part && part !== '-').join('/')
}
return value
}
function productCategoryValue (row) {
return row?.kategori || ''
}
function productFirstGroupValue (row) {
return cleanProductFirstGroupValue(row?.urun_ilk_grubu)
}
function productMainGroupValue (row) {
const direct = String(row?.urun_ana_grubu || '').trim()
if (direct) return direct
if (row?.__group && row.group_field === 'urun_ana_grubu') {
const groupValue = String(row.group_value || row.label || '').trim()
if (groupValue) return groupValue
}
return String(row?.urun_alt_grubu || row?.kategori || '').trim()
}
function cleanOptionalGroupValue (value) {
const text = String(value || '').trim()
return text === '-' ? '' : text
}
function cleanProductFirstGroupValue (value) {
const text = String(value || '').trim()
if (text === '-') return ''
const normalized = text
.toLocaleUpperCase('tr-TR')
.replaceAll('İ', 'I')
.replaceAll('Ş', 'S')
.replaceAll('Ğ', 'G')
.replaceAll('Ü', 'U')
.replaceAll('Ö', 'O')
.replaceAll('Ç', 'C')
return ['YETISKIN', 'YETISKIN/GARSON', 'GARSON'].includes(normalized) ? '' : text
}
function rawProductCellValue (row, name) {
switch (name) {
case 'color_yaka': return colorYakaValue(row)
case 'kategori': return productCategoryValue(row)
case 'urun_ilk_grubu': return productFirstGroupValue(row)
case 'askili_yan': return cleanOptionalGroupValue(row?.askili_yan)
case 'urun_ana_grubu': return productMainGroupValue(row)
case 'market_key': return displayMarketName(row.market_key)
case 'performance_bucket': return bucketLabel(row.performance_bucket)
default: return row[name]
}
}
function rawColumnCellValue (row, col) {
const name = col?.name || col
switch (name) {
case 'color_yaka': return colorYakaValue(row)
case 'kategori': return productCategoryValue(row)
case 'urun_ilk_grubu': return productFirstGroupValue(row)
case 'askili_yan': return cleanOptionalGroupValue(row?.askili_yan)
case 'urun_ana_grubu': return productMainGroupValue(row)
case 'market_key': return displayMarketName(row.market_key)
case 'performance_bucket': return bucketLabel(row.performance_bucket)
case 'is_overdue': return row.is_overdue ? 'Evet' : 'Hayır'
default:
if (row[name] !== undefined && row[name] !== null) return row[name]
if (typeof col?.field === 'function') return col.field(row)
if (typeof col?.field === 'string') return row[col.field]
return ''
}
}
function attachColumnSorts (targetColumns) {
targetColumns.forEach(col => {
if (!col || col.name === 'image') return
col.sortable = col.sortable !== false
col.sort = (_a, _b, rowA, rowB) => compareColumnSortValues(sortValueForColumn(rowA, col), sortValueForColumn(rowB, col))
})
}
function sortValueForColumn (row, col) {
const name = col?.name || col
const raw = rawColumnCellValue(row, col)
const direct = row?.[name]
if (typeof direct === 'number' && Number.isFinite(direct)) return direct
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
if (direct instanceof Date) return direct.getTime()
if (raw instanceof Date) return raw.getTime()
if (typeof direct === 'boolean') return direct ? 1 : 0
if (typeof raw === 'boolean') return raw ? 1 : 0
if (isNumericSortColumn(name, col)) {
const numeric = parseSortNumber(direct ?? raw)
return Number.isFinite(numeric) ? numeric : 0
}
return String(raw ?? '').trim().toLocaleLowerCase('tr-TR')
}
function compareColumnSortValues (left, right) {
const leftNumber = typeof left === 'number' && Number.isFinite(left)
const rightNumber = typeof right === 'number' && Number.isFinite(right)
if (leftNumber && rightNumber) return left - right
return String(left ?? '').localeCompare(String(right ?? ''), 'tr', { numeric: true, sensitivity: 'base' })
}
function isNumericSortColumn (name, col) {
return col?.align === 'right' || isMoneyField(name) || isPercentField(name) || isIntegerField(name) || isDecimalField(name)
}
function parseSortNumber (value) {
if (typeof value === 'number') return value
if (value === null || value === undefined || value === '') return NaN
const text = String(value).trim()
if (!text) return NaN
const normalized = text
.replace(/\s/g, '')
.replace(/[%$\u20ba\u20ac\u00a3]/g, '')
.replace(/\.(?=\d{3}(\D|$))/g, '')
.replace(/,/g, '.')
.replace(/[^\d.-]/g, '')
return Number(normalized)
}
function formattedColumnCellValue (row, col) {
if (typeof col?.field === 'function') return col.field(row)
return formatProductCell(row, col?.name || col)
}
function formatTableCell (row, colOrName) {
const col = typeof colOrName === 'object' ? colOrName : null
const name = col?.name || colOrName
if (name === 'image') return ''
if (name === 'color_yaka') return colorYakaValue(row)
if (['kategori', 'urun_ilk_grubu', 'urun_ana_grubu'].includes(name)) return rawProductCellValue(row, name) || ''
if (name === 'market_key') return displayMarketName(row.market_key)
if (name === 'performance_bucket') return bucketLabel(row.performance_bucket)
if (name === 'is_overdue') return row.is_overdue ? 'Evet' : 'Hayır'
if (isMoneyField(name)) return formatMoney(row[name], 'USD')
if (isPercentField(name)) return formatPercent(row[name])
if (isIntegerField(name)) return formatNumber(row[name], 0)
if (isDecimalField(name)) return formatNumber(row[name], 2)
if (typeof col?.field === 'function') return col.field(row)
return formatProductCell(row, name)
}
function isMoneyField (name) {
return /(^|_)(usd|cost_value_usd|value_usd)$/.test(name) ||
/(price_usd|cost_usd|profit.*usd|sales_usd|order_usd|base_price_usd|cost_price_usd|idle_cost_usd|stock_cost_value_usd|risk_stock_cost_value_usd)/.test(name)
}
function isPercentField (name) {
return /(margin|gross_margin|expected_margin)/.test(name)
}
function isIntegerField (name) {
return /(qty|count|stock_qty|line_count|invoice_count|order_count|product_count|market_count|customer_count|overdue_qty|net_stock_after_order)$/.test(name)
}
function isDecimalField (name) {
return /(days|turnover|index|score|avg_daily)/.test(name)
}
function formatProductCell (row, name) {
switch (name) {
case 'stock_qty':
case 'sales_qty_90d':
case 'sales_qty_180d':
case 'sales_qty_365d':
case 'sales_qty_total':
case 'market_count_90d':
case 'customer_count_90d':
case 'market_count_total':
case 'customer_count_total':
return formatNumber(row[name], 0)
case 'stock_days_90d':
case 'stock_days_180d':
case 'stock_days_total':
case 'stock_turnover_90d':
case 'stock_turnover_180d':
case 'stock_turnover_365d':
case 'stock_turnover_total':
case 'sales_index_90d':
case 'sales_index_total':
case 'performance_score':
return formatNumber(row[name], 2)
case 'sales_usd_90d':
case 'sales_usd_180d':
case 'sales_usd_365d':
case 'sales_usd_total':
case 'avg_price_usd_90d':
case 'avg_price_usd_180d':
case 'avg_price_usd_365d':
case 'avg_price_usd_total':
case 'base_price_usd':
case 'cost_price_usd':
case 'unit_profit_base_90d':
case 'unit_profit_cost_90d':
case 'unit_profit_base_180d':
case 'unit_profit_cost_180d':
case 'unit_profit_base_total':
case 'unit_profit_cost_total':
case 'gross_profit_usd_90d':
case 'gross_profit_usd_180d':
case 'gross_profit_usd_total':
case 'gross_profit_base_usd_90d':
case 'gross_profit_cost_usd_90d':
case 'gross_profit_base_usd_180d':
case 'gross_profit_cost_usd_180d':
case 'gross_profit_base_usd_365d':
case 'gross_profit_cost_usd_365d':
case 'gross_profit_base_usd_total':
case 'gross_profit_cost_usd_total':
return formatMoney(row[name], 'USD')
case 'gross_margin_base_90d':
case 'gross_margin_cost_90d':
case 'gross_margin_base_180d':
case 'gross_margin_cost_180d':
case 'gross_margin_base_365d':
case 'gross_margin_cost_365d':
case 'gross_margin_base_total':
case 'gross_margin_cost_total':
case 'gross_margin_90d':
case 'gross_margin_180d':
return formatPercent(row[name])
case 'kategori':
return productCategoryValue(row)
case 'urun_ilk_grubu':
return productFirstGroupValue(row)
case 'urun_ana_grubu':
return productMainGroupValue(row)
case 'market_key':
return displayMarketName(row.market_key)
default:
return rawProductCellValue(row, name) || ''
}
}
function normalizeRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
return withPeriodScores(withGeneralMargins(withProductMargins({
...row,
row_key: `${product}|${color}|${yaka}|${market}`
})))
}
function withPeriodScores (row) {
const next = { ...row }
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_90d')) next.performance_score_90d = productSalesPeriodScore(productPeriodMetricSource(next, '90d'))
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_180d')) next.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(next, '180d'))
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_365d')) next.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(next, '365d'))
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score_total')) next.performance_score_total = productSalesPeriodScore(productPeriodMetricSource(next, 'total'))
if (!Object.prototype.hasOwnProperty.call(next, 'performance_score')) next.performance_score = next.performance_score_90d
return next
}
function performanceVariantKey (row) {
return [
String(row?.product_code || '').trim(),
String(row?.color_code || '').trim(),
String(row?.yaka_kodu || '').trim(),
displayMarketName(row?.market_key)
].join('|')
}
function generalMetricFields (row) {
return {
period_start: row?.period_start || '',
period_end: row?.period_end || '',
sales_qty_total: Number(row?.sales_qty_total || 0),
sales_usd_total: Number(row?.sales_usd_total || 0),
avg_daily_sales_total: Number(row?.avg_daily_sales_total || 0),
stock_days_total: Number(row?.stock_days_total || 0),
avg_price_usd_total: Number(row?.avg_price_usd_total || 0),
gross_profit_usd_total: Number(row?.gross_profit_usd_total || 0),
gross_margin_total: Number(row?.gross_margin_total || 0),
unit_profit_cost_total: Number(row?.unit_profit_cost_total || 0),
unit_profit_base_total: Number(row?.unit_profit_base_total || 0),
market_count_total: Number(row?.market_count_total || 0),
customer_count_total: Number(row?.customer_count_total || 0),
invoice_count_total: Number(row?.invoice_count_total || 0),
sales_index_total: Number(row?.sales_index_total || 0)
}
}
function normalizeGeneralRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
return withPeriodScores(withGeneralMargins({
...row,
row_key: `general|${product}|${color}|${yaka}|${market}`
}))
}
function normalizeOrderAnalysisRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
return withOrderPeriodScores({
...row,
row_key: `orders|${product}|${color}|${yaka}|${market}`
})
}
function normalizeOrderGroupRow (row) {
const key = String(row?.group_key || '').trim()
const mode = String(row?.breakdown || '').trim()
return withOrderPeriodScores({
...row,
row_key: `order-group|${mode}|${key}|${row?.market_key || ''}|${row?.customer_code || ''}`
})
}
function normalizeOrderProductCustomerRow (row) {
const product = String(row?.product_code || '').trim()
const color = String(row?.color_code || '').trim()
const yaka = String(row?.yaka_kodu || '').trim()
const market = String(row?.market_key || '').trim()
const customer = String(row?.customer_code || '').trim()
return withOrderPeriodScores({
...row,
row_key: `order-product-customer|${product}|${color}|${yaka}|${market}|${customer}`
})
}
function normalizeOrderMarketDetailRow (row) {
return withOrderPeriodScores({
...row,
row_key: `order-market-detail|${row?.market_key || ''}|${row?.customer_code || ''}|${row?.order_number || ''}|${row?.product_code || ''}|${row?.color_code || ''}|${row?.yaka_kodu || ''}`
})
}
function withOrderPeriodScores (row) {
const next = { ...row }
const score = Number(next.performance_score || orderGroupPerformanceScore(next))
next.performance_score_90d = Number(next.performance_score_90d || score)
next.performance_score_180d = Number(next.performance_score_180d || score)
next.performance_score_365d = Number(next.performance_score_365d || score)
next.performance_score_total = Number(next.performance_score_total || score)
next.performance_score = score
return next
}
function normalizeSalesBreakdownRow (row) {
const next = withPeriodScores({ ...row })
next.customer_score_90d = Number(next.customer_score_90d || customerSalesPeriodScore(periodMetricSource(next, '90d')))
next.customer_score_180d = Number(next.customer_score_180d || customerSalesPeriodScore(periodMetricSource(next, '180d')))
next.customer_score_365d = Number(next.customer_score_365d || customerSalesPeriodScore(periodMetricSource(next, '365d')))
next.customer_score_total = Number(next.customer_score_total || customerSalesPeriodScore(periodMetricSource(next, 'total')))
return {
...next,
row_key: `sales-breakdown|${row?.breakdown || ''}|${row?.product_code || ''}|${row?.color_code || ''}|${row?.yaka_kodu || ''}|${row?.country || ''}|${row?.customer_segment || ''}|${row?.market_key || ''}|${row?.customer_code || ''}`
}
}
function orderGroupLabel (row) {
if (row?.breakdown === 'customer') {
return [row.customer_code, row.customer_name].filter(Boolean).join(' - ') || '-'
}
return row?.group_key || row?.market_key || '-'
}
function productImageKey (row) {
if (!hasProductVariantImageKey(row)) return ''
const product = String(row?.image_product_code || row?.product_code || '').trim()
const color = String(row?.image_color_code || row?.color_code || '').trim()
const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
return `${product}|${color}|${yaka}`
}
function productVariantMetricKey (row) {
const product = String(row?.image_product_code || row?.product_code || '').trim()
if (!product) return ''
const color = String(row?.image_color_code || row?.color_code || '').trim()
const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
return `${product}|${color}|${yaka}`
}
function displayMarketName (value) {
const parts = String(value || '').split('|').map(part => part.trim()).filter(Boolean)
return parts.length ? parts[parts.length - 1] : ''
}
function resolveProductImageUrl (item) {
return resolveProductImageUrls(item)[0] || ''
}
function resolveProductImageUrls (item) {
if (!item || typeof item !== 'object') return []
const urls = []
const addURL = value => {
const url = String(value || '').trim()
if (url && !urls.includes(url)) urls.push(url)
}
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
addURL(thumbURL)
const fullURL = String(item.full_url || item.fullUrl || '').trim()
addURL(fullURL)
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) addURL(contentURL)
else if (contentURL.startsWith('/')) addURL(`/api${contentURL}`)
return urls
}
async function fetchProductImagesForRow (row) {
if (!hasProductVariantImageKey(row)) return []
const key = productImageKey(row)
if (imageFailedKeys.value.has(key)) return []
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) {
return imageListByKey.value[key]
}
if (performanceStore.hasImageKey(key)) {
const urls = performanceStore.imageList(key)
imageListByKey.value = { ...imageListByKey.value, [key]: urls }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
return urls
}
const code = String(row?.image_product_code || row?.product_code || '').trim()
if (!code) return []
const color = String(row?.image_color_code || row?.color_code || '').trim()
const yaka = String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
try {
const resp = await api.get('/product-images', {
params: {
code,
dim1: color || undefined,
dim3: yaka || undefined
},
timeout: 60000
})
const list = Array.isArray(resp?.data) ? resp.data : []
const urls = list.flatMap(resolveProductImageUrls).filter(Boolean)
performanceStore.setImageCache(key, urls)
if (urls.length && imageFailedKeys.value.has(key)) {
const nextFailed = new Set(imageFailedKeys.value)
nextFailed.delete(key)
imageFailedKeys.value = nextFailed
}
imageListByKey.value = { ...imageListByKey.value, [key]: urls }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: urls[0] || '' }
return urls
} catch {
performanceStore.setImageCache(key, [])
imageListByKey.value = { ...imageListByKey.value, [key]: [] }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' }
return []
}
}
function getCachedProductImageUrl (row) {
if (!canShowProductImage(row)) return ''
const key = productImageKey(row)
if (imageFailedKeys.value.has(key)) return ''
const url = imageUrlByKey.value[key] || performanceStore.imageUrl(key) || ''
if (!url) queueVisibleProductImage(row)
return url
}
function queueVisibleProductImage (row) {
const key = productImageKey(row)
if (!key || imageLoadingKeys.value.has(key) || imageFailedKeys.value.has(key)) return
if (Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) return
imageLoadingKeys.value = new Set([...imageLoadingKeys.value, key])
void fetchProductImagesForRow(row).finally(() => {
const next = new Set(imageLoadingKeys.value)
next.delete(key)
imageLoadingKeys.value = next
})
}
function markProductImageFailed (row, failedUrl = '') {
const key = productImageKey(row)
if (!key) return
const currentUrl = String(failedUrl || imageUrlByKey.value[key] || performanceStore.imageUrl(key) || '').trim()
const list = imageListByKey.value[key] || performanceStore.imageList(key) || []
const nextList = Array.isArray(list) ? list.filter(url => url && !sameImageUrl(url, currentUrl)) : []
if (nextList.length) {
performanceStore.setImageCache(key, nextList)
imageListByKey.value = { ...imageListByKey.value, [key]: nextList }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: nextList[0] || '' }
return
}
imageFailedKeys.value = new Set([...imageFailedKeys.value, key])
performanceStore.setImageCache(key, [])
imageListByKey.value = { ...imageListByKey.value, [key]: [] }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' }
}
function sameImageUrl (left, right) {
const a = String(left || '').trim()
const b = String(right || '').trim()
if (!a || !b) return false
if (a === b) return true
try {
return new URL(a, window.location.origin).href === new URL(b, window.location.origin).href
} catch {
return a.endsWith(b) || b.endsWith(a)
}
}
function markDialogImageFailed (url) {
const failedUrl = String(url || '').trim()
if (!failedUrl) return
imageDialogUrls.value = imageDialogUrls.value.filter(item => item && item !== failedUrl)
if (imageSlide.value >= imageDialogUrls.value.length) imageSlide.value = 0
if (imageDialogRow.value) markProductImageFailed(imageDialogRow.value, failedUrl)
}
async function primeProductImages (sourceRows) {
if (!isProductKpiTab.value) return
imagePrimeActiveRequests += 1
imagePrimeLoading.value = true
const items = []
const seen = new Set()
try {
for (const row of sourceRows || []) {
if (!canShowProductImage(row)) continue
const key = productImageKey(row)
const code = String(row?.image_product_code || row?.product_code || '').trim()
if (!key || !code || imageFailedKeys.value.has(key) || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key) || performanceStore.hasImageKey(key)) continue
seen.add(key)
items.push({
key,
code,
dim1: String(row?.image_color_code || row?.color_code || '').trim(),
dim3: String(row?.image_yaka_kodu || row?.yaka_kodu || '').trim()
})
if (items.length >= 160) break
}
if (!items.length) return
const batch = await performanceStore.fetchImageBatch(items)
const nextLists = { ...imageListByKey.value }
const nextUrls = { ...imageUrlByKey.value }
for (const item of items) {
const urls = batch?.lists?.[item.key] || performanceStore.imageList(item.key) || []
if (!urls.length) imageFailedKeys.value = new Set([...imageFailedKeys.value, item.key])
nextLists[item.key] = urls
nextUrls[item.key] = imageFailedKeys.value.has(item.key) ? '' : (batch?.urls?.[item.key] || urls[0] || '')
}
imageListByKey.value = nextLists
imageUrlByKey.value = nextUrls
} catch {
await Promise.all(items.slice(0, 24).map(item => fetchProductImagesForRow({
product_code: item.code,
color_code: item.dim1,
yaka_kodu: item.dim3
})))
} finally {
imagePrimeActiveRequests = Math.max(0, imagePrimeActiveRequests - 1)
imagePrimeLoading.value = imagePrimeActiveRequests > 0
}
}
async function openProductImageDialog (row) {
imageDialogRow.value = row
const urls = await fetchProductImagesForRow(row)
imageDialogUrls.value = urls
imageSlide.value = 0
imageDialogTitle.value = String(row?.image_product_code || row?.product_code || '-')
imageDialogSubtitle.value = [
row?.item_description,
row?.image_color_code || row?.color_code,
row?.image_yaka_kodu || row?.yaka_kodu
].filter(Boolean).join(' | ')
imageDialog.value = true
await loadProductDetailRows(row)
}
async function openPerformanceDialog (row) {
performanceDialogRow.value = row
performanceDialogTitle.value = String(row?.product_code || '-')
performanceDialogSubtitle.value = [
row?.item_description,
row?.color_code,
row?.yaka_kodu,
row?.market_key
].filter(Boolean).join(' | ')
performanceDialog.value = true
await loadProductDetailRows(row)
}
async function loadProductDetailRows (row) {
detailLoading.value = true
detailSalesRows.value = []
detailStockSizes.value = []
try {
const params = {
product_code: row?.image_product_code || row?.product_code || '',
color_code: row?.image_color_code || row?.color_code || '',
yaka_kodu: row?.image_yaka_kodu || row?.yaka_kodu || ''
}
if (!params.product_code) return
const [salesResp, stockResp] = await Promise.all([
api.get('/pricing/product-performance/sales-details', { params: { ...params, limit: 500 }, timeout: 60000 }),
api.get('/pricing/product-performance/stock-sizes', { params, timeout: 60000 })
])
detailSalesRows.value = (Array.isArray(salesResp?.data) ? salesResp.data : []).map((item, idx) => ({
...item,
detail_key: `${item.sales_date}|${item.ref_number}|${item.customer_code}|${idx}`
})).sort((a, b) => Number(b.sales_usd || 0) - Number(a.sales_usd || 0))
detailStockSizes.value = Array.isArray(stockResp?.data) ? stockResp.data : []
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün detay analizi alınamadı' })
} finally {
detailLoading.value = false
}
}
function backendGroupedSupportedTab (tabKey = activeTab.value) {
return tabKey === 'products' ||
tabKey === 'product_detail' ||
tabKey === 'idle' ||
tabKey === 'order_product_customers' ||
tabKey === 'order_market_details' ||
salesBreakdownTabKeys.includes(tabKey)
}
function activeExpandedGroupKeys () {
const tabPrefix = `tab:${activeTab.value}|`
const validFields = new Set(activeGroupLevels.value.map(level => level.key))
const maxKeys = maxBackendExpandedKeysForTab(activeTab.value)
const keys = Object.entries(expandedGroups.value)
.filter(([, value]) => value === true)
.map(([key]) => key)
.filter(key => key.startsWith(tabPrefix))
.filter(key => key.split('|').slice(1).every(part => validFields.has(String(part).split(':')[0])))
.sort((a, b) => {
const depthDiff = a.split('|').length - b.split('|').length
if (depthDiff !== 0) return depthDiff
return a.localeCompare(b, 'tr', { numeric: true })
})
const manualKey = lastManualExpandedGroupKey.value
const out = []
if (manualKey && keys.includes(manualKey)) out.push(manualKey)
for (const key of keys) {
if (out.length >= maxKeys) break
if (key !== manualKey) out.push(key)
}
return out
}
function pruneActiveExpandedGroups () {
const tabPrefix = `tab:${activeTab.value}|`
const allowed = new Set(activeExpandedGroupKeys())
const next = {}
for (const [key, value] of Object.entries(expandedGroups.value)) {
if (!value) continue
if (key.startsWith(tabPrefix) && !allowed.has(key)) continue
next[key] = value
}
expandedGroups.value = next
}
function scheduleLoadBackendGroupedRows () {
if (backendGroupedSupportedTab(activeTab.value)) backendGroupedLoading.value = true
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
backendGroupedTimer = window.setTimeout(() => {
void loadBackendGroupedRows()
}, 260)
}
function cleanupTopScrollbarSync () {
if (activeTableMiddleEl) {
activeTableMiddleEl.removeEventListener('scroll', syncTopScrollbarFromTable)
}
if (topScrollbarRef.value) {
topScrollbarRef.value.removeEventListener('scroll', syncTableFromTopScrollbar)
}
if (topScrollbarResizeObserver) {
topScrollbarResizeObserver.disconnect()
topScrollbarResizeObserver = null
}
activeTableMiddleEl = null
}
function updateTopScrollbarSize () {
const outer = topScrollbarRef.value
const inner = topScrollbarInnerRef.value
const tableMiddle = activeTableMiddleEl
if (!outer || !inner || !tableMiddle) return
inner.style.width = `${tableMiddle.scrollWidth}px`
outer.scrollLeft = tableMiddle.scrollLeft
outer.style.display = tableMiddle.scrollWidth > tableMiddle.clientWidth + 2 ? 'block' : 'none'
}
function syncTopScrollbarFromTable () {
if (topScrollbarSyncing || !topScrollbarRef.value || !activeTableMiddleEl) return
topScrollbarSyncing = true
topScrollbarRef.value.scrollLeft = activeTableMiddleEl.scrollLeft
topScrollbarSyncing = false
}
function syncTableFromTopScrollbar () {
if (topScrollbarSyncing || !topScrollbarRef.value || !activeTableMiddleEl) return
topScrollbarSyncing = true
activeTableMiddleEl.scrollLeft = topScrollbarRef.value.scrollLeft
topScrollbarSyncing = false
}
function setupTopScrollbarSync () {
if (typeof document === 'undefined') return
void nextTick(() => {
cleanupTopScrollbarSync()
activeTableMiddleEl = document.querySelector('.product-performance-page .performance-table .q-table__middle')
if (!activeTableMiddleEl || !topScrollbarRef.value) return
activeTableMiddleEl.addEventListener('scroll', syncTopScrollbarFromTable, { passive: true })
topScrollbarRef.value.addEventListener('scroll', syncTableFromTopScrollbar, { passive: true })
if (typeof ResizeObserver !== 'undefined') {
topScrollbarResizeObserver = new ResizeObserver(updateTopScrollbarSize)
topScrollbarResizeObserver.observe(activeTableMiddleEl)
const table = activeTableMiddleEl.querySelector('table')
if (table) topScrollbarResizeObserver.observe(table)
}
updateTopScrollbarSize()
window.setTimeout(updateTopScrollbarSize, 120)
})
}
function switchPerformanceTab (tabName) {
if (!tabName || tabName === activeTab.value || loading.value) return
activeTab.value = tabName
}
async function loadBackendGroupedRows (options = {}) {
const tabKey = activeTab.value
if (!backendGroupedSupportedTab(tabKey)) return
const filterState = backendGroupedFilterState(tabKey)
if (filterState.unsupported) {
backendGroupedLoading.value = false
return
}
if (tabKey === 'product_detail' && !ensureDetailMainGroupSelection()) {
backendGroupedRows.value = {
...backendGroupedRows.value,
product_detail: []
}
backendGroupedLoading.value = false
return
}
void ensureBackendGroupedFilterOptions(tabKey)
pruneActiveExpandedGroups()
backendGroupedLoading.value = true
const filtersKey = JSON.stringify(filterState.filters)
const expandThroughLevel = selectedExpandThroughLevel.value
const sortState = tableSortState(tabKey)
const requestKey = `${tabKey}|${expandThroughLevel}|${selectedDetailMainGroup.value || ''}|${filtersKey}|${sortState.sortBy}|${sortState.descending ? 1 : 0}`
const params = {
mode: tabKey,
groupLevels: activeGroupLevels.value.map(level => level.key),
expandedKeys: activeExpandedGroupKeys(),
expandThroughLevel,
limit: tabKey === 'products' || tabKey === 'product_detail' || tabKey === 'idle' ? productKpiFetchLimit : 50000,
filters: filterState.filters,
sortBy: sortState.sortBy,
descending: sortState.descending
}
if (tabKey === 'product_detail') {
params.urunAnaGrubu = selectedDetailMainGroup.value
}
try {
const startedAt = performance.now()
const rows = await performanceStore.fetchGroupedRows(params, { force: options.force === true })
const currentFilterState = backendGroupedFilterState(activeTab.value)
const currentSortState = tableSortState(activeTab.value)
const currentKey = `${activeTab.value}|${selectedExpandThroughLevel.value}|${selectedDetailMainGroup.value || ''}|${JSON.stringify(currentFilterState.filters)}|${currentSortState.sortBy}|${currentSortState.descending ? 1 : 0}`
if (requestKey !== currentKey) return
const normalizedRows = Array.isArray(rows) ? rows.map(row => normalizeGroupedDisplayRow(tabKey, row)) : []
backendGroupedRows.value = {
...backendGroupedRows.value,
[tabKey]: normalizedRows
}
console.info('[ProductPerformance][ui] grouped applied', {
tab: tabKey,
rows: normalizedRows.length,
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2))
})
} catch (err) {
console.warn('product performance grouped rows failed', err)
} finally {
backendGroupedLoading.value = false
}
}
function normalizeGroupedDisplayRow (tabKey, row) {
const nextRow = { ...row }
normalizeGroupedProductHierarchyValues(nextRow)
return withPeriodScores(withGeneralMargins(withProductMargins(nextRow)))
}
function normalizeGroupedProductHierarchyValues (row) {
row.urun_ilk_grubu = cleanProductFirstGroupValue(row.urun_ilk_grubu)
if (row.__group && row.group_field === 'urun_ilk_grubu') {
const cleanValue = cleanProductFirstGroupValue(row.group_value || row.label || row.urun_ilk_grubu)
row.group_value = cleanValue
row.label = cleanValue
row.urun_ilk_grubu = cleanValue
}
return row
}
function responseRowCount (data) {
if (Array.isArray(data)) return data.length
if (Array.isArray(data?.rows)) return data.rows.length
if (data && typeof data === 'object') return Object.keys(data).length
return 0
}
async function timedProductPerformanceGet (label, url, config = {}) {
const startedAt = performance.now()
console.info('[ProductPerformance][api] start', {
label,
url,
params: config?.params || {}
})
try {
const resp = await api.get(url, config)
console.info('[ProductPerformance][api] done', {
label,
rows: responseRowCount(resp?.data),
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2))
})
return resp
} catch (err) {
console.warn('[ProductPerformance][api] failed', {
label,
elapsedSec: Number(((performance.now() - startedAt) / 1000).toFixed(2)),
message: err?.response?.data || err?.message || err
})
throw err
}
}
function productPerformanceExcelExportTableKey () {
if (isProductKpiTab.value) return activeProductTableKey.value
if (backendGroupedSupportedTab(activeTab.value)) return activeTab.value
return 'products'
}
function buildProductPerformanceExcelExportFilters () {
const tableKey = productPerformanceExcelExportTableKey()
const out = {}
for (const col of filterColumnsForTableKey(tableKey) || []) {
const name = String(col?.name || '').trim()
if (!productPerformanceExcelExportFilterFields.has(name)) continue
const selected = selectedColumnFilters(tableKey, name)
if (selected.length) out[name] = selected
}
if (activeTab.value === 'product_detail') {
const mainGroup = String(selectedDetailMainGroup.value || '').trim()
if (mainGroup && !out.urun_ana_grubu?.includes(mainGroup)) {
out.urun_ana_grubu = [...(out.urun_ana_grubu || []), mainGroup]
}
}
return out
}
function productPerformanceExcelFilename (headers = {}) {
const disposition = String(headers?.['content-disposition'] || headers?.['Content-Disposition'] || '').trim()
const utfMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utfMatch?.[1]) return decodeURIComponent(utfMatch[1].replaceAll('"', '').trim())
const match = disposition.match(/filename="?([^";]+)"?/i)
return match?.[1]?.trim() || `product_performance_color_yaka_${new Date().toISOString().slice(0, 10)}.xlsx`
}
function downloadProductPerformanceExcelBlob (blob, filename) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
async function exportProductPerformanceExcel () {
if (excelExportLoading.value) return
excelExportLoading.value = true
try {
const tableKey = productPerformanceExcelExportTableKey()
const pagination = tablePagination[tableKey] || tablePagination.products || {}
const response = await api.post('/pricing/product-performance/export-excel', {
filters: buildProductPerformanceExcelExportFilters(),
sort_by: pagination.sortBy || '',
descending: pagination.descending !== false
}, {
responseType: 'blob',
timeout: 180000
})
downloadProductPerformanceExcelBlob(response.data, productPerformanceExcelFilename(response.headers || {}))
} catch (err) {
const detail = await extractApiErrorDetail(err)
Notify.create({ type: 'negative', message: detail || 'Excel çıktısı alınamadı' })
} finally {
excelExportLoading.value = false
}
}
async function reload () {
loading.value = true
startLoadingTimer('Rapor verileri hazırlanıyor...')
try {
rows.value = []
detailMainGroupOptionRows.value = []
generalRowsLoaded.value = false
generalRows.value = []
orderProductCustomersLoaded.value = false
orderProductCustomerRows.value = []
orderMarketDetailsLoaded.value = false
orderMarketDetailRows.value = []
orderAnalysisLoaded.value = false
orderAnalysisRows.value = []
orderMarketRows.value = []
orderCustomerRows.value = []
marketRowsLoaded.value = false
marketRows.value = []
countryRowsLoaded.value = false
countryRows.value = []
customerRowsLoaded.value = false
customerRows.value = []
for (const key of salesBreakdownTabKeys) salesBreakdownRows[key] = []
loadingStage.value = 'Ana KPI ve özet yükleniyor...'
const [summaryResp] = await Promise.all([
timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 180000 }),
loadDetailMainGroupOptions(true)
])
summary.value = summaryResp?.data || {}
loadingStage.value = 'Rapor ekranı hazırlanıyor...'
await nextTick()
ensureDetailMainGroupSelection()
applyActiveExpansionThroughLevel(autoExpandThroughLevel.value)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
} finally {
loading.value = false
stopLoadingTimer()
}
}
async function loadDetailMainGroupOptions (force = false) {
const optionRows = await performanceStore.fetchGroupedRows({
mode: 'products',
groupLevels: ['urun_ana_grubu'],
expandThroughLevel: -1,
limit: productKpiFetchLimit
}, { force })
detailMainGroupOptionRows.value = (Array.isArray(optionRows) ? optionRows : [])
.filter(row => row?.__group)
.map(row => {
const normalized = normalizeGroupedDisplayRow('products', row)
const value = productMainGroupValue(normalized) || String(normalized.group_value || normalized.label || '').trim()
return {
value,
label: value || '-',
stock_qty: Number(normalized.stock_qty || 0),
sales_qty_90d: Number(normalized.sales_qty_90d || 0)
}
})
.filter(option => option.value)
}
function startLoadingTimer (stage = '') {
loadingStage.value = stage
loadingStartedAt.value = Date.now()
loadingNow.value = Date.now()
if (loadingTimer) window.clearInterval(loadingTimer)
loadingTimer = window.setInterval(() => {
loadingNow.value = Date.now()
}, 1000)
}
function stopLoadingTimer () {
if (loadingTimer) {
window.clearInterval(loadingTimer)
loadingTimer = null
}
loadingStartedAt.value = 0
loadingNow.value = 0
loadingStage.value = ''
}
async function preloadAllReportTabs () {
const tasks = [
loadGeneralRows(false),
loadOrderProductCustomers(false),
loadOrderMarketDetails(false),
loadMarketRows(false),
loadCountryRows(false),
loadCustomerRows(false),
loadAllSalesBreakdownRows(false)
]
const results = await Promise.allSettled(tasks)
const failed = results.filter(item => item.status === 'rejected')
if (failed.length) {
console.warn('[ProductPerformance][preload] partial failure', failed.map(item => item.reason?.message || item.reason))
}
}
async function loadAllSalesBreakdownRows (showLoading = true) {
if (showLoading) loading.value = true
try {
await Promise.all(salesBreakdownTabKeys.map(key => loadSalesBreakdownRows(key, false)))
} finally {
if (showLoading) loading.value = false
}
}
async function loadSalesBreakdownRows (tabKey, showLoading = true) {
if (!salesBreakdownTabKeys.includes(tabKey)) return
if (Array.isArray(salesBreakdownRows[tabKey]) && salesBreakdownRows[tabKey].length) return
if (showLoading) loading.value = true
try {
const mode = salesBreakdownModes[tabKey] || tabKey
const resp = await timedProductPerformanceGet(`sales:${tabKey}`, '/pricing/product-performance/sales-breakdown', {
params: { mode, limit: reportFetchLimit },
timeout: 180000
})
salesBreakdownRows[tabKey] = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeSalesBreakdownRow)
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Satış kırılım verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadGeneralRows (showLoading = true) {
if (generalRowsLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('general', '/pricing/product-performance/general', { params: { limit: reportFetchLimit }, timeout: 180000 })
const normalizedGeneralRows = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeGeneralRow)
generalRows.value = normalizedGeneralRows
generalRowsLoaded.value = true
const generalByVariant = new Map(normalizedGeneralRows.map(row => [performanceVariantKey(row), generalMetricFields(row)]))
rows.value = rows.value.map(row => {
const generalMetrics = generalByVariant.get(performanceVariantKey(row)) || {}
return Object.keys(generalMetrics).length ? normalizeRow({ ...row, ...generalMetrics }) : row
})
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Genel performans verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadOrderProductCustomers (showLoading = true) {
if (orderProductCustomersLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: reportFetchLimit }, timeout: 180000 })
orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow)
orderProductCustomersLoaded.value = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün müşteri sipariş analizi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadOrderMarketDetails (showLoading = true) {
if (orderMarketDetailsLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: reportFetchLimit }, timeout: 180000 })
orderMarketDetailRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderMarketDetailRow)
orderMarketDetailsLoaded.value = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Piyasa sipariş detay analizi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadMarketRows (showLoading = true) {
if (marketRowsLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('markets', '/pricing/product-performance/markets', { params: { limit: reportFetchLimit }, timeout: 180000 })
marketRows.value = aggregateMarketRowsByDisplayName(Array.isArray(resp?.data) ? resp.data : [])
marketRowsLoaded.value = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Piyasa performans verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
function aggregateMarketRowsByDisplayName (sourceRows) {
const grouped = new Map()
for (const row of sourceRows || []) {
const display = displayMarketName(row?.market_key) || String(row?.market_key || '').trim() || '-'
const current = grouped.get(display) || {
...row,
row_key: `market|${display}`,
market_key: display,
product_count: 0,
star_count: 0,
stock_risk_count: 0,
stock_qty: 0,
stock_cost_value_usd: 0,
risk_stock_cost_value_usd: 0,
sales_qty_90d: 0,
sales_usd_90d: 0,
gross_profit_usd_90d: 0,
_margin_weight: 0,
_margin_total: 0,
_stock_days_weight: 0,
_stock_days_total: 0
}
current.product_count += Number(row?.product_count || 0)
current.star_count += Number(row?.star_count || 0)
current.stock_risk_count += Number(row?.stock_risk_count || 0)
current.stock_qty += Number(row?.stock_qty || 0)
current.stock_cost_value_usd += Number(row?.stock_cost_value_usd || 0)
current.risk_stock_cost_value_usd += Number(row?.risk_stock_cost_value_usd || 0)
current.sales_qty_90d += Number(row?.sales_qty_90d || 0)
current.sales_usd_90d += Number(row?.sales_usd_90d || 0)
current.gross_profit_usd_90d += Number(row?.gross_profit_usd_90d || 0)
const marginWeight = Math.abs(Number(row?.sales_usd_90d || 0))
current._margin_weight += marginWeight
current._margin_total += Number(row?.avg_gross_margin_90d || 0) * marginWeight
const stockDaysWeight = Math.abs(Number(row?.stock_qty || 0))
current._stock_days_weight += stockDaysWeight
current._stock_days_total += Number(row?.avg_stock_days_90d || 0) * stockDaysWeight
grouped.set(display, current)
}
return Array.from(grouped.values())
.map(row => {
row.avg_gross_margin_90d = row._margin_weight > 0 ? row._margin_total / row._margin_weight : 0
row.avg_stock_days_90d = row._stock_days_weight > 0 ? row._stock_days_total / row._stock_days_weight : 0
delete row._margin_weight
delete row._margin_total
delete row._stock_days_weight
delete row._stock_days_total
return row
})
.sort((a, b) => Number(b.risk_stock_cost_value_usd || 0) - Number(a.risk_stock_cost_value_usd || 0) ||
Number(b.stock_cost_value_usd || 0) - Number(a.stock_cost_value_usd || 0) ||
Number(b.sales_usd_90d || 0) - Number(a.sales_usd_90d || 0))
}
async function loadCountryRows (showLoading = true) {
if (countryRowsLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('countries', '/pricing/product-performance/countries', { params: { limit: reportFetchLimit }, timeout: 180000 })
countryRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(row => ({
...row,
country_key: `${row.country || '-'}|${row.customer_segment || '-'}|${row.market_key || '-'}`
}))
countryRowsLoaded.value = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ülke performans verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function loadCustomerRows (showLoading = true) {
if (customerRowsLoaded.value) return
if (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('customers', '/pricing/product-performance/customers', {
params: { limit: reportFetchLimit, breakdown: customerBreakdown.value },
timeout: 180000
})
customerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(row => ({
...row,
customer_key: `${row.breakdown || '-'}|${row.market_key || '-'}|${row.country || '-'}|${row.customer_segment || '-'}|${row.customer_code || '-'}`
}))
customerRowsLoaded.value = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Müşteri performans verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
async function ensureActiveTabData (showLoading = true) {
const tab = activeTab.value
if (tab === 'general') return loadGeneralRows(showLoading)
if (tab === 'markets') return loadMarketRows(showLoading)
if (tab === 'countries') return loadCountryRows(showLoading)
if (tab === 'customers') return loadCustomerRows(showLoading)
if (backendGroupedSupportedTab(tab)) {
scheduleLoadBackendGroupedRows()
}
}
async function loadOrderAnalysis (showLoading = true) {
if (showLoading) loading.value = true
try {
const [productResp, marketResp, customerResp] = await Promise.all([
timedProductPerformanceGet('orders:products', '/pricing/product-performance/orders', { params: { limit: reportFetchLimit }, timeout: 90000 }),
timedProductPerformanceGet('orders:groups:market', '/pricing/product-performance/orders/groups', { params: { limit: reportFetchLimit, breakdown: 'market' }, timeout: 90000 }),
timedProductPerformanceGet('orders:groups:customer', '/pricing/product-performance/orders/groups', { params: { limit: reportFetchLimit, breakdown: 'customer' }, timeout: 90000 })
])
orderAnalysisRows.value = (Array.isArray(productResp?.data) ? productResp.data : []).map(normalizeOrderAnalysisRow)
orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow)
orderCustomerRows.value = (Array.isArray(customerResp?.data) ? customerResp.data : []).map(normalizeOrderGroupRow)
orderAnalysisLoaded.value = true
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Sipariş analiz verisi alınamadı' })
} finally {
if (showLoading) loading.value = false
}
}
function bucketLabel (value) {
return bucketOptions.find(x => x.value === value)?.label || value || '-'
}
function bucketColor (value) {
switch (value) {
case 'YILDIZ_URUN': return 'green-7'
case 'STOKSUZ_TALEP': return 'blue-7'
case 'STOK_RISKI': return 'orange-8'
case 'FIYAT_BASKISI': return 'red-7'
case 'FIYAT_FIRSATI': return 'teal-7'
case 'MALIYET_YOK': return 'blue-grey-7'
default: return 'grey-7'
}
}
watch(activeTab, () => {
void ensureActiveTabData(false).then(() => {
if (backendGroupedSupportedTab(activeTab.value)) applyActiveExpansionThroughLevel(autoExpandThroughLevel.value)
})
setupTopScrollbarSync()
})
watch(activeBackendGroupedSortSignature, () => {
if (!backendGroupedSupportedTab(activeTab.value) || loading.value) return
scheduleLoadBackendGroupedRows()
})
watch(activeDisplayedTableRows, () => {
setupTopScrollbarSync()
}, { flush: 'post' })
watch(detailMainGroupOptions, () => {
ensureDetailMainGroupSelection()
}, { immediate: true })
watch(selectedDetailMainGroup, () => {
if (activeTab.value === 'product_detail') {
void nextTick(() => {
const level = selectedExpandThroughLevel.value
applyActiveExpansionThroughLevel(level >= 0 ? autoExpandThroughLevel.value : -1)
})
}
})
watch(activeDisplayedTableRows, tableRows => {
if (!isProductKpiTab.value) return
const imageRows = tableRows
.filter(row => !row.__group || groupShowsImage(row))
.slice(0, 240)
void primeProductImages(imageRows)
}, { immediate: true })
onMounted(() => {
void reload()
setupTopScrollbarSync()
})
onBeforeUnmount(() => {
cleanupTopScrollbarSync()
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
stopLoadingTimer()
})
</script>
<style scoped>
.product-performance-page {
position: relative;
background: #f6f7f9;
}
.page-busy-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: #f6f7f9;
backdrop-filter: blur(2px);
pointer-events: all;
}
.page-busy-label {
color: #1f2937;
font-size: 13px;
font-weight: 700;
}
.page-busy-time {
color: #5f6b7a;
font-size: 13px;
font-weight: 600;
}
.performance-top-bar {
min-height: 34px;
}
.performance-page-title {
font-size: 14px;
font-weight: 700;
line-height: 1.12;
}
.performance-page-subtitle {
font-size: 10px;
line-height: 1.15;
margin-top: 1px;
}
.performance-top-actions {
min-height: 30px;
}
.performance-top-actions :deep(.q-btn) {
min-height: 26px;
padding: 2px 8px;
font-size: 11px;
line-height: 1.1;
}
.performance-top-actions :deep(.q-btn .q-icon) {
font-size: 16px;
}
.period-selector {
min-height: 26px;
border: 1px solid #d5dce5;
border-radius: 6px;
display: flex;
align-items: center;
}
.period-selector :deep(.q-option-group) {
gap: 4px;
}
.period-selector :deep(.q-checkbox) {
min-height: 22px;
}
.period-selector :deep(.q-checkbox__inner) {
font-size: 26px;
width: 26px;
height: 26px;
}
.period-selector :deep(.q-checkbox__label) {
font-size: 11px;
line-height: 1.1;
}
.performance-tab-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
gap: 4px;
}
.performance-tab-button {
min-height: 24px;
padding: 3px 6px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid #d5dce5;
border-radius: 6px;
background: #fff;
color: #4b5563;
font-size: 10px;
font-weight: 700;
line-height: 1.15;
text-align: center;
cursor: pointer;
}
.performance-tab-button span {
white-space: normal;
overflow-wrap: anywhere;
}
.performance-tab-button.active {
border-color: var(--q-primary);
color: var(--q-primary);
background: #eef6ff;
}
.performance-tab-button:disabled {
cursor: wait;
opacity: 0.62;
}
.main-group-bar {
display: flex;
gap: 6px;
overflow-x: auto;
padding: 6px;
border: 1px solid #d7dde5;
border-radius: 6px;
background: #ffffff;
}
.main-group-button {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 4px 10px;
border: 1px solid #c9d2dd;
border-radius: 6px;
background: #f7f9fb;
color: #26384c;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.main-group-button small {
color: #64748b;
font-size: 10px;
font-weight: 600;
}
.main-group-button.active {
border-color: #1d4ed8;
background: #dbeafe;
color: #173a75;
}
.main-group-button:disabled {
cursor: wait;
opacity: 0.62;
}
.metric-card {
min-height: 38px;
}
.metric-card :deep(.q-card__section) {
padding: 5px 7px;
}
.metric-card :deep(.text-caption) {
font-size: 10px;
line-height: 1.1;
}
.metric-value {
font-size: 14px;
font-weight: 700;
line-height: 1.08;
margin-top: 1px;
}
.detail-level-menu {
width: 230px;
padding: 7px;
}
.performance-table {
max-height: calc(100vh - 160px);
font-size: 11px;
}
.performance-top-scrollbar {
display: none;
height: 14px;
overflow-x: auto;
overflow-y: hidden;
background: #fff;
border: 1px solid #d5dce5;
border-radius: 4px;
}
.performance-top-scrollbar-inner {
height: 1px;
}
.performance-table :deep(.q-table__middle) {
max-height: calc(100vh - 220px);
overflow: auto;
}
.performance-table :deep(.q-table__middle::-webkit-scrollbar:horizontal) {
height: 0;
}
.performance-table :deep(.q-table__bottom) {
display: none;
}
.performance-table :deep(.q-table) {
border-collapse: separate;
border-spacing: 0;
}
.performance-table :deep(.q-table th),
.performance-table :deep(.q-table td) {
border-right: 1px solid #d5dce5;
border-bottom: 1px solid #d5dce5;
vertical-align: middle;
white-space: normal;
padding: 4px 5px;
}
.performance-table :deep(.q-table th) {
height: 42px;
min-height: 42px;
padding: 3px 4px;
}
.performance-table :deep(.q-table tbody tr),
.performance-table :deep(.q-table tbody td) {
height: auto;
min-height: 38px;
}
.performance-table :deep(.q-table tbody td) {
max-width: 320px;
padding-top: 4px;
padding-bottom: 4px;
overflow-wrap: anywhere;
}
.performance-table :deep(.q-table tbody td span:not(.q-badge):not(.q-icon)) {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.25;
}
.performance-table :deep(.q-table th.text-right),
.performance-table :deep(.q-table td.text-right) {
min-width: 98px;
width: 98px;
max-width: none;
white-space: nowrap;
overflow-wrap: normal;
word-break: normal;
}
.performance-table :deep(.q-table td.text-right span:not(.q-badge):not(.q-icon)) {
display: inline-block;
-webkit-line-clamp: unset;
-webkit-box-orient: initial;
max-width: none;
white-space: nowrap;
overflow: visible;
line-height: 1.25;
}
.performance-table :deep(.q-table th.text-right .header-label) {
white-space: normal;
overflow-wrap: anywhere;
}
.performance-table :deep(.q-table th:not(.text-right)),
.performance-table :deep(.q-table td:not(.text-right)) {
white-space: normal;
overflow-wrap: anywhere;
}
.performance-table :deep(.q-table tbody td .q-field),
.performance-table :deep(.q-table tbody td input),
.performance-table :deep(.q-table tbody td textarea) {
min-width: 72px;
max-width: 100%;
}
.performance-table :deep(.q-table tbody td .q-field__control),
.performance-table :deep(.q-table tbody td .q-field__native),
.performance-table :deep(.q-table tbody td .q-field__input) {
min-height: 24px;
font-size: 10px;
line-height: 1.2;
}
.performance-table :deep(.q-table th.color-yaka-cell),
.performance-table :deep(.q-table td.color-yaka-cell) {
width: 170px;
min-width: 170px;
max-width: 190px;
font-size: 10.5px;
}
.performance-table :deep(.q-table th:first-child),
.performance-table :deep(.q-table td:first-child) {
border-left: 1px solid #d5dce5;
}
.performance-table :deep(.q-table thead),
.performance-table :deep(.q-table thead tr),
.performance-table :deep(.q-table th) {
position: sticky;
top: 0;
z-index: 20;
background: #f8fbff;
}
.performance-table :deep(th) {
border-top: 1px solid #d5dce5;
}
.product-breakdown-table :deep(.q-table) {
min-width: 3170px;
table-layout: fixed;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(-n+10)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(-n+10)) {
position: sticky;
z-index: 2;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(-n+10)) {
z-index: 30;
background: #f8fbff;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) {
background: #fff;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(1)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(1)) {
left: 0;
width: 190px;
min-width: 190px;
max-width: 190px;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(2)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(2)) {
left: 190px;
width: 140px;
min-width: 140px;
max-width: 140px;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(3)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(3)) {
left: 330px;
width: 110px;
min-width: 110px;
max-width: 110px;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(4)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(4)) {
left: 440px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(5)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(5)) {
left: 590px;
width: 170px;
min-width: 170px;
max-width: 170px;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(6)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(6)) {
left: 760px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(7)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(7)) {
left: 910px;
width: 130px;
min-width: 130px;
max-width: 130px;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(8)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(8)) {
left: 1040px;
width: 90px;
min-width: 90px;
max-width: 90px;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(9)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(9)) {
left: 1130px;
width: 80px;
min-width: 80px;
max-width: 80px;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(10)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(10)) {
left: 1210px;
width: 130px;
min-width: 130px;
max-width: 130px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
.product-breakdown-table.compact-detail-columns :deep(.q-table) {
min-width: 2450px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(-n+4)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(-n+4)) {
position: sticky;
z-index: 2;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(-n+4)) {
z-index: 30;
background: #f8fbff;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+4)) {
background: #fff;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(1)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(1)) {
left: 0;
width: 86px;
min-width: 86px;
max-width: 86px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(2)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(2)) {
left: 86px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(3)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(3)) {
left: 236px;
width: 112px;
min-width: 112px;
max-width: 112px;
}
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(4)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(4)) {
left: 348px;
width: 132px;
min-width: 132px;
max-width: 132px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
.product-breakdown-table.product-detail-table :deep(.q-table) {
min-width: 2620px;
}
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(n+8)) {
position: static;
left: auto;
z-index: auto;
box-shadow: none;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(n+8)) {
left: auto;
z-index: 20;
box-shadow: none;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(-n+7)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(-n+7)) {
position: sticky;
z-index: 2;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(-n+7)) {
z-index: 30;
background: #f8fbff;
}
.product-breakdown-table.product-detail-table :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+7)) {
background: #fff;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(1)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(1)) {
left: 0;
width: 190px;
min-width: 190px;
max-width: 190px;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(2)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(2)) {
left: 190px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(3)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(3)) {
left: 340px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(4)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(4)) {
left: 490px;
width: 130px;
min-width: 130px;
max-width: 130px;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(5)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(5)) {
left: 620px;
width: 170px;
min-width: 170px;
max-width: 170px;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(6)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(6)) {
left: 790px;
width: 170px;
min-width: 170px;
max-width: 190px;
}
.product-breakdown-table.product-detail-table :deep(.q-table th:nth-child(7)),
.product-breakdown-table.product-detail-table :deep(.q-table td:nth-child(7)) {
left: 960px;
width: 120px;
min-width: 120px;
max-width: 120px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
.sticky-dim-table :deep(.q-table) {
min-width: 1700px;
table-layout: fixed;
}
.sticky-dim-table :deep(.q-table th),
.sticky-dim-table :deep(.q-table td) {
min-width: 118px;
width: 118px;
max-width: 180px;
}
.sticky-dim-table :deep(.q-table th.text-right),
.sticky-dim-table :deep(.q-table td.text-right),
.product-breakdown-table :deep(.q-table th.text-right),
.product-breakdown-table :deep(.q-table td.text-right) {
min-width: 124px;
width: 124px;
max-width: none;
white-space: nowrap;
}
.product-breakdown-table :deep(.q-table td.text-right) {
font-size: 10.5px;
font-variant-numeric: tabular-nums;
}
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table th:nth-child(n+11):not(.text-right)),
.product-breakdown-table:not(.product-detail-table):not(.compact-detail-columns) :deep(.q-table td:nth-child(n+11):not(.text-right)),
.product-breakdown-table.compact-detail-columns :deep(.q-table th:nth-child(n+5):not(.text-right)),
.product-breakdown-table.compact-detail-columns :deep(.q-table td:nth-child(n+5):not(.text-right)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(n+4):not(.text-right)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(n+4):not(.text-right)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(n+5):not(.text-right)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(n+5):not(.text-right)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(n+6):not(.text-right)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(n+6):not(.text-right)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(n+7):not(.text-right)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table td:nth-child(n+7):not(.text-right)),
.sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(n+8):not(.text-right)),
.sticky-dim-table.sticky-dim-7 :deep(.q-table td:nth-child(n+8):not(.text-right)),
.sticky-dim-table.sticky-dim-8 :deep(.q-table th:nth-child(n+9):not(.text-right)),
.sticky-dim-table.sticky-dim-8 :deep(.q-table td:nth-child(n+9):not(.text-right)),
.sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(n+10):not(.text-right)),
.sticky-dim-table.sticky-dim-9 :deep(.q-table td:nth-child(n+10):not(.text-right)),
.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(n+11):not(.text-right)),
.sticky-dim-table.sticky-dim-10 :deep(.q-table td:nth-child(n+11):not(.text-right)),
.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(n+12):not(.text-right)),
.sticky-dim-table.sticky-dim-11 :deep(.q-table td:nth-child(n+12):not(.text-right)) {
min-width: 140px;
width: 140px;
max-width: 210px;
}
.sticky-dim-table :deep(.q-table th:nth-child(1)),
.sticky-dim-table :deep(.q-table td:nth-child(1)) {
left: 0;
position: sticky;
z-index: 2;
background: #fff;
}
.sticky-dim-table :deep(.q-table th:nth-child(2)),
.sticky-dim-table :deep(.q-table td:nth-child(2)) {
left: 118px;
}
.sticky-dim-table :deep(.q-table th:nth-child(3)),
.sticky-dim-table :deep(.q-table td:nth-child(3)) {
left: 236px;
}
.sticky-dim-table :deep(.q-table th:nth-child(4)),
.sticky-dim-table :deep(.q-table td:nth-child(4)) {
left: 354px;
}
.sticky-dim-table :deep(.q-table th:nth-child(5)),
.sticky-dim-table :deep(.q-table td:nth-child(5)) {
left: 472px;
}
.sticky-dim-table :deep(.q-table th:nth-child(6)),
.sticky-dim-table :deep(.q-table td:nth-child(6)) {
left: 590px;
}
.sticky-dim-table :deep(.q-table th:nth-child(7)),
.sticky-dim-table :deep(.q-table td:nth-child(7)) {
left: 708px;
}
.sticky-dim-table :deep(.q-table th:nth-child(8)),
.sticky-dim-table :deep(.q-table td:nth-child(8)) {
left: 826px;
}
.sticky-dim-table :deep(.q-table th:nth-child(9)),
.sticky-dim-table :deep(.q-table td:nth-child(9)) {
left: 944px;
}
.sticky-dim-table :deep(.q-table th:nth-child(10)),
.sticky-dim-table :deep(.q-table td:nth-child(10)) {
left: 1062px;
}
.sticky-dim-table :deep(.q-table th:nth-child(11)),
.sticky-dim-table :deep(.q-table td:nth-child(11)) {
left: 1180px;
}
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(-n+3)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(-n+4)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(-n+4)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(-n+5)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table td:nth-child(-n+6)),
.sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(-n+7)),
.sticky-dim-table.sticky-dim-7 :deep(.q-table td:nth-child(-n+7)),
.sticky-dim-table.sticky-dim-8 :deep(.q-table th:nth-child(-n+8)),
.sticky-dim-table.sticky-dim-8 :deep(.q-table td:nth-child(-n+8)),
.sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(-n+9)),
.sticky-dim-table.sticky-dim-9 :deep(.q-table td:nth-child(-n+9)),
.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(-n+10)),
.sticky-dim-table.sticky-dim-10 :deep(.q-table td:nth-child(-n+10)),
.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(-n+11)),
.sticky-dim-table.sticky-dim-11 :deep(.q-table td:nth-child(-n+11)) {
position: sticky;
z-index: 2;
}
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(-n+3)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(-n+4)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(-n+5)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(-n+6)),
.sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(-n+7)),
.sticky-dim-table.sticky-dim-8 :deep(.q-table th:nth-child(-n+8)),
.sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(-n+9)),
.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(-n+10)),
.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(-n+11)) {
z-index: 30;
background: #f8fbff;
}
.sticky-dim-table :deep(.q-table tbody td:nth-child(-n+11)) {
background: #fff;
}
.sticky-dim-table.sticky-dim-3 :deep(.q-table th:nth-child(3)),
.sticky-dim-table.sticky-dim-3 :deep(.q-table td:nth-child(3)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table th:nth-child(4)),
.sticky-dim-table.sticky-dim-4 :deep(.q-table td:nth-child(4)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table th:nth-child(5)),
.sticky-dim-table.sticky-dim-5 :deep(.q-table td:nth-child(5)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table th:nth-child(6)),
.sticky-dim-table.sticky-dim-6 :deep(.q-table td:nth-child(6)),
.sticky-dim-table.sticky-dim-7 :deep(.q-table th:nth-child(7)),
.sticky-dim-table.sticky-dim-7 :deep(.q-table td:nth-child(7)),
.sticky-dim-table.sticky-dim-8 :deep(.q-table th:nth-child(8)),
.sticky-dim-table.sticky-dim-8 :deep(.q-table td:nth-child(8)),
.sticky-dim-table.sticky-dim-9 :deep(.q-table th:nth-child(9)),
.sticky-dim-table.sticky-dim-9 :deep(.q-table td:nth-child(9)),
.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(10)),
.sticky-dim-table.sticky-dim-10 :deep(.q-table td:nth-child(10)),
.sticky-dim-table.sticky-dim-11 :deep(.q-table th:nth-child(11)),
.sticky-dim-table.sticky-dim-11 :deep(.q-table td:nth-child(11)) {
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
.sales-color-yaka-table :deep(.q-table th:nth-child(1)),
.sales-color-yaka-table :deep(.q-table td:nth-child(1)) {
left: 0;
width: 170px;
min-width: 170px;
max-width: 190px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(2)),
.sales-color-yaka-table :deep(.q-table td:nth-child(2)) {
left: 170px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(3)),
.sales-color-yaka-table :deep(.q-table td:nth-child(3)) {
left: 288px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(4)),
.sales-color-yaka-table :deep(.q-table td:nth-child(4)) {
left: 406px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(5)),
.sales-color-yaka-table :deep(.q-table td:nth-child(5)) {
left: 524px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(6)),
.sales-color-yaka-table :deep(.q-table td:nth-child(6)) {
left: 642px;
width: 130px;
min-width: 130px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(7)),
.sales-color-yaka-table :deep(.q-table td:nth-child(7)) {
left: 772px;
width: 170px;
min-width: 170px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(8)),
.sales-color-yaka-table :deep(.q-table td:nth-child(8)) {
left: 942px;
width: 116px;
min-width: 116px;
}
.sales-color-yaka-table :deep(.q-table th:nth-child(9)),
.sales-color-yaka-table :deep(.q-table td:nth-child(9)) {
left: 1058px;
width: 190px;
min-width: 190px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
.filterable-header-cell {
vertical-align: middle;
}
.header-with-filter {
display: grid;
grid-template-columns: minmax(0, 1fr) 18px;
align-items: center;
gap: 2px;
min-width: 82px;
width: 100%;
}
.header-with-filter > span {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
line-height: 1.12;
white-space: normal;
overflow: hidden;
overflow-wrap: anywhere;
text-overflow: clip;
font-size: 10px;
font-weight: 700;
}
.header-filter-btn {
width: 18px;
height: 18px;
min-height: 18px;
padding: 0;
}
.header-filter-ghost {
opacity: 0;
pointer-events: none;
}
.excel-filter-menu {
width: 260px;
padding: 8px;
}
.excel-filter-select :deep(.q-field__control) {
min-height: 32px;
}
.excel-filter-actions {
gap: 8px;
}
.excel-filter-options {
max-height: 260px;
margin-top: 4px;
border: 1px solid #e0e3e7;
border-radius: 6px;
}
.excel-filter-option {
min-height: 32px;
}
.excel-filter-empty {
padding: 12px;
color: #7a8390;
text-align: center;
}
.performance-table :deep(.q-table tbody tr.group-row td) {
background: #edf3fb;
font-weight: 600;
color: #1f2937;
border-color: #c4d2e2;
white-space: nowrap;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-0 td) {
background: #123b63;
color: #fff;
border-color: #0b2845;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-1 td) {
background: #1f6f54;
color: #fff;
border-color: #18563f;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-2 td) {
background: #ffe2a8;
color: #1f2937;
border-color: #d7a84d;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-3 td) {
background: #d8f0dc;
color: #1f2937;
border-color: #9bc7a3;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-4 td) {
background: #e7ecff;
color: #1f2937;
border-color: #b8c2ef;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-level-5 td) {
background: #f2f3f5;
color: #1f2937;
border-color: #c4c9d1;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-field-color_yaka td) {
background: #f7e3bf;
color: #1f2937;
border-top: 2px solid #c99032;
border-bottom: 2px solid #c99032;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-field-color_yaka td:first-child) {
border-left: 6px solid #b76a00;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-field-market_key td) {
background: #d9e8ff;
color: #132238;
border-top: 2px solid #7da9e6;
border-bottom: 2px solid #7da9e6;
}
.performance-table :deep(.q-table tbody tr.group-row.group-row-field-market_key td:first-child) {
border-left: 6px solid #2563b8;
}
.performance-table :deep(.q-table tbody tr.group-row td span),
.performance-table :deep(.q-table tbody tr.group-row .group-title),
.performance-table :deep(.q-table tbody tr.group-row .q-btn),
.performance-table :deep(.q-table tbody tr.group-row .q-icon) {
color: inherit;
opacity: 1;
}
.group-cell-label {
display: flex;
align-items: center;
gap: 3px;
width: 100%;
min-width: 0;
max-width: 100%;
}
.group-cell-label :deep(.q-btn) {
flex: 0 0 20px;
width: 20px;
height: 20px;
min-height: 20px;
}
.group-title {
flex: 1 1 auto;
min-width: 0;
margin-left: 0;
white-space: normal;
overflow: visible;
overflow-wrap: anywhere;
line-height: 1.18;
}
.product-image-cell {
width: 190px;
min-width: 190px;
max-width: 190px;
padding: 10px !important;
text-align: center;
vertical-align: middle;
}
.product-thumb-button {
width: 162px;
height: 216px;
border: 1px solid #d9dde3;
border-radius: 8px;
background: #fff;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.product-thumb,
.product-thumb-placeholder {
width: 100%;
height: 100%;
}
.product-thumb {
display: block;
object-fit: contain;
object-position: center;
}
.product-thumb-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #9aa3af;
background: #f3f5f7;
border-radius: 4px;
}
.product-image-dialog {
background: #f6f7f9;
}
.product-image-dialog-body {
height: calc(100vh - 74px);
padding: 12px;
overflow: hidden;
}
.performance-image-layout {
display: grid;
grid-template-columns: minmax(280px, 330px) minmax(360px, 1fr) minmax(440px, 1.25fr);
gap: 12px;
height: 100%;
min-height: 0;
}
.image-product-fields,
.image-sales-panel,
.image-media-panel {
min-height: 0;
}
.image-product-fields,
.image-sales-panel {
overflow: auto;
background: #fff;
border: 1px solid #d9dde3;
border-radius: 8px;
padding: 12px;
}
.image-section-title {
font-size: 12px;
font-weight: 700;
color: var(--q-primary);
text-transform: uppercase;
letter-spacing: 0;
margin-bottom: 8px;
}
.image-field-row {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 8px;
align-items: start;
min-height: 30px;
padding: 6px 0;
border-bottom: 1px solid #edf0f3;
font-size: 12px;
}
.image-field-row .k {
color: #6b7280;
font-weight: 600;
}
.image-field-row .v {
color: #1f2937;
font-weight: 700;
overflow-wrap: anywhere;
}
.image-product-section {
position: relative;
margin-top: 14px;
}
.image-size-stock-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(58px, 1fr));
gap: 8px;
}
.image-size-stock-cell {
min-height: 54px;
padding: 8px 6px;
border: 1px solid #dfe4ea;
border-radius: 6px;
background: #f8fafc;
text-align: center;
}
.image-size-label {
display: block;
font-size: 11px;
font-weight: 700;
color: #6b7280;
}
.image-size-qty {
display: block;
margin-top: 3px;
font-size: 16px;
font-weight: 800;
color: #111827;
}
.image-empty-text {
color: #7a8390;
font-size: 12px;
}
.product-image-carousel {
height: 100%;
background: #fff;
border: 1px solid #d9dde3;
border-radius: 8px;
}
.product-image-large {
width: 100%;
height: 100%;
}
.product-image-empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #7a8390;
background: #fff;
border: 1px solid #d9dde3;
border-radius: 8px;
}
.image-metric-card .q-card__section {
padding: 8px;
}
.image-metric-card .metric-value {
font-size: 14px;
}
.product-performance-dialog {
background: #f6f7f9;
}
.product-performance-dialog :deep(.q-table__container),
.product-image-dialog :deep(.q-table__container) {
width: 100%;
min-width: 0;
}
.product-performance-dialog :deep(.q-table__middle),
.product-image-dialog :deep(.q-table__middle) {
overflow-x: hidden;
}
.dialog-detail-table,
.dialog-stock-table {
width: 100%;
min-width: 0;
font-size: 11px;
}
.dialog-detail-table :deep(.q-table),
.dialog-stock-table :deep(.q-table) {
width: 100%;
table-layout: fixed;
}
.dialog-detail-table :deep(.q-table th),
.dialog-detail-table :deep(.q-table td),
.dialog-stock-table :deep(.q-table th),
.dialog-stock-table :deep(.q-table td) {
padding: 4px 5px;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.18;
vertical-align: top;
}
.dialog-detail-table :deep(.q-table th) {
font-size: 10px;
}
.dialog-detail-table :deep(.q-table th:nth-child(1)),
.dialog-detail-table :deep(.q-table td:nth-child(1)) {
width: 9%;
}
.dialog-detail-table :deep(.q-table th:nth-child(2)),
.dialog-detail-table :deep(.q-table td:nth-child(2)) {
width: 12%;
}
.dialog-detail-table :deep(.q-table th:nth-child(3)),
.dialog-detail-table :deep(.q-table td:nth-child(3)) {
width: 11%;
}
.dialog-detail-table :deep(.q-table th:nth-child(4)),
.dialog-detail-table :deep(.q-table td:nth-child(4)) {
width: 9%;
}
.dialog-detail-table :deep(.q-table th:nth-child(5)),
.dialog-detail-table :deep(.q-table td:nth-child(5)) {
width: 12%;
}
.dialog-detail-table :deep(.q-table th:nth-child(6)),
.dialog-detail-table :deep(.q-table td:nth-child(6)) {
width: 23%;
}
.dialog-detail-table :deep(.q-table th:nth-child(7)),
.dialog-detail-table :deep(.q-table td:nth-child(7)),
.dialog-detail-table :deep(.q-table th:nth-child(8)),
.dialog-detail-table :deep(.q-table td:nth-child(8)),
.dialog-detail-table :deep(.q-table th:nth-child(9)),
.dialog-detail-table :deep(.q-table td:nth-child(9)) {
width: 8%;
white-space: nowrap;
overflow-wrap: normal;
word-break: normal;
}
.dialog-stock-table :deep(.q-table th:nth-child(1)),
.dialog-stock-table :deep(.q-table td:nth-child(1)) {
width: 58%;
}
.dialog-stock-table :deep(.q-table th:nth-child(2)),
.dialog-stock-table :deep(.q-table td:nth-child(2)) {
width: 42%;
white-space: nowrap;
}
@media (max-width: 1260px) {
.product-image-dialog-body {
overflow: auto;
}
.performance-image-layout {
grid-template-columns: 1fr;
height: auto;
}
.image-media-panel {
min-height: 520px;
}
}
</style>