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

5790 lines
242 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">Yükleniyor...</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-dropdown
v-model="detailLevelMenuOpen"
dense
outline
color="primary"
:icon="allProductGroupsExpanded ? 'unfold_less' : 'unfold_more'"
:label="allProductGroupsExpanded ? 'Detayları Kapat' : 'Tüm Detayları '"
:disable="pageBusy"
>
<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="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 v-if="activeTab === 'product_detail'" class="main-group-bar q-mb-xs">
<button
v-for="option in detailMainGroupOptions"
:key="option.value"
type="button"
:class="['main-group-button', { active: selectedDetailMainGroup === option.value }]"
:disabled="pageBusy"
@click="selectedDetailMainGroup = option.value"
>
<span>{{ option.label }}</span>
<small>{{ formatNumber(option.stock_qty, 0) }} stok</small>
</button>
</div>
<q-table
v-if="activeTab === 'general'"
flat
bordered
row-key="row_key"
class="performance-table sticky-dim-table sticky-dim-7 bg-white"
:rows="filteredGeneralRows"
:columns="generalColumns"
:loading="loading"
:pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }"
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('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="markProductImageFailed(props.row)"
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="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 0, sortBy: 'order_usd', descending: true }"
: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_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="markProductImageFailed(props.row)"
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="markProductImageFailed(props.row)"
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="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@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="markProductImageFailed(props.row)"
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="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 0, sortBy: 'order_date', descending: true }"
: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="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@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',
activeTab === 'product_detail' ? 'product-detail-table' : 'product-summary-table'
]"
:rows="displayProductKpiTableRows"
:columns="activeProductColumns"
:loading="loading || backendGroupedLoading"
:pagination="pagination"
: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(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="markProductImageFailed(props.row)"
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="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@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="markProductImageFailed(props.row)"
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="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 0 }"
: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('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="markProductImageFailed(props.row)"
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="markProductImageFailed(props.row)"
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="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@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="markProductImageFailed(props.row)"
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="loading"
:pagination="{ rowsPerPage: 0 }"
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="loading || backendGroupedLoading"
:pagination="{ rowsPerPage: 0, sortBy: 'performance_score_total', descending: true }"
: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(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="isGroupExpanded(props.row.key) ? 'expand_more' : 'chevron_right'"
@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="loading"
:pagination="{ rowsPerPage: 0 }"
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="loading"
:pagination="{ rowsPerPage: 0 }"
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" />
</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, onMounted, reactive, ref, watch } from 'vue'
import { Notify } from 'quasar'
import api from 'src/services/api'
import { useProductPerformanceStore } from 'src/stores/productPerformanceStore'
const performanceStore = useProductPerformanceStore()
function formatNumber (value, fraction = 2) {
const n = Number(value || 0)
const digits = Math.max(0, Math.min(2, Number(fraction || 0)))
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 rows = 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 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 backendGroupedLoading = ref(false)
const imagePrimeLoading = ref(false)
const filterBusy = ref(false)
const bulkExpandLoading = ref(false)
const generalRowsLoaded = ref(false)
const detailLevelMenuOpen = ref(false)
let backendGroupedTimer = null
let imagePrimeActiveRequests = 0
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: ['color_yaka'],
idle: ['urun_ilk_grubu', 'askili_yan', 'kategori'],
sales_product_country_segment_market_customer: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
sales_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
sales_country_segment_market_customer_product: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
order_product_customers: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan'],
order_market_details: ['market_key', 'customer_code', 'customer_name', 'urun_ilk_grubu', 'askili_yan']
})
const productKpiFetchLimit = 50000
const reportFetchLimit = 50000
const maxBulkExpandKeys = 500
const maxBackendExpandedKeys = 120
const tableVirtualSliceSize = 160
const maxAutoExpandLevelByTab = {
products: 8,
product_detail: 4,
idle: 7,
sales_color_yaka_market_customer: 5
}
const performanceTabs = [
{ name: 'products', icon: 'dashboard', label: 'Genel Özet KPI' },
{ name: 'product_detail', icon: 'category', label: 'Detay KPI' },
{ name: 'sales_color_yaka_market_customer', icon: 'palette', label: '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 || filterBusy.value || bulkExpandLoading.value)
const pagination = ref({
page: 1,
rowsPerPage: 0,
sortBy: 'performance_score_total',
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, 0), 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. 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 Stok Devir', field: row => formatNumber(row.stock_turnover_90d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_180d', label: '180G Stok Devir', field: row => formatNumber(row.stock_turnover_180d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_365d', label: '360G Stok Devir', field: row => formatNumber(row.stock_turnover_365d, 2), align: 'right', sortable: true },
{ name: 'stock_turnover_total', label: 'Genel Stok Devir', field: row => formatNumber(row.stock_turnover_total, 2), align: 'right', sortable: true },
{ name: 'avg_price_usd_90d', label: 'Ort. USD', field: row => formatMoney(row.avg_price_usd_90d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_180d', label: '180G Ort. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_365d', label: '360G Ort. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' },
{ name: 'avg_price_usd_total', label: 'Genel Ort. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right' },
{ name: 'base_price_usd', label: 'Ort. USD Taban Maliyeti', field: row => formatMoney(row.base_price_usd, 'USD'), align: 'right', sortable: true },
{ name: 'cost_price_usd', label: 'Ort. 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_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_score', label: 'Skor', field: row => formatNumber(row.performance_score, 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'])
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 activeProductColumns = computed(() => activeTab.value === 'product_detail' ? productDetailColumns.value : productColumns.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 Satış', field: row => formatNumber(row.sales_qty_total, 0), align: 'right', sortable: true },
{ name: 'sales_usd_total', label: 'Toplam 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. 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_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', label: 'Genel Skor', field: row => formatNumber(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 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. 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. 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. 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 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. 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. 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: 'Ort. USD', field: 'avg_price_usd_90d', align: 'right', sortable: true },
{ name: 'stock_turnover_90d', label: '90G 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. USD', field: row => formatMoney(row.avg_price_usd_180d, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_180d', label: '180G 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. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_365d', label: '360G 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. USD', field: row => formatMoney(row.avg_price_usd_total, 'USD'), align: 'right', sortable: true },
{ name: 'stock_turnover_total', label: 'Genel 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_score', label: 'Skor', field: row => formatNumber(row.performance_score, 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 }
]
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))
;[
generalColumns,
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: 'score', label: 'Skor', value: formatNumber(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. USD Satış', 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: 'color_yaka', label: 'Renk/Yaka' },
{ 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: 'country', label: 'Ülke' },
{ key: 'market_key', label: 'Piyasa' },
{ key: 'customer_segment', label: 'Segment' },
{ key: 'customer_code', label: 'Müşteri Kodu' },
{ key: 'customer_name', label: 'Müşteri' }
],
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: '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_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: '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: '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_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] : 1
}
function maxBackendExpandedKeysForTab (tabKey = activeTab.value) {
if (tabKey === 'product_detail') return 260
if (tabKey === 'products' || tabKey === 'idle') return 260
if (salesBreakdownTabKeys.includes(tabKey)) return 180
return 100
}
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 tableFilterSourceMap = computed(() => ({
products: { rows: filterSourceRowsForTab('products', rows.value), columns: productColumns.value },
product_detail: { rows: filterSourceRowsForTab('product_detail', detailProductRows.value), columns: productDetailColumns.value },
general: { rows: generalRows.value, columns: generalColumns },
order_product_customers: { rows: filterSourceRowsForTab('order_product_customers', orderProductCustomerRows.value), columns: orderProductCustomerColumns },
order_market_details: { rows: filterSourceRowsForTab('order_market_details', orderMarketDetailRows.value), columns: orderMarketDetailColumns },
idle: { rows: filterSourceRowsForTab('idle', idleRows.value), columns: idleColumns },
markets: { rows: marketRows.value, columns: marketColumns },
countries: { rows: countryRows.value, columns: countryColumns },
customers: { rows: customerRows.value, columns: customerColumns },
sales_color_yaka_market_customer: { rows: filterSourceRowsForTab('sales_color_yaka_market_customer', salesBreakdownRows.sales_color_yaka_market_customer), columns: visibleSalesBreakdownColumns.value },
sales_product_country_segment_market_customer: { rows: filterSourceRowsForTab('sales_product_country_segment_market_customer', salesBreakdownRows.sales_product_country_segment_market_customer), columns: visibleSalesBreakdownColumns.value },
sales_market_customer_product: { rows: filterSourceRowsForTab('sales_market_customer_product', salesBreakdownRows.sales_market_customer_product), columns: visibleSalesBreakdownColumns.value },
sales_country_segment_market_customer_product: { rows: filterSourceRowsForTab('sales_country_segment_market_customer_product', salesBreakdownRows.sales_country_segment_market_customer_product), columns: visibleSalesBreakdownColumns.value }
}))
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 = {}
for (const [tableKey, source] of Object.entries(tableFilterSourceMap.value)) {
out[tableKey] = {}
for (const col of source.columns) {
if (!isColumnFilterable(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(() => {
const out = []
appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels)
return out
})
const detailProductTableRows = computed(() => buildGroupedTableRows('product_detail', filteredDetailProductRows.value))
const idleTableRows = computed(() => buildGroupedTableRows('idle', filteredIdleRows.value))
const orderProductCustomerTableRows = computed(() => buildGroupedTableRows('order_product_customers', filteredOrderProductCustomerRows.value))
const orderMarketDetailTableRows = computed(() => buildGroupedTableRows('order_market_details', filteredOrderMarketDetailRows.value))
const activeSalesBreakdownTableRows = computed(() => 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 detailMainGroupOptions = computed(() => {
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 }
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())
.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(() => {
const keys = productAutoExpandKeys.value
return keys.length > 0 && keys.every(key => expandedGroups.value[key] === true)
})
function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLevels) {
appendGroupRowsAt(out, sourceRows, level, 0, parentKeys, levels)
}
function appendGroupRowsAt (out, sourceRows, level, visualLevel, parentKeys, levels = groupLevels) {
if (level >= levels.length) {
out.push(...sortProductLeafRows(sourceRows))
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 (shouldSkipGroupValue(groupDef.key, value)) {
appendGroupRowsAt(out, groupRows, level + 1, visualLevel, parentKeys, levels)
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)
}
})
}
function buildGroupedTableRows (tableKey, sourceRows) {
const levels = tabGroupLevels[tableKey] || groupLevels
const out = []
appendGroupRows(out, sourceRows, 0, [`tab:${tableKey}`], levels)
return out
}
function backendRowsForTab (tabKey, fallbackRows) {
const rows = backendGroupedRows.value[tabKey]
const backendRows = Array.isArray(rows) && rows.length ? rows : []
const sourceRows = tabKey === 'product_detail'
? filterRowsBySelectedDetailMainGroup(backendRows.length ? backendRows : fallbackRows)
: (backendRows.length ? backendRows : fallbackRows)
if (hasActiveTableFilters(tabKey)) {
return filterRowsForTable(tabKey, sourceRows, columnsForTableKey(tabKey))
}
return sourceRows
}
function filterSourceRowsForTab (tabKey, fallbackRows) {
const rows = backendGroupedRows.value[tabKey]
return Array.isArray(rows) && rows.length ? rows : fallbackRows
}
function columnsForTableKey (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 sortProductGroupedTableRows (tableRows, sortBy, descending) {
const source = Array.isArray(tableRows) ? tableRows : []
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: distinctCount(groupRows, 'market_key'),
customer_count_90d: sumRows(groupRows, 'customer_count_90d'),
market_count_total: sumRows(groupRows, 'market_count_total'),
customer_count_total: sumRows(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: 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 (field === 'stock_qty') {
out[field] = distinctVariantStockQty(sourceRows)
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 (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 /(_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) {
return /^(avg_|unit_|base_price|cost_price|gross_margin|expected_margin|sales_index|performance_score|stock_days|stock_turnover)/i.test(field) ||
/(_price_usd|_margin|_index|_days)$/i.test(field)
}
function weightFieldForMetric (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 = '') {
for (const suffix of ['90d', '180d', '365d', 'total']) {
const sales = Number(out[`sales_usd_${suffix}`] || 0)
const qty = Number(out[`sales_qty_${suffix}`] || 0)
if (qty > 0) out[`avg_price_usd_${suffix}`] = sales / qty
if (sales > 0) {
if (out[`gross_profit_base_usd_${suffix}`] !== undefined) {
out[`gross_margin_base_${suffix}`] = Number(out[`gross_profit_base_usd_${suffix}`] || 0) / sales
}
if (out[`gross_profit_cost_usd_${suffix}`] !== undefined) {
out[`gross_margin_cost_${suffix}`] = Number(out[`gross_profit_cost_usd_${suffix}`] || 0) / sales
}
if (out[`gross_profit_usd_${suffix}`] !== undefined) {
out[`gross_margin_${suffix}`] = Number(out[`gross_profit_usd_${suffix}`] || 0) / sales
}
}
}
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')
}
out.customer_score_90d = customerSalesPeriodScore(periodMetricSource(out, '90d'))
out.customer_score_180d = customerSalesPeriodScore(periodMetricSource(out, '180d'))
out.customer_score_365d = customerSalesPeriodScore(periodMetricSource(out, '365d'))
out.customer_score_total = customerSalesPeriodScore(periodMetricSource(out, 'total'))
out.performance_score_90d = productSalesPeriodScore(productPeriodMetricSource(out, '90d'))
out.performance_score_180d = productSalesPeriodScore(productPeriodMetricSource(out, '180d'))
out.performance_score_365d = productSalesPeriodScore(productPeriodMetricSource(out, '365d'))
out.performance_score_total = productSalesPeriodScore(productPeriodMetricSource(out, 'total'))
out.performance_score = groupPerformanceScore(out, groupField)
}
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 salesIndex = Number(row?.sales_index_90d || 0)
const margin = Number(row?.gross_margin_cost_90d ?? 0)
const invoices = Number(row?.invoice_count_90d || 0)
const qty = Number(row?.sales_qty_90d || 0)
let stockTurnover = Number(row?.stock_turnover_90d || 0)
const stockQty = Number(row?.stock_qty || 0)
if (!stockTurnover && stockQty > 0) stockTurnover = qty / stockQty
return Math.min(35, Math.max(0, salesIndex) * 18) +
Math.min(30, Math.max(0, margin) * 60) +
Math.min(15, invoices * 1.5) +
Math.min(10, qty / 10) +
Math.min(10, Math.max(0, stockTurnover) * 5)
}
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 turnoverKey = `stock_turnover_${suffix}`
const hasTurnover = Object.prototype.hasOwnProperty.call(row || {}, turnoverKey)
return {
sales_index_90d: salesIndex,
gross_margin_cost_90d: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0),
gross_margin_90d: Number(row?.[`gross_margin_${suffix}`] ?? 0),
invoice_count_90d: Number(row?.[`invoice_count_${suffix}`] || 0),
sales_qty_90d: qty,
stock_turnover_90d: hasTurnover ? Number(row?.[turnoverKey] || 0) : (stockQty > 0 ? qty / stockQty : 0),
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) {
const salesUSD = Number(row?.sales_usd || 0)
const margin = Number(row?.gross_margin_cost ?? 0)
const invoices = Number(row?.invoice_count || 0)
const qty = Number(row?.sales_qty || 0)
const productCount = Number(row?.product_count || 0)
return Math.min(35, salesUSD / 1000) +
Math.min(25, Math.max(0, margin) * 55) +
Math.min(20, invoices * 2) +
Math.min(10, qty / 10) +
Math.min(10, productCount)
}
function periodMetricSource (row, suffix) {
return {
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),
invoice_count: Number(row?.[`invoice_count_${suffix}`] || 0),
sales_qty: Number(row?.[`sales_qty_${suffix}`] || 0),
product_count: Number(row?.product_count || 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) {
const seen = new Set()
let total = 0
let keyed = false
for (const row of sourceRows) {
const key = productVariantStockKey(row)
const stockQty = Number(row?.stock_qty || 0)
if (!key) continue
keyed = true
if (seen.has(key)) continue
seen.add(key)
total += stockQty
}
return keyed ? total : sumRows(sourceRows, 'stock_qty')
}
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 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) {
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 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 stockSource = variants.size > 0 ? Array.from(variants.values()) : sourceRows
const stockRows = stockSource
.map(row => ({
value: Number(row[valueField] || 0),
qty: Number(row.stock_qty || 0)
}))
.filter(row => Number.isFinite(row.value) && Number.isFinite(row.qty) && row.value > 0 && row.qty > 0)
const stockQty = stockRows.reduce((sum, row) => sum + row.qty, 0)
if (stockQty > 0) {
return stockRows.reduce((sum, row) => sum + row.value * row.qty, 0) / stockQty
}
return 0
}
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()
}
function shouldSkipGroupValue (field, value) {
const text = String(value || '').trim()
if (field === 'urun_ilk_grubu') return !text || text === '-'
if (field === 'askili_yan') return !text || text === '-'
return false
}
function isGroupExpanded (key) {
return expandedGroups.value[key] === true
}
function toggleGroup (key) {
lastManualExpandedGroupKey.value = key
expandedGroups.value = {
...expandedGroups.value,
[key]: !isGroupExpanded(key)
}
scheduleLoadBackendGroupedRows()
}
async function collapseAllProductGroups () {
if (bulkExpandLoading.value) return
detailLevelMenuOpen.value = false
const tabPrefix = `tab:${activeTab.value}|`
expandedGroups.value = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith(tabPrefix))
)
lastManualExpandedGroupKey.value = ''
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
bulkExpandLoading.value = true
try {
await loadBackendGroupedRows({ force: true })
} finally {
bulkExpandLoading.value = false
}
}
async function expandSelectedProductGroups () {
if (bulkExpandLoading.value) return
detailLevelMenuOpen.value = false
const keys = productAutoExpandKeys.value
const maxKeys = Math.min(maxBulkExpandKeys, maxBackendExpandedKeysForTab(activeTab.value))
const expandableKeys = keys.slice(0, maxKeys)
const tabPrefix = `tab:${activeTab.value}|`
const next = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith(tabPrefix))
)
for (const key of expandableKeys) next[key] = true
expandedGroups.value = next
lastManualExpandedGroupKey.value = ''
if (keys.length > expandableKeys.length) {
Notify.create({
type: 'warning',
message: `${formatNumber(expandableKeys.length, 0)} grup açıldı. Daha derin detaylar için satırları kademeli açın.`
})
}
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
bulkExpandLoading.value = true
try {
await loadBackendGroupedRows({ force: true })
} 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) {
return {
...row,
avg_price_usd_365d: Number(row?.sales_qty_365d || 0) > 0 ? Number(row?.sales_usd_365d || 0) / Number(row?.sales_qty_365d || 0) : 0,
stock_turnover_90d: existingOrStockTurnover(row?.stock_turnover_90d, row?.sales_qty_90d, row?.stock_qty),
stock_turnover_180d: existingOrStockTurnover(row?.stock_turnover_180d, row?.sales_qty_180d, row?.stock_qty),
stock_turnover_365d: existingOrStockTurnover(row?.stock_turnover_365d, row?.sales_qty_365d, row?.stock_qty),
gross_margin_base_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.base_price_usd),
gross_margin_cost_90d: marginFromSalesCost(row?.sales_usd_90d, row?.sales_qty_90d, row?.cost_price_usd),
gross_margin_base_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.base_price_usd),
gross_margin_cost_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.cost_price_usd)
}
}
function withGeneralMargins (row) {
return {
...row,
stock_turnover_total: existingOrStockTurnover(row?.stock_turnover_total, row?.sales_qty_total, row?.stock_qty),
gross_margin_base_total: marginFromSalesCost(row?.sales_usd_total, row?.sales_qty_total, row?.base_price_usd),
gross_margin_cost_total: marginFromSalesCost(row?.sales_usd_total, row?.sales_qty_total, row?.cost_price_usd)
}
}
function stockTurnover (salesQty, stockQty) {
const stock = Number(stockQty || 0)
if (stock <= 0) return 0
return Number(salesQty || 0) / stock
}
function existingOrStockTurnover (value, salesQty, stockQty) {
const current = Number(value)
return value !== undefined && value !== null && Number.isFinite(current) ? current : stockTurnover(salesQty, stockQty)
}
function filterKey (tableKey, name) {
return `${tableKey}:${name}`
}
function isColumnFilterable (name) {
return name !== 'image'
}
function columnFilterOptions (tableKey, name) {
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) {
filterBusy.value = true
const done = () => {
filterBusy.value = false
}
const execute = () => {
apply()
if (typeof window === 'undefined') {
setTimeout(done, 0)
return
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(done)
})
}
if (typeof window === 'undefined') {
execute()
return
}
window.requestAnimationFrame(execute)
}
function filterRowsForTable (tableKey, sourceRows, sourceColumns) {
return sourceRows.filter(row => {
return sourceColumns.every(col => {
if (!isColumnFilterable(col.name)) return true
const selected = selectedColumnFilters(tableKey, col.name)
if (!selected.length) return true
return selected.includes(columnFilterValue(row, 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) {
return row?.urun_ana_grubu || ''
}
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(/[%$₺€£]/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':
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_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(withProductMargins({
...row,
row_key: `${product}|${color}|${yaka}|${market}`
}))
}
function withPeriodScores (row) {
const next = { ...row }
next.performance_score_90d = Number(next.performance_score_90d || productSalesPeriodScore(productPeriodMetricSource(next, '90d')))
next.performance_score_180d = Number(next.performance_score_180d || productSalesPeriodScore(productPeriodMetricSource(next, '180d')))
next.performance_score_365d = Number(next.performance_score_365d || productSalesPeriodScore(productPeriodMetricSource(next, '365d')))
next.performance_score_total = Number(next.performance_score_total || productSalesPeriodScore(productPeriodMetricSource(next, 'total')))
if (!Number(next.performance_score || 0)) 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 displayMarketName (value) {
const parts = String(value || '').split('|').map(part => part.trim()).filter(Boolean)
return parts.length ? parts[parts.length - 1] : ''
}
function normalizeUploadsPath (storagePath) {
const raw = String(storagePath || '').trim()
if (!raw) return ''
const normalized = raw.replace(/\\/g, '/')
const idx = normalized.toLowerCase().indexOf('/uploads/')
if (idx >= 0) return normalized.slice(idx)
if (normalized.toLowerCase().startsWith('uploads/')) return `/${normalized}`
return ''
}
function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return ''
const contentURL = String(item.content_url || item.ContentURL || '').trim()
if (contentURL.startsWith('/api/')) return contentURL
if (contentURL.startsWith('/')) return `/api${contentURL}`
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
const thumbURL = String(item.thumb_url || item.thumbUrl || '').trim()
if (thumbURL) return thumbURL
const fullURL = String(item.full_url || item.fullUrl || '').trim()
if (fullURL) return fullURL
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage || '')
if (uploadsPath) return uploadsPath
const fileName = String(item.file_name || item.FileName || '').trim()
return fileName ? `/uploads/image/${fileName}` : ''
}
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.map(resolveProductImageUrl).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 ''
return imageUrlByKey.value[key] || performanceStore.imageUrl(key) || ''
}
function markProductImageFailed (row) {
const key = productImageKey(row)
if (!key) return
imageFailedKeys.value = new Set([...imageFailedKeys.value, key])
performanceStore.setImageCache(key, [])
imageListByKey.value = { ...imageListByKey.value, [key]: [] }
imageUrlByKey.value = { ...imageUrlByKey.value, [key]: '' }
}
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 switchPerformanceTab (tabName) {
if (!tabName || tabName === activeTab.value || loading.value) return
backendGroupedLoading.value = true
activeTab.value = tabName
}
async function loadBackendGroupedRows (options = {}) {
const tabKey = activeTab.value
if (!backendGroupedSupportedTab(tabKey)) return
if (tabKey === 'product_detail' && !ensureDetailMainGroupSelection()) {
backendGroupedRows.value = {
...backendGroupedRows.value,
product_detail: []
}
backendGroupedLoading.value = false
return
}
pruneActiveExpandedGroups()
backendGroupedLoading.value = true
const requestKey = `${tabKey}|${activeExpandedGroupKeys().join('~')}|${selectedDetailMainGroup.value || ''}`
const params = {
mode: tabKey,
groupLevels: activeGroupLevels.value.map(level => level.key),
expandedKeys: activeExpandedGroupKeys(),
limit: tabKey === 'products' || tabKey === 'product_detail' || tabKey === 'idle' ? productKpiFetchLimit : 50000
}
if (tabKey === 'product_detail') {
params.urunAnaGrubu = selectedDetailMainGroup.value
}
try {
const startedAt = performance.now()
const rows = await performanceStore.fetchGroupedRows(params, { force: options.force === true })
const currentKey = `${activeTab.value}|${activeExpandedGroupKeys().join('~')}|${selectedDetailMainGroup.value || ''}`
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)
if (tabKey === 'order_product_customers' || tabKey === 'order_market_details') {
return withOrderPeriodScores(nextRow)
}
const next = withPeriodScores(nextRow)
if (tabKey !== 'products' && tabKey !== 'product_detail' && tabKey !== 'idle') {
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
}
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
}
}
async function reload () {
loading.value = true
try {
generalRowsLoaded.value = false
generalRows.value = []
marketRowsLoaded.value = false
countryRowsLoaded.value = false
customerRowsLoaded.value = false
const [summaryResp, listResp] = await Promise.all([
timedProductPerformanceGet('summary', '/pricing/product-performance/summary', { timeout: 60000 }),
timedProductPerformanceGet('product-kpi', '/pricing/product-performance', {
params: {
page: 1,
limit: productKpiFetchLimit
},
timeout: 60000
})
])
summary.value = summaryResp?.data || {}
rows.value = (Array.isArray(listResp?.data?.rows) ? listResp.data.rows : []).map(normalizeRow)
await ensureActiveTabData(false)
await loadBackendGroupedRows({ force: true })
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
} finally {
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: 90000 })
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 (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('orders:product-customers', '/pricing/product-performance/orders/product-customers', { params: { limit: reportFetchLimit }, timeout: 90000 })
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 (showLoading) loading.value = true
try {
const resp = await timedProductPerformanceGet('orders:market-details', '/pricing/product-performance/orders/market-details', { params: { limit: reportFetchLimit }, timeout: 90000 })
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: 60000 })
marketRows.value = 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
}
}
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: 60000 })
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: 60000
})
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)
}
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)
scheduleLoadBackendGroupedRows()
})
watch(detailMainGroupOptions, () => {
ensureDetailMainGroupSelection()
}, { immediate: true })
watch(selectedDetailMainGroup, () => {
if (activeTab.value === 'product_detail') {
expandedGroups.value = Object.fromEntries(
Object.entries(expandedGroups.value).filter(([key]) => !key.startsWith('tab:product_detail|'))
)
backendGroupedRows.value = {
...backendGroupedRows.value,
product_detail: []
}
scheduleLoadBackendGroupedRows()
}
})
watch(productTableRows, tableRows => {
if (!isProductKpiTab.value) return
const sourceRows = activeTab.value === 'product_detail' ? displayDetailProductTableRows.value : tableRows
const imageRows = sourceRows
.filter(row => !row.__group || groupShowsImage(row))
.slice(0, 240)
void primeProductImages(imageRows)
}, { immediate: true })
onMounted(reload)
</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: rgba(246, 247, 249, 0.82);
backdrop-filter: blur(2px);
pointer-events: all;
}
.page-busy-label {
color: #1f2937;
font-size: 13px;
font-weight: 700;
}
.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-table :deep(.q-table__middle) {
max-height: calc(100vh - 220px);
overflow: auto;
}
.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: 3400px;
table-layout: fixed;
}
.product-breakdown-table :deep(.q-table th:nth-child(-n+10)),
.product-breakdown-table :deep(.q-table td:nth-child(-n+10)) {
position: sticky;
z-index: 2;
}
.product-breakdown-table :deep(.q-table th:nth-child(-n+10)) {
z-index: 30;
background: #f8fbff;
}
.product-breakdown-table :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) {
background: #fff;
}
.product-breakdown-table :deep(.q-table th:nth-child(1)),
.product-breakdown-table :deep(.q-table td:nth-child(1)) {
left: 0;
width: 420px;
min-width: 420px;
max-width: 420px;
}
.product-breakdown-table :deep(.q-table th:nth-child(2)),
.product-breakdown-table :deep(.q-table td:nth-child(2)) {
left: 420px;
width: 140px;
min-width: 140px;
max-width: 140px;
}
.product-breakdown-table :deep(.q-table th:nth-child(3)),
.product-breakdown-table :deep(.q-table td:nth-child(3)) {
left: 560px;
width: 110px;
min-width: 110px;
max-width: 110px;
}
.product-breakdown-table :deep(.q-table th:nth-child(4)),
.product-breakdown-table :deep(.q-table td:nth-child(4)) {
left: 670px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table :deep(.q-table th:nth-child(5)),
.product-breakdown-table :deep(.q-table td:nth-child(5)) {
left: 820px;
width: 170px;
min-width: 170px;
max-width: 170px;
}
.product-breakdown-table :deep(.q-table th:nth-child(6)),
.product-breakdown-table :deep(.q-table td:nth-child(6)) {
left: 990px;
width: 150px;
min-width: 150px;
max-width: 150px;
}
.product-breakdown-table :deep(.q-table th:nth-child(7)),
.product-breakdown-table :deep(.q-table td:nth-child(7)) {
left: 1140px;
width: 130px;
min-width: 130px;
max-width: 130px;
}
.product-breakdown-table :deep(.q-table th:nth-child(8)),
.product-breakdown-table :deep(.q-table td:nth-child(8)) {
left: 1270px;
width: 90px;
min-width: 90px;
max-width: 90px;
}
.product-breakdown-table :deep(.q-table th:nth-child(9)),
.product-breakdown-table :deep(.q-table td:nth-child(9)) {
left: 1360px;
width: 80px;
min-width: 80px;
max-width: 80px;
}
.product-breakdown-table :deep(.q-table th:nth-child(10)),
.product-breakdown-table :deep(.q-table td:nth-child(10)) {
left: 1440px;
width: 130px;
min-width: 130px;
max-width: 130px;
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
}
.product-breakdown-table.product-detail-table :deep(.q-table) {
min-width: 2850px;
}
.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: 420px;
min-width: 420px;
max-width: 420px;
}
.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: 420px;
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: 570px;
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: 720px;
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: 850px;
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: 1020px;
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: 1190px;
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 :deep(.q-table th:nth-child(n+11):not(.text-right)),
.product-breakdown-table :deep(.q-table td:nth-child(n+11):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-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-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-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-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: 420px;
min-width: 420px;
max-width: 420px;
padding: 10px !important;
text-align: center;
vertical-align: middle;
}
.product-thumb-button {
width: 400px;
height: 400px;
border: 1px solid #d9dde3;
border-radius: 8px;
background: #fff;
padding: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.product-thumb,
.product-thumb-placeholder {
width: 384px;
height: 384px;
}
.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>