ui: update ProductPerformanceProfitability page to enhance table views and tabs management
This commit is contained in:
@@ -2600,6 +2600,10 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
|
||||
levels = defaultProductPerformanceGroupLevels(req.Mode)
|
||||
}
|
||||
|
||||
if out, ok, err := listProductPerformanceGroupedSQL(ctx, pg, req, levels); ok || err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2609,6 +2613,302 @@ func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductP
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type productPerformanceSQLGroupFilter struct {
|
||||
Field string
|
||||
Value string
|
||||
}
|
||||
|
||||
func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) {
|
||||
mode := strings.TrimSpace(req.Mode)
|
||||
if mode != "products" && mode != "idle" {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err := EnsureProductPerformanceTables(pg); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
out := make([]map[string]any, 0, 512)
|
||||
err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, []string{"tab:" + mode}, nil, req.ExpandedKeys, req.Limit)
|
||||
return out, true, err
|
||||
}
|
||||
|
||||
func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, limit int) error {
|
||||
if level >= len(levels) {
|
||||
rows, err := queryProductPerformanceSQLLeafRows(ctx, pg, mode, filters, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*out = append(*out, rows...)
|
||||
return nil
|
||||
}
|
||||
|
||||
field := levels[level]
|
||||
rows, err := queryProductPerformanceSQLGroupRows(ctx, pg, mode, field, level, parentKeys, filters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
value := stringFromMap(row, "group_value")
|
||||
keyPart := field + ":" + value
|
||||
key := strings.Join(append(parentKeys, keyPart), "|")
|
||||
row["row_key"] = "group|" + key
|
||||
row["key"] = key
|
||||
row["level"] = level
|
||||
row["group_field"] = field
|
||||
row["group_value"] = value
|
||||
row["label"] = value
|
||||
row["__group"] = true
|
||||
row[field] = value
|
||||
*out = append(*out, row)
|
||||
if expandedKeys[key] {
|
||||
nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value})
|
||||
if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, append(parentKeys, keyPart), nextFilters, expandedKeys, limit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryProductPerformanceSQLGroupRows(ctx context.Context, pg *sql.DB, mode, field string, level int, parentKeys []string, filters []productPerformanceSQLGroupFilter) ([]map[string]any, error) {
|
||||
groupExpr, ok := productPerformanceSQLGroupExpr(field)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported product performance group field: %s", field)
|
||||
}
|
||||
whereSQL, args, err := productPerformanceSQLFilterWhere(filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
|
||||
SELECT jsonb_build_object(
|
||||
'group_value', group_value,
|
||||
'label', group_value,
|
||||
'count', row_count,
|
||||
'recommendation', row_count::text || ' satir',
|
||||
'image_product_code', image_product_code,
|
||||
'image_color_code', image_color_code,
|
||||
'image_yaka_kodu', image_yaka_kodu,
|
||||
'product_code', CASE WHEN $%d = 'product_code' THEN group_value ELSE product_code END,
|
||||
'color_code', CASE WHEN $%d = 'color_code' THEN group_value ELSE color_code END,
|
||||
'yaka_kodu', CASE WHEN $%d = 'yaka_kodu' THEN group_value ELSE yaka_kodu END,
|
||||
'item_description', item_description,
|
||||
'kategori', CASE WHEN $%d = 'kategori' THEN group_value ELSE kategori END,
|
||||
'askili_yan', CASE WHEN $%d = 'askili_yan' THEN group_value ELSE askili_yan END,
|
||||
'urun_ilk_grubu', CASE WHEN $%d = 'urun_ilk_grubu' THEN group_value ELSE urun_ilk_grubu END,
|
||||
'urun_ana_grubu', CASE WHEN $%d = 'urun_ana_grubu' THEN group_value ELSE urun_ana_grubu END,
|
||||
'urun_alt_grubu', CASE WHEN $%d = 'urun_alt_grubu' THEN group_value ELSE urun_alt_grubu END,
|
||||
'market_key', CASE WHEN $%d = 'market_key' THEN group_value ELSE market_key END,
|
||||
'stock_qty', stock_qty,
|
||||
'sales_qty_90d', sales_qty_90d,
|
||||
'sales_qty_180d', sales_qty_180d,
|
||||
'sales_qty_365d', sales_qty_365d,
|
||||
'sales_usd_90d', sales_usd_90d,
|
||||
'sales_usd_180d', sales_usd_180d,
|
||||
'sales_usd_365d', sales_usd_365d,
|
||||
'avg_price_usd_90d', avg_price_usd_90d,
|
||||
'avg_price_usd_180d', avg_price_usd_180d,
|
||||
'cost_price_usd', cost_price_usd,
|
||||
'base_price_usd', base_price_usd,
|
||||
'base_price_try', base_price_try,
|
||||
'gross_profit_usd_90d', gross_profit_usd_90d,
|
||||
'gross_profit_usd_180d', gross_profit_usd_180d,
|
||||
'gross_margin_90d', gross_margin_90d,
|
||||
'gross_margin_180d', gross_margin_180d,
|
||||
'unit_profit_cost_90d', unit_profit_cost_90d,
|
||||
'unit_profit_cost_180d', unit_profit_cost_180d,
|
||||
'unit_profit_base_90d', unit_profit_base_90d,
|
||||
'unit_profit_base_180d', unit_profit_base_180d,
|
||||
'market_count_90d', market_count_90d,
|
||||
'customer_count_90d', customer_count_90d,
|
||||
'sales_index_90d', sales_index_90d,
|
||||
'price_index_90d', price_index_90d,
|
||||
'margin_index_90d', margin_index_90d,
|
||||
'performance_score', performance_score,
|
||||
'performance_bucket', performance_bucket,
|
||||
'idle_cost_usd', idle_cost_usd,
|
||||
'stock_turnover_90d', CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END,
|
||||
'stock_turnover_180d', CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END,
|
||||
'stock_turnover_365d', CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END
|
||||
) AS row_json
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE(NULLIF(%s,''), '-') AS group_value,
|
||||
COUNT(*)::integer AS row_count,
|
||||
(ARRAY_AGG(product_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_product_code,
|
||||
(ARRAY_AGG(color_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_color_code,
|
||||
(ARRAY_AGG(yaka_kodu ORDER BY performance_score DESC NULLS LAST))[1] AS image_yaka_kodu,
|
||||
COALESCE((ARRAY_AGG(NULLIF(product_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS product_code,
|
||||
COALESCE((ARRAY_AGG(NULLIF(color_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS color_code,
|
||||
COALESCE((ARRAY_AGG(NULLIF(yaka_kodu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS yaka_kodu,
|
||||
COALESCE((ARRAY_AGG(NULLIF(item_description,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS item_description,
|
||||
COALESCE((ARRAY_AGG(NULLIF(kategori,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS kategori,
|
||||
COALESCE((ARRAY_AGG(NULLIF(askili_yan,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS askili_yan,
|
||||
COALESCE((ARRAY_AGG(NULLIF(urun_ilk_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ilk_grubu,
|
||||
COALESCE((ARRAY_AGG(NULLIF(urun_ana_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ana_grubu,
|
||||
COALESCE((ARRAY_AGG(NULLIF(urun_alt_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_alt_grubu,
|
||||
COALESCE((ARRAY_AGG(NULLIF(market_key,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS market_key,
|
||||
COALESCE(SUM(stock_qty),0) AS stock_qty,
|
||||
COALESCE(SUM(sales_qty_90d),0) AS sales_qty_90d,
|
||||
COALESCE(SUM(sales_qty_180d),0) AS sales_qty_180d,
|
||||
COALESCE(SUM(sales_qty_365d),0) AS sales_qty_365d,
|
||||
COALESCE(SUM(sales_usd_90d),0) AS sales_usd_90d,
|
||||
COALESCE(SUM(sales_usd_180d),0) AS sales_usd_180d,
|
||||
COALESCE(SUM(sales_usd_365d),0) AS sales_usd_365d,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_usd_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS avg_price_usd_90d,
|
||||
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(sales_usd_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS avg_price_usd_180d,
|
||||
CASE WHEN SUM(stock_qty) > 0 THEN SUM(cost_price_usd * stock_qty) / NULLIF(SUM(stock_qty),0) ELSE AVG(cost_price_usd) END AS cost_price_usd,
|
||||
CASE WHEN SUM(stock_qty) > 0 THEN SUM(base_price_usd * stock_qty) / NULLIF(SUM(stock_qty),0) ELSE AVG(base_price_usd) END AS base_price_usd,
|
||||
CASE WHEN SUM(stock_qty) > 0 THEN SUM(base_price_try * stock_qty) / NULLIF(SUM(stock_qty),0) ELSE AVG(base_price_try) END AS base_price_try,
|
||||
COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d,
|
||||
COALESCE(SUM(gross_profit_usd_180d),0) AS gross_profit_usd_180d,
|
||||
CASE WHEN SUM(sales_usd_90d) > 0 THEN SUM(gross_profit_usd_90d) / NULLIF(SUM(sales_usd_90d),0) ELSE 0 END AS gross_margin_90d,
|
||||
CASE WHEN SUM(sales_usd_180d) > 0 THEN SUM(gross_profit_usd_180d) / NULLIF(SUM(sales_usd_180d),0) ELSE 0 END AS gross_margin_180d,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_cost_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(unit_profit_cost_90d) END AS unit_profit_cost_90d,
|
||||
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_cost_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE AVG(unit_profit_cost_180d) END AS unit_profit_cost_180d,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_base_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(unit_profit_base_90d) END AS unit_profit_base_90d,
|
||||
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_base_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE AVG(unit_profit_base_180d) END AS unit_profit_base_180d,
|
||||
COALESCE(SUM(market_count_90d),0)::integer AS market_count_90d,
|
||||
COALESCE(SUM(customer_count_90d),0)::integer AS customer_count_90d,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(sales_index_90d) END AS sales_index_90d,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(price_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(price_index_90d) END AS price_index_90d,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(margin_index_90d) END AS margin_index_90d,
|
||||
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(performance_score * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE AVG(performance_score) END AS performance_score,
|
||||
MODE() WITHIN GROUP (ORDER BY performance_bucket) AS performance_bucket,
|
||||
COALESCE(SUM(idle_cost_usd),0) AS idle_cost_usd
|
||||
FROM Source
|
||||
%s
|
||||
GROUP BY COALESCE(NULLIF(%s,''), '-')
|
||||
) g
|
||||
ORDER BY group_value
|
||||
`, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, groupExpr, whereSQL, groupExpr)
|
||||
args = append(args, field)
|
||||
return queryProductPerformanceJSONRows(ctx, pg, query, args...)
|
||||
}
|
||||
|
||||
func queryProductPerformanceSQLLeafRows(ctx context.Context, pg *sql.DB, mode string, filters []productPerformanceSQLGroupFilter, limit int) ([]map[string]any, error) {
|
||||
whereSQL, args, err := productPerformanceSQLFilterWhere(filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, limit)
|
||||
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
|
||||
SELECT to_jsonb(t) || jsonb_build_object(
|
||||
'row_key', 'leaf|' || product_code || '|' || color_code || '|' || yaka_kodu || '|' || market_key,
|
||||
'stock_turnover_90d', CASE WHEN stock_qty > 0 THEN sales_qty_90d / NULLIF(stock_qty,0) ELSE 0 END,
|
||||
'stock_turnover_180d', CASE WHEN stock_qty > 0 THEN sales_qty_180d / NULLIF(stock_qty,0) ELSE 0 END,
|
||||
'stock_turnover_365d', CASE WHEN stock_qty > 0 THEN sales_qty_365d / NULLIF(stock_qty,0) ELSE 0 END
|
||||
) AS row_json
|
||||
FROM Source t
|
||||
%s
|
||||
ORDER BY performance_score DESC, sales_usd_90d DESC
|
||||
LIMIT $%d
|
||||
`, whereSQL, len(args))
|
||||
return queryProductPerformanceJSONRows(ctx, pg, query, args...)
|
||||
}
|
||||
|
||||
func queryProductPerformanceJSONRows(ctx context.Context, pg *sql.DB, query string, args ...any) ([]map[string]any, error) {
|
||||
rows, err := pg.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func productPerformanceSQLSourceCTE(mode string) string {
|
||||
idleWhere := ""
|
||||
if mode == "idle" {
|
||||
idleWhere = `
|
||||
AND stock_qty > 0
|
||||
AND (performance_bucket = 'STOK_RISKI' OR COALESCE(sales_qty_90d,0) = 0 OR COALESCE(stock_days_90d,0) > 180)`
|
||||
}
|
||||
return `
|
||||
WITH Source AS (
|
||||
SELECT
|
||||
product_code,
|
||||
color_code,
|
||||
yaka_kodu,
|
||||
item_description,
|
||||
kategori,
|
||||
seri,
|
||||
yas_grubu,
|
||||
askili_yan,
|
||||
COALESCE(NULLIF(urun_ilk_grubu,''), yas_grubu) AS urun_ilk_grubu,
|
||||
COALESCE(NULLIF(urun_ana_grubu,''), seri) AS urun_ana_grubu,
|
||||
urun_alt_grubu,
|
||||
market_key,
|
||||
COALESCE(stock_qty,0) AS stock_qty,
|
||||
COALESCE(sales_qty_90d,0) AS sales_qty_90d,
|
||||
COALESCE(sales_qty_180d,0) AS sales_qty_180d,
|
||||
COALESCE(sales_qty_365d,0) AS sales_qty_365d,
|
||||
COALESCE(sales_usd_90d,0) AS sales_usd_90d,
|
||||
COALESCE(sales_usd_180d,0) AS sales_usd_180d,
|
||||
COALESCE(sales_usd_365d,0) AS sales_usd_365d,
|
||||
COALESCE(avg_price_usd_90d,0) AS avg_price_usd_90d,
|
||||
COALESCE(avg_price_usd_180d,0) AS avg_price_usd_180d,
|
||||
COALESCE(cost_price_usd,0) AS cost_price_usd,
|
||||
COALESCE(base_price_usd,0) AS base_price_usd,
|
||||
COALESCE(base_price_try,0) AS base_price_try,
|
||||
COALESCE(gross_profit_usd_90d,0) AS gross_profit_usd_90d,
|
||||
COALESCE(gross_profit_usd_180d,0) AS gross_profit_usd_180d,
|
||||
COALESCE(gross_margin_90d,0) AS gross_margin_90d,
|
||||
COALESCE(gross_margin_180d,0) AS gross_margin_180d,
|
||||
COALESCE(unit_profit_cost_90d,0) AS unit_profit_cost_90d,
|
||||
COALESCE(unit_profit_cost_180d,0) AS unit_profit_cost_180d,
|
||||
COALESCE(unit_profit_base_90d,0) AS unit_profit_base_90d,
|
||||
COALESCE(unit_profit_base_180d,0) AS unit_profit_base_180d,
|
||||
COALESCE(market_count_90d,0) AS market_count_90d,
|
||||
COALESCE(customer_count_90d,0) AS customer_count_90d,
|
||||
COALESCE(sales_index_90d,0) AS sales_index_90d,
|
||||
COALESCE(price_index_90d,0) AS price_index_90d,
|
||||
COALESCE(margin_index_90d,0) AS margin_index_90d,
|
||||
COALESCE(performance_score,0) AS performance_score,
|
||||
COALESCE(performance_bucket,'') AS performance_bucket,
|
||||
COALESCE(stock_qty,0) * COALESCE(cost_price_usd,0) AS idle_cost_usd
|
||||
FROM mk_product_performance_kpi_daily
|
||||
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)` + idleWhere + `
|
||||
)`
|
||||
}
|
||||
|
||||
func productPerformanceSQLGroupExpr(field string) (string, bool) {
|
||||
switch field {
|
||||
case "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_code", "yaka_kodu":
|
||||
return field, true
|
||||
case "market_key":
|
||||
return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceSQLFilterWhere(filters []productPerformanceSQLGroupFilter) (string, []any, error) {
|
||||
if len(filters) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
parts := make([]string, 0, len(filters))
|
||||
args := make([]any, 0, len(filters))
|
||||
for _, filter := range filters {
|
||||
expr, ok := productPerformanceSQLGroupExpr(filter.Field)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("unsupported product performance filter field: %s", filter.Field)
|
||||
}
|
||||
args = append(args, normalizeProductPerformanceGroupValue(filter.Value))
|
||||
parts = append(parts, fmt.Sprintf("COALESCE(NULLIF(%s,''), '-') = $%d", expr, len(args)))
|
||||
}
|
||||
return "WHERE " + strings.Join(parts, " AND "), args, nil
|
||||
}
|
||||
|
||||
func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
|
||||
switch mode {
|
||||
case "products":
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
<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="row items-center justify-between q-col-gutter-sm q-mb-sm">
|
||||
<div class="col-12 col-md">
|
||||
<div class="text-h6">Ürün Performans ve Karlılık Analizi</div>
|
||||
@@ -14,6 +31,7 @@
|
||||
type="checkbox"
|
||||
inline
|
||||
dense
|
||||
:disable="pageBusy"
|
||||
class="period-selector bg-white q-px-sm q-py-xs"
|
||||
/>
|
||||
<q-btn-dropdown
|
||||
@@ -22,6 +40,7 @@
|
||||
color="primary"
|
||||
:icon="allProductGroupsExpanded ? 'unfold_less' : 'unfold_more'"
|
||||
:label="allProductGroupsExpanded ? 'Detayları Kapat' : 'Tüm Detayları Aç'"
|
||||
:disable="pageBusy"
|
||||
@click="toggleAllProductGroups"
|
||||
>
|
||||
<div class="detail-level-menu">
|
||||
@@ -33,12 +52,12 @@
|
||||
/>
|
||||
<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" @click="expandedGroups = {}" />
|
||||
<q-btn dense color="primary" label="Seçileni Aç" @click="expandSelectedProductGroups" />
|
||||
<q-btn dense flat color="grey-7" label="Kapat" :disable="pageBusy" @click="collapseAllProductGroups" />
|
||||
<q-btn dense color="primary" label="Seçileni Aç" :disable="pageBusy" @click="expandSelectedProductGroups" />
|
||||
</div>
|
||||
</div>
|
||||
</q-btn-dropdown>
|
||||
<q-btn outline color="secondary" icon="refresh" label="Yenile" :loading="loading" @click="reload" />
|
||||
<q-btn outline color="secondary" icon="refresh" label="Yenile" :loading="loading" :disable="pageBusy" @click="reload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,23 +72,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-tabs
|
||||
v-model="activeTab"
|
||||
dense
|
||||
class="bg-white text-grey-8 q-mb-sm"
|
||||
active-color="primary"
|
||||
indicator-color="primary"
|
||||
align="left"
|
||||
>
|
||||
<q-tab name="products" icon="inventory_2" label="Ürün KPI" />
|
||||
<q-tab name="sales_color_yaka_market_customer" icon="palette" label="Renk > Yaka > Piyasa > Müşteri" />
|
||||
<q-tab name="idle" icon="warning" label="Atıl Stok / Maliyet" />
|
||||
<q-tab name="sales_product_country_segment_market_customer" icon="account_tree" label="Ürün > Ülke > Segment > Piyasa > Müşteri" />
|
||||
<q-tab name="sales_market_customer_product" icon="groups" label="Piyasa > Müşteri > Ürün Satış KPI" />
|
||||
<q-tab name="sales_country_segment_market_customer_product" icon="public" label="Ülke > Segment > Piyasa > Müşteri > Ürün Satış KPI" />
|
||||
<q-tab name="order_product_customers" icon="assignment_ind" label="Ürün > Piyasa > Müşteri Sipariş" />
|
||||
<q-tab name="order_market_details" icon="receipt_long" label="Piyasa > Müşteri > Ürün Sipariş" />
|
||||
</q-tabs>
|
||||
<div class="performance-tab-grid q-mb-sm">
|
||||
<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>
|
||||
|
||||
<q-table
|
||||
v-if="activeTab === 'general'"
|
||||
@@ -81,6 +96,8 @@
|
||||
:columns="generalColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -189,6 +206,8 @@
|
||||
:columns="orderProductCustomerColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -373,6 +392,8 @@
|
||||
:columns="orderMarketDetailColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -507,6 +528,8 @@
|
||||
:columns="productColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="pagination"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
@request="onRequest"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
@@ -658,6 +681,8 @@
|
||||
:columns="idleColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -838,6 +863,8 @@
|
||||
:columns="marketColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -936,6 +963,8 @@
|
||||
:columns="visibleSalesBreakdownColumns"
|
||||
:loading="loading || backendGroupedLoading"
|
||||
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -1091,6 +1120,8 @@
|
||||
:columns="customerColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -1190,6 +1221,8 @@
|
||||
:columns="countryColumns"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 100 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="64"
|
||||
>
|
||||
<template #header-cell="props">
|
||||
<q-th :props="props" class="filterable-header-cell">
|
||||
@@ -1396,6 +1429,8 @@
|
||||
:columns="detailStockColumns"
|
||||
:loading="detailLoading"
|
||||
:pagination="{ rowsPerPage: 50 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="48"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
@@ -1409,6 +1444,8 @@
|
||||
:columns="detailSalesColumns"
|
||||
:loading="detailLoading"
|
||||
:pagination="{ rowsPerPage: 50 }"
|
||||
virtual-scroll
|
||||
:virtual-scroll-item-size="48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1422,8 +1459,11 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import api from 'src/services/api'
|
||||
import { useProductPerformanceStore } from 'src/stores/productPerformanceStore'
|
||||
import { formatMoney, formatNumber, formatPercent } from 'src/utils/formatters'
|
||||
|
||||
const performanceStore = useProductPerformanceStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const generalRows = ref([])
|
||||
@@ -1466,7 +1506,10 @@ const detailSalesRows = ref([])
|
||||
const detailStockSizes = ref([])
|
||||
const backendGroupedRows = ref({})
|
||||
const backendGroupedLoading = ref(false)
|
||||
const imagePrimeLoading = ref(false)
|
||||
const filterBusy = ref(false)
|
||||
let backendGroupedTimer = null
|
||||
let imagePrimeActiveRequests = 0
|
||||
|
||||
const columnFilters = reactive({})
|
||||
const columnFilterSearch = reactive({})
|
||||
@@ -1482,6 +1525,17 @@ const selectedExpandLevelKeysByTab = reactive({
|
||||
order_market_details: ['market_key', 'customer_code', 'product_code', 'color_code', 'yaka_kodu']
|
||||
})
|
||||
const productKpiFetchLimit = 50000
|
||||
const performanceTabs = [
|
||||
{ name: 'products', icon: 'inventory_2', label: 'Ürün 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_market_customer_product', icon: 'groups', label: 'Piyasa > Müşteri > Ürün Satış KPI' },
|
||||
{ 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 || backendGroupedLoading.value || imagePrimeLoading.value || filterBusy.value)
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
@@ -2516,13 +2570,17 @@ function toggleGroup (key) {
|
||||
function toggleAllProductGroups () {
|
||||
const keys = productAutoExpandKeys.value
|
||||
if (allProductGroupsExpanded.value) {
|
||||
expandedGroups.value = {}
|
||||
scheduleLoadBackendGroupedRows()
|
||||
collapseAllProductGroups()
|
||||
return
|
||||
}
|
||||
expandSelectedProductGroups()
|
||||
}
|
||||
|
||||
function collapseAllProductGroups () {
|
||||
expandedGroups.value = {}
|
||||
scheduleLoadBackendGroupedRows()
|
||||
}
|
||||
|
||||
function expandSelectedProductGroups () {
|
||||
const keys = productAutoExpandKeys.value
|
||||
const next = { ...expandedGroups.value }
|
||||
@@ -2617,19 +2675,47 @@ function isColumnFilterValueSelected (tableKey, name, 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]
|
||||
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) {
|
||||
columnFilters[filterKey(tableKey, name)] = []
|
||||
runWithFilterBusy(() => {
|
||||
columnFilters[filterKey(tableKey, name)] = []
|
||||
})
|
||||
}
|
||||
|
||||
function selectAllColumnFilterOptions (tableKey, name) {
|
||||
columnFilters[filterKey(tableKey, name)] = columnFilterOptions(tableKey, name).map(option => option.value)
|
||||
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) {
|
||||
@@ -2931,6 +3017,12 @@ async function fetchProductImagesForRow (row) {
|
||||
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 []
|
||||
@@ -2949,10 +3041,12 @@ async function fetchProductImagesForRow (row) {
|
||||
})
|
||||
const list = Array.isArray(resp?.data) ? resp.data : []
|
||||
const urls = list.map(resolveProductImageUrl).filter(Boolean)
|
||||
performanceStore.setImageCache(key, urls)
|
||||
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 []
|
||||
@@ -2960,44 +3054,38 @@ async function fetchProductImagesForRow (row) {
|
||||
}
|
||||
|
||||
function getCachedProductImageUrl (row) {
|
||||
return imageUrlByKey.value[productImageKey(row)] || ''
|
||||
const key = productImageKey(row)
|
||||
return imageUrlByKey.value[key] || performanceStore.imageUrl(key) || ''
|
||||
}
|
||||
|
||||
async function primeProductImages (sourceRows) {
|
||||
imagePrimeActiveRequests += 1
|
||||
imagePrimeLoading.value = true
|
||||
const items = []
|
||||
const seen = new Set()
|
||||
for (const row of sourceRows || []) {
|
||||
const key = productImageKey(row)
|
||||
if (!key || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) continue
|
||||
seen.add(key)
|
||||
items.push({
|
||||
key,
|
||||
code: String(row?.image_product_code || row?.product_code || '').trim(),
|
||||
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
|
||||
|
||||
try {
|
||||
const resp = await api.post('/product-images/batch', { items }, { timeout: 60000 })
|
||||
for (const row of sourceRows || []) {
|
||||
const key = productImageKey(row)
|
||||
const code = String(row?.image_product_code || row?.product_code || '').trim()
|
||||
if (!key || !code || 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 }
|
||||
const returned = new Set()
|
||||
for (const item of Array.isArray(resp?.data) ? resp.data : []) {
|
||||
const key = String(item?.key || '').trim()
|
||||
if (!key) continue
|
||||
returned.add(key)
|
||||
const urls = (Array.isArray(item.images) ? item.images : []).map(resolveProductImageUrl).filter(Boolean)
|
||||
nextLists[key] = urls
|
||||
nextUrls[key] = urls[0] || ''
|
||||
}
|
||||
for (const item of items) {
|
||||
if (!returned.has(item.key)) {
|
||||
nextLists[item.key] = []
|
||||
nextUrls[item.key] = ''
|
||||
}
|
||||
const urls = batch?.lists?.[item.key] || performanceStore.imageList(item.key) || []
|
||||
nextLists[item.key] = urls
|
||||
nextUrls[item.key] = batch?.urls?.[item.key] || urls[0] || ''
|
||||
}
|
||||
imageListByKey.value = nextLists
|
||||
imageUrlByKey.value = nextUrls
|
||||
@@ -3007,6 +3095,9 @@ async function primeProductImages (sourceRows) {
|
||||
color_code: item.dim1,
|
||||
yaka_kodu: item.dim3
|
||||
})))
|
||||
} finally {
|
||||
imagePrimeActiveRequests = Math.max(0, imagePrimeActiveRequests - 1)
|
||||
imagePrimeLoading.value = imagePrimeActiveRequests > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3080,29 +3171,34 @@ function activeExpandedGroupKeys () {
|
||||
}
|
||||
|
||||
function scheduleLoadBackendGroupedRows () {
|
||||
if (backendGroupedSupportedTab(activeTab.value)) backendGroupedLoading.value = true
|
||||
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
|
||||
backendGroupedTimer = window.setTimeout(() => {
|
||||
void loadBackendGroupedRows()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function switchPerformanceTab (tabName) {
|
||||
if (!tabName || tabName === activeTab.value || pageBusy.value) return
|
||||
backendGroupedLoading.value = true
|
||||
activeTab.value = tabName
|
||||
}
|
||||
|
||||
async function loadBackendGroupedRows () {
|
||||
const tabKey = activeTab.value
|
||||
if (!backendGroupedSupportedTab(tabKey)) return
|
||||
backendGroupedLoading.value = true
|
||||
const params = {
|
||||
mode: tabKey,
|
||||
groupLevels: activeGroupLevels.value.map(level => level.key),
|
||||
expandedKeys: activeExpandedGroupKeys(),
|
||||
limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000
|
||||
}
|
||||
try {
|
||||
const resp = await api.get('/pricing/product-performance/grouped', {
|
||||
params: {
|
||||
mode: tabKey,
|
||||
group_levels: activeGroupLevels.value.map(level => level.key).join(','),
|
||||
expanded_keys: activeExpandedGroupKeys().join(','),
|
||||
limit: tabKey === 'products' || tabKey === 'idle' ? productKpiFetchLimit : 50000
|
||||
},
|
||||
timeout: 90000
|
||||
})
|
||||
const rows = await performanceStore.fetchGroupedRows(params)
|
||||
backendGroupedRows.value = {
|
||||
...backendGroupedRows.value,
|
||||
[tabKey]: Array.isArray(resp?.data) ? resp.data : []
|
||||
[tabKey]: Array.isArray(rows) ? rows : []
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('product performance grouped rows failed', err)
|
||||
@@ -3168,7 +3264,7 @@ async function reload () {
|
||||
await loadOrderMarketDetails(false)
|
||||
}
|
||||
await loadBackendGroupedRows()
|
||||
void primeProductImages(rows.value)
|
||||
await primeProductImages(rows.value)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
|
||||
} finally {
|
||||
@@ -3182,7 +3278,7 @@ async function loadOrderProductCustomers (showLoading = true) {
|
||||
const resp = await api.get('/pricing/product-performance/orders/product-customers', { params: { limit: 500 }, timeout: 90000 })
|
||||
orderProductCustomerRows.value = (Array.isArray(resp?.data) ? resp.data : []).map(normalizeOrderProductCustomerRow)
|
||||
orderProductCustomersLoaded.value = true
|
||||
void primeProductImages(orderProductCustomerRows.value)
|
||||
await primeProductImages(orderProductCustomerRows.value)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün müşteri sipariş analizi alınamadı' })
|
||||
} finally {
|
||||
@@ -3215,7 +3311,7 @@ async function loadOrderAnalysis (showLoading = true) {
|
||||
orderMarketRows.value = (Array.isArray(marketResp?.data) ? marketResp.data : []).map(normalizeOrderGroupRow)
|
||||
orderCustomerRows.value = (Array.isArray(customerResp?.data) ? customerResp.data : []).map(normalizeOrderGroupRow)
|
||||
orderAnalysisLoaded.value = true
|
||||
void primeProductImages(orderAnalysisRows.value)
|
||||
await primeProductImages(orderAnalysisRows.value)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Sipariş analiz verisi alınamadı' })
|
||||
} finally {
|
||||
@@ -3271,9 +3367,70 @@ onMounted(reload)
|
||||
|
||||
<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-tab-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(128px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.performance-tab-button {
|
||||
min-height: 38px;
|
||||
padding: 6px 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
border: 1px solid #d5dce5;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #4b5563;
|
||||
font-size: 11px;
|
||||
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;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 78px;
|
||||
}
|
||||
@@ -3311,8 +3468,17 @@ onMounted(reload)
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody tr),
|
||||
.performance-table :deep(.q-table tbody td) {
|
||||
height: auto;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody td) {
|
||||
max-width: 320px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table tbody td span:not(.q-badge):not(.q-icon)) {
|
||||
@@ -3323,6 +3489,13 @@ onMounted(reload)
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.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: 96px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.performance-table :deep(.q-table th:first-child),
|
||||
.performance-table :deep(.q-table td:first-child) {
|
||||
border-left: 1px solid #d5dce5;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import api from 'src/services/api'
|
||||
|
||||
const GROUPED_TTL_MS = 2 * 60 * 1000
|
||||
const IMAGE_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
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}` : ''
|
||||
}
|
||||
|
||||
function sortedList (value) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.map(x => String(x || '').trim()).filter(Boolean).sort()
|
||||
}
|
||||
|
||||
export const useProductPerformanceStore = defineStore('product-performance-store', {
|
||||
state: () => ({
|
||||
groupedRowsByKey: {},
|
||||
groupedLoadedAtByKey: {},
|
||||
groupedLoadingByKey: {},
|
||||
groupedInFlightByKey: {},
|
||||
imageUrlByKey: {},
|
||||
imageListByKey: {},
|
||||
imageLoadedAtByKey: {}
|
||||
}),
|
||||
|
||||
getters: {
|
||||
groupedRows: state => key => state.groupedRowsByKey[key] || [],
|
||||
hasImageKey: state => key => Object.prototype.hasOwnProperty.call(state.imageListByKey, key),
|
||||
imageUrl: state => key => state.imageUrlByKey[key] || '',
|
||||
imageList: state => key => state.imageListByKey[key] || []
|
||||
},
|
||||
|
||||
actions: {
|
||||
groupedCacheKey (params = {}) {
|
||||
return JSON.stringify({
|
||||
mode: String(params.mode || ''),
|
||||
groupLevels: sortedList(params.groupLevels),
|
||||
expandedKeys: sortedList(params.expandedKeys),
|
||||
limit: Number(params.limit || 0)
|
||||
})
|
||||
},
|
||||
|
||||
async fetchGroupedRows (params = {}, options = {}) {
|
||||
const key = this.groupedCacheKey(params)
|
||||
const now = Date.now()
|
||||
const loadedAt = Number(this.groupedLoadedAtByKey[key] || 0)
|
||||
const cached = this.groupedRowsByKey[key]
|
||||
if (!options.force && Array.isArray(cached) && now - loadedAt < GROUPED_TTL_MS) return cached
|
||||
if (this.groupedInFlightByKey[key]) return this.groupedInFlightByKey[key]
|
||||
|
||||
this.groupedLoadingByKey = { ...this.groupedLoadingByKey, [key]: true }
|
||||
const request = api.get('/pricing/product-performance/grouped', {
|
||||
params: {
|
||||
mode: params.mode,
|
||||
group_levels: Array.isArray(params.groupLevels) ? params.groupLevels.join(',') : params.groupLevels,
|
||||
expanded_keys: Array.isArray(params.expandedKeys) ? params.expandedKeys.join(',') : params.expandedKeys,
|
||||
limit: params.limit
|
||||
},
|
||||
timeout: 90000
|
||||
}).then(resp => {
|
||||
const rows = Array.isArray(resp?.data) ? resp.data : []
|
||||
this.groupedRowsByKey = { ...this.groupedRowsByKey, [key]: rows }
|
||||
this.groupedLoadedAtByKey = { ...this.groupedLoadedAtByKey, [key]: Date.now() }
|
||||
return rows
|
||||
}).finally(() => {
|
||||
const nextLoading = { ...this.groupedLoadingByKey }
|
||||
const nextInFlight = { ...this.groupedInFlightByKey }
|
||||
delete nextLoading[key]
|
||||
delete nextInFlight[key]
|
||||
this.groupedLoadingByKey = nextLoading
|
||||
this.groupedInFlightByKey = nextInFlight
|
||||
})
|
||||
|
||||
this.groupedInFlightByKey = { ...this.groupedInFlightByKey, [key]: request }
|
||||
return request
|
||||
},
|
||||
|
||||
setImageCache (key, urls = []) {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
if (!normalizedKey) return
|
||||
const list = Array.isArray(urls) ? urls.filter(Boolean) : []
|
||||
this.imageListByKey = { ...this.imageListByKey, [normalizedKey]: list }
|
||||
this.imageUrlByKey = { ...this.imageUrlByKey, [normalizedKey]: list[0] || '' }
|
||||
this.imageLoadedAtByKey = { ...this.imageLoadedAtByKey, [normalizedKey]: Date.now() }
|
||||
},
|
||||
|
||||
async fetchImageBatch (items = [], options = {}) {
|
||||
const now = Date.now()
|
||||
const pending = []
|
||||
const seen = new Set()
|
||||
for (const item of items) {
|
||||
const key = String(item?.key || '').trim()
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const loadedAt = Number(this.imageLoadedAtByKey[key] || 0)
|
||||
if (!options.force && Object.prototype.hasOwnProperty.call(this.imageListByKey, key) && now - loadedAt < IMAGE_TTL_MS) continue
|
||||
pending.push({
|
||||
key,
|
||||
code: String(item?.code || '').trim(),
|
||||
dim1: String(item?.dim1 || '').trim(),
|
||||
dim3: String(item?.dim3 || '').trim()
|
||||
})
|
||||
}
|
||||
|
||||
if (!pending.length) {
|
||||
const keys = Array.from(seen.values())
|
||||
return {
|
||||
lists: Object.fromEntries(keys.map(key => [key, this.imageListByKey[key] || []])),
|
||||
urls: Object.fromEntries(keys.map(key => [key, this.imageUrlByKey[key] || '']))
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await api.post('/product-images/batch', { items: pending }, { timeout: 60000 })
|
||||
const returned = new Set()
|
||||
const lists = {}
|
||||
const urlsByKey = {}
|
||||
for (const item of Array.isArray(resp?.data) ? resp.data : []) {
|
||||
const key = String(item?.key || '').trim()
|
||||
if (!key) continue
|
||||
returned.add(key)
|
||||
const urls = (Array.isArray(item.images) ? item.images : []).map(resolveProductImageUrl).filter(Boolean)
|
||||
this.setImageCache(key, urls)
|
||||
lists[key] = urls
|
||||
urlsByKey[key] = urls[0] || ''
|
||||
}
|
||||
for (const item of pending) {
|
||||
if (returned.has(item.key)) continue
|
||||
this.setImageCache(item.key, [])
|
||||
lists[item.key] = []
|
||||
urlsByKey[item.key] = ''
|
||||
}
|
||||
|
||||
return { lists, urls: urlsByKey }
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user