ui: update ProductPerformanceProfitability page to enhance table views and tabs management

This commit is contained in:
M_Kececi
2026-07-02 18:11:54 +03:00
parent d98ce372c7
commit 593a7d4816
2 changed files with 798 additions and 147 deletions
+60 -8
View File
@@ -825,20 +825,68 @@ StockAgg AS (
WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate)
GROUP BY s.product_code, s.color_code, s.yaka_kodu
),
Dim AS (
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
product_code,
color_code,
yaka_kodu,
item_description,
kategori,
seri,
yas_grubu,
askili_yan,
urun_ilk_grubu,
urun_ana_grubu,
urun_alt_grubu
FROM (
SELECT
product_code,
color_code,
yaka_kodu,
item_description,
kategori,
seri,
yas_grubu,
askili_yan,
urun_ilk_grubu,
urun_ana_grubu,
urun_alt_grubu,
1 AS src_rank
FROM mk_product_performance_kpi_daily
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
UNION ALL
SELECT
product_code,
color_code,
yaka_kodu,
MAX(item_description) AS item_description,
MAX(kategori) AS kategori,
MAX(seri) AS seri,
MAX(yas_grubu) AS yas_grubu,
MAX(askili_yan) AS askili_yan,
MAX(urun_ilk_grubu) AS urun_ilk_grubu,
MAX(urun_ana_grubu) AS urun_ana_grubu,
MAX(urun_alt_grubu) AS urun_alt_grubu,
2 AS src_rank
FROM mk_product_performance_sales_daily
GROUP BY product_code, color_code, yaka_kodu
) x
ORDER BY product_code, color_code, yaka_kodu, src_rank
),
StockOnly AS (
SELECT
s.product_code,
s.color_code,
s.yaka_kodu,
'STOK'::text AS market_key,
MAX(s.item_description) AS item_description,
MAX(s.kategori) AS kategori,
MAX(s.seri) AS seri,
MAX(s.yas_grubu) AS yas_grubu,
MAX(s.askili_yan) AS askili_yan,
MAX(s.urun_ilk_grubu) AS urun_ilk_grubu,
MAX(s.urun_ana_grubu) AS urun_ana_grubu,
MAX(s.urun_alt_grubu) AS urun_alt_grubu,
COALESCE(MAX(d.item_description),'') AS item_description,
COALESCE(MAX(d.kategori),'') AS kategori,
COALESCE(MAX(d.seri),'') AS seri,
COALESCE(MAX(d.yas_grubu),'') AS yas_grubu,
COALESCE(MAX(d.askili_yan),'') AS askili_yan,
COALESCE(MAX(d.urun_ilk_grubu),'') AS urun_ilk_grubu,
COALESCE(MAX(d.urun_ana_grubu),'') AS urun_ana_grubu,
COALESCE(MAX(d.urun_alt_grubu),'') AS urun_alt_grubu,
NULL::date AS first_sale_date,
NULL::date AS last_sale_date,
''::text AS last_ref_number,
@@ -847,6 +895,10 @@ StockOnly AS (
0::integer AS customer_count_total,
0::integer AS invoice_count_total
FROM mk_product_performance_stock_daily s
LEFT JOIN Dim d
ON d.product_code = s.product_code
AND d.color_code = s.color_code
AND d.yaka_kodu = s.yaka_kodu
WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate)
AND NOT EXISTS (
SELECT 1
+727 -128
View File
@@ -1,4 +1,4 @@
<template>
<template>
<q-page class="product-performance-page q-pa-sm">
<div class="row items-center justify-between q-col-gutter-sm q-mb-sm">
<div class="col-12 col-md">
@@ -9,15 +9,6 @@
</div>
<div class="col-12 col-md-auto row items-center q-gutter-sm">
<q-btn outline color="secondary" icon="refresh" label="Yenile" :loading="loading" @click="reload" />
<q-btn
v-if="canUpdate"
color="primary"
unelevated
icon="sync"
label="Delta Çalıştır"
:loading="refreshing"
@click="runDelta"
/>
</div>
</div>
@@ -56,11 +47,81 @@
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="generalRows"
:rows="filteredGeneralRows"
:columns="generalColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
>
<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)">
@@ -93,11 +154,81 @@
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="orderProductCustomerRows"
:rows="filteredOrderProductCustomerRows"
:columns="orderProductCustomerColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }"
>
<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)">
@@ -140,11 +271,81 @@
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="orderMarketDetailRows"
:rows="filteredOrderMarketDetailRows"
:columns="orderMarketDetailColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }"
>
<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)">
@@ -176,33 +377,75 @@
:pagination="pagination"
@request="onRequest"
>
<template #header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
<q-tr class="table-filter-row">
<q-th v-for="col in props.cols" :key="`filter-${col.name}`">
<q-select
v-if="isColumnFilterable(col.name)"
v-model="columnFilters[col.name]"
:options="columnFilterOptions(col.name)"
<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
borderless
flat
round
size="8px"
icon="filter_alt"
:color="hasColumnFilter('products', props.col.name) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="hasColumnFilter('products', props.col.name)" color="primary" floating rounded>
{{ getColumnFilterBadgeValue('products', 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('products', props.col.name)]"
dense
outlined
clearable
multiple
use-chips
emit-value
map-options
options-dense
class="header-filter"
popup-content-class="header-filter-popup"
placeholder="Seç"
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('products', props.col.name)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter('products', props.col.name)" />
</div>
<q-virtual-scroll
v-if="columnFilterOptions('products', props.col.name).length > 0"
class="excel-filter-options"
:items="columnFilterOptions('products', 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('products', props.col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected('products', props.col.name, option.value)"
@update:model-value="() => toggleColumnFilterValue('products', 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>
</q-tr>
</template>
<template #body="props">
@@ -266,11 +509,81 @@
bordered
row-key="row_key"
class="performance-table bg-white"
:rows="idleRows"
:rows="filteredIdleRows"
:columns="idleColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
>
<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)">
@@ -309,11 +622,81 @@
bordered
row-key="market_key"
class="performance-table bg-white"
:rows="marketRows"
:rows="filteredMarketRows"
:columns="marketColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
>
<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') }}
@@ -353,11 +736,81 @@
bordered
row-key="customer_key"
class="performance-table bg-white"
:rows="customerRows"
:rows="filteredCustomerRows"
:columns="customerColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
>
<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') }}
@@ -382,11 +835,81 @@
bordered
row-key="country_key"
class="performance-table bg-white"
:rows="countryRows"
:rows="filteredCountryRows"
:columns="countryColumns"
:loading="loading"
:pagination="{ rowsPerPage: 100 }"
>
<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') }}
@@ -499,13 +1022,8 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Notify } from 'quasar'
import api from 'src/services/api'
import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters'
import { usePermissionStore } from 'stores/permissionStore'
const perm = usePermissionStore()
const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
const loading = ref(false)
const refreshing = ref(false)
const rows = ref([])
const generalRows = ref([])
const orderAnalysisRows = ref([])
@@ -539,11 +1057,12 @@ const detailSalesRows = ref([])
const detailStockSizes = ref([])
const columnFilters = reactive({})
const columnFilterSearch = reactive({})
const expandedGroups = ref({})
const pagination = ref({
page: 1,
rowsPerPage: 100,
rowsPerPage: 500,
rowsNumber: 0,
sortBy: 'performance_score',
descending: true
@@ -891,43 +1410,6 @@ const performanceCards = computed(() => {
]
})
const filterableColumnNames = new Set([
'product_code',
'color_code',
'yaka_kodu',
'item_description',
'kategori',
'urun_ilk_grubu',
'askili_yan',
'urun_ana_grubu',
'urun_alt_grubu',
'market_key',
'stock_qty',
'sales_qty_90d',
'sales_qty_180d',
'sales_usd_90d',
'sales_usd_180d',
'stock_days_90d',
'stock_days_180d',
'avg_price_usd_90d',
'avg_price_usd_180d',
'base_price_usd',
'cost_price_usd',
'unit_profit_base_90d',
'unit_profit_cost_90d',
'unit_profit_base_180d',
'unit_profit_cost_180d',
'gross_profit_usd_90d',
'gross_profit_usd_180d',
'gross_margin_90d',
'gross_margin_180d',
'market_count_90d',
'customer_count_90d',
'sales_index_90d',
'performance_score',
'performance_bucket'
])
const groupLevels = [
{ key: 'kategori', label: 'Kategori' },
{ key: 'urun_ilk_grubu', label: 'Ürün İlk Grubu', fallback: 'yas_grubu' },
@@ -938,33 +1420,47 @@ const groupLevels = [
{ key: 'color_code', label: 'Renk' }
]
const filteredProductRows = computed(() => rows.value.filter(row => {
return columns.every(col => {
if (!isColumnFilterable(col.name)) return true
const selected = Array.isArray(columnFilters[col.name]) ? columnFilters[col.name] : []
if (!selected.length) return true
return selected.includes(columnFilterValue(row, col.name))
})
const tableFilterSourceMap = computed(() => ({
products: { rows: rows.value, columns },
general: { rows: generalRows.value, columns: generalColumns },
order_product_customers: { rows: orderProductCustomerRows.value, columns: orderProductCustomerColumns },
order_market_details: { rows: orderMarketDetailRows.value, columns: orderMarketDetailColumns },
idle: { rows: idleRows.value, columns: idleColumns },
markets: { rows: marketRows.value, columns: marketColumns },
countries: { rows: countryRows.value, columns: countryColumns },
customers: { rows: customerRows.value, columns: customerColumns }
}))
const filteredProductRows = computed(() => filterRowsForTable('products', rows.value, columns))
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 columnFilterOptionMap = computed(() => {
const out = {}
for (const col of columns) {
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 rows.value) {
const value = columnFilterValue(row, col.name)
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.name),
label: columnFilterLabel(row, col),
value
})
}
}
out[col.name] = Array.from(seen.values())
out[tableKey][col.name] = Array.from(seen.values())
.sort((a, b) => String(a.label).localeCompare(String(b.label), 'tr', { numeric: true }))
}
}
return out
})
@@ -1072,20 +1568,71 @@ function toggleGroup (key) {
}
}
function filterKey (tableKey, name) {
return `${tableKey}:${name}`
}
function isColumnFilterable (name) {
return filterableColumnNames.has(name)
return name !== 'image'
}
function columnFilterOptions (name) {
return columnFilterOptionMap.value[name] || []
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 columnFilterValue (row, name) {
return String(rawProductCellValue(row, name) ?? '').trim()
function hasColumnFilter (tableKey, name) {
return selectedColumnFilters(tableKey, name).length > 0
}
function columnFilterLabel (row, name) {
return formatProductCell(row, name) || columnFilterValue(row, name)
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 isColumnFilterValueSelected (tableKey, name, value) {
return selectedColumnFilters(tableKey, name).includes(value)
}
function toggleColumnFilterValue (tableKey, name, value) {
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) {
columnFilters[filterKey(tableKey, name)] = []
}
function selectAllColumnFilterOptions (tableKey, name) {
columnFilters[filterKey(tableKey, name)] = columnFilterOptions(tableKey, name).map(option => option.value)
}
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 rawProductCellValue (row, name) {
@@ -1097,6 +1644,26 @@ function rawProductCellValue (row, name) {
}
}
function rawColumnCellValue (row, col) {
const name = col?.name || col
switch (name) {
case 'urun_ilk_grubu': return row.urun_ilk_grubu || row.yas_grubu || ''
case 'urun_ana_grubu': return row.urun_ana_grubu || row.seri || ''
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 formattedColumnCellValue (row, col) {
if (typeof col?.field === 'function') return col.field(row)
return formatProductCell(row, col?.name || col)
}
function formatProductCell (row, name) {
switch (name) {
case 'stock_qty':
@@ -1356,10 +1923,10 @@ async function reload () {
timeout: 60000
}),
api.get('/pricing/product-performance/general', { params: { limit: 500 }, timeout: 60000 }),
api.get('/pricing/product-performance/markets', { params: { limit: 200 }, timeout: 60000 }),
api.get('/pricing/product-performance/countries', { params: { limit: 200 }, timeout: 60000 }),
api.get('/pricing/product-performance/markets', { params: { limit: 500 }, timeout: 60000 }),
api.get('/pricing/product-performance/countries', { params: { limit: 500 }, timeout: 60000 }),
api.get('/pricing/product-performance/customers', {
params: { limit: 200, breakdown: customerBreakdown.value },
params: { limit: 500, breakdown: customerBreakdown.value },
timeout: 60000
})
])
@@ -1437,22 +2004,6 @@ async function loadOrderAnalysis (showLoading = true) {
}
}
async function runDelta () {
refreshing.value = true
try {
const resp = await api.post('/pricing/product-performance/refresh', { mode: 'delta' }, { timeout: 4 * 60 * 60 * 1000 })
Notify.create({
type: 'positive',
message: `Delta tamamlandı: satış ${resp?.data?.sales_rows || 0}, stok ${resp?.data?.stock_rows || 0}, KPI ${resp?.data?.kpi_rows || 0}`
})
await reload()
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Delta çalıştırılamadı' })
} finally {
refreshing.value = false
}
}
function onRequest (props) {
pagination.value = { ...pagination.value, ...props.pagination }
reload()
@@ -1516,18 +2067,64 @@ onMounted(reload)
background: #fff;
}
.performance-table :deep(thead tr:nth-child(2) th) {
top: 40px;
z-index: 2;
.filterable-header-cell {
vertical-align: middle;
}
.table-filter-row :deep(.q-field__control) {
min-height: 28px;
height: 28px;
.header-with-filter {
display: grid;
grid-template-columns: minmax(0, 1fr) 20px;
align-items: center;
gap: 2px;
min-width: 78px;
}
.header-filter {
min-width: 96px;
.header-with-filter > span {
line-height: 1.15;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
}
.header-filter-btn {
width: 20px;
height: 20px;
min-height: 20px;
}
.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;
}
.group-row td {
@@ -1608,3 +2205,5 @@ onMounted(reload)
border-radius: 8px;
}
</style>