ui: update ProductPerformanceProfitability page to enhance table views and tabs management
This commit is contained in:
+10
@@ -829,6 +829,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
|||||||
"order", "view",
|
"order", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductImagesHandler(pgDB))),
|
wrapV3(http.HandlerFunc(routes.GetProductImagesHandler(pgDB))),
|
||||||
)
|
)
|
||||||
|
bindV3(r, pgDB,
|
||||||
|
"/api/product-images/batch", "POST",
|
||||||
|
"order", "view",
|
||||||
|
wrapV3(http.HandlerFunc(routes.PostProductImagesBatchHandler(pgDB))),
|
||||||
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/product-images/{id}/content", "GET",
|
"/api/product-images/{id}/content", "GET",
|
||||||
"order", "view",
|
"order", "view",
|
||||||
@@ -984,6 +989,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
|||||||
"pricing", "view",
|
"pricing", "view",
|
||||||
wrapV3(routes.GetProductPerformanceSalesBreakdownHandler(pgDB)),
|
wrapV3(routes.GetProductPerformanceSalesBreakdownHandler(pgDB)),
|
||||||
)
|
)
|
||||||
|
bindV3(r, pgDB,
|
||||||
|
"/api/pricing/product-performance/grouped", "GET",
|
||||||
|
"pricing", "view",
|
||||||
|
wrapV3(routes.GetProductPerformanceGroupedHandler(pgDB)),
|
||||||
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/product-performance/sales-details", "GET",
|
"/api/pricing/product-performance/sales-details", "GET",
|
||||||
"pricing", "view",
|
"pricing", "view",
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ var routeMetaCache sync.Map
|
|||||||
|
|
||||||
var routeMetaFallback = map[string]routeMeta{
|
var routeMetaFallback = map[string]routeMeta{
|
||||||
"GET /api/product-images": {module: "order", action: "view"},
|
"GET /api/product-images": {module: "order", action: "view"},
|
||||||
|
"POST /api/product-images/batch": {module: "order", action: "view"},
|
||||||
"GET /api/product-images/{id}/content": {module: "order", action: "view"},
|
"GET /api/product-images/{id}/content": {module: "order", action: "view"},
|
||||||
"GET /api/product-stock-query-by-attributes": {module: "order", action: "view"},
|
"GET /api/product-stock-query-by-attributes": {module: "order", action: "view"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bssapp-backend/models"
|
"bssapp-backend/models"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -2583,6 +2584,419 @@ LIMIT $1
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProductPerformanceGroupedRequest struct {
|
||||||
|
Mode string
|
||||||
|
GroupLevels []string
|
||||||
|
ExpandedKeys map[string]bool
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) {
|
||||||
|
if req.Limit <= 0 || req.Limit > 50000 {
|
||||||
|
req.Limit = 50000
|
||||||
|
}
|
||||||
|
levels := sanitizeProductPerformanceGroupLevels(req.GroupLevels)
|
||||||
|
if len(levels) == 0 {
|
||||||
|
levels = defaultProductPerformanceGroupLevels(req.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(sourceRows))
|
||||||
|
appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
|
||||||
|
switch mode {
|
||||||
|
case "products":
|
||||||
|
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
||||||
|
return structsToMaps(rows), err
|
||||||
|
case "idle":
|
||||||
|
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return productPerformanceIdleSourceRows(structsToMaps(rows)), nil
|
||||||
|
case "sales_color_yaka_market_customer", "sales_product_country_segment_market_customer", "sales_market_customer_product", "sales_country_segment_market_customer_product":
|
||||||
|
rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, mode, limit)
|
||||||
|
return structsToMaps(rows), err
|
||||||
|
case "order_product_customers":
|
||||||
|
rows, err := ListProductPerformanceOrderProductCustomers(ctx, pg, limit)
|
||||||
|
return structsToMaps(rows), err
|
||||||
|
case "order_market_details":
|
||||||
|
rows, err := ListProductPerformanceOrderMarketDetails(ctx, pg, limit)
|
||||||
|
return structsToMaps(rows), err
|
||||||
|
default:
|
||||||
|
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
||||||
|
return structsToMaps(rows), err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceIdleSourceRows(rows []map[string]any) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
stockQty := floatFromMap(row, "stock_qty")
|
||||||
|
salesQty90 := floatFromMap(row, "sales_qty_90d")
|
||||||
|
stockDays90 := floatFromMap(row, "stock_days_90d")
|
||||||
|
if stockQty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stringFromMap(row, "performance_bucket") != "STOK_RISKI" && salesQty90 != 0 && stockDays90 <= 180 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next := cloneMap(row)
|
||||||
|
next["idle_cost_usd"] = stockQty * floatFromMap(row, "cost_price_usd")
|
||||||
|
out = append(out, next)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map[string]any, levels []string, level int, parentKeys []string, expandedKeys map[string]bool) {
|
||||||
|
if level >= len(levels) {
|
||||||
|
*out = append(*out, sourceRows...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
field := levels[level]
|
||||||
|
grouped := make(map[string][]map[string]any)
|
||||||
|
for _, row := range sourceRows {
|
||||||
|
value := normalizeProductPerformanceGroupValue(mapGroupValue(row, field))
|
||||||
|
grouped[value] = append(grouped[value], row)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]string, 0, len(grouped))
|
||||||
|
for value := range grouped {
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
sort.Slice(values, func(i, j int) bool {
|
||||||
|
return strings.Compare(values[i], values[j]) < 0
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, value := range values {
|
||||||
|
keyPart := field + ":" + value
|
||||||
|
key := strings.Join(append(parentKeys, keyPart), "|")
|
||||||
|
groupRows := grouped[value]
|
||||||
|
*out = append(*out, makeProductPerformanceGroupedRow(key, level, field, value, groupRows))
|
||||||
|
if expandedKeys[key] {
|
||||||
|
appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, append(parentKeys, keyPart), expandedKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeProductPerformanceGroupedRow(key string, level int, field, value string, rows []map[string]any) map[string]any {
|
||||||
|
row := aggregateProductPerformanceRows(rows)
|
||||||
|
row["__group"] = true
|
||||||
|
row["row_key"] = "group|" + key
|
||||||
|
row["key"] = key
|
||||||
|
row["level"] = level
|
||||||
|
row["group_field"] = field
|
||||||
|
row["group_value"] = value
|
||||||
|
row["label"] = value
|
||||||
|
row["count"] = len(rows)
|
||||||
|
row[field] = value
|
||||||
|
row["recommendation"] = fmt.Sprintf("%d satır", len(rows))
|
||||||
|
|
||||||
|
image := firstProductPerformanceImageSource(rows)
|
||||||
|
row["image_product_code"] = stringFromMap(image, "product_code")
|
||||||
|
row["image_color_code"] = stringFromMap(image, "color_code")
|
||||||
|
row["image_yaka_kodu"] = stringFromMap(image, "yaka_kodu")
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggregateProductPerformanceRows(rows []map[string]any) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for _, row := range rows {
|
||||||
|
for key, value := range row {
|
||||||
|
if key == "row_key" || key == "key" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if shouldSumProductPerformanceField(key) {
|
||||||
|
out[key] = floatFromAny(out[key]) + floatFromAny(value)
|
||||||
|
} else if _, ok := out[key]; !ok && !shouldAverageProductPerformanceField(key) {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
for key := range row {
|
||||||
|
if shouldAverageProductPerformanceField(key) {
|
||||||
|
out[key] = weightedAverageProductPerformanceRows(rows, key, productPerformanceMetricWeightField(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deriveProductPerformanceGroupMetrics(out)
|
||||||
|
out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket")
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveProductPerformanceGroupMetrics(out map[string]any) {
|
||||||
|
for _, suffix := range []string{"90d", "180d", "365d", "total"} {
|
||||||
|
sales := floatFromMap(out, "sales_usd_"+suffix)
|
||||||
|
qty := floatFromMap(out, "sales_qty_"+suffix)
|
||||||
|
stockQty := floatFromMap(out, "stock_qty")
|
||||||
|
if stockQty > 0 {
|
||||||
|
out["stock_turnover_"+suffix] = qty / stockQty
|
||||||
|
}
|
||||||
|
if qty > 0 {
|
||||||
|
out["avg_price_usd_"+suffix] = sales / qty
|
||||||
|
}
|
||||||
|
if sales > 0 {
|
||||||
|
if _, ok := out["gross_profit_base_usd_"+suffix]; ok {
|
||||||
|
out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_usd_"+suffix) / sales
|
||||||
|
}
|
||||||
|
if _, ok := out["gross_profit_cost_usd_"+suffix]; ok {
|
||||||
|
out["gross_margin_cost_"+suffix] = floatFromMap(out, "gross_profit_cost_usd_"+suffix) / sales
|
||||||
|
}
|
||||||
|
if _, ok := out["gross_profit_usd_"+suffix]; ok {
|
||||||
|
out["gross_margin_"+suffix] = floatFromMap(out, "gross_profit_usd_"+suffix) / sales
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orderUSD := floatFromMap(out, "order_usd")
|
||||||
|
if orderUSD > 0 {
|
||||||
|
out["expected_margin_base"] = floatFromMap(out, "expected_profit_base_usd") / orderUSD
|
||||||
|
out["expected_margin_cost"] = floatFromMap(out, "expected_profit_cost_usd") / orderUSD
|
||||||
|
}
|
||||||
|
orderQty := floatFromMap(out, "order_qty")
|
||||||
|
if orderQty > 0 {
|
||||||
|
out["avg_order_price_usd"] = orderUSD / orderQty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeProductPerformanceGroupLevels(levels []string) []string {
|
||||||
|
allowed := map[string]bool{
|
||||||
|
"kategori": true, "askili_yan": true, "urun_ilk_grubu": true, "urun_ana_grubu": true, "urun_alt_grubu": true,
|
||||||
|
"product_code": true, "color_code": true, "yaka_kodu": true, "market_key": true, "customer_code": true,
|
||||||
|
"country": true, "customer_segment": true,
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(levels))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, level := range levels {
|
||||||
|
level = strings.TrimSpace(level)
|
||||||
|
if allowed[level] && !seen[level] {
|
||||||
|
out = append(out, level)
|
||||||
|
seen[level] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultProductPerformanceGroupLevels(mode string) []string {
|
||||||
|
switch mode {
|
||||||
|
case "sales_color_yaka_market_customer":
|
||||||
|
return []string{"color_code", "yaka_kodu", "market_key", "customer_code"}
|
||||||
|
case "idle":
|
||||||
|
return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code"}
|
||||||
|
case "sales_product_country_segment_market_customer":
|
||||||
|
return []string{"product_code", "country", "customer_segment", "market_key", "customer_code"}
|
||||||
|
case "sales_market_customer_product":
|
||||||
|
return []string{"market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
|
||||||
|
case "sales_country_segment_market_customer_product":
|
||||||
|
return []string{"country", "customer_segment", "market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
|
||||||
|
case "order_product_customers":
|
||||||
|
return []string{"product_code", "market_key", "customer_code"}
|
||||||
|
case "order_market_details":
|
||||||
|
return []string{"market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
|
||||||
|
default:
|
||||||
|
return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "market_key", "product_code"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapGroupValue(row map[string]any, field string) string {
|
||||||
|
switch field {
|
||||||
|
case "urun_ilk_grubu":
|
||||||
|
if value := stringFromMap(row, "urun_ilk_grubu"); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return stringFromMap(row, "yas_grubu")
|
||||||
|
case "urun_ana_grubu":
|
||||||
|
if value := stringFromMap(row, "urun_ana_grubu"); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return stringFromMap(row, "seri")
|
||||||
|
case "market_key":
|
||||||
|
return displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
|
||||||
|
default:
|
||||||
|
return stringFromMap(row, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSumProductPerformanceField(field string) bool {
|
||||||
|
return strings.HasSuffix(field, "_qty") ||
|
||||||
|
strings.HasSuffix(field, "_usd") ||
|
||||||
|
strings.HasSuffix(field, "_count") ||
|
||||||
|
strings.HasSuffix(field, "_value_usd") ||
|
||||||
|
field == "stock_qty" ||
|
||||||
|
field == "line_count" ||
|
||||||
|
field == "invoice_count" ||
|
||||||
|
field == "order_count" ||
|
||||||
|
field == "product_count" ||
|
||||||
|
field == "market_count" ||
|
||||||
|
field == "customer_count" ||
|
||||||
|
field == "overdue_qty" ||
|
||||||
|
field == "net_stock_after_order" ||
|
||||||
|
field == "idle_cost_usd"
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldAverageProductPerformanceField(field string) bool {
|
||||||
|
return strings.HasPrefix(field, "avg_") ||
|
||||||
|
strings.HasPrefix(field, "unit_") ||
|
||||||
|
strings.HasPrefix(field, "base_price") ||
|
||||||
|
strings.HasPrefix(field, "cost_price") ||
|
||||||
|
strings.HasPrefix(field, "gross_margin") ||
|
||||||
|
strings.HasPrefix(field, "expected_margin") ||
|
||||||
|
strings.HasPrefix(field, "sales_index") ||
|
||||||
|
strings.HasPrefix(field, "performance_score") ||
|
||||||
|
strings.HasPrefix(field, "stock_days") ||
|
||||||
|
strings.HasPrefix(field, "stock_turnover")
|
||||||
|
}
|
||||||
|
|
||||||
|
func productPerformanceMetricWeightField(field string) string {
|
||||||
|
if strings.Contains(field, "_180d") {
|
||||||
|
return "sales_qty_180d"
|
||||||
|
}
|
||||||
|
if strings.Contains(field, "_365d") {
|
||||||
|
return "sales_qty_365d"
|
||||||
|
}
|
||||||
|
if strings.Contains(field, "_total") {
|
||||||
|
return "sales_qty_total"
|
||||||
|
}
|
||||||
|
if strings.Contains(field, "order") || strings.Contains(field, "expected_") {
|
||||||
|
return "order_qty"
|
||||||
|
}
|
||||||
|
if strings.Contains(field, "stock") {
|
||||||
|
return "stock_qty"
|
||||||
|
}
|
||||||
|
return "sales_qty_90d"
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qtyField string) float64 {
|
||||||
|
var weighted, qty, sum float64
|
||||||
|
var count int
|
||||||
|
for _, row := range rows {
|
||||||
|
value := floatFromMap(row, valueField)
|
||||||
|
weight := floatFromMap(row, qtyField)
|
||||||
|
if weight > 0 {
|
||||||
|
weighted += value * weight
|
||||||
|
qty += weight
|
||||||
|
}
|
||||||
|
if value != 0 {
|
||||||
|
sum += value
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if qty > 0 {
|
||||||
|
return weighted / qty
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return sum / float64(count)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func dominantProductPerformanceValue(rows []map[string]any, field string) string {
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, row := range rows {
|
||||||
|
value := stringFromMap(row, field)
|
||||||
|
if value != "" {
|
||||||
|
counts[value]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var best string
|
||||||
|
var bestCount int
|
||||||
|
for value, count := range counts {
|
||||||
|
if count > bestCount {
|
||||||
|
best = value
|
||||||
|
bestCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstProductPerformanceImageSource(rows []map[string]any) map[string]any {
|
||||||
|
for _, row := range rows {
|
||||||
|
if stringFromMap(row, "product_code") != "" {
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rows) > 0 {
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func structsToMaps[T any](rows []T) []map[string]any {
|
||||||
|
raw, err := json.Marshal(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMap(row map[string]any) map[string]any {
|
||||||
|
out := make(map[string]any, len(row))
|
||||||
|
for key, value := range row {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProductPerformanceGroupValue(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayProductPerformanceMarketName(value string) string {
|
||||||
|
parts := strings.Split(value, "|")
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
part := strings.TrimSpace(parts[i])
|
||||||
|
if part != "" {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFromMap(row map[string]any, field string) string {
|
||||||
|
value, ok := row[field]
|
||||||
|
if !ok || value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatFromMap(row map[string]any, field string) float64 {
|
||||||
|
return floatFromAny(row[field])
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatFromAny(value any) float64 {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case float64:
|
||||||
|
return v
|
||||||
|
case float32:
|
||||||
|
return float64(v)
|
||||||
|
case int:
|
||||||
|
return float64(v)
|
||||||
|
case int64:
|
||||||
|
return float64(v)
|
||||||
|
case json.Number:
|
||||||
|
f, _ := v.Float64()
|
||||||
|
return f
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) {
|
func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) {
|
||||||
if limit <= 0 || limit > 500 {
|
if limit <= 0 || limit > 500 {
|
||||||
limit = 100
|
limit = 100
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ type ProductImageItem struct {
|
|||||||
FullURL string `json:"full_url,omitempty"`
|
FullURL string `json:"full_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProductImageBatchItem struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Dim1 string `json:"dim1"`
|
||||||
|
Dim3 string `json:"dim3"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductImageBatchRequest struct {
|
||||||
|
Items []ProductImageBatchItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductImageBatchResponseItem struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Images []ProductImageItem `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
var uuidPattern = regexp.MustCompile(`(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
|
var uuidPattern = regexp.MustCompile(`(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
|
||||||
|
|
||||||
func normalizeDimParam(v string) string {
|
func normalizeDimParam(v string) string {
|
||||||
@@ -119,6 +136,138 @@ func extractImageUUID(storagePath, fileName string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/product-images/batch
|
||||||
|
func PostProductImagesBatchHandler(pg *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reqID := strings.TrimSpace(r.Header.Get("X-Request-ID"))
|
||||||
|
if reqID == "" {
|
||||||
|
reqID = uuid.NewString()
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Request-ID", reqID)
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
var req ProductImageBatchRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Gecersiz gorsel batch istegi: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Items) > 300 {
|
||||||
|
req.Items = req.Items[:300]
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanItems := make([]ProductImageBatchItem, 0, len(req.Items))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, item := range req.Items {
|
||||||
|
item.Key = strings.TrimSpace(item.Key)
|
||||||
|
item.Code = strings.TrimSpace(item.Code)
|
||||||
|
item.Dim1 = normalizeDimParam(item.Dim1)
|
||||||
|
item.Dim3 = normalizeDimParam(item.Dim3)
|
||||||
|
if item.Key == "" {
|
||||||
|
item.Key = item.Code + "|" + item.Dim1 + "|" + item.Dim3
|
||||||
|
}
|
||||||
|
if item.Code == "" || seen[item.Key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item.Key] = true
|
||||||
|
cleanItems = append(cleanItems, item)
|
||||||
|
}
|
||||||
|
if len(cleanItems) == 0 {
|
||||||
|
_ = json.NewEncoder(w).Encode([]ProductImageBatchResponseItem{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(cleanItems)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Gorsel batch hazirlanamadi: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := pg.Query(`
|
||||||
|
WITH req AS (
|
||||||
|
SELECT *
|
||||||
|
FROM jsonb_to_recordset($1::jsonb) AS x(key text, code text, dim1 text, dim3 text)
|
||||||
|
),
|
||||||
|
matched_item AS (
|
||||||
|
SELECT DISTINCT ON (r.key)
|
||||||
|
r.key,
|
||||||
|
r.code,
|
||||||
|
r.dim1,
|
||||||
|
r.dim3,
|
||||||
|
m.id AS mmitem_id
|
||||||
|
FROM req r
|
||||||
|
JOIN mmitem m ON
|
||||||
|
UPPER(REPLACE(COALESCE(m.code,''), ' ', '')) = UPPER(REPLACE(COALESCE(r.code,''), ' ', ''))
|
||||||
|
OR UPPER(REPLACE(REGEXP_REPLACE(COALESCE(m.code,''), '^.*-', ''), ' ', '')) =
|
||||||
|
UPPER(REPLACE(REGEXP_REPLACE(COALESCE(r.code,''), '^.*-', ''), ' ', ''))
|
||||||
|
ORDER BY r.key, m.id
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT
|
||||||
|
mi.key,
|
||||||
|
b.id,
|
||||||
|
COALESCE(b.file_name,'') AS file_name,
|
||||||
|
COALESCE(b.file_size,0) AS file_size,
|
||||||
|
COALESCE(b.storage_path,'') AS storage_path,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY mi.key
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN mi.dim1 <> '' AND mi.dim3 <> '' AND COALESCE(b.dimval1::text,'') = mi.dim1 AND COALESCE(b.dimval3::text,'') = mi.dim3 THEN 0
|
||||||
|
WHEN mi.dim1 <> '' AND COALESCE(b.dimval1::text,'') = mi.dim1 AND COALESCE(b.dimval3::text,'') = '' THEN 1
|
||||||
|
WHEN COALESCE(b.dimval1::text,'') = '' AND COALESCE(b.dimval3::text,'') = '' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
COALESCE(b.sort_order,999999),
|
||||||
|
b.id
|
||||||
|
) AS rn
|
||||||
|
FROM matched_item mi
|
||||||
|
JOIN dfblob b ON b.src_table='mmitem'
|
||||||
|
AND b.typ='img'
|
||||||
|
AND b.src_id=mi.mmitem_id
|
||||||
|
)
|
||||||
|
SELECT key, id, file_name, file_size, storage_path
|
||||||
|
FROM ranked
|
||||||
|
WHERE rn <= 1
|
||||||
|
ORDER BY key, rn
|
||||||
|
`, string(payload))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("product_images.batch.query_failed", "req_id", reqID, "err", err.Error())
|
||||||
|
http.Error(w, "Gorsel batch sorgu hatasi: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
grouped := map[string][]ProductImageItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
var it ProductImageItem
|
||||||
|
if err := rows.Scan(&key, &it.ID, &it.FileName, &it.FileSize, &it.Storage); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
|
||||||
|
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
|
||||||
|
it.UUID = u
|
||||||
|
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
|
||||||
|
it.FullURL = "/uploads/image/" + u + ".jpg"
|
||||||
|
}
|
||||||
|
grouped[key] = append(grouped[key], it)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
http.Error(w, "Gorsel batch okuma hatasi: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]ProductImageBatchResponseItem, 0, len(cleanItems))
|
||||||
|
for _, item := range cleanItems {
|
||||||
|
out = append(out, ProductImageBatchResponseItem{
|
||||||
|
Key: item.Key,
|
||||||
|
Images: grouped[item.Key],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/product-images?code=...&dim1=...&dim3=...
|
// GET /api/product-images?code=...&dim1=...&dim3=...
|
||||||
func GetProductImagesHandler(pg *sql.DB) http.HandlerFunc {
|
func GetProductImagesHandler(pg *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -261,6 +261,47 @@ func GetProductPerformanceSalesBreakdownHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetProductPerformanceGroupedHandler(pg *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
traceID := utils.TraceIDFromRequest(r)
|
||||||
|
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
expanded := map[string]bool{}
|
||||||
|
for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key != "" {
|
||||||
|
expanded[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := queries.ListProductPerformanceGrouped(ctx, pg, queries.ProductPerformanceGroupedRequest{
|
||||||
|
Mode: strings.TrimSpace(r.URL.Query().Get("mode")),
|
||||||
|
GroupLevels: splitCSVQuery(r.URL.Query().Get("group_levels")),
|
||||||
|
ExpandedKeys: expanded,
|
||||||
|
Limit: intQuery(r, "limit", 50000),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSVQuery(value string) []string {
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func intQuery(r *http.Request, key string, fallback int) int {
|
func intQuery(r *http.Request, key string, fallback int) int {
|
||||||
raw := strings.TrimSpace(r.URL.Query().Get(key))
|
raw := strings.TrimSpace(r.URL.Query().Get(key))
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -1222,49 +1222,58 @@ async function reloadData ({ page = 1 } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadImagesForRows (list) {
|
async function loadImagesForRows (list) {
|
||||||
const targets = []
|
const items = []
|
||||||
|
const rowByKey = new Map()
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
for (const row of list) {
|
for (const row of list) {
|
||||||
const key = `${row.productCode}|${row.dim1 || 0}|${row.dim3 || 0}`
|
const key = `${row.productCode}|${row.dim1 || 0}|${row.dim3 || 0}`
|
||||||
if (!row.productCode || seen.has(key)) continue
|
if (!row.productCode || seen.has(key)) continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
targets.push({ row, key })
|
|
||||||
}
|
|
||||||
const concurrency = 12
|
|
||||||
let cursor = 0
|
|
||||||
let loaded = 0
|
|
||||||
const workers = Array.from({ length: Math.min(concurrency, targets.length) }, async () => {
|
|
||||||
for (;;) {
|
|
||||||
const target = targets[cursor]
|
|
||||||
cursor += 1
|
|
||||||
if (!target) return
|
|
||||||
const { row, key } = target
|
|
||||||
if (imageCache.has(key)) {
|
if (imageCache.has(key)) {
|
||||||
row.imageUrl = imageCache.get(key)
|
row.imageUrl = imageCache.get(key)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
rowByKey.set(key, row)
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
code: row.productCode,
|
||||||
|
dim1: row.dim1 || '',
|
||||||
|
dim3: row.dim3 || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!items.length) {
|
||||||
|
rows.value = [...rows.value]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = []
|
||||||
|
for (let i = 0; i < items.length; i += 250) chunks.push(items.slice(i, i + 250))
|
||||||
|
for (const chunk of chunks) {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/product-images', {
|
const res = await api.post('/product-images/batch', { items: chunk }, { timeout: 30000 })
|
||||||
params: {
|
const returned = new Set()
|
||||||
code: row.productCode,
|
for (const item of Array.isArray(res?.data) ? res.data : []) {
|
||||||
dim1_id: row.dim1 || '',
|
const key = toText(item?.key)
|
||||||
dim3_id: row.dim3 || ''
|
if (!key) continue
|
||||||
},
|
returned.add(key)
|
||||||
timeout: 15000
|
const list = Array.isArray(item.images) ? item.images : []
|
||||||
})
|
const url = resolveProductImageUrl(list[0])
|
||||||
const first = Array.isArray(res?.data) ? res.data[0] : null
|
imageCache.set(key, url)
|
||||||
const url = resolveProductImageUrl(first)
|
imageListCache.set(key, list)
|
||||||
imageCache.set(key, url)
|
const row = rowByKey.get(key)
|
||||||
row.imageUrl = url
|
if (row) row.imageUrl = url
|
||||||
imageListCache.set(key, Array.isArray(res?.data) ? res.data : [])
|
}
|
||||||
|
for (const item of chunk) {
|
||||||
|
if (!returned.has(item.key)) {
|
||||||
|
imageCache.set(item.key, '')
|
||||||
|
imageListCache.set(item.key, [])
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
imageCache.set(key, '')
|
for (const item of chunk) imageCache.set(item.key, '')
|
||||||
}
|
}
|
||||||
loaded += 1
|
rows.value = [...rows.value]
|
||||||
if (loaded % 12 === 0) rows.value = [...rows.value]
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
await Promise.all(workers)
|
|
||||||
rows.value = [...rows.value]
|
rows.value = [...rows.value]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
row-key="row_key"
|
row-key="row_key"
|
||||||
class="performance-table bg-white"
|
class="performance-table sticky-dim-table sticky-dim-7 bg-white"
|
||||||
:rows="filteredGeneralRows"
|
:rows="filteredGeneralRows"
|
||||||
:columns="generalColumns"
|
:columns="generalColumns"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@@ -155,12 +155,13 @@
|
|||||||
<template #body-cell-image="props">
|
<template #body-cell-image="props">
|
||||||
<q-td :props="props" class="product-image-cell">
|
<q-td :props="props" class="product-image-cell">
|
||||||
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
|
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
|
||||||
<q-img
|
<img
|
||||||
v-if="getCachedProductImageUrl(props.row)"
|
v-if="getCachedProductImageUrl(props.row)"
|
||||||
:src="getCachedProductImageUrl(props.row)"
|
:src="getCachedProductImageUrl(props.row)"
|
||||||
fit="contain"
|
|
||||||
class="product-thumb"
|
class="product-thumb"
|
||||||
no-spinner
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt=""
|
||||||
/>
|
/>
|
||||||
<div v-else class="product-thumb-placeholder">
|
<div v-else class="product-thumb-placeholder">
|
||||||
<q-icon name="image" size="22px" />
|
<q-icon name="image" size="22px" />
|
||||||
@@ -183,10 +184,10 @@
|
|||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
row-key="row_key"
|
row-key="row_key"
|
||||||
class="performance-table bg-white"
|
class="performance-table sticky-dim-table sticky-dim-10 bg-white"
|
||||||
:rows="filteredOrderProductCustomerRows"
|
:rows="displayOrderProductCustomerTableRows"
|
||||||
:columns="orderProductCustomerColumns"
|
:columns="orderProductCustomerColumns"
|
||||||
:loading="loading"
|
:loading="loading || backendGroupedLoading"
|
||||||
:pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }"
|
:pagination="{ rowsPerPage: 100, sortBy: 'order_usd', descending: true }"
|
||||||
>
|
>
|
||||||
<template #header-cell="props">
|
<template #header-cell="props">
|
||||||
@@ -262,12 +263,13 @@
|
|||||||
<template #body-cell-image="props">
|
<template #body-cell-image="props">
|
||||||
<q-td :props="props" class="product-image-cell">
|
<q-td :props="props" class="product-image-cell">
|
||||||
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
|
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
|
||||||
<q-img
|
<img
|
||||||
v-if="getCachedProductImageUrl(props.row)"
|
v-if="getCachedProductImageUrl(props.row)"
|
||||||
:src="getCachedProductImageUrl(props.row)"
|
:src="getCachedProductImageUrl(props.row)"
|
||||||
fit="contain"
|
|
||||||
class="product-thumb"
|
class="product-thumb"
|
||||||
no-spinner
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt=""
|
||||||
/>
|
/>
|
||||||
<div v-else class="product-thumb-placeholder">
|
<div v-else class="product-thumb-placeholder">
|
||||||
<q-icon name="image" size="22px" />
|
<q-icon name="image" size="22px" />
|
||||||
@@ -293,6 +295,72 @@
|
|||||||
{{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
|
{{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr
|
||||||
|
v-if="props.row.__group"
|
||||||
|
:props="props"
|
||||||
|
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
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: `${props.row.level * 18}px` }"
|
||||||
|
>
|
||||||
|
<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">{{ props.row.label }}</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"
|
||||||
|
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>
|
||||||
|
|
||||||
<q-table
|
<q-table
|
||||||
@@ -300,10 +368,10 @@
|
|||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
row-key="row_key"
|
row-key="row_key"
|
||||||
class="performance-table bg-white"
|
class="performance-table sticky-dim-table sticky-dim-10 bg-white"
|
||||||
:rows="filteredOrderMarketDetailRows"
|
:rows="displayOrderMarketDetailTableRows"
|
||||||
:columns="orderMarketDetailColumns"
|
:columns="orderMarketDetailColumns"
|
||||||
:loading="loading"
|
:loading="loading || backendGroupedLoading"
|
||||||
:pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }"
|
:pagination="{ rowsPerPage: 100, sortBy: 'order_date', descending: true }"
|
||||||
>
|
>
|
||||||
<template #header-cell="props">
|
<template #header-cell="props">
|
||||||
@@ -393,6 +461,40 @@
|
|||||||
{{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
|
{{ formatMoney(props.row.expected_profit_cost_usd, 'USD') }}
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr
|
||||||
|
v-if="props.row.__group"
|
||||||
|
:props="props"
|
||||||
|
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
|
||||||
|
>
|
||||||
|
<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: `${props.row.level * 18}px` }"
|
||||||
|
>
|
||||||
|
<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">{{ props.row.label }}</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>
|
||||||
|
|
||||||
<q-table
|
<q-table
|
||||||
@@ -401,9 +503,9 @@
|
|||||||
bordered
|
bordered
|
||||||
row-key="row_key"
|
row-key="row_key"
|
||||||
class="performance-table product-breakdown-table bg-white"
|
class="performance-table product-breakdown-table bg-white"
|
||||||
:rows="productTableRows"
|
:rows="displayProductTableRows"
|
||||||
:columns="productColumns"
|
:columns="productColumns"
|
||||||
:loading="loading"
|
:loading="loading || backendGroupedLoading"
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
@request="onRequest"
|
@request="onRequest"
|
||||||
>
|
>
|
||||||
@@ -487,12 +589,13 @@
|
|||||||
<q-td v-for="col in props.cols" :key="col.name" :props="props" :class="groupCellClass(col.name)">
|
<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)">
|
<template v-if="col.name === 'image' && groupShowsImage(props.row)">
|
||||||
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
|
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
|
||||||
<q-img
|
<img
|
||||||
v-if="getCachedProductImageUrl(props.row)"
|
v-if="getCachedProductImageUrl(props.row)"
|
||||||
:src="getCachedProductImageUrl(props.row)"
|
:src="getCachedProductImageUrl(props.row)"
|
||||||
fit="contain"
|
|
||||||
class="product-thumb"
|
class="product-thumb"
|
||||||
no-spinner
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt=""
|
||||||
/>
|
/>
|
||||||
<div v-else class="product-thumb-placeholder">
|
<div v-else class="product-thumb-placeholder">
|
||||||
<q-icon name="image" size="22px" />
|
<q-icon name="image" size="22px" />
|
||||||
@@ -522,12 +625,13 @@
|
|||||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
<template v-if="col.name === 'image'">
|
<template v-if="col.name === 'image'">
|
||||||
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
|
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
|
||||||
<q-img
|
<img
|
||||||
v-if="getCachedProductImageUrl(props.row)"
|
v-if="getCachedProductImageUrl(props.row)"
|
||||||
:src="getCachedProductImageUrl(props.row)"
|
:src="getCachedProductImageUrl(props.row)"
|
||||||
fit="contain"
|
|
||||||
class="product-thumb"
|
class="product-thumb"
|
||||||
no-spinner
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt=""
|
||||||
/>
|
/>
|
||||||
<div v-else class="product-thumb-placeholder">
|
<div v-else class="product-thumb-placeholder">
|
||||||
<q-icon name="image" size="22px" />
|
<q-icon name="image" size="22px" />
|
||||||
@@ -549,10 +653,10 @@
|
|||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
row-key="row_key"
|
row-key="row_key"
|
||||||
class="performance-table bg-white"
|
class="performance-table sticky-dim-table sticky-dim-6 bg-white"
|
||||||
:rows="filteredIdleRows"
|
:rows="displayIdleTableRows"
|
||||||
:columns="idleColumns"
|
:columns="idleColumns"
|
||||||
:loading="loading"
|
:loading="loading || backendGroupedLoading"
|
||||||
:pagination="{ rowsPerPage: 100 }"
|
:pagination="{ rowsPerPage: 100 }"
|
||||||
>
|
>
|
||||||
<template #header-cell="props">
|
<template #header-cell="props">
|
||||||
@@ -628,12 +732,13 @@
|
|||||||
<template #body-cell-image="props">
|
<template #body-cell-image="props">
|
||||||
<q-td :props="props" class="product-image-cell">
|
<q-td :props="props" class="product-image-cell">
|
||||||
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
|
<button type="button" class="product-thumb-button" @click.stop="openProductImageDialog(props.row)">
|
||||||
<q-img
|
<img
|
||||||
v-if="getCachedProductImageUrl(props.row)"
|
v-if="getCachedProductImageUrl(props.row)"
|
||||||
:src="getCachedProductImageUrl(props.row)"
|
:src="getCachedProductImageUrl(props.row)"
|
||||||
fit="contain"
|
|
||||||
class="product-thumb"
|
class="product-thumb"
|
||||||
no-spinner
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt=""
|
||||||
/>
|
/>
|
||||||
<div v-else class="product-thumb-placeholder">
|
<div v-else class="product-thumb-placeholder">
|
||||||
<q-icon name="image" size="22px" />
|
<q-icon name="image" size="22px" />
|
||||||
@@ -655,6 +760,72 @@
|
|||||||
</q-badge>
|
</q-badge>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr
|
||||||
|
v-if="props.row.__group"
|
||||||
|
:props="props"
|
||||||
|
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
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: `${props.row.level * 18}px` }"
|
||||||
|
>
|
||||||
|
<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">{{ props.row.label }}</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"
|
||||||
|
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>
|
||||||
|
|
||||||
<q-table
|
<q-table
|
||||||
@@ -662,7 +833,7 @@
|
|||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
row-key="market_key"
|
row-key="market_key"
|
||||||
class="performance-table bg-white"
|
class="performance-table sticky-dim-table sticky-dim-9 bg-white"
|
||||||
:rows="filteredMarketRows"
|
:rows="filteredMarketRows"
|
||||||
:columns="marketColumns"
|
:columns="marketColumns"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@@ -760,10 +931,10 @@
|
|||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
row-key="row_key"
|
row-key="row_key"
|
||||||
class="performance-table bg-white"
|
class="performance-table sticky-dim-table sticky-dim-9 bg-white"
|
||||||
:rows="filteredActiveSalesBreakdownRows"
|
:rows="displayActiveSalesBreakdownTableRows"
|
||||||
:columns="visibleSalesBreakdownColumns"
|
:columns="visibleSalesBreakdownColumns"
|
||||||
:loading="loading"
|
:loading="loading || backendGroupedLoading"
|
||||||
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
|
:pagination="{ rowsPerPage: 100, sortBy: 'performance_score', descending: true }"
|
||||||
>
|
>
|
||||||
<template #header-cell="props">
|
<template #header-cell="props">
|
||||||
@@ -858,6 +1029,40 @@
|
|||||||
</q-badge>
|
</q-badge>
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
<template #body="props">
|
||||||
|
<q-tr
|
||||||
|
v-if="props.row.__group"
|
||||||
|
:props="props"
|
||||||
|
:class="['group-row', `group-row-level-${Math.min(Number(props.row.level || 0), 5)}`]"
|
||||||
|
>
|
||||||
|
<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: `${props.row.level * 18}px` }"
|
||||||
|
>
|
||||||
|
<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">{{ props.row.label }}</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>
|
||||||
|
|
||||||
<div v-else-if="activeTab === 'customers'">
|
<div v-else-if="activeTab === 'customers'">
|
||||||
@@ -881,7 +1086,7 @@
|
|||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
row-key="customer_key"
|
row-key="customer_key"
|
||||||
class="performance-table bg-white"
|
class="performance-table sticky-dim-table sticky-dim-5 bg-white"
|
||||||
:rows="filteredCustomerRows"
|
:rows="filteredCustomerRows"
|
||||||
:columns="customerColumns"
|
:columns="customerColumns"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@@ -980,7 +1185,7 @@
|
|||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
row-key="country_key"
|
row-key="country_key"
|
||||||
class="performance-table bg-white"
|
class="performance-table sticky-dim-table sticky-dim-3 bg-white"
|
||||||
:rows="filteredCountryRows"
|
:rows="filteredCountryRows"
|
||||||
:columns="countryColumns"
|
:columns="countryColumns"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@@ -1080,28 +1285,78 @@
|
|||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<q-card-section class="product-image-dialog-body">
|
<q-card-section class="product-image-dialog-body">
|
||||||
<q-carousel
|
<div class="performance-image-layout">
|
||||||
v-if="imageDialogUrls.length"
|
<aside class="image-product-fields">
|
||||||
v-model="imageSlide"
|
<div class="image-section-title">Ürün Bilgileri</div>
|
||||||
animated
|
<div v-for="item in imageDialogInfoRows" :key="item.key" class="image-field-row">
|
||||||
arrows
|
<span class="k">{{ item.label }}</span>
|
||||||
navigation
|
<span class="v">{{ item.value }}</span>
|
||||||
infinite
|
</div>
|
||||||
swipeable
|
|
||||||
class="product-image-carousel"
|
<div class="image-product-section">
|
||||||
>
|
<div class="image-section-title">Beden Stokları</div>
|
||||||
<q-carousel-slide
|
<q-inner-loading :showing="detailLoading">
|
||||||
v-for="(url, idx) in imageDialogUrls"
|
<q-spinner size="24px" color="primary" />
|
||||||
:key="`${url}-${idx}`"
|
</q-inner-loading>
|
||||||
:name="idx"
|
<div v-if="detailStockSizes.length" class="image-size-stock-grid">
|
||||||
class="column no-wrap flex-center"
|
<div v-for="item in detailStockSizes" :key="item.size_code" class="image-size-stock-cell">
|
||||||
>
|
<span class="image-size-label">{{ item.size_code || '-' }}</span>
|
||||||
<q-img :src="url" fit="contain" class="product-image-large" />
|
<span class="image-size-qty">{{ formatNumber(item.stock_qty, 0) }}</span>
|
||||||
</q-carousel-slide>
|
</div>
|
||||||
</q-carousel>
|
</div>
|
||||||
<div v-else class="product-image-empty">
|
<div v-else-if="!detailLoading" class="image-empty-text">Beden stoku bulunamadi</div>
|
||||||
<q-icon name="image_not_supported" size="48px" />
|
</div>
|
||||||
<div class="text-subtitle2 q-mt-sm">Foto bulunamadi</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
|
||||||
|
title="Satış / Müşteri Özeti"
|
||||||
|
row-key="detail_key"
|
||||||
|
:rows="detailSalesRows"
|
||||||
|
:columns="detailSalesColumns"
|
||||||
|
:loading="detailLoading"
|
||||||
|
:pagination="{ rowsPerPage: 50 }"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -1201,6 +1456,7 @@ const imageDialogUrls = ref([])
|
|||||||
const imageDialogTitle = ref('')
|
const imageDialogTitle = ref('')
|
||||||
const imageDialogSubtitle = ref('')
|
const imageDialogSubtitle = ref('')
|
||||||
const imageSlide = ref(0)
|
const imageSlide = ref(0)
|
||||||
|
const imageDialogRow = ref(null)
|
||||||
const performanceDialog = ref(false)
|
const performanceDialog = ref(false)
|
||||||
const performanceDialogRow = ref(null)
|
const performanceDialogRow = ref(null)
|
||||||
const performanceDialogTitle = ref('')
|
const performanceDialogTitle = ref('')
|
||||||
@@ -1208,6 +1464,9 @@ const performanceDialogSubtitle = ref('')
|
|||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const detailSalesRows = ref([])
|
const detailSalesRows = ref([])
|
||||||
const detailStockSizes = ref([])
|
const detailStockSizes = ref([])
|
||||||
|
const backendGroupedRows = ref({})
|
||||||
|
const backendGroupedLoading = ref(false)
|
||||||
|
let backendGroupedTimer = null
|
||||||
|
|
||||||
const columnFilters = reactive({})
|
const columnFilters = reactive({})
|
||||||
const columnFilterSearch = reactive({})
|
const columnFilterSearch = reactive({})
|
||||||
@@ -1305,6 +1564,10 @@ const columns = [
|
|||||||
{ name: 'stock_days_90d', label: 'Stok Gün', field: row => formatNumber(row.stock_days_90d, 1), 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_180d', label: '180G Stok Gün', field: row => formatNumber(row.stock_days_180d, 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_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_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_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_365d', label: '360G Ort. USD', field: row => formatMoney(row.avg_price_usd_365d, 'USD'), align: 'right' },
|
||||||
@@ -1523,6 +1786,7 @@ const idleColumns = [
|
|||||||
{ name: 'idle_cost_usd', label: 'Stok Maliyeti USD', field: 'idle_cost_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: '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_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: 'performance_bucket', label: 'Durum', field: 'performance_bucket', align: 'left' },
|
||||||
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
|
{ name: 'recommendation', label: 'Öneri', field: 'recommendation', align: 'left' }
|
||||||
]
|
]
|
||||||
@@ -1707,23 +1971,48 @@ const visibleOrderAnalysisColumns = computed(() => {
|
|||||||
return orderAnalysisColumns
|
return orderAnalysisColumns
|
||||||
})
|
})
|
||||||
|
|
||||||
const performanceCards = computed(() => {
|
function performanceCardsForRow (row) {
|
||||||
const row = performanceDialogRow.value || {}
|
const source = row || {}
|
||||||
return [
|
return [
|
||||||
{ key: 'score', label: 'Skor', value: formatNumber(row.performance_score, 1) },
|
{ key: 'score', label: 'Skor', value: formatNumber(source.performance_score, 1) },
|
||||||
{ key: 'stock', label: 'Stok', value: formatNumber(row.stock_qty, 0) },
|
{ key: 'stock', label: 'Stok', value: formatNumber(source.stock_qty, 0) },
|
||||||
{ key: 'sales90', label: '90G Satış', value: formatNumber(row.sales_qty_90d, 0) },
|
{ key: 'sales90', label: '90G Satış', value: formatNumber(source.sales_qty_90d, 0) },
|
||||||
{ key: 'sales180', label: '180G Satış', value: formatNumber(row.sales_qty_180d, 0) },
|
{ key: 'sales180', label: '180G Satış', value: formatNumber(source.sales_qty_180d, 0) },
|
||||||
{ key: 'markets', label: '90G Piyasa', value: formatNumber(row.market_count_90d, 0) },
|
{ key: 'markets', label: '90G Piyasa', value: formatNumber(source.market_count_90d, 0) },
|
||||||
{ key: 'customers', label: '90G Müşteri', value: formatNumber(row.customer_count_90d, 0) },
|
{ key: 'customers', label: '90G Müşteri', value: formatNumber(source.customer_count_90d, 0) },
|
||||||
{ key: 'avg', label: 'Ort. USD Satış', value: formatMoney(row.avg_price_usd_90d, 'USD') },
|
{ 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: 'desc', label: 'Açıklama', value: row.item_description || '-' },
|
||||||
|
{ key: 'category', label: 'Kategori', value: row.kategori || '-' },
|
||||||
|
{ key: 'hanger', label: 'Askılı/Yan', value: row.askili_yan || '-' },
|
||||||
|
{ key: 'first', label: 'İlk Grup', value: row.urun_ilk_grubu || row.yas_grubu || '-' },
|
||||||
|
{ key: 'main', label: 'Ana Grup', value: row.urun_ana_grubu || row.seri || '-' },
|
||||||
|
{ 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: 'base', label: 'Taban Maliyet', value: formatMoney(row.base_price_usd, 'USD') },
|
||||||
{ key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(row.cost_price_usd, 'USD') },
|
{ key: 'cost', label: 'Çıplak Maliyet', value: formatMoney(row.cost_price_usd, 'USD') }
|
||||||
{ key: 'profitBase', label: 'P.Başı Taban K/Z', value: formatMoney(row.unit_profit_base_90d, 'USD') },
|
|
||||||
{ key: 'profitCost', label: 'P.Başı Çıplak K/Z', value: formatMoney(row.unit_profit_cost_90d, 'USD') },
|
|
||||||
{ key: 'marginBase', label: '90G Taban Marj', value: formatPercent(row.gross_margin_base_90d) },
|
|
||||||
{ key: 'marginCost', label: '90G Çıplak Marj', value: formatPercent(row.gross_margin_cost_90d) },
|
|
||||||
{ key: 'bucket', label: 'Durum', value: bucketLabel(row.performance_bucket) }
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1841,6 +2130,14 @@ const filteredMarketRows = computed(() => filterRowsForTable('markets', marketRo
|
|||||||
const filteredCountryRows = computed(() => filterRowsForTable('countries', countryRows.value, countryColumns))
|
const filteredCountryRows = computed(() => filterRowsForTable('countries', countryRows.value, countryColumns))
|
||||||
const filteredCustomerRows = computed(() => filterRowsForTable('customers', customerRows.value, customerColumns))
|
const filteredCustomerRows = computed(() => filterRowsForTable('customers', customerRows.value, customerColumns))
|
||||||
const filteredActiveSalesBreakdownRows = computed(() => filterRowsForTable(activeTab.value, salesBreakdownRows[activeTab.value] || [], visibleSalesBreakdownColumns.value))
|
const filteredActiveSalesBreakdownRows = computed(() => filterRowsForTable(activeTab.value, salesBreakdownRows[activeTab.value] || [], visibleSalesBreakdownColumns.value))
|
||||||
|
const activeGroupSourceRows = computed(() => {
|
||||||
|
if (activeTab.value === 'products') return filteredProductRows.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 columnFilterOptionMap = computed(() => {
|
||||||
const out = {}
|
const out = {}
|
||||||
@@ -1868,19 +2165,29 @@ const columnFilterOptionMap = computed(() => {
|
|||||||
|
|
||||||
const productTableRows = computed(() => {
|
const productTableRows = computed(() => {
|
||||||
const out = []
|
const out = []
|
||||||
appendGroupRows(out, filteredProductRows.value, 0, [], groupLevels)
|
appendGroupRows(out, filteredProductRows.value, 0, ['tab:products'], groupLevels)
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 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 productGroupKeys = computed(() => {
|
const productGroupKeys = computed(() => {
|
||||||
const keys = []
|
const keys = []
|
||||||
collectGroupKeys(keys, filteredProductRows.value, 0, [], groupLevels)
|
collectGroupKeys(keys, filteredProductRows.value, 0, ['tab:products'], groupLevels)
|
||||||
return keys
|
return keys
|
||||||
})
|
})
|
||||||
|
|
||||||
const productAutoExpandKeys = computed(() => {
|
const productAutoExpandKeys = computed(() => {
|
||||||
const keys = []
|
const keys = []
|
||||||
collectGroupKeys(keys, filteredProductRows.value, 0, [], activeGroupLevels.value, autoExpandThroughLevel.value)
|
collectGroupKeys(keys, activeGroupSourceRows.value, 0, [`tab:${activeTab.value}`], activeGroupLevels.value, autoExpandThroughLevel.value)
|
||||||
return keys
|
return keys
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1914,6 +2221,18 @@ function appendGroupRows (out, sourceRows, level, parentKeys, levels = groupLeve
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
return Array.isArray(rows) && rows.length ? rows : fallbackRows
|
||||||
|
}
|
||||||
|
|
||||||
function collectGroupKeys (out, sourceRows, level, parentKeys, levels = groupLevels, maxLevel = levels.length - 1) {
|
function collectGroupKeys (out, sourceRows, level, parentKeys, levels = groupLevels, maxLevel = levels.length - 1) {
|
||||||
if (level >= levels.length) return
|
if (level >= levels.length) return
|
||||||
|
|
||||||
@@ -2013,10 +2332,81 @@ function makeGroupRow (key, level, groupDef, value, groupRows) {
|
|||||||
sales_index_total: averageRows(groupRows, 'sales_index_total'),
|
sales_index_total: averageRows(groupRows, 'sales_index_total'),
|
||||||
performance_score: weightedAverageOrAverage(groupRows, 'performance_score', 'sales_qty_90d'),
|
performance_score: weightedAverageOrAverage(groupRows, 'performance_score', 'sales_qty_90d'),
|
||||||
performance_bucket: dominantValue(groupRows, 'performance_bucket'),
|
performance_bucket: dominantValue(groupRows, 'performance_bucket'),
|
||||||
|
...aggregateGroupFields(groupRows),
|
||||||
recommendation: `${formatNumber(groupRows.length, 0)} satır`
|
recommendation: `${formatNumber(groupRows.length, 0)} satır`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function aggregateGroupFields (sourceRows) {
|
||||||
|
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 (shouldSumField(field)) {
|
||||||
|
out[field] = sumRows(sourceRows, field)
|
||||||
|
} else if (shouldAverageField(field)) {
|
||||||
|
out[field] = weightedAverageOrAverage(sourceRows, field, weightFieldForMetric(field))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDerivedGroupMetrics(out, sourceRows)
|
||||||
|
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) {
|
||||||
|
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 (sourceRows.some(row => row.performance_bucket)) {
|
||||||
|
out.performance_bucket = dominantValue(sourceRows, 'performance_bucket')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sumRows (sourceRows, field) {
|
function sumRows (sourceRows, field) {
|
||||||
return sourceRows.reduce((sum, row) => sum + Number(row[field] || 0), 0)
|
return sourceRows.reduce((sum, row) => sum + Number(row[field] || 0), 0)
|
||||||
}
|
}
|
||||||
@@ -2120,12 +2510,14 @@ function toggleGroup (key) {
|
|||||||
...expandedGroups.value,
|
...expandedGroups.value,
|
||||||
[key]: !isGroupExpanded(key)
|
[key]: !isGroupExpanded(key)
|
||||||
}
|
}
|
||||||
|
scheduleLoadBackendGroupedRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAllProductGroups () {
|
function toggleAllProductGroups () {
|
||||||
const keys = productAutoExpandKeys.value
|
const keys = productAutoExpandKeys.value
|
||||||
if (allProductGroupsExpanded.value) {
|
if (allProductGroupsExpanded.value) {
|
||||||
expandedGroups.value = {}
|
expandedGroups.value = {}
|
||||||
|
scheduleLoadBackendGroupedRows()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expandSelectedProductGroups()
|
expandSelectedProductGroups()
|
||||||
@@ -2136,6 +2528,7 @@ function expandSelectedProductGroups () {
|
|||||||
const next = { ...expandedGroups.value }
|
const next = { ...expandedGroups.value }
|
||||||
for (const key of keys) next[key] = true
|
for (const key of keys) next[key] = true
|
||||||
expandedGroups.value = next
|
expandedGroups.value = next
|
||||||
|
scheduleLoadBackendGroupedRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupLabelColumnName (row) {
|
function groupLabelColumnName (row) {
|
||||||
@@ -2146,23 +2539,29 @@ function groupShowsImage (row) {
|
|||||||
return Number(row?.level ?? 0) < autoExpandThroughLevel.value
|
return Number(row?.level ?? 0) < autoExpandThroughLevel.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupCellClass (name) {
|
function groupCellClass (colOrName) {
|
||||||
|
const name = colOrName?.name || colOrName
|
||||||
if (name === 'image') return 'product-image-cell'
|
if (name === 'image') return 'product-image-cell'
|
||||||
return ['stock_qty', 'sales_qty_90d', 'sales_qty_180d', 'sales_qty_365d', 'sales_qty_total', 'sales_usd_90d', 'sales_usd_180d', 'sales_usd_365d', 'sales_usd_total', 'stock_days_90d', 'stock_days_180d', 'stock_days_total', 'avg_price_usd_90d', 'avg_price_usd_180d', 'avg_price_usd_365d', 'avg_price_usd_total', 'base_price_usd', 'cost_price_usd', 'unit_profit_base_90d', 'unit_profit_cost_90d', 'unit_profit_base_180d', 'unit_profit_cost_180d', 'unit_profit_base_total', 'unit_profit_cost_total', 'gross_profit_usd_90d', 'gross_profit_usd_180d', 'gross_profit_usd_total', 'gross_margin_base_90d', 'gross_margin_cost_90d', 'gross_margin_base_180d', 'gross_margin_cost_180d', 'gross_margin_base_total', 'gross_margin_cost_total', 'gross_margin_90d', 'gross_margin_180d', 'market_count_90d', 'customer_count_90d', 'market_count_total', 'customer_count_total', 'sales_index_90d', 'sales_index_total', 'performance_score'].includes(name)
|
if (colOrName?.align === 'right') return 'text-right'
|
||||||
|
return ['stock_qty', 'sales_qty_90d', 'sales_qty_180d', 'sales_qty_365d', 'sales_qty_total', 'sales_usd_90d', 'sales_usd_180d', 'sales_usd_365d', 'sales_usd_total', 'stock_days_90d', 'stock_days_180d', 'stock_days_total', 'stock_turnover_90d', 'stock_turnover_180d', 'stock_turnover_365d', 'stock_turnover_total', 'avg_price_usd_90d', 'avg_price_usd_180d', 'avg_price_usd_365d', 'avg_price_usd_total', 'base_price_usd', 'cost_price_usd', 'unit_profit_base_90d', 'unit_profit_cost_90d', 'unit_profit_base_180d', 'unit_profit_cost_180d', 'unit_profit_base_total', 'unit_profit_cost_total', 'gross_profit_usd_90d', 'gross_profit_usd_180d', 'gross_profit_usd_total', 'gross_margin_base_90d', 'gross_margin_cost_90d', 'gross_margin_base_180d', 'gross_margin_cost_180d', 'gross_margin_base_total', 'gross_margin_cost_total', 'gross_margin_90d', 'gross_margin_180d', 'market_count_90d', 'customer_count_90d', 'market_count_total', 'customer_count_total', 'sales_index_90d', 'sales_index_total', 'performance_score'].includes(name)
|
||||||
? 'text-right'
|
? 'text-right'
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatGroupCell (row, name) {
|
function formatGroupCell (row, colOrName) {
|
||||||
|
const name = colOrName?.name || colOrName
|
||||||
if (name === 'image') return ''
|
if (name === 'image') return ''
|
||||||
if (name === 'recommendation') return row.recommendation || ''
|
if (name === 'recommendation') return row.recommendation || ''
|
||||||
return formatProductCell(row, name)
|
return formatTableCell(row, colOrName)
|
||||||
}
|
}
|
||||||
|
|
||||||
function withProductMargins (row) {
|
function withProductMargins (row) {
|
||||||
return {
|
return {
|
||||||
...row,
|
...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,
|
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: stockTurnover(row?.sales_qty_90d, row?.stock_qty),
|
||||||
|
stock_turnover_180d: stockTurnover(row?.sales_qty_180d, row?.stock_qty),
|
||||||
|
stock_turnover_365d: stockTurnover(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_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_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_base_180d: marginFromSalesCost(row?.sales_usd_180d, row?.sales_qty_180d, row?.base_price_usd),
|
||||||
@@ -2173,11 +2572,18 @@ function withProductMargins (row) {
|
|||||||
function withGeneralMargins (row) {
|
function withGeneralMargins (row) {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
|
stock_turnover_total: stockTurnover(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_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)
|
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 filterKey (tableKey, name) {
|
function filterKey (tableKey, name) {
|
||||||
return `${tableKey}:${name}`
|
return `${tableKey}:${name}`
|
||||||
}
|
}
|
||||||
@@ -2276,6 +2682,38 @@ function formattedColumnCellValue (row, col) {
|
|||||||
return formatProductCell(row, col?.name || col)
|
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 === '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], name === 'performance_score' ? 1 : 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) {
|
function formatProductCell (row, name) {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'stock_qty':
|
case 'stock_qty':
|
||||||
@@ -2291,6 +2729,10 @@ function formatProductCell (row, name) {
|
|||||||
case 'stock_days_90d':
|
case 'stock_days_90d':
|
||||||
case 'stock_days_180d':
|
case 'stock_days_180d':
|
||||||
case 'stock_days_total':
|
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_90d':
|
||||||
case 'sales_index_total':
|
case 'sales_index_total':
|
||||||
case 'performance_score':
|
case 'performance_score':
|
||||||
@@ -2522,19 +2964,54 @@ function getCachedProductImageUrl (row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function primeProductImages (sourceRows) {
|
async function primeProductImages (sourceRows) {
|
||||||
const uniqueRows = []
|
const items = []
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
for (const row of sourceRows || []) {
|
for (const row of sourceRows || []) {
|
||||||
const key = productImageKey(row)
|
const key = productImageKey(row)
|
||||||
if (!key || seen.has(key)) continue
|
if (!key || seen.has(key) || Object.prototype.hasOwnProperty.call(imageListByKey.value, key)) continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
uniqueRows.push(row)
|
items.push({
|
||||||
if (uniqueRows.length >= 80) break
|
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 })
|
||||||
|
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] = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
await Promise.all(uniqueRows.map(row => fetchProductImagesForRow(row)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProductImageDialog (row) {
|
async function openProductImageDialog (row) {
|
||||||
|
imageDialogRow.value = row
|
||||||
const urls = await fetchProductImagesForRow(row)
|
const urls = await fetchProductImagesForRow(row)
|
||||||
imageDialogUrls.value = urls
|
imageDialogUrls.value = urls
|
||||||
imageSlide.value = 0
|
imageSlide.value = 0
|
||||||
@@ -2545,6 +3022,7 @@ async function openProductImageDialog (row) {
|
|||||||
row?.image_yaka_kodu || row?.yaka_kodu
|
row?.image_yaka_kodu || row?.yaka_kodu
|
||||||
].filter(Boolean).join(' | ')
|
].filter(Boolean).join(' | ')
|
||||||
imageDialog.value = true
|
imageDialog.value = true
|
||||||
|
await loadProductDetailRows(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPerformanceDialog (row) {
|
async function openPerformanceDialog (row) {
|
||||||
@@ -2557,15 +3035,20 @@ async function openPerformanceDialog (row) {
|
|||||||
row?.market_key
|
row?.market_key
|
||||||
].filter(Boolean).join(' | ')
|
].filter(Boolean).join(' | ')
|
||||||
performanceDialog.value = true
|
performanceDialog.value = true
|
||||||
|
await loadProductDetailRows(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProductDetailRows (row) {
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
detailSalesRows.value = []
|
detailSalesRows.value = []
|
||||||
detailStockSizes.value = []
|
detailStockSizes.value = []
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
product_code: row?.product_code || '',
|
product_code: row?.image_product_code || row?.product_code || '',
|
||||||
color_code: row?.color_code || '',
|
color_code: row?.image_color_code || row?.color_code || '',
|
||||||
yaka_kodu: row?.yaka_kodu || ''
|
yaka_kodu: row?.image_yaka_kodu || row?.yaka_kodu || ''
|
||||||
}
|
}
|
||||||
|
if (!params.product_code) return
|
||||||
const [salesResp, stockResp] = await Promise.all([
|
const [salesResp, stockResp] = await Promise.all([
|
||||||
api.get('/pricing/product-performance/sales-details', { params: { ...params, limit: 200 }, timeout: 60000 }),
|
api.get('/pricing/product-performance/sales-details', { params: { ...params, limit: 200 }, timeout: 60000 }),
|
||||||
api.get('/pricing/product-performance/stock-sizes', { params, timeout: 60000 })
|
api.get('/pricing/product-performance/stock-sizes', { params, timeout: 60000 })
|
||||||
@@ -2582,6 +3065,52 @@ async function openPerformanceDialog (row) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function backendGroupedSupportedTab (tabKey = activeTab.value) {
|
||||||
|
return tabKey === 'products' ||
|
||||||
|
tabKey === 'idle' ||
|
||||||
|
tabKey === 'order_product_customers' ||
|
||||||
|
tabKey === 'order_market_details' ||
|
||||||
|
salesBreakdownTabKeys.includes(tabKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeExpandedGroupKeys () {
|
||||||
|
return Object.entries(expandedGroups.value)
|
||||||
|
.filter(([, value]) => value === true)
|
||||||
|
.map(([key]) => key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleLoadBackendGroupedRows () {
|
||||||
|
if (backendGroupedTimer) window.clearTimeout(backendGroupedTimer)
|
||||||
|
backendGroupedTimer = window.setTimeout(() => {
|
||||||
|
void loadBackendGroupedRows()
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBackendGroupedRows () {
|
||||||
|
const tabKey = activeTab.value
|
||||||
|
if (!backendGroupedSupportedTab(tabKey)) return
|
||||||
|
backendGroupedLoading.value = true
|
||||||
|
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
|
||||||
|
})
|
||||||
|
backendGroupedRows.value = {
|
||||||
|
...backendGroupedRows.value,
|
||||||
|
[tabKey]: Array.isArray(resp?.data) ? resp.data : []
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('product performance grouped rows failed', err)
|
||||||
|
} finally {
|
||||||
|
backendGroupedLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function reload () {
|
async function reload () {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -2638,6 +3167,7 @@ async function reload () {
|
|||||||
if (activeTab.value === 'order_market_details') {
|
if (activeTab.value === 'order_market_details') {
|
||||||
await loadOrderMarketDetails(false)
|
await loadOrderMarketDetails(false)
|
||||||
}
|
}
|
||||||
|
await loadBackendGroupedRows()
|
||||||
void primeProductImages(rows.value)
|
void primeProductImages(rows.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
|
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Ürün performans verisi alınamadı' })
|
||||||
@@ -2721,6 +3251,11 @@ watch(activeTab, tab => {
|
|||||||
if (tab === 'order_market_details' && !orderMarketDetailsLoaded.value) {
|
if (tab === 'order_market_details' && !orderMarketDetailsLoaded.value) {
|
||||||
void loadOrderMarketDetails(true)
|
void loadOrderMarketDetails(true)
|
||||||
}
|
}
|
||||||
|
scheduleLoadBackendGroupedRows()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(activeSelectedExpandLevelKeys, () => {
|
||||||
|
scheduleLoadBackendGroupedRows()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(productTableRows, tableRows => {
|
watch(productTableRows, tableRows => {
|
||||||
@@ -2777,7 +3312,7 @@ onMounted(reload)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.performance-table :deep(.q-table tbody td) {
|
.performance-table :deep(.q-table tbody td) {
|
||||||
max-width: 220px;
|
max-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.performance-table :deep(.q-table tbody td span:not(.q-badge):not(.q-icon)) {
|
.performance-table :deep(.q-table tbody td span:not(.q-badge):not(.q-icon)) {
|
||||||
@@ -2802,110 +3337,222 @@ onMounted(reload)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table) {
|
.product-breakdown-table :deep(.q-table) {
|
||||||
min-width: 2050px;
|
min-width: 3200px;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(-n+10)),
|
.product-breakdown-table :deep(.q-table th:nth-child(-n+11)),
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(-n+10)) {
|
.product-breakdown-table :deep(.q-table td:nth-child(-n+11)) {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(-n+10)) {
|
.product-breakdown-table :deep(.q-table th:nth-child(-n+11)) {
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
background: #f8fbff;
|
background: #f8fbff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+10)) {
|
.product-breakdown-table :deep(.q-table tbody tr:not(.group-row) td:nth-child(-n+11)) {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(1)),
|
.product-breakdown-table :deep(.q-table th:nth-child(1)),
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(1)) {
|
.product-breakdown-table :deep(.q-table td:nth-child(1)) {
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 80px;
|
|
||||||
min-width: 80px;
|
|
||||||
max-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(2)),
|
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(2)) {
|
|
||||||
left: 80px;
|
|
||||||
width: 96px;
|
|
||||||
min-width: 96px;
|
|
||||||
max-width: 96px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(3)),
|
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(3)) {
|
|
||||||
left: 176px;
|
|
||||||
width: 48px;
|
|
||||||
min-width: 48px;
|
|
||||||
max-width: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(4)),
|
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(4)) {
|
|
||||||
left: 224px;
|
|
||||||
width: 48px;
|
|
||||||
min-width: 48px;
|
|
||||||
max-width: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(5)),
|
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(5)) {
|
|
||||||
left: 272px;
|
|
||||||
width: 150px;
|
|
||||||
min-width: 150px;
|
|
||||||
max-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(6)),
|
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(6)) {
|
|
||||||
left: 422px;
|
|
||||||
width: 72px;
|
|
||||||
min-width: 72px;
|
|
||||||
max-width: 72px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(7)),
|
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(7)) {
|
|
||||||
left: 494px;
|
|
||||||
width: 72px;
|
|
||||||
min-width: 72px;
|
|
||||||
max-width: 72px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(8)),
|
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(8)) {
|
|
||||||
left: 566px;
|
|
||||||
width: 92px;
|
width: 92px;
|
||||||
min-width: 92px;
|
min-width: 92px;
|
||||||
max-width: 92px;
|
max-width: 92px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-breakdown-table :deep(.q-table th:nth-child(2)),
|
||||||
|
.product-breakdown-table :deep(.q-table td:nth-child(2)) {
|
||||||
|
left: 92px;
|
||||||
|
width: 120px;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-breakdown-table :deep(.q-table th:nth-child(3)),
|
||||||
|
.product-breakdown-table :deep(.q-table td:nth-child(3)) {
|
||||||
|
left: 212px;
|
||||||
|
width: 64px;
|
||||||
|
min-width: 64px;
|
||||||
|
max-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-breakdown-table :deep(.q-table th:nth-child(4)),
|
||||||
|
.product-breakdown-table :deep(.q-table td:nth-child(4)) {
|
||||||
|
left: 276px;
|
||||||
|
width: 64px;
|
||||||
|
min-width: 64px;
|
||||||
|
max-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-breakdown-table :deep(.q-table th:nth-child(5)),
|
||||||
|
.product-breakdown-table :deep(.q-table td:nth-child(5)) {
|
||||||
|
left: 340px;
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-breakdown-table :deep(.q-table th:nth-child(6)),
|
||||||
|
.product-breakdown-table :deep(.q-table td:nth-child(6)) {
|
||||||
|
left: 540px;
|
||||||
|
width: 110px;
|
||||||
|
min-width: 110px;
|
||||||
|
max-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-breakdown-table :deep(.q-table th:nth-child(7)),
|
||||||
|
.product-breakdown-table :deep(.q-table td:nth-child(7)) {
|
||||||
|
left: 650px;
|
||||||
|
width: 100px;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-breakdown-table :deep(.q-table th:nth-child(8)),
|
||||||
|
.product-breakdown-table :deep(.q-table td:nth-child(8)) {
|
||||||
|
left: 750px;
|
||||||
|
width: 130px;
|
||||||
|
min-width: 130px;
|
||||||
|
max-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(9)),
|
.product-breakdown-table :deep(.q-table th:nth-child(9)),
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(9)) {
|
.product-breakdown-table :deep(.q-table td:nth-child(9)) {
|
||||||
left: 658px;
|
left: 880px;
|
||||||
width: 96px;
|
width: 130px;
|
||||||
min-width: 96px;
|
min-width: 130px;
|
||||||
max-width: 96px;
|
max-width: 130px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(10)),
|
.product-breakdown-table :deep(.q-table th:nth-child(10)),
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(10)) {
|
.product-breakdown-table :deep(.q-table td:nth-child(10)) {
|
||||||
left: 754px;
|
left: 1010px;
|
||||||
width: 96px;
|
width: 130px;
|
||||||
min-width: 96px;
|
min-width: 130px;
|
||||||
max-width: 96px;
|
max-width: 130px;
|
||||||
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-breakdown-table :deep(.q-table th:nth-child(11)),
|
.product-breakdown-table :deep(.q-table th:nth-child(11)),
|
||||||
.product-breakdown-table :deep(.q-table td:nth-child(11)) {
|
.product-breakdown-table :deep(.q-table td:nth-child(11)) {
|
||||||
width: 180px;
|
left: 1140px;
|
||||||
min-width: 140px;
|
width: 190px;
|
||||||
|
min-width: 190px;
|
||||||
|
max-width: 190px;
|
||||||
|
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-dim-table :deep(.q-table) {
|
||||||
|
min-width: 2200px;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-dim-table :deep(.q-table th),
|
||||||
|
.sticky-dim-table :deep(.q-table td) {
|
||||||
|
min-width: 130px;
|
||||||
|
width: 130px;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-dim-table :deep(.q-table th:nth-child(3)),
|
||||||
|
.sticky-dim-table :deep(.q-table td:nth-child(3)) {
|
||||||
|
left: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-dim-table :deep(.q-table th:nth-child(4)),
|
||||||
|
.sticky-dim-table :deep(.q-table td:nth-child(4)) {
|
||||||
|
left: 390px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-dim-table :deep(.q-table th:nth-child(5)),
|
||||||
|
.sticky-dim-table :deep(.q-table td:nth-child(5)) {
|
||||||
|
left: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-dim-table :deep(.q-table th:nth-child(6)),
|
||||||
|
.sticky-dim-table :deep(.q-table td:nth-child(6)) {
|
||||||
|
left: 650px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-dim-table :deep(.q-table th:nth-child(7)),
|
||||||
|
.sticky-dim-table :deep(.q-table td:nth-child(7)) {
|
||||||
|
left: 780px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-dim-table :deep(.q-table th:nth-child(8)),
|
||||||
|
.sticky-dim-table :deep(.q-table td:nth-child(8)) {
|
||||||
|
left: 910px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-dim-table :deep(.q-table th:nth-child(9)),
|
||||||
|
.sticky-dim-table :deep(.q-table td:nth-child(9)) {
|
||||||
|
left: 1040px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-dim-table :deep(.q-table th:nth-child(10)),
|
||||||
|
.sticky-dim-table :deep(.q-table td:nth-child(10)) {
|
||||||
|
left: 1170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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)) {
|
||||||
|
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-9 :deep(.q-table th:nth-child(-n+9)),
|
||||||
|
.sticky-dim-table.sticky-dim-10 :deep(.q-table th:nth-child(-n+10)) {
|
||||||
|
z-index: 4;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-dim-table :deep(.q-table tbody td:nth-child(-n+10)) {
|
||||||
|
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-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)) {
|
||||||
|
box-shadow: 8px 0 10px -10px rgba(17, 24, 39, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterable-header-cell {
|
.filterable-header-cell {
|
||||||
@@ -2914,10 +3561,10 @@ onMounted(reload)
|
|||||||
|
|
||||||
.header-with-filter {
|
.header-with-filter {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 20px;
|
grid-template-columns: minmax(0, 1fr) 24px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
min-width: 42px;
|
min-width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-with-filter > span {
|
.header-with-filter > span {
|
||||||
@@ -2928,9 +3575,9 @@ onMounted(reload)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-filter-btn {
|
.header-filter-btn {
|
||||||
width: 20px;
|
width: 24px;
|
||||||
height: 20px;
|
height: 24px;
|
||||||
min-height: 20px;
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-filter-ghost {
|
.header-filter-ghost {
|
||||||
@@ -3047,6 +3694,11 @@ onMounted(reload)
|
|||||||
height: 82px;
|
height: 82px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-thumb {
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.product-thumb-placeholder {
|
.product-thumb-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3063,6 +3715,101 @@ onMounted(reload)
|
|||||||
.product-image-dialog-body {
|
.product-image-dialog-body {
|
||||||
height: calc(100vh - 74px);
|
height: calc(100vh - 74px);
|
||||||
padding: 12px;
|
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 {
|
.product-image-carousel {
|
||||||
@@ -3074,7 +3821,7 @@ onMounted(reload)
|
|||||||
|
|
||||||
.product-image-large {
|
.product-image-large {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100vh - 130px);
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-image-empty {
|
.product-image-empty {
|
||||||
@@ -3088,6 +3835,29 @@ onMounted(reload)
|
|||||||
border: 1px solid #d9dde3;
|
border: 1px solid #d9dde3;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-metric-card .q-card__section {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-metric-card .metric-value {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -444,6 +444,8 @@ const IMAGE_LIST_CONCURRENCY = 8
|
|||||||
const FILTER_OPTIONS_CACHE_TTL_MS = 60 * 1000
|
const FILTER_OPTIONS_CACHE_TTL_MS = 60 * 1000
|
||||||
const FILTER_OPTIONS_DEBOUNCE_MS = 250
|
const FILTER_OPTIONS_DEBOUNCE_MS = 250
|
||||||
let imageListActiveRequests = 0
|
let imageListActiveRequests = 0
|
||||||
|
let productImageBatchTimer = null
|
||||||
|
const productImageBatchQueue = new Map()
|
||||||
let filterOptionsDebounceTimer = null
|
let filterOptionsDebounceTimer = null
|
||||||
let filterOptionsRequestSeq = 0
|
let filterOptionsRequestSeq = 0
|
||||||
const imageListWaitQueue = []
|
const imageListWaitQueue = []
|
||||||
@@ -729,11 +731,70 @@ function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id =
|
|||||||
const key = buildImageKey(code, color, secondColor, dim1Id, dim3Id)
|
const key = buildImageKey(code, color, secondColor, dim1Id, dim3Id)
|
||||||
const existing = productImageCache.value[key]
|
const existing = productImageCache.value[key]
|
||||||
if (existing !== undefined) return existing || ''
|
if (existing !== undefined) return existing || ''
|
||||||
|
queueProductImageBatch(code, color, secondColor, dim1Id, dim3Id)
|
||||||
void ensureProductImage(code, color, secondColor, dim1Id, dim3Id)
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queueProductImageBatch(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
|
||||||
|
const key = buildImageKey(code, color, secondColor, dim1Id, dim3Id)
|
||||||
|
const codeTrim = String(code || '').trim().toUpperCase()
|
||||||
|
if (!codeTrim || productImageCache.value[key] !== undefined || productImageLoading.value[key]) return
|
||||||
|
productImageLoading.value[key] = true
|
||||||
|
productImageBatchQueue.set(key, {
|
||||||
|
key,
|
||||||
|
code: codeTrim,
|
||||||
|
dim1: String(dim1Id || color || '').trim().toUpperCase(),
|
||||||
|
dim3: String(dim3Id || secondColor || '').trim().toUpperCase(),
|
||||||
|
listKey: buildImageKey(
|
||||||
|
codeTrim,
|
||||||
|
String(color || '').trim().toUpperCase(),
|
||||||
|
String(secondColor || '').trim().toUpperCase(),
|
||||||
|
String(dim1Id || '').trim().toUpperCase(),
|
||||||
|
String(dim3Id || '').trim().toUpperCase()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (productImageBatchTimer) window.clearTimeout(productImageBatchTimer)
|
||||||
|
productImageBatchTimer = window.setTimeout(flushProductImageBatch, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushProductImageBatch() {
|
||||||
|
const batch = Array.from(productImageBatchQueue.values()).slice(0, 250)
|
||||||
|
for (const item of batch) productImageBatchQueue.delete(item.key)
|
||||||
|
if (!batch.length) return
|
||||||
|
try {
|
||||||
|
const res = await api.post('/product-images/batch', {
|
||||||
|
items: batch.map(item => ({ key: item.key, code: item.code, dim1: item.dim1, dim3: item.dim3 }))
|
||||||
|
}, { timeout: 30000 })
|
||||||
|
const returned = new Set()
|
||||||
|
for (const item of Array.isArray(res?.data) ? res.data : []) {
|
||||||
|
const key = String(item?.key || '').trim()
|
||||||
|
if (!key) continue
|
||||||
|
returned.add(key)
|
||||||
|
const queued = batch.find(entry => entry.key === key)
|
||||||
|
const list = Array.isArray(item.images) ? item.images : []
|
||||||
|
const first = list[0] || null
|
||||||
|
const resolved = resolveProductImageUrl(first)
|
||||||
|
const url = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
|
||||||
|
productImageCache.value[key] = String(url || '').trim()
|
||||||
|
productImageFallbackByKey.value[key] = resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
|
||||||
|
if (queued) productImageListByCode.value[queued.listKey] = list
|
||||||
|
delete productImageLoading.value[key]
|
||||||
|
}
|
||||||
|
for (const item of batch) {
|
||||||
|
if (!returned.has(item.key)) {
|
||||||
|
productImageCache.value[item.key] = ''
|
||||||
|
productImageListByCode.value[item.listKey] = []
|
||||||
|
delete productImageLoading.value[item.key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const item of batch) {
|
||||||
|
delete productImageLoading.value[item.key]
|
||||||
|
void ensureProductImage(item.code, item.dim1, item.dim3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onProductImageError(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
|
async function onProductImageError(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
|
||||||
const key = buildImageKey(code, color, secondColor, dim1Id, dim3Id)
|
const key = buildImageKey(code, color, secondColor, dim1Id, dim3Id)
|
||||||
productImageCache.value[key] = String(productImageFallbackByKey.value[key] || '').trim()
|
productImageCache.value[key] = String(productImageFallbackByKey.value[key] || '').trim()
|
||||||
|
|||||||
@@ -409,6 +409,8 @@ const productImageFullscreenDragOriginX = ref(0)
|
|||||||
const productImageFullscreenDragOriginY = ref(0)
|
const productImageFullscreenDragOriginY = ref(0)
|
||||||
const IMAGE_LIST_CONCURRENCY = 8
|
const IMAGE_LIST_CONCURRENCY = 8
|
||||||
let imageListActiveRequests = 0
|
let imageListActiveRequests = 0
|
||||||
|
let productImageBatchTimer = null
|
||||||
|
const productImageBatchQueue = new Map()
|
||||||
const imageListWaitQueue = []
|
const imageListWaitQueue = []
|
||||||
const activeSchema = ref(storeSchemaByKey.tak)
|
const activeSchema = ref(storeSchemaByKey.tak)
|
||||||
const activeGrpKey = ref('tak')
|
const activeGrpKey = ref('tak')
|
||||||
@@ -708,10 +710,70 @@ function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id =
|
|||||||
const key = buildImageKey(code, color, secondColor, dim1Id, dim3Id)
|
const key = buildImageKey(code, color, secondColor, dim1Id, dim3Id)
|
||||||
const existing = productImageCache.value[key]
|
const existing = productImageCache.value[key]
|
||||||
if (existing !== undefined) return existing || ''
|
if (existing !== undefined) return existing || ''
|
||||||
void ensureProductImage(code, color, secondColor, dim1Id, dim3Id)
|
queueProductImageBatch(code, color, secondColor, dim1Id, dim3Id)
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queueProductImageBatch(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
|
||||||
|
const key = buildImageKey(code, color, secondColor, dim1Id, dim3Id)
|
||||||
|
const codeTrim = String(code || '').trim().toUpperCase()
|
||||||
|
if (!codeTrim || productImageCache.value[key] !== undefined || productImageLoading.value[key]) return
|
||||||
|
productImageLoading.value[key] = true
|
||||||
|
productImageBatchQueue.set(key, {
|
||||||
|
key,
|
||||||
|
code: codeTrim,
|
||||||
|
dim1: String(dim1Id || color || '').trim().toUpperCase(),
|
||||||
|
dim3: String(dim3Id || secondColor || '').trim().toUpperCase(),
|
||||||
|
listKey: buildImageKey(
|
||||||
|
codeTrim,
|
||||||
|
String(color || '').trim().toUpperCase(),
|
||||||
|
String(secondColor || '').trim().toUpperCase(),
|
||||||
|
String(dim1Id || '').trim().toUpperCase(),
|
||||||
|
String(dim3Id || '').trim().toUpperCase()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (productImageBatchTimer) window.clearTimeout(productImageBatchTimer)
|
||||||
|
productImageBatchTimer = window.setTimeout(flushProductImageBatch, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushProductImageBatch() {
|
||||||
|
const batch = Array.from(productImageBatchQueue.values()).slice(0, 250)
|
||||||
|
for (const item of batch) productImageBatchQueue.delete(item.key)
|
||||||
|
if (!batch.length) return
|
||||||
|
try {
|
||||||
|
const res = await api.post('/product-images/batch', {
|
||||||
|
items: batch.map(item => ({ key: item.key, code: item.code, dim1: item.dim1, dim3: item.dim3 }))
|
||||||
|
}, { timeout: 30000 })
|
||||||
|
const returned = new Set()
|
||||||
|
for (const item of Array.isArray(res?.data) ? res.data : []) {
|
||||||
|
const key = String(item?.key || '').trim()
|
||||||
|
if (!key) continue
|
||||||
|
returned.add(key)
|
||||||
|
const queued = batch.find(entry => entry.key === key)
|
||||||
|
const list = Array.isArray(item.images) ? item.images : []
|
||||||
|
const first = list[0] || null
|
||||||
|
const resolved = resolveProductImageUrl(first)
|
||||||
|
const url = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
|
||||||
|
productImageCache.value[key] = String(url || '').trim()
|
||||||
|
productImageFallbackByKey.value[key] = resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || ''
|
||||||
|
if (queued) productImageListByCode.value[queued.listKey] = list
|
||||||
|
delete productImageLoading.value[key]
|
||||||
|
}
|
||||||
|
for (const item of batch) {
|
||||||
|
if (!returned.has(item.key)) {
|
||||||
|
productImageCache.value[item.key] = ''
|
||||||
|
productImageListByCode.value[item.listKey] = []
|
||||||
|
delete productImageLoading.value[item.key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const item of batch) {
|
||||||
|
delete productImageLoading.value[item.key]
|
||||||
|
void ensureProductImage(item.code, item.dim1, item.dim3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onProductImageError(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
|
async function onProductImageError(code, color, secondColor = '', dim1Id = '', dim3Id = '') {
|
||||||
const key = buildImageKey(code, color, secondColor, dim1Id, dim3Id)
|
const key = buildImageKey(code, color, secondColor, dim1Id, dim3Id)
|
||||||
productImageCache.value[key] = String(productImageFallbackByKey.value[key] || '').trim()
|
productImageCache.value[key] = String(productImageFallbackByKey.value[key] || '').trim()
|
||||||
|
|||||||
Reference in New Issue
Block a user