Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -38,3 +38,21 @@ Did you forget to install it?
|
||||
Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
|
||||
Did you forget to install it?
|
||||
|
||||
|
||||
App • ERROR • SPA UI
|
||||
|
||||
Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
|
||||
Did you forget to install it?
|
||||
|
||||
|
||||
App • ERROR • SPA UI in ./src/router/routes.js
|
||||
|
||||
Module not found: Can't resolve imported dependency "pages/WholesaleCampaigns.vue"
|
||||
Did you forget to install it?
|
||||
|
||||
|
||||
App • ERROR • SPA UI
|
||||
|
||||
Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
|
||||
Did you forget to install it?
|
||||
|
||||
|
||||
@@ -142,3 +142,109 @@ Y88b.Y8b88P Y88b 888 888 888 X88 888 888 888
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 760ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 750ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 552ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 430ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 394ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 597ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 535ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 666ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled by Webpack with errors • 24ms
|
||||
|
||||
App • COMPILATION FAILED • Please check the log above for details.
|
||||
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • Applying quasar.config file changes...
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 275ms
|
||||
App • Applying quasar.config file changes...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled by Webpack with errors • 209ms
|
||||
|
||||
App • COMPILATION FAILED • Please check the log above for details.
|
||||
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 599ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 762ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 646ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 1037ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 743ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 692ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 1009ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 743ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 917ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 1090ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 582ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 960ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 886ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 958ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 882ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 930ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 1038ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 758ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 620ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 855ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 919ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 718ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 965ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 612ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 913ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 674ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 594ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 876ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 945ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 604ms
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • DONE • "SPA UI" compiled with success by Webpack • 781ms
|
||||
|
||||
App • The quasar.config file (or its dependencies) changed. Reading it again...
|
||||
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
|
||||
App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
|
||||
App • Scheduled to apply quasar.config changes in 550ms
|
||||
App • DONE • "SPA UI" compiled by Webpack with errors • 28ms
|
||||
|
||||
App • COMPILATION FAILED • Please check the log above for details.
|
||||
|
||||
App • Applying quasar.config file changes...
|
||||
|
||||
35
svc/main.go
35
svc/main.go
@@ -840,6 +840,41 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/wholesale-campaigns", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetWholesaleCampaignsHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/wholesale-campaigns/assignments", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetWholesaleCampaignAssignmentsHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/wholesale-campaigns/variants", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetWholesaleCampaignVariantStockHandler(mssql)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/wholesale-campaigns/variant-rows", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetWholesaleCampaignVariantRowsHandler(pgDB, mssql)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/wholesale-campaigns/save", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.SaveWholesaleCampaignAssignmentsHandler(pgDB, ml)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/wholesale-campaigns/{code}/campaign-history", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetWholesaleCampaignHistoryHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/wholesale-campaigns/{code}/campaign-history/delete-selected", "POST",
|
||||
"pricing", "update",
|
||||
wrapV3(routes.PostDeleteSelectedWholesaleCampaignHistoryHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/brand-classification/lookups", "GET",
|
||||
"pricing", "view",
|
||||
|
||||
@@ -548,6 +548,44 @@ func enrichAllProductPricingRows(ctx context.Context, out []models.ProductPricin
|
||||
}
|
||||
_ = pgRows.Close()
|
||||
}
|
||||
|
||||
// Last pricing date should reflect the most recent price publish, not the Nebim base price date.
|
||||
// E-commerce reads pg.sdprc; use its latest write timestamp as the authoritative "last pricing" signal.
|
||||
dateRows, err := pg.QueryContext(ctx, `
|
||||
SELECT
|
||||
mmitem.code AS code,
|
||||
to_char(MAX(sdprc.zlins_dttm), 'YYYY-MM-DD') AS last_pricing_date
|
||||
FROM sdprc
|
||||
JOIN mmitem ON mmitem.id = sdprc.mmitem_id
|
||||
WHERE mmitem.code = ANY($1)
|
||||
GROUP BY mmitem.code;
|
||||
`, pq.Array(chunk))
|
||||
if err == nil {
|
||||
for dateRows.Next() {
|
||||
var code, ymd string
|
||||
if err := dateRows.Scan(&code, &ymd); err != nil {
|
||||
_ = dateRows.Close()
|
||||
return err
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
ymd = strings.TrimSpace(ymd)
|
||||
if code == "" || len(ymd) != 10 {
|
||||
continue
|
||||
}
|
||||
if idx, ok := indexByCode[code]; ok {
|
||||
cur := strings.TrimSpace(out[idx].LastPricingDate)
|
||||
// both are YYYY-MM-DD, lexicographical compare is safe
|
||||
if cur == "" || cur < ymd {
|
||||
out[idx].LastPricingDate = ymd
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := dateRows.Err(); err != nil {
|
||||
_ = dateRows.Close()
|
||||
return err
|
||||
}
|
||||
_ = dateRows.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,14 @@ import (
|
||||
// GetProductPricingFilterOptions returns distinct option values for ProductPricing filters.
|
||||
// This is used to render filter dropdowns without loading the full dataset.
|
||||
func GetProductPricingFilterOptions(ctx context.Context, field string, q string, limit int, scopeUrunIlkGrubu []string) ([]string, error) {
|
||||
pg := db.PgDB
|
||||
mssql := db.MssqlDB
|
||||
if mssql == nil {
|
||||
// Some option fields can still be served from PG cache table.
|
||||
if pg == nil {
|
||||
return nil, fmt.Errorf("mssql db is nil")
|
||||
}
|
||||
}
|
||||
field = strings.TrimSpace(field)
|
||||
q = strings.TrimSpace(q)
|
||||
if limit <= 0 || limit > 200 {
|
||||
@@ -24,6 +28,83 @@ func GetProductPricingFilterOptions(ctx context.Context, field string, q string,
|
||||
scopeUrunIlkGrubu = scopeUrunIlkGrubu[:3]
|
||||
}
|
||||
|
||||
// Fast path: use PG-derived pricing parameter cache for most fields.
|
||||
// This avoids scanning ProductFilterWithDescription('TR') on MSSQL, which can be slow and cause 504s.
|
||||
// productCode is not available in mk_urunpricingprmtr, keep MSSQL for that.
|
||||
if pg != nil && field != "productCode" {
|
||||
pgCol := ""
|
||||
switch field {
|
||||
case "brandGroupSelection":
|
||||
pgCol = "brand_group_sec"
|
||||
case "marka":
|
||||
pgCol = "marka"
|
||||
case "askiliYan":
|
||||
pgCol = "askili_yan"
|
||||
case "kategori":
|
||||
pgCol = "kategori"
|
||||
case "urunIlkGrubu":
|
||||
pgCol = "urun_ilk_grubu"
|
||||
case "urunAnaGrubu":
|
||||
pgCol = "urun_ana_grubu"
|
||||
case "urunAltGrubu":
|
||||
pgCol = "urun_alt_grubu"
|
||||
case "icerik":
|
||||
pgCol = "icerik"
|
||||
case "karisim":
|
||||
// "karisim" is intentionally deprecated in mk_urunpricingprmtr, keep MSSQL fallback.
|
||||
pgCol = ""
|
||||
default:
|
||||
pgCol = ""
|
||||
}
|
||||
|
||||
if pgCol != "" {
|
||||
args := make([]any, 0, 8)
|
||||
where := []string{
|
||||
"is_active = TRUE",
|
||||
fmt.Sprintf("NULLIF(BTRIM(%s), '') IS NOT NULL", pgCol),
|
||||
}
|
||||
if len(scopeUrunIlkGrubu) > 0 && field != "urunIlkGrubu" {
|
||||
args = append(args, scopeUrunIlkGrubu)
|
||||
where = append(where, fmt.Sprintf("urun_ilk_grubu = ANY($%d::text[])", len(args)))
|
||||
}
|
||||
if q != "" {
|
||||
like := q + "%"
|
||||
args = append(args, like)
|
||||
where = append(where, fmt.Sprintf("%s ILIKE $%d", pgCol, len(args)))
|
||||
}
|
||||
whereSQL := strings.Join(where, " AND ")
|
||||
|
||||
// Note: DISTINCT+ORDER+LIMIT is fine here due to small mk_urunpricingprmtr cardinality (~1000 rows).
|
||||
sqlText := fmt.Sprintf(`
|
||||
SELECT DISTINCT %s AS val
|
||||
FROM mk_urunpricingprmtr
|
||||
WHERE %s
|
||||
ORDER BY val ASC
|
||||
LIMIT %d
|
||||
`, pgCol, whereSQL, limit)
|
||||
|
||||
rows, err := pg.QueryContext(ctx, sqlText, args...)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
out := make([]string, 0, limit)
|
||||
for rows.Next() {
|
||||
var v sql.NullString
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s := strings.TrimSpace(v.String); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// If PG path fails, fall back to MSSQL below.
|
||||
}
|
||||
}
|
||||
|
||||
// Map UI filter fields -> MSSQL expression in ProductFilterWithDescription('TR')
|
||||
var expr string
|
||||
switch field {
|
||||
|
||||
99
svc/queries/wholesale_campaign_variants_mssql.go
Normal file
99
svc/queries/wholesale_campaign_variants_mssql.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package queries
|
||||
|
||||
// GetWholesaleCampaignVariantStockByProducts:
|
||||
// Returns per-product variant keys (ColorCode/ItemDim1Code/ItemDim3Code) and available stock qty.
|
||||
// We aggregate across warehouses/stores; semantics align with product-stock-query's "Kullanilabilir_Envanter".
|
||||
const GetWholesaleCampaignVariantStockByProducts = `
|
||||
DECLARE @Codes NVARCHAR(MAX) = @p1;
|
||||
|
||||
;WITH INP AS (
|
||||
-- SQL Server 2008 compatibility: string_split() does not exist.
|
||||
-- Split CSV via XML nodes().
|
||||
SELECT LTRIM(RTRIM(X.C.value('.', 'NVARCHAR(50)'))) AS ItemCode
|
||||
FROM (
|
||||
SELECT CAST('<i>' + REPLACE(REPLACE(@Codes, '&', '&'), ',', '</i><i>') + '</i>' AS XML) AS XmlData
|
||||
) D
|
||||
CROSS APPLY D.XmlData.nodes('/i') AS X(C)
|
||||
WHERE LTRIM(RTRIM(X.C.value('.', 'NVARCHAR(50)'))) <> ''
|
||||
),
|
||||
STOCK AS (
|
||||
SELECT
|
||||
S.ItemCode,
|
||||
LTRIM(RTRIM(ISNULL(S.ColorCode,''))) AS ColorCode,
|
||||
LTRIM(RTRIM(ISNULL(S.ItemDim3Code,''))) AS ItemDim3Code,
|
||||
MAX(LTRIM(RTRIM(ISNULL(S.ItemDim1Code,'')))) AS ItemDim1Code,
|
||||
SUM(S.In_Qty1 - S.Out_Qty1) AS InventoryQty1
|
||||
FROM trStock S WITH(NOLOCK)
|
||||
JOIN INP ON INP.ItemCode = S.ItemCode
|
||||
WHERE S.ItemTypeCode = 1
|
||||
AND LEN(S.ItemCode) = 13
|
||||
GROUP BY
|
||||
S.ItemCode, S.ColorCode, S.ItemDim3Code
|
||||
),
|
||||
PICK AS (
|
||||
SELECT
|
||||
P.ItemCode,
|
||||
LTRIM(RTRIM(ISNULL(P.ColorCode,''))) AS ColorCode,
|
||||
LTRIM(RTRIM(ISNULL(P.ItemDim3Code,''))) AS ItemDim3Code,
|
||||
MAX(LTRIM(RTRIM(ISNULL(P.ItemDim1Code,'')))) AS ItemDim1Code,
|
||||
SUM(P.Qty1) AS PickingQty1
|
||||
FROM PickingStates P
|
||||
JOIN INP ON INP.ItemCode = P.ItemCode
|
||||
WHERE P.ItemTypeCode = 1
|
||||
AND LEN(P.ItemCode) = 13
|
||||
GROUP BY
|
||||
P.ItemCode, P.ColorCode, P.ItemDim3Code
|
||||
),
|
||||
RESERVE AS (
|
||||
SELECT
|
||||
R.ItemCode,
|
||||
LTRIM(RTRIM(ISNULL(R.ColorCode,''))) AS ColorCode,
|
||||
LTRIM(RTRIM(ISNULL(R.ItemDim3Code,''))) AS ItemDim3Code,
|
||||
MAX(LTRIM(RTRIM(ISNULL(R.ItemDim1Code,'')))) AS ItemDim1Code,
|
||||
SUM(R.Qty1) AS ReserveQty1
|
||||
FROM ReserveStates R
|
||||
JOIN INP ON INP.ItemCode = R.ItemCode
|
||||
WHERE R.ItemTypeCode = 1
|
||||
AND LEN(R.ItemCode) = 13
|
||||
GROUP BY
|
||||
R.ItemCode, R.ColorCode, R.ItemDim3Code
|
||||
),
|
||||
DISP AS (
|
||||
SELECT
|
||||
D.ItemCode,
|
||||
LTRIM(RTRIM(ISNULL(D.ColorCode,''))) AS ColorCode,
|
||||
LTRIM(RTRIM(ISNULL(D.ItemDim3Code,''))) AS ItemDim3Code,
|
||||
MAX(LTRIM(RTRIM(ISNULL(D.ItemDim1Code,'')))) AS ItemDim1Code,
|
||||
SUM(D.Qty1) AS DispOrderQty1
|
||||
FROM DispOrderStates D
|
||||
JOIN INP ON INP.ItemCode = D.ItemCode
|
||||
WHERE D.ItemTypeCode = 1
|
||||
AND LEN(D.ItemCode) = 13
|
||||
GROUP BY
|
||||
D.ItemCode, D.ColorCode, D.ItemDim3Code
|
||||
)
|
||||
SELECT
|
||||
S.ItemCode AS ItemCode,
|
||||
S.ColorCode AS ColorCode,
|
||||
S.ItemDim1Code AS ItemDim1Code,
|
||||
S.ItemDim3Code AS ItemDim3Code,
|
||||
CAST(ROUND(
|
||||
S.InventoryQty1
|
||||
- ISNULL(PK.PickingQty1,0)
|
||||
- ISNULL(RS.ReserveQty1,0)
|
||||
- ISNULL(DP.DispOrderQty1,0),
|
||||
2
|
||||
) AS FLOAT) AS StockQty
|
||||
FROM STOCK S
|
||||
LEFT JOIN PICK PK
|
||||
ON PK.ItemCode=S.ItemCode AND PK.ColorCode=S.ColorCode AND PK.ItemDim3Code=S.ItemDim3Code
|
||||
LEFT JOIN RESERVE RS
|
||||
ON RS.ItemCode=S.ItemCode AND RS.ColorCode=S.ColorCode AND RS.ItemDim3Code=S.ItemDim3Code
|
||||
LEFT JOIN DISP DP
|
||||
ON DP.ItemCode=S.ItemCode AND DP.ColorCode=S.ColorCode AND DP.ItemDim3Code=S.ItemDim3Code
|
||||
WHERE (S.InventoryQty1
|
||||
- ISNULL(PK.PickingQty1,0)
|
||||
- ISNULL(RS.ReserveQty1,0)
|
||||
- ISNULL(DP.DispOrderQty1,0)) <> 0
|
||||
ORDER BY S.ItemCode, S.ColorCode, S.ItemDim3Code;
|
||||
`
|
||||
@@ -254,8 +254,14 @@ func ExportAllProductPricingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
rows = filterProductPricingExportRows(rows, parseProductPricingExportFilters(r))
|
||||
priceFields := parseExportPriceFields(r.URL.Query().Get("price_fields"))
|
||||
var content string
|
||||
if len(priceFields) > 0 {
|
||||
content = buildProductPricingExportCSVWithPriceFields(rows, priceFields)
|
||||
} else {
|
||||
currencies := parseExportCurrencies(r.URL.Query().Get("currencies"))
|
||||
content := buildProductPricingExportCSV(rows, currencies)
|
||||
content = buildProductPricingExportCSV(rows, currencies)
|
||||
}
|
||||
filename := fmt.Sprintf("product_pricing_all_%s.csv", time.Now().Format("2006-01-02"))
|
||||
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
@@ -402,6 +408,39 @@ func parseExportCurrencies(raw string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func parseExportPriceFields(raw string) []string {
|
||||
requested := splitCSVParam(raw)
|
||||
if len(requested) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, 18)
|
||||
seen := map[string]bool{}
|
||||
for _, item := range requested {
|
||||
v := strings.ToLower(strings.TrimSpace(item))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
// Accept both "usd1" and "USD1" etc.
|
||||
switch v {
|
||||
case "usd1", "usd2", "usd3", "usd4", "usd5", "usd6",
|
||||
"eur1", "eur2", "eur3", "eur4", "eur5", "eur6",
|
||||
"try1", "try2", "try3", "try4", "try5", "try6":
|
||||
// ok
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if seen[v] {
|
||||
continue
|
||||
}
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func matchesProductPricingValueFilters(row models.ProductPricing, filters map[string][]string) bool {
|
||||
if len(filters) == 0 {
|
||||
return true
|
||||
@@ -509,7 +548,60 @@ func csvFloat(value float64) string {
|
||||
return fmt.Sprintf("%.2f", value)
|
||||
}
|
||||
|
||||
func buildProductPricingExportCSV(rows []models.ProductPricing, currencies []string) string {
|
||||
func exportPriceFieldTitle(field string) string {
|
||||
field = strings.ToLower(strings.TrimSpace(field))
|
||||
if len(field) != 4 {
|
||||
return strings.ToUpper(field)
|
||||
}
|
||||
cur := strings.ToUpper(field[:3])
|
||||
lv := field[3:]
|
||||
return fmt.Sprintf("%s %s", cur, lv)
|
||||
}
|
||||
|
||||
func exportPriceFieldValue(row models.ProductPricing, field string) float64 {
|
||||
switch strings.ToLower(strings.TrimSpace(field)) {
|
||||
case "usd1":
|
||||
return row.USD1
|
||||
case "usd2":
|
||||
return row.USD2
|
||||
case "usd3":
|
||||
return row.USD3
|
||||
case "usd4":
|
||||
return row.USD4
|
||||
case "usd5":
|
||||
return row.USD5
|
||||
case "usd6":
|
||||
return row.USD6
|
||||
case "eur1":
|
||||
return row.EUR1
|
||||
case "eur2":
|
||||
return row.EUR2
|
||||
case "eur3":
|
||||
return row.EUR3
|
||||
case "eur4":
|
||||
return row.EUR4
|
||||
case "eur5":
|
||||
return row.EUR5
|
||||
case "eur6":
|
||||
return row.EUR6
|
||||
case "try1":
|
||||
return row.TRY1
|
||||
case "try2":
|
||||
return row.TRY2
|
||||
case "try3":
|
||||
return row.TRY3
|
||||
case "try4":
|
||||
return row.TRY4
|
||||
case "try5":
|
||||
return row.TRY5
|
||||
case "try6":
|
||||
return row.TRY6
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func buildProductPricingExportCSVWithPriceFields(rows []models.ProductPricing, priceFields []string) string {
|
||||
var b strings.Builder
|
||||
|
||||
headers := []string{
|
||||
@@ -536,16 +628,14 @@ func buildProductPricingExportCSV(rows []models.ProductPricing, currencies []str
|
||||
b.WriteString(csvEscape(h))
|
||||
b.WriteString(";")
|
||||
}
|
||||
for idx, cur := range currencies {
|
||||
for tier := 1; tier <= 6; tier++ {
|
||||
b.WriteString(csvEscape(fmt.Sprintf("%s %d", cur, tier)))
|
||||
if idx == len(currencies)-1 && tier == 6 {
|
||||
for i, pf := range priceFields {
|
||||
b.WriteString(csvEscape(exportPriceFieldTitle(pf)))
|
||||
if i == len(priceFields)-1 {
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
b.WriteString(";")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
base := []string{
|
||||
@@ -572,22 +662,39 @@ func buildProductPricingExportCSV(rows []models.ProductPricing, currencies []str
|
||||
b.WriteString(csvEscape(item))
|
||||
b.WriteString(";")
|
||||
}
|
||||
for idx, cur := range currencies {
|
||||
values := productPricingCurrencyValues(row, cur)
|
||||
for tierIdx, value := range values {
|
||||
b.WriteString(csvEscape(csvFloat(value)))
|
||||
if idx == len(currencies)-1 && tierIdx == len(values)-1 {
|
||||
for i, pf := range priceFields {
|
||||
b.WriteString(csvEscape(csvFloat(exportPriceFieldValue(row, pf))))
|
||||
if i == len(priceFields)-1 {
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
b.WriteString(";")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildProductPricingExportCSV(rows []models.ProductPricing, currencies []string) string {
|
||||
// Backward compatible export: USD/EUR/TRY and all 1..6 tiers per currency.
|
||||
fields := make([]string, 0, 18)
|
||||
for _, cur := range currencies {
|
||||
cur = strings.ToUpper(strings.TrimSpace(cur))
|
||||
switch cur {
|
||||
case "USD", "EUR", "TRY":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
for lv := 1; lv <= 6; lv++ {
|
||||
fields = append(fields, strings.ToLower(fmt.Sprintf("%s%d", cur, lv)))
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"usd1", "usd2", "usd3", "usd4", "usd5", "usd6", "eur1", "eur2", "eur3", "eur4", "eur5", "eur6", "try1", "try2", "try3", "try4", "try5", "try6"}
|
||||
}
|
||||
return buildProductPricingExportCSVWithPriceFields(rows, fields)
|
||||
}
|
||||
|
||||
func productPricingCurrencyValues(row models.ProductPricing, currency string) []float64 {
|
||||
switch currency {
|
||||
case "USD":
|
||||
|
||||
@@ -44,6 +44,10 @@ type priceListExportRequest struct {
|
||||
USDLevels []int `json:"usd_levels"` // 1..6
|
||||
EURLevels []int `json:"eur_levels"` // 1..6
|
||||
TRYLevels []int `json:"try_levels"` // 1..6
|
||||
|
||||
// Optional: explicit per-tier selection like ["usd1","eur3","try6"].
|
||||
// If provided, it overrides USDLevels/EURLevels/TRYLevels.
|
||||
PriceFields []string `json:"price_fields"`
|
||||
}
|
||||
|
||||
type exportCol struct {
|
||||
@@ -102,6 +106,34 @@ func resolvePriceListColumns(req priceListExportRequest) []exportCol {
|
||||
)
|
||||
}
|
||||
|
||||
// Explicit per-tier selection path (preserve order).
|
||||
if len(req.PriceFields) > 0 {
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range req.PriceFields {
|
||||
v := strings.ToLower(strings.TrimSpace(raw))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
switch v {
|
||||
case "usd1", "usd2", "usd3", "usd4", "usd5", "usd6",
|
||||
"eur1", "eur2", "eur3", "eur4", "eur5", "eur6",
|
||||
"try1", "try2", "try3", "try4", "try5", "try6":
|
||||
// ok
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
cur := strings.ToUpper(v[:3])
|
||||
lv := v[3:]
|
||||
key := fmt.Sprintf("%s%s", cur, lv)
|
||||
cols = append(cols, exportCol{Key: key, Title: fmt.Sprintf("%s %s", cur, lv), Width: 12, Align: "R"})
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
usd := cleanLevels(req.USDLevels)
|
||||
eur := cleanLevels(req.EURLevels)
|
||||
tr := cleanLevels(req.TRYLevels)
|
||||
|
||||
286
svc/routes/wholesale_campaign_mail.go
Normal file
286
svc/routes/wholesale_campaign_mail.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bssapp-backend/db"
|
||||
"bssapp-backend/internal/mailer"
|
||||
"bssapp-backend/models"
|
||||
"bssapp-backend/queries"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type wholesaleCampaignMailRow struct {
|
||||
ProductCode string
|
||||
UrunIlkGrubu string
|
||||
Marka string
|
||||
BrandGroupSec string
|
||||
Dim1 int64
|
||||
Dim3 int64
|
||||
CampaignCode string
|
||||
CampaignTitle string
|
||||
DiscountRate float64
|
||||
}
|
||||
|
||||
func buildWholesaleCampaignChangeMailHTML(firstGroupCode string, rows []wholesaleCampaignMailRow, actor string, at time.Time) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<div style="font-family:Segoe UI, Arial, sans-serif; font-size:18px; line-height:1.35; -webkit-text-size-adjust:100%;">`)
|
||||
b.WriteString(`<div style="margin-bottom:10px;">`)
|
||||
b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Kampanya Degisikligi</b></div>`)
|
||||
b.WriteString(`<div>Urun Ilk Grubu: <b>` + htmlEscapeMini(firstGroupCode) + `</b></div>`)
|
||||
if strings.TrimSpace(actor) != "" {
|
||||
b.WriteString(`<div>Islem Yapan: <b>` + htmlEscapeMini(actor) + `</b></div>`)
|
||||
}
|
||||
b.WriteString(`<div>Tarih: <b>` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `</b></div>`)
|
||||
b.WriteString(`<div>Varyant Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`)
|
||||
b.WriteString(`</div>`)
|
||||
|
||||
b.WriteString(`<div style="max-width:100%; overflow-x:auto;">`)
|
||||
b.WriteString(`<table style="border-collapse:collapse; font-size:16px; white-space:nowrap;">`)
|
||||
b.WriteString(`<thead><tr>`)
|
||||
heads := []string{
|
||||
"MARKA GRUBU",
|
||||
"MARKA",
|
||||
"URUN KODU",
|
||||
"DIM1",
|
||||
"DIM3",
|
||||
"KAMPANYA",
|
||||
"IND %",
|
||||
}
|
||||
for _, h := range heads {
|
||||
b.WriteString(`<th style="border:1px solid #d0d0d0; background:#f3f3f3; padding:8px 10px; text-align:left; font-size:16px;">` + htmlEscapeMini(h) + `</th>`)
|
||||
}
|
||||
b.WriteString(`</tr></thead><tbody>`)
|
||||
|
||||
for _, r := range rows {
|
||||
b.WriteString(`<tr>`)
|
||||
campaignLabel := strings.TrimSpace(r.CampaignCode)
|
||||
if t := strings.TrimSpace(r.CampaignTitle); t != "" {
|
||||
if campaignLabel != "" {
|
||||
campaignLabel = campaignLabel + " - " + t
|
||||
} else {
|
||||
campaignLabel = t
|
||||
}
|
||||
}
|
||||
cells := []string{
|
||||
r.BrandGroupSec,
|
||||
r.Marka,
|
||||
r.ProductCode,
|
||||
fmt.Sprintf("%d", r.Dim1),
|
||||
func() string {
|
||||
if r.Dim3 > 0 {
|
||||
return fmt.Sprintf("%d", r.Dim3)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
campaignLabel,
|
||||
fmt.Sprintf("%.2f", r.DiscountRate),
|
||||
}
|
||||
for i, c := range cells {
|
||||
align := "left"
|
||||
if i == 3 || i == 4 || i == 6 {
|
||||
align = "right"
|
||||
}
|
||||
b.WriteString(`<td style="border:1px solid #e0e0e0; padding:8px 10px; text-align:` + align + `;">` + htmlEscapeMini(strings.TrimSpace(c)) + `</td>`)
|
||||
}
|
||||
b.WriteString(`</tr>`)
|
||||
}
|
||||
|
||||
b.WriteString(`</tbody></table></div>`)
|
||||
b.WriteString(`<div style="margin-top:12px; font-size:14px; color:#666;">Bu e-posta BSSApp sistemi tarafindan otomatik olusturulmustur.</div>`)
|
||||
b.WriteString(`</div>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sendWholesaleCampaignChangeMails sends one mail per UrunIlkGrubu using existing pricing mail mapping tables.
|
||||
// It lists only variants that currently have a campaign assigned.
|
||||
func sendWholesaleCampaignChangeMails(bg context.Context, ml *mailer.GraphMailer, productCodes []string, actor string) {
|
||||
if ml == nil {
|
||||
return
|
||||
}
|
||||
pg := db.PgDB
|
||||
if pg == nil {
|
||||
log.Printf("[campaign-mail] skipped: pg not ready")
|
||||
return
|
||||
}
|
||||
// Ensure mapping tables exist (reuse pricing mapping).
|
||||
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
|
||||
log.Printf("[campaign-mail] mapping bootstrap error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(bg, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
codes := make([]string, 0, len(productCodes))
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range productCodes {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[c]; ok {
|
||||
continue
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
codes = append(codes, c)
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Product info for grouping (UrunIlkGrubu, Marka, BrandGroupSec) comes from Nebim query.
|
||||
// This is best-effort: if MSSQL is down, we still send a single mail under group "UNKNOWN".
|
||||
productInfo := map[string]models.ProductPricing{}
|
||||
{
|
||||
rows, err := queries.GetAllProductPricingRows(ctx, 500, queries.ProductPricingFilters{ProductCode: codes}, "productCode", false)
|
||||
if err == nil {
|
||||
for _, r := range rows {
|
||||
code := strings.TrimSpace(r.ProductCode)
|
||||
if code != "" {
|
||||
productInfo[code] = r
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type dbRow struct {
|
||||
ProductCode string
|
||||
Dim1 int64
|
||||
Dim3 sql.NullInt64
|
||||
CampaignCode string
|
||||
CampaignTitle string
|
||||
DiscountRate float64
|
||||
}
|
||||
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
WITH mm AS (
|
||||
SELECT id AS mmitem_id, code
|
||||
FROM mmitem
|
||||
WHERE code = ANY($1::text[])
|
||||
),
|
||||
latest AS (
|
||||
SELECT DISTINCT ON (z.mmitem_id, z.dim1, COALESCE(z.dim3, 0))
|
||||
z.mmitem_id,
|
||||
z.dim1,
|
||||
z.dim3,
|
||||
z.sdcampaign_id
|
||||
FROM zbggcampaign z
|
||||
JOIN mm ON mm.mmitem_id = z.mmitem_id
|
||||
ORDER BY z.mmitem_id, z.dim1, COALESCE(z.dim3, 0), z.id DESC
|
||||
)
|
||||
SELECT
|
||||
mm.code AS product_code,
|
||||
l.dim1,
|
||||
l.dim3,
|
||||
COALESCE(sc.code,'') AS campaign_code,
|
||||
COALESCE(sc.title,'') AS campaign_title,
|
||||
COALESCE(sc.discount_rate, 0)::float8 AS discount_rate
|
||||
FROM mm
|
||||
JOIN latest l
|
||||
ON l.mmitem_id = mm.mmitem_id
|
||||
JOIN sdcampaign sc
|
||||
ON sc.id = l.sdcampaign_id
|
||||
WHERE COALESCE(sc.is_active, TRUE) = TRUE
|
||||
ORDER BY mm.code, l.dim1, COALESCE(l.dim3, 0), sc.discount_rate DESC;
|
||||
`, pq.Array(codes))
|
||||
if err != nil {
|
||||
log.Printf("[campaign-mail] campaign rows query error: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
mailRows := make([]wholesaleCampaignMailRow, 0, 1024)
|
||||
for rows.Next() {
|
||||
var r dbRow
|
||||
if err := rows.Scan(&r.ProductCode, &r.Dim1, &r.Dim3, &r.CampaignCode, &r.CampaignTitle, &r.DiscountRate); err != nil {
|
||||
log.Printf("[campaign-mail] scan error: %v", err)
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(r.ProductCode)
|
||||
info := productInfo[code]
|
||||
group := strings.TrimSpace(info.UrunIlkGrubu)
|
||||
if group == "" {
|
||||
group = "UNKNOWN"
|
||||
}
|
||||
d3 := int64(0)
|
||||
if r.Dim3.Valid {
|
||||
d3 = r.Dim3.Int64
|
||||
}
|
||||
mailRows = append(mailRows, wholesaleCampaignMailRow{
|
||||
ProductCode: code,
|
||||
UrunIlkGrubu: group,
|
||||
Marka: strings.TrimSpace(info.Marka),
|
||||
BrandGroupSec: strings.TrimSpace(info.BrandGroupSec),
|
||||
Dim1: r.Dim1,
|
||||
Dim3: d3,
|
||||
CampaignCode: strings.TrimSpace(r.CampaignCode),
|
||||
CampaignTitle: strings.TrimSpace(r.CampaignTitle),
|
||||
DiscountRate: r.DiscountRate,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("[campaign-mail] rows error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(mailRows) == 0 {
|
||||
// Nothing assigned => no mail.
|
||||
return
|
||||
}
|
||||
|
||||
byGroup := map[string][]wholesaleCampaignMailRow{}
|
||||
for _, r := range mailRows {
|
||||
g := strings.TrimSpace(r.UrunIlkGrubu)
|
||||
if g == "" {
|
||||
g = "UNKNOWN"
|
||||
}
|
||||
byGroup[g] = append(byGroup[g], r)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for group, list := range byGroup {
|
||||
recipients, err := loadPricingRecipients(pg, group)
|
||||
if err != nil {
|
||||
log.Printf("[campaign-mail] recipient query error group=%s err=%v", group, err)
|
||||
continue
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
log.Printf("[campaign-mail] no recipients mapped group=%s", group)
|
||||
continue
|
||||
}
|
||||
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].ProductCode != list[j].ProductCode {
|
||||
return list[i].ProductCode < list[j].ProductCode
|
||||
}
|
||||
if list[i].Dim1 != list[j].Dim1 {
|
||||
return list[i].Dim1 < list[j].Dim1
|
||||
}
|
||||
return list[i].Dim3 < list[j].Dim3
|
||||
})
|
||||
|
||||
subject := fmt.Sprintf("Kampanya Degisikligi | %s | %s | %d varyant", group, now.Format("02.01.2006 15:04"), len(list))
|
||||
html := buildWholesaleCampaignChangeMailHTML(group, list, actor, now)
|
||||
|
||||
stepCtx, stepCancel := context.WithTimeout(bg, 25*time.Second)
|
||||
err = ml.Send(stepCtx, mailer.Message{
|
||||
To: recipients,
|
||||
Subject: subject,
|
||||
BodyHTML: html,
|
||||
})
|
||||
stepCancel()
|
||||
if err != nil {
|
||||
log.Printf("[campaign-mail] send failed group=%s err=%v", group, err)
|
||||
} else {
|
||||
log.Printf("[campaign-mail] sent group=%s to=%d variants=%d", group, len(recipients), len(list))
|
||||
}
|
||||
}
|
||||
}
|
||||
1028
svc/routes/wholesale_campaigns.go
Normal file
1028
svc/routes/wholesale_campaigns.go
Normal file
@@ -0,0 +1,1028 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bssapp-backend/auth"
|
||||
"bssapp-backend/queries"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bssapp-backend/internal/mailer"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type wholesaleCampaignRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Dtst string `json:"dtst,omitempty"`
|
||||
Dtfn string `json:"dtfn,omitempty"`
|
||||
DiscountRate float64 `json:"discount_rate"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// GET /api/pricing/wholesale-campaigns
|
||||
func GetWholesaleCampaignsHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := buildPricingTraceID(r)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(NULLIF(BTRIM(code),''),'') AS code,
|
||||
COALESCE(NULLIF(BTRIM(title),''),'') AS title,
|
||||
COALESCE(is_active, TRUE) AS is_active,
|
||||
COALESCE(to_char(dtst, 'YYYY-MM-DD HH24:MI:SS'), '') AS dtst,
|
||||
COALESCE(to_char(dtfn, 'YYYY-MM-DD HH24:MI:SS'), '') AS dtfn,
|
||||
COALESCE(discount_rate, 0)::float8 AS discount_rate,
|
||||
COALESCE(NULLIF(BTRIM(notes),''),'') AS notes
|
||||
FROM sdcampaign
|
||||
WHERE COALESCE(is_active, TRUE) = TRUE
|
||||
ORDER BY discount_rate ASC, id ASC;
|
||||
`)
|
||||
if err != nil {
|
||||
http.Error(w, "campaign list error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]wholesaleCampaignRow, 0, 64)
|
||||
for rows.Next() {
|
||||
var it wholesaleCampaignRow
|
||||
var dtst, dtfn string
|
||||
if err := rows.Scan(&it.ID, &it.Code, &it.Title, &it.IsActive, &dtst, &dtfn, &it.DiscountRate, &it.Notes); err != nil {
|
||||
http.Error(w, "campaign scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
it.Dtst = strings.TrimSpace(dtst)
|
||||
it.Dtfn = strings.TrimSpace(dtfn)
|
||||
out = append(out, it)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
log.Printf("[WholesaleCampaigns] trace=%s user=%s id=%d count=%d", traceID, claims.Username, claims.ID, len(out))
|
||||
}
|
||||
}
|
||||
|
||||
type campaignAssignmentRow struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
CampaignID *int64 `json:"campaign_id"`
|
||||
CampaignCode string `json:"campaign_code"`
|
||||
DiscountRate float64 `json:"discount_rate"`
|
||||
IsMixed bool `json:"is_mixed"`
|
||||
VariantRows int `json:"variant_rows"`
|
||||
AssignedDim1s int `json:"assigned_dim1s"`
|
||||
AssignedDim3s int `json:"assigned_dim3s"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// GET /api/pricing/wholesale-campaigns/assignments?product_code=A,B,C
|
||||
func GetWholesaleCampaignAssignmentsHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := buildPricingTraceID(r)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
codes := splitCSVParam(r.URL.Query().Get("product_code"))
|
||||
if len(codes) == 0 {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode([]campaignAssignmentRow{})
|
||||
return
|
||||
}
|
||||
if len(codes) > 500 {
|
||||
http.Error(w, "product_code too many", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
WITH inp AS (
|
||||
SELECT UNNEST($1::text[]) AS code
|
||||
),
|
||||
mm AS (
|
||||
SELECT m.id AS mmitem_id, m.code
|
||||
FROM mmitem m
|
||||
JOIN inp ON inp.code = m.code
|
||||
),
|
||||
latest AS (
|
||||
-- "Current" assignment = latest row per variant key (mmitem_id, dim1, dim3_key).
|
||||
SELECT DISTINCT ON (z.mmitem_id, z.dim1, COALESCE(z.dim3, 0))
|
||||
z.mmitem_id,
|
||||
z.dim1,
|
||||
COALESCE(z.dim3, 0) AS dim3_key,
|
||||
z.sdcampaign_id
|
||||
FROM zbggcampaign z
|
||||
JOIN mm ON mm.mmitem_id = z.mmitem_id
|
||||
ORDER BY z.mmitem_id, z.dim1, COALESCE(z.dim3, 0), z.id DESC
|
||||
),
|
||||
agg AS (
|
||||
SELECT
|
||||
mm.code AS product_code,
|
||||
COUNT(*)::int AS variant_rows,
|
||||
COUNT(DISTINCT l.dim1) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS assigned_dim1s,
|
||||
COUNT(DISTINCT l.dim3_key) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS assigned_dim3s,
|
||||
COUNT(DISTINCT l.sdcampaign_id) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS distinct_campaigns,
|
||||
MAX(l.sdcampaign_id)::bigint AS any_campaign_id
|
||||
FROM mm
|
||||
LEFT JOIN latest l
|
||||
ON l.mmitem_id = mm.mmitem_id
|
||||
GROUP BY mm.code
|
||||
),
|
||||
single AS (
|
||||
SELECT
|
||||
a.product_code,
|
||||
CASE WHEN a.distinct_campaigns = 1 THEN a.any_campaign_id ELSE NULL END AS campaign_id,
|
||||
CASE WHEN a.distinct_campaigns > 1 THEN TRUE ELSE FALSE END AS is_mixed,
|
||||
a.variant_rows,
|
||||
a.assigned_dim1s,
|
||||
a.assigned_dim3s
|
||||
FROM agg a
|
||||
)
|
||||
SELECT
|
||||
s.product_code,
|
||||
s.campaign_id,
|
||||
COALESCE(sc.code,'') AS campaign_code,
|
||||
COALESCE(sc.discount_rate, 0)::float8 AS discount_rate,
|
||||
s.is_mixed,
|
||||
s.variant_rows,
|
||||
s.assigned_dim1s,
|
||||
s.assigned_dim3s,
|
||||
COALESCE(NULLIF(BTRIM(sc.notes),''),'') AS notes
|
||||
FROM single s
|
||||
LEFT JOIN sdcampaign sc
|
||||
ON sc.id = s.campaign_id
|
||||
ORDER BY s.product_code;
|
||||
`, pq.Array(codes))
|
||||
if err != nil {
|
||||
http.Error(w, "assignment list error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]campaignAssignmentRow, 0, len(codes))
|
||||
for rows.Next() {
|
||||
var it campaignAssignmentRow
|
||||
var cid sql.NullInt64
|
||||
if err := rows.Scan(&it.ProductCode, &cid, &it.CampaignCode, &it.DiscountRate, &it.IsMixed, &it.VariantRows, &it.AssignedDim1s, &it.AssignedDim3s, &it.Notes); err != nil {
|
||||
http.Error(w, "assignment scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cid.Valid {
|
||||
v := cid.Int64
|
||||
it.CampaignID = &v
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
log.Printf("[WholesaleCampaignAssignments] trace=%s user=%s id=%d products=%d", traceID, claims.Username, claims.ID, len(out))
|
||||
}
|
||||
}
|
||||
|
||||
type saveWholesaleCampaignItem struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
Dim1 int64 `json:"dim1"`
|
||||
Dim3 *int64 `json:"dim3"`
|
||||
CampaignID *int64 `json:"campaign_id"`
|
||||
}
|
||||
|
||||
type saveWholesaleCampaignPayload struct {
|
||||
Items []saveWholesaleCampaignItem `json:"items"`
|
||||
}
|
||||
|
||||
// POST /api/pricing/wholesale-campaigns/save
|
||||
// Appends a new row to zbggcampaign per variant (dim1+dim3), preserving history.
|
||||
func SaveWholesaleCampaignAssignmentsHandler(pg *sql.DB, ml *mailer.GraphMailer) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
started := time.Now()
|
||||
traceID := buildPricingTraceID(r)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var payload saveWholesaleCampaignPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(payload.Items) == 0 {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "saved": 0})
|
||||
return
|
||||
}
|
||||
if len(payload.Items) > 500 {
|
||||
http.Error(w, "too many items", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := pg.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "tx begin error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Resolve mmitem ids in bulk.
|
||||
codeList := make([]string, 0, len(payload.Items))
|
||||
seenCode := make(map[string]struct{}, len(payload.Items))
|
||||
for _, it := range payload.Items {
|
||||
code := strings.TrimSpace(it.ProductCode)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenCode[code]; ok {
|
||||
continue
|
||||
}
|
||||
seenCode[code] = struct{}{}
|
||||
codeList = append(codeList, code)
|
||||
}
|
||||
|
||||
codeToItemID := make(map[string]int64, len(codeList))
|
||||
if len(codeList) > 0 {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT code, id
|
||||
FROM mmitem
|
||||
WHERE code = ANY($1::text[])
|
||||
`, pq.Array(codeList))
|
||||
if err != nil {
|
||||
http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for rows.Next() {
|
||||
var code string
|
||||
var id int64
|
||||
if err := rows.Scan(&code, &id); err != nil {
|
||||
rows.Close()
|
||||
http.Error(w, "mmitem scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code != "" && id > 0 {
|
||||
codeToItemID[code] = id
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
saved := 0
|
||||
for _, it := range payload.Items {
|
||||
code := strings.TrimSpace(it.ProductCode)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
mmitemID := codeToItemID[code]
|
||||
if mmitemID <= 0 {
|
||||
continue
|
||||
}
|
||||
if it.Dim1 <= 0 {
|
||||
continue
|
||||
}
|
||||
d3k := int64(0)
|
||||
if it.Dim3 != nil && *it.Dim3 > 0 {
|
||||
d3k = *it.Dim3
|
||||
}
|
||||
|
||||
// Normalize requested campaign id (nullable).
|
||||
var requested any = nil
|
||||
if it.CampaignID != nil && *it.CampaignID > 0 {
|
||||
requested = *it.CampaignID
|
||||
}
|
||||
|
||||
// Skip write if "current" assignment is already the same (latest row).
|
||||
{
|
||||
var cur sql.NullInt64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT sdcampaign_id
|
||||
FROM zbggcampaign
|
||||
WHERE mmitem_id = $1 AND dim1 = $2 AND COALESCE(dim3, 0) = $3
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`, mmitemID, it.Dim1, d3k).Scan(&cur)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
http.Error(w, "current campaign lookup error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err == sql.ErrNoRows && requested == nil {
|
||||
// Clearing a non-existent assignment: no-op.
|
||||
continue
|
||||
}
|
||||
if err == nil {
|
||||
// requested == nil means "clear"
|
||||
if requested == nil && !cur.Valid {
|
||||
continue
|
||||
}
|
||||
if requested != nil && cur.Valid && cur.Int64 == requested.(int64) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO zbggcampaign (mmitem_id, dim1, dim3, sdcampaign_id)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
`, mmitemID, it.Dim1, func() any {
|
||||
if d3k > 0 {
|
||||
return d3k
|
||||
}
|
||||
return nil
|
||||
}(), requested); err != nil {
|
||||
http.Error(w, "insert campaign row error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
saved++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, "commit error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Send campaign mail (post-commit, best-effort).
|
||||
if ml != nil {
|
||||
codes := make([]string, 0, len(payload.Items))
|
||||
seen := map[string]struct{}{}
|
||||
for _, it := range payload.Items {
|
||||
c := strings.TrimSpace(it.ProductCode)
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[c]; ok {
|
||||
continue
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
codes = append(codes, c)
|
||||
}
|
||||
go sendWholesaleCampaignChangeMails(context.Background(), ml, codes, claims.Username)
|
||||
}
|
||||
|
||||
log.Printf("[WholesaleCampaignSave] trace=%s user=%s id=%d items=%d saved=%d duration_ms=%d",
|
||||
traceID, claims.Username, claims.ID, len(payload.Items), saved, time.Since(started).Milliseconds(),
|
||||
)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true,
|
||||
"saved": saved,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type wholesaleVariantStockRow struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
VariantCode string `json:"variant_code"`
|
||||
StockQty float64 `json:"stock_qty"`
|
||||
}
|
||||
|
||||
type wholesaleVariantRow struct {
|
||||
ProductCode string `json:"product_code"`
|
||||
VariantCode string `json:"variant_code"`
|
||||
StockQty float64 `json:"stock_qty"`
|
||||
Dim1 int64 `json:"dim1"`
|
||||
Dim3 *int64 `json:"dim3"`
|
||||
CampaignID *int64 `json:"campaign_id"`
|
||||
CampaignCode string `json:"campaign_code"`
|
||||
CampaignTitle string `json:"campaign_title"`
|
||||
DiscountRate float64 `json:"discount_rate"`
|
||||
CampaignLast string `json:"campaign_last_dttm"`
|
||||
}
|
||||
|
||||
type wholesaleCampaignHistoryRow struct {
|
||||
ID int64 `json:"id"`
|
||||
CampaignID *int64 `json:"campaign_id"`
|
||||
CampaignCode string `json:"campaign_code"`
|
||||
Title string `json:"campaign_title"`
|
||||
DiscountRate float64 `json:"discount_rate"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
type wholesaleCampaignHistoryResponse struct {
|
||||
Rows []wholesaleCampaignHistoryRow `json:"rows"`
|
||||
}
|
||||
|
||||
type deleteSelectedIDsPayload struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
// GET /api/pricing/wholesale-campaigns/{code}/campaign-history?dim1=..&dim3=..
|
||||
func GetWholesaleCampaignHistoryHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := buildPricingTraceID(r)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(mux.Vars(r)["code"])
|
||||
if code == "" {
|
||||
http.Error(w, "missing code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
dim1, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim1")), 10, 64)
|
||||
dim3, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim3")), 10, 64)
|
||||
if dim1 <= 0 {
|
||||
http.Error(w, "missing dim1", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d3k := dim3
|
||||
if d3k < 0 {
|
||||
d3k = 0
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var mmitemID int64
|
||||
if err := pg.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code = $1`, code).Scan(&mmitemID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(wholesaleCampaignHistoryResponse{Rows: []wholesaleCampaignHistoryRow{}})
|
||||
return
|
||||
}
|
||||
http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT
|
||||
z.id,
|
||||
z.sdcampaign_id,
|
||||
COALESCE(sc.code,'') AS code,
|
||||
COALESCE(sc.title,'') AS title,
|
||||
COALESCE(sc.discount_rate, 0)::float8 AS discount_rate,
|
||||
COALESCE(to_char(COALESCE(z.zlupd_dttm, z.zlins_dttm), 'YYYY-MM-DD HH24:MI:SS'), '') AS at
|
||||
FROM zbggcampaign z
|
||||
LEFT JOIN sdcampaign sc
|
||||
ON sc.id = z.sdcampaign_id
|
||||
WHERE z.mmitem_id = $1
|
||||
AND z.dim1 = $2
|
||||
AND COALESCE(z.dim3, 0) = $3
|
||||
ORDER BY z.id DESC
|
||||
LIMIT 200;
|
||||
`, mmitemID, dim1, d3k)
|
||||
if err != nil {
|
||||
http.Error(w, "campaign history query error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]wholesaleCampaignHistoryRow, 0, 64)
|
||||
for rows.Next() {
|
||||
var it wholesaleCampaignHistoryRow
|
||||
var cid sql.NullInt64
|
||||
if err := rows.Scan(&it.ID, &cid, &it.CampaignCode, &it.Title, &it.DiscountRate, &it.At); err != nil {
|
||||
http.Error(w, "campaign history scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cid.Valid && cid.Int64 > 0 {
|
||||
v := cid.Int64
|
||||
it.CampaignID = &v
|
||||
}
|
||||
it.CampaignCode = strings.TrimSpace(it.CampaignCode)
|
||||
it.Title = strings.TrimSpace(it.Title)
|
||||
it.At = strings.TrimSpace(it.At)
|
||||
out = append(out, it)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(wholesaleCampaignHistoryResponse{Rows: out})
|
||||
log.Printf("[WholesaleCampaignHistory] trace=%s user=%s id=%d code=%s dim1=%d dim3=%d rows=%d",
|
||||
traceID, claims.Username, claims.ID, code, dim1, d3k, len(out),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/pricing/wholesale-campaigns/{code}/campaign-history/delete-selected?dim1=..&dim3=..
|
||||
func PostDeleteSelectedWholesaleCampaignHistoryHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := buildPricingTraceID(r)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(mux.Vars(r)["code"])
|
||||
if code == "" {
|
||||
http.Error(w, "missing code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dim1, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim1")), 10, 64)
|
||||
dim3, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim3")), 10, 64)
|
||||
if dim1 <= 0 {
|
||||
http.Error(w, "missing dim1", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d3k := dim3
|
||||
if d3k < 0 {
|
||||
d3k = 0
|
||||
}
|
||||
|
||||
var payload deleteSelectedIDsPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(payload.IDs) == 0 {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "deleted": 0})
|
||||
return
|
||||
}
|
||||
if len(payload.IDs) > 500 {
|
||||
http.Error(w, "too many ids", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var mmitemID int64
|
||||
if err := pg.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code = $1`, code).Scan(&mmitemID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "unknown code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := pg.ExecContext(ctx, `
|
||||
DELETE FROM zbggcampaign
|
||||
WHERE id = ANY($1::bigint[])
|
||||
AND mmitem_id = $2
|
||||
AND dim1 = $3
|
||||
AND COALESCE(dim3, 0) = $4
|
||||
`, pq.Array(payload.IDs), mmitemID, dim1, d3k)
|
||||
if err != nil {
|
||||
http.Error(w, "delete error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
deleted, _ := res.RowsAffected()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "deleted": deleted})
|
||||
log.Printf("[WholesaleCampaignHistoryDelete] trace=%s user=%s id=%d code=%s dim1=%d dim3=%d deleted=%d",
|
||||
traceID, claims.Username, claims.ID, code, dim1, d3k, deleted,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/pricing/wholesale-campaigns/variant-rows?product_code=A,B,C
|
||||
// Returns variant-level rows with resolved PG dims and current campaign assignment (if any).
|
||||
func GetWholesaleCampaignVariantRowsHandler(pg *sql.DB, mssql *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := buildPricingTraceID(r)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if pg == nil {
|
||||
http.Error(w, "pg not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if mssql == nil {
|
||||
http.Error(w, "mssql not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
codes := splitCSVParam(r.URL.Query().Get("product_code"))
|
||||
if len(codes) == 0 {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode([]wholesaleVariantRow{})
|
||||
return
|
||||
}
|
||||
if len(codes) > 250 {
|
||||
http.Error(w, "product_code too many", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// MSSQL 2008 + stock breakdown over many items can be slow; keep a generous timeout.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Resolve mmitem ids in bulk.
|
||||
codeToItem := make(map[string]int64, len(codes))
|
||||
{
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
SELECT code, id
|
||||
FROM mmitem
|
||||
WHERE code = ANY($1::text[])
|
||||
`, pq.Array(codes))
|
||||
if err != nil {
|
||||
http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for rows.Next() {
|
||||
var c string
|
||||
var id int64
|
||||
if err := rows.Scan(&c, &id); err != nil {
|
||||
rows.Close()
|
||||
http.Error(w, "mmitem scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c = strings.TrimSpace(c)
|
||||
if c != "" && id > 0 {
|
||||
codeToItem[c] = id
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
// Dim token -> id resolver (fast path: mk_dim_token_map; fallback: dfblob file_name token inference).
|
||||
dimCache := make(map[string]int64, 1024)
|
||||
parseDimID := func(s string) (int64, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
s2 := strings.TrimLeft(s, "0")
|
||||
if s2 == "" {
|
||||
s2 = "0"
|
||||
}
|
||||
n, err := strconv.ParseInt(s2, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
resolveDimID := func(column, token string) (int64, bool) {
|
||||
token = strings.ToUpper(normalizeDimParam(token))
|
||||
if token == "" {
|
||||
return 0, false
|
||||
}
|
||||
k := column + "|" + token
|
||||
if v, ok := dimCache[k]; ok {
|
||||
return v, v > 0
|
||||
}
|
||||
// persistent cache
|
||||
{
|
||||
var id int64
|
||||
if err := pg.QueryRowContext(ctx, `
|
||||
SELECT dim_id
|
||||
FROM mk_dim_token_map
|
||||
WHERE dim_column = $1 AND token = $2
|
||||
`, column, token).Scan(&id); err == nil && id > 0 {
|
||||
dimCache[k] = id
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
// fallback: infer id from dfblob metadata (token -> dimval id)
|
||||
v := resolveDimvalFromFileNameToken(pg, column, token)
|
||||
if v == "" {
|
||||
dimCache[k] = 0
|
||||
return 0, false
|
||||
}
|
||||
id, ok := parseDimID(v)
|
||||
if !ok {
|
||||
dimCache[k] = 0
|
||||
return 0, false
|
||||
}
|
||||
_, _ = pg.ExecContext(ctx, `
|
||||
INSERT INTO mk_dim_token_map (dim_column, token, dim_id, updated_at)
|
||||
VALUES ($1,$2,$3,now())
|
||||
ON CONFLICT (dim_column, token)
|
||||
DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
|
||||
`, column, token, id)
|
||||
dimCache[k] = id
|
||||
return id, true
|
||||
}
|
||||
|
||||
// MSSQL: variant+stock list for selected products.
|
||||
joined := strings.Join(codes, ",")
|
||||
msRows, err := mssql.QueryContext(ctx, queries.GetWholesaleCampaignVariantStockByProducts, joined)
|
||||
if err != nil {
|
||||
http.Error(w, "variant stock query error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer msRows.Close()
|
||||
|
||||
type tmpRow struct {
|
||||
ProductCode string
|
||||
VariantCode string
|
||||
StockQty float64
|
||||
ItemID int64
|
||||
Dim1 int64
|
||||
Dim3Key int64
|
||||
}
|
||||
// Deduplicate by (mmitem_id, dim1, dim3_key) and aggregate stock qty.
|
||||
tmpMap := make(map[string]tmpRow, 4096)
|
||||
for msRows.Next() {
|
||||
var itemCode, colorCode, dim1Code, dim3Code string
|
||||
var qty sql.NullFloat64
|
||||
if err := msRows.Scan(&itemCode, &colorCode, &dim1Code, &dim3Code, &qty); err != nil {
|
||||
http.Error(w, "variant stock scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
itemCode = strings.TrimSpace(itemCode)
|
||||
if itemCode == "" {
|
||||
continue
|
||||
}
|
||||
itemID := codeToItem[itemCode]
|
||||
if itemID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Variant token: prefer ColorCode; ItemDim1Code may represent a different attribute.
|
||||
t1 := strings.TrimSpace(colorCode)
|
||||
if t1 == "" || t1 == "0" {
|
||||
t1 = strings.TrimSpace(dim1Code)
|
||||
}
|
||||
t3 := strings.TrimSpace(dim3Code)
|
||||
varCode := strings.TrimSpace(t1)
|
||||
if varCode != "" && t3 != "" && t3 != "0" {
|
||||
varCode = varCode + "-" + strings.TrimSpace(t3)
|
||||
}
|
||||
if varCode == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
d1 := int64(0)
|
||||
// Resolve dim1: prefer ColorCode first (matches e-comm expectation: dim1=Color).
|
||||
if id, ok := resolveDimID("dimval1", colorCode); ok {
|
||||
d1 = id
|
||||
} else if id, ok := resolveDimID("dimval1", dim1Code); ok {
|
||||
d1 = id
|
||||
}
|
||||
if d1 <= 0 {
|
||||
continue
|
||||
}
|
||||
d3k := int64(0)
|
||||
if id, ok := resolveDimID("dimval3", t3); ok {
|
||||
d3k = id
|
||||
}
|
||||
|
||||
q := 0.0
|
||||
if qty.Valid {
|
||||
q = qty.Float64
|
||||
}
|
||||
key := fmt.Sprintf("%d|%d|%d", itemID, d1, d3k)
|
||||
if prev, ok := tmpMap[key]; ok {
|
||||
prev.StockQty += q
|
||||
// Keep the first non-empty variant code.
|
||||
if prev.VariantCode == "" {
|
||||
prev.VariantCode = varCode
|
||||
}
|
||||
tmpMap[key] = prev
|
||||
} else {
|
||||
tmpMap[key] = tmpRow{
|
||||
ProductCode: itemCode,
|
||||
VariantCode: varCode,
|
||||
StockQty: q,
|
||||
ItemID: itemID,
|
||||
Dim1: d1,
|
||||
Dim3Key: d3k,
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := msRows.Err(); err != nil {
|
||||
http.Error(w, "variant stock read error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmp := make([]tmpRow, 0, len(tmpMap))
|
||||
for _, v := range tmpMap {
|
||||
tmp = append(tmp, v)
|
||||
}
|
||||
|
||||
// Bulk load campaign assignment for each (mmitem_id, dim1, dim3_key)
|
||||
type keyRec struct {
|
||||
ItemID int64 `json:"mmitem_id"`
|
||||
Dim1 int64 `json:"dim1"`
|
||||
Dim3Key int64 `json:"dim3_key"`
|
||||
}
|
||||
keys := make([]keyRec, 0, len(tmp))
|
||||
seenKey := make(map[string]struct{}, len(tmp))
|
||||
for _, t := range tmp {
|
||||
k := fmt.Sprintf("%d|%d|%d", t.ItemID, t.Dim1, t.Dim3Key)
|
||||
if _, ok := seenKey[k]; ok {
|
||||
continue
|
||||
}
|
||||
seenKey[k] = struct{}{}
|
||||
keys = append(keys, keyRec{ItemID: t.ItemID, Dim1: t.Dim1, Dim3Key: t.Dim3Key})
|
||||
}
|
||||
rawKeys, _ := json.Marshal(keys)
|
||||
|
||||
type campAgg struct {
|
||||
CampaignID sql.NullInt64
|
||||
CampaignCode string
|
||||
CampaignTitle string
|
||||
DiscountRate float64
|
||||
CampaignLast string
|
||||
}
|
||||
campMap := make(map[string]campAgg, len(keys))
|
||||
if len(keys) > 0 {
|
||||
rows, err := pg.QueryContext(ctx, `
|
||||
WITH input AS (
|
||||
SELECT *
|
||||
FROM jsonb_to_recordset($1::jsonb) AS x(mmitem_id bigint, dim1 int, dim3_key int)
|
||||
),
|
||||
latest AS (
|
||||
SELECT
|
||||
i.mmitem_id,
|
||||
i.dim1,
|
||||
i.dim3_key,
|
||||
MAX(z.id)::bigint AS z_id
|
||||
FROM input i
|
||||
LEFT JOIN zbggcampaign z
|
||||
ON z.mmitem_id = i.mmitem_id
|
||||
AND z.dim1 = i.dim1
|
||||
AND COALESCE(z.dim3, 0) = i.dim3_key
|
||||
GROUP BY i.mmitem_id, i.dim1, i.dim3_key
|
||||
)
|
||||
SELECT
|
||||
l.mmitem_id,
|
||||
l.dim1,
|
||||
l.dim3_key,
|
||||
z.sdcampaign_id,
|
||||
COALESCE(sc.code,'') AS code,
|
||||
COALESCE(sc.title,'') AS title,
|
||||
COALESCE(sc.discount_rate, 0)::float8 AS discount_rate,
|
||||
COALESCE(to_char(COALESCE(z.zlupd_dttm, z.zlins_dttm), 'YYYY-MM-DD HH24:MI:SS'), '') AS campaign_last_dttm
|
||||
FROM latest l
|
||||
LEFT JOIN zbggcampaign z
|
||||
ON z.id = l.z_id
|
||||
LEFT JOIN sdcampaign sc
|
||||
ON sc.id = z.sdcampaign_id
|
||||
`, rawKeys)
|
||||
if err != nil {
|
||||
http.Error(w, "campaign lookup error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for rows.Next() {
|
||||
var itemID, d1, d3k int64
|
||||
var cid sql.NullInt64
|
||||
var code, title string
|
||||
var rate float64
|
||||
var last string
|
||||
if err := rows.Scan(&itemID, &d1, &d3k, &cid, &code, &title, &rate, &last); err != nil {
|
||||
rows.Close()
|
||||
http.Error(w, "campaign scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
campMap[fmt.Sprintf("%d|%d|%d", itemID, d1, d3k)] = campAgg{
|
||||
CampaignID: cid,
|
||||
CampaignCode: strings.TrimSpace(code),
|
||||
CampaignTitle: strings.TrimSpace(title),
|
||||
DiscountRate: rate,
|
||||
CampaignLast: strings.TrimSpace(last),
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
out := make([]wholesaleVariantRow, 0, len(tmp))
|
||||
for _, t := range tmp {
|
||||
agg := campMap[fmt.Sprintf("%d|%d|%d", t.ItemID, t.Dim1, t.Dim3Key)]
|
||||
var cidp *int64
|
||||
if agg.CampaignID.Valid && agg.CampaignID.Int64 > 0 {
|
||||
v := agg.CampaignID.Int64
|
||||
cidp = &v
|
||||
}
|
||||
var d3p *int64
|
||||
if t.Dim3Key > 0 {
|
||||
v := t.Dim3Key
|
||||
d3p = &v
|
||||
}
|
||||
out = append(out, wholesaleVariantRow{
|
||||
ProductCode: t.ProductCode,
|
||||
VariantCode: t.VariantCode,
|
||||
StockQty: t.StockQty,
|
||||
Dim1: t.Dim1,
|
||||
Dim3: d3p,
|
||||
CampaignID: cidp,
|
||||
CampaignCode: agg.CampaignCode,
|
||||
CampaignTitle: agg.CampaignTitle,
|
||||
DiscountRate: agg.DiscountRate,
|
||||
CampaignLast: agg.CampaignLast,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
log.Printf("[WholesaleCampaignVariantRows] trace=%s user=%s id=%d products=%d rows=%d",
|
||||
traceID, claims.Username, claims.ID, len(codes), len(out),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/pricing/wholesale-campaigns/variants?product_code=A,B,C
|
||||
func GetWholesaleCampaignVariantStockHandler(mssql *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := buildPricingTraceID(r)
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
|
||||
claims, ok := auth.GetClaimsFromContext(r.Context())
|
||||
if !ok || claims == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if mssql == nil {
|
||||
http.Error(w, "mssql not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
codes := splitCSVParam(r.URL.Query().Get("product_code"))
|
||||
if len(codes) == 0 {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode([]wholesaleVariantStockRow{})
|
||||
return
|
||||
}
|
||||
if len(codes) > 300 {
|
||||
http.Error(w, "product_code too many", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
|
||||
defer cancel()
|
||||
|
||||
joined := strings.Join(codes, ",")
|
||||
rows, err := mssql.QueryContext(ctx, queries.GetWholesaleCampaignVariantStockByProducts, joined)
|
||||
if err != nil {
|
||||
http.Error(w, "variant stock query error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]wholesaleVariantStockRow, 0, 2048)
|
||||
for rows.Next() {
|
||||
var itemCode, colorCode, dim1Code, dim3Code string
|
||||
var qty sql.NullFloat64
|
||||
if err := rows.Scan(&itemCode, &colorCode, &dim1Code, &dim3Code, &qty); err != nil {
|
||||
http.Error(w, "variant stock scan error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
itemCode = strings.TrimSpace(itemCode)
|
||||
if itemCode == "" {
|
||||
continue
|
||||
}
|
||||
// Variant token: prefer ColorCode; ItemDim1Code may represent a different attribute.
|
||||
t1 := strings.TrimSpace(colorCode)
|
||||
if t1 == "" || t1 == "0" {
|
||||
t1 = strings.TrimSpace(dim1Code)
|
||||
}
|
||||
t3 := strings.TrimSpace(dim3Code)
|
||||
varCode := t1
|
||||
if t1 != "" && t3 != "" && t3 != "0" {
|
||||
varCode = t1 + "-" + t3
|
||||
}
|
||||
if varCode == "" {
|
||||
continue
|
||||
}
|
||||
q := 0.0
|
||||
if qty.Valid {
|
||||
q = qty.Float64
|
||||
}
|
||||
out = append(out, wholesaleVariantStockRow{
|
||||
ProductCode: itemCode,
|
||||
VariantCode: varCode,
|
||||
StockQty: q,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
log.Printf("[WholesaleCampaignVariants] trace=%s user=%s id=%d products=%d rows=%d",
|
||||
traceID, claims.Username, claims.ID, len(codes), len(out),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -358,6 +358,11 @@ const menuItems = [
|
||||
to: '/app/pricing/product-pricing',
|
||||
permission: 'pricing:view'
|
||||
},
|
||||
{
|
||||
label: 'Toptan Kampanya Yönetimi',
|
||||
to: '/app/pricing/wholesale-campaigns',
|
||||
permission: 'pricing:view'
|
||||
},
|
||||
{
|
||||
label: 'Marka Sınıflandırma',
|
||||
to: '/app/pricing/brand-classification',
|
||||
|
||||
3261
ui/src/pages/WholesaleCampaigns.vue
Normal file
3261
ui/src/pages/WholesaleCampaigns.vue
Normal file
@@ -0,0 +1,3261 @@
|
||||
<template>
|
||||
<q-page class="q-pa-xs pricing-page">
|
||||
<div class="top-bar row items-center justify-between q-mb-xs">
|
||||
<div class="text-subtitle1 text-weight-bold">Toptan Kampanya Yonetimi</div>
|
||||
<div class="top-actions">
|
||||
<div class="row items-center q-gutter-xs top-actions-row top-actions-row--filters">
|
||||
<q-select
|
||||
v-model="topUrunIlkGrubu"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
emit-value
|
||||
map-options
|
||||
:options="topUrunIlkGrubuOptions"
|
||||
:loading="Boolean(serverFilterLoading.urunIlkGrubu)"
|
||||
label="Urun Ilk Grubu"
|
||||
style="min-width: 220px"
|
||||
@filter="onTopFilterSearchUrunIlkGrubu"
|
||||
@update:model-value="onTopUrunIlkGrubuChange"
|
||||
/>
|
||||
<q-select
|
||||
v-model="topUrunAnaGrubu"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
multiple
|
||||
use-chips
|
||||
emit-value
|
||||
map-options
|
||||
:options="topUrunAnaGrubuOptions"
|
||||
:loading="Boolean(serverFilterLoading.urunAnaGrubu)"
|
||||
label="Urun Ana Grubu (max 3)"
|
||||
style="min-width: 260px"
|
||||
@filter="onTopFilterSearchUrunAnaGrubu"
|
||||
@update:model-value="onTopUrunAnaGrubuChange"
|
||||
/>
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="filter_alt"
|
||||
label="Gruplari Getir"
|
||||
:disable="!canFetchByGroup"
|
||||
:loading="store.loading"
|
||||
@click="reloadData({ page: 1 })"
|
||||
/>
|
||||
<q-btn
|
||||
flat
|
||||
color="grey-7"
|
||||
icon="restart_alt"
|
||||
label="Secimleri Sifirla"
|
||||
@click="resetGroupSelections"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row items-center q-gutter-xs top-actions-row top-actions-row--actions">
|
||||
<div class="toolbar-group">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
color="grey-8"
|
||||
icon="view_sidebar"
|
||||
:label="leftDetailsExpanded ? 'Detaylari Gizle' : 'Detaylari Goster'"
|
||||
@click="leftDetailsExpanded = !leftDetailsExpanded"
|
||||
/>
|
||||
<q-btn-dropdown dense color="secondary" outline icon="view_module" label="Gosterge Fiyat Sec" :auto-close="false">
|
||||
<q-list dense class="currency-menu-list">
|
||||
<q-item clickable @click="selectAllPriceOptions">
|
||||
<q-item-section>Tumunu Sec</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable @click="clearAllPriceOptions">
|
||||
<q-item-section>Tumunu Temizle</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item v-for="option in priceOptionOptions" :key="option.value" clickable @click="togglePriceOptionRow(option.value)">
|
||||
<q-item-section avatar>
|
||||
<q-checkbox
|
||||
:model-value="isPriceOptionSelected(option.value)"
|
||||
dense
|
||||
@update:model-value="(val) => togglePriceOption(option.value, val)"
|
||||
@click.stop
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>{{ option.label }}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-chip
|
||||
v-for="opt in selectedPriceOptionChipOptions"
|
||||
:key="opt.value"
|
||||
dense
|
||||
square
|
||||
color="grey-3"
|
||||
text-color="grey-9"
|
||||
class="price-option-chip"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</q-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
:color="showSelectedOnly ? 'primary' : 'grey-7'"
|
||||
:icon="showSelectedOnly ? 'checklist_rtl' : 'list_alt'"
|
||||
:label="showSelectedOnly ? `Secililer (${selectedRowCount})` : 'Secili Olanlari Getir'"
|
||||
:disable="!showSelectedOnly && selectedRowCount === 0"
|
||||
@click="toggleShowSelectedOnly"
|
||||
/>
|
||||
<q-btn
|
||||
dense
|
||||
color="primary"
|
||||
icon="save"
|
||||
:label="saveButtonLabel"
|
||||
:disable="selectedDirtyCount === 0 || saving"
|
||||
:loading="saving"
|
||||
@click="saveSelectedRows"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<q-btn-dropdown dense color="primary" outline icon="download" label="Cikti Al" :auto-close="true">
|
||||
<q-list dense style="min-width: 260px;">
|
||||
<q-item clickable :disable="filteredRows.length === 0" @click="exportCurrentView">
|
||||
<q-item-section avatar><q-icon name="grid_on" /></q-item-section>
|
||||
<q-item-section>Sayfayi Excel'e Aktar</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable :disable="filteredRows.length === 0 || exportAllLoading" @click="exportAllFiltered">
|
||||
<q-item-section avatar><q-icon name="download_for_offline" /></q-item-section>
|
||||
<q-item-section>Tum Filtreyi Excel'e Aktar</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item clickable :disable="store.loading" @click="openPriceListExportDialog()">
|
||||
<q-item-section avatar><q-icon name="receipt_long" /></q-item-section>
|
||||
<q-item-section>Fiyat Listesi Ciktisi...</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
|
||||
<q-space />
|
||||
|
||||
<div class="toolbar-group toolbar-group--paging">
|
||||
<q-pagination
|
||||
v-model="currentPage"
|
||||
color="primary"
|
||||
:max="Math.max(1, store.totalPages || 1)"
|
||||
:max-pages="8"
|
||||
boundary-links
|
||||
direction-links
|
||||
@update:model-value="onPageChange"
|
||||
/>
|
||||
<div class="text-caption text-grey-8">
|
||||
Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" :style="{ '--sticky-scroll-comp': `${stickyScrollComp}px` }">
|
||||
<q-inner-loading :showing="saving">
|
||||
<q-spinner-gears size="46px" color="primary" />
|
||||
</q-inner-loading>
|
||||
<div v-if="showGuidanceOverlay" class="empty-overlay">
|
||||
<div class="empty-overlay-inner">
|
||||
<div class="text-subtitle1 text-weight-bold">Calismaya Baslamak Icin</div>
|
||||
<div class="text-body2 q-mt-xs">
|
||||
Urun Ilk Grubu veya Urun Ana Grubu secin ve <b>GRUPLARI GETIR</b>'e basin.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="topScrollRef"
|
||||
class="top-x-scroll"
|
||||
@scroll.passive="onTopScroll"
|
||||
>
|
||||
<div
|
||||
ref="topScrollInnerRef"
|
||||
class="top-x-scroll-inner"
|
||||
:style="{ width: `${tableMinWidth}px` }"
|
||||
/>
|
||||
</div>
|
||||
<q-table
|
||||
ref="mainTableRef"
|
||||
class="pane-table pricing-table"
|
||||
flat
|
||||
dense
|
||||
row-key="rowKey"
|
||||
:rows="filteredRows"
|
||||
:columns="visibleColumns"
|
||||
:loading="tableLoading"
|
||||
:rows-per-page-options="[0]"
|
||||
:pagination="tablePagination"
|
||||
hide-bottom
|
||||
:table-style="tableStyle"
|
||||
@update:pagination="onPaginationChange"
|
||||
>
|
||||
<template #header="props">
|
||||
<q-tr :props="props" class="header-row-fixed">
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
:class="[col.headerClasses, { 'sticky-col': isStickyCol(col.name), 'sticky-boundary': isStickyBoundary(col.name) }]"
|
||||
:style="getHeaderCellStyle(col)"
|
||||
>
|
||||
<q-checkbox
|
||||
v-if="col.name === 'select'"
|
||||
size="sm"
|
||||
color="primary"
|
||||
:model-value="allSelectedVisible"
|
||||
:indeterminate="someSelectedVisible && !allSelectedVisible"
|
||||
@update:model-value="toggleSelectAllVisible"
|
||||
/>
|
||||
<div v-else class="header-with-filter">
|
||||
<span :title="col.label">{{ col.label }}</span>
|
||||
<q-tooltip
|
||||
v-if="col.label"
|
||||
anchor="top middle"
|
||||
self="bottom middle"
|
||||
:offset="[0, 6]"
|
||||
>
|
||||
{{ col.label }}
|
||||
</q-tooltip>
|
||||
<q-btn
|
||||
v-if="isHeaderFilterField(col.field)"
|
||||
dense
|
||||
flat
|
||||
round
|
||||
size="8px"
|
||||
icon="filter_alt"
|
||||
:color="hasFilter(col.field) ? 'primary' : 'grey-7'"
|
||||
class="header-filter-btn"
|
||||
>
|
||||
<q-badge v-if="hasFilter(col.field)" color="primary" floating rounded>
|
||||
{{ getFilterBadgeValue(col.field) }}
|
||||
</q-badge>
|
||||
<q-menu
|
||||
anchor="bottom right"
|
||||
self="top right"
|
||||
:offset="[0, 4]"
|
||||
@before-show="() => onFilterMenuBeforeShow(col.field)"
|
||||
>
|
||||
<div v-if="isMultiSelectFilterField(col.field)" class="excel-filter-menu">
|
||||
<q-input
|
||||
v-model="columnFilterSearch[col.field]"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
use-input
|
||||
class="excel-filter-select"
|
||||
placeholder="Ara"
|
||||
/>
|
||||
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
|
||||
<q-btn flat dense size="sm" label="Tumunu Sec" @click="selectAllColumnFilterOptions(col.field)" />
|
||||
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter(col.field)" />
|
||||
</div>
|
||||
<q-virtual-scroll
|
||||
v-if="getFilterOptionsForField(col.field).length > 0"
|
||||
class="excel-filter-options"
|
||||
:items="getFilterOptionsForField(col.field)"
|
||||
:virtual-scroll-item-size="32"
|
||||
separator
|
||||
>
|
||||
<template #default="{ item: option }">
|
||||
<q-item
|
||||
:key="`${col.field}-${option.value}`"
|
||||
dense
|
||||
clickable
|
||||
class="excel-filter-option"
|
||||
@click="toggleColumnFilterValue(col.field, option.value)"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-checkbox
|
||||
dense
|
||||
size="sm"
|
||||
:model-value="isColumnFilterValueSelected(col.field, option.value)"
|
||||
@update:model-value="() => toggleColumnFilterValue(col.field, option.value)"
|
||||
@click.stop
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ option.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-virtual-scroll>
|
||||
<div v-else class="excel-filter-empty">
|
||||
Sonuc yok
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="isValueSelectFilterField(col.field)" class="excel-filter-menu">
|
||||
<q-input
|
||||
v-model="valueFilterSearch[col.field]"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
use-input
|
||||
class="excel-filter-select"
|
||||
placeholder="Deger ara"
|
||||
/>
|
||||
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
|
||||
<q-btn flat dense size="sm" label="Tumunu Sec" @click="selectAllColumnFilterOptions(col.field)" />
|
||||
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter(col.field)" />
|
||||
</div>
|
||||
<q-virtual-scroll
|
||||
v-if="getFilterOptionsForField(col.field).length > 0"
|
||||
class="excel-filter-options"
|
||||
:items="getFilterOptionsForField(col.field)"
|
||||
:virtual-scroll-item-size="32"
|
||||
separator
|
||||
>
|
||||
<template #default="{ item: option }">
|
||||
<q-item
|
||||
:key="`${col.field}-${option.value}`"
|
||||
dense
|
||||
clickable
|
||||
class="excel-filter-option"
|
||||
@click="toggleColumnFilterValue(col.field, option.value)"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-checkbox
|
||||
dense
|
||||
size="sm"
|
||||
:model-value="isColumnFilterValueSelected(col.field, option.value)"
|
||||
@update:model-value="() => toggleColumnFilterValue(col.field, option.value)"
|
||||
@click.stop
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ option.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-virtual-scroll>
|
||||
<div v-else class="excel-filter-empty">
|
||||
Sonuc yok
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="isNumberRangeFilterField(col.field)" class="excel-filter-menu">
|
||||
<div class="range-filter-grid">
|
||||
<q-input
|
||||
v-model="numberRangeFilters[col.field].min"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
label="Min"
|
||||
inputmode="decimal"
|
||||
class="range-filter-field"
|
||||
/>
|
||||
<q-input
|
||||
v-model="numberRangeFilters[col.field].max"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
label="Max"
|
||||
inputmode="decimal"
|
||||
class="range-filter-field"
|
||||
/>
|
||||
</div>
|
||||
<div class="row justify-end q-pt-xs">
|
||||
<q-btn flat dense size="sm" label="Temizle" @click="clearRangeFilter(col.field)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="isDateRangeFilterField(col.field)" class="excel-filter-menu">
|
||||
<div class="range-filter-grid">
|
||||
<q-input
|
||||
v-model="dateRangeFilters[col.field].from"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
type="date"
|
||||
label="Baslangic"
|
||||
class="range-filter-field"
|
||||
/>
|
||||
<q-input
|
||||
v-model="dateRangeFilters[col.field].to"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
type="date"
|
||||
label="Bitis"
|
||||
class="range-filter-field"
|
||||
/>
|
||||
</div>
|
||||
<div class="row justify-end q-pt-xs">
|
||||
<q-btn flat dense size="sm" label="Temizle" @click="clearRangeFilter(col.field)" />
|
||||
</div>
|
||||
</div>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-else
|
||||
dense
|
||||
flat
|
||||
round
|
||||
size="8px"
|
||||
icon="filter_alt"
|
||||
class="header-filter-btn header-filter-ghost"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
||||
<template #body-cell-select="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
class="text-center selection-col"
|
||||
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<q-checkbox
|
||||
size="sm"
|
||||
color="primary"
|
||||
:model-value="isRowSelected(rowSelectionKey(props.row))"
|
||||
@update:model-value="(val) => onRowCheckboxChange(props.row, val)"
|
||||
@click.stop
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-historyAction="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
size="sm"
|
||||
color="grey-8"
|
||||
icon="history"
|
||||
:disable="!props.row?.productCode"
|
||||
@click="openCampaignHistoryDialog(props.row)"
|
||||
>
|
||||
<q-tooltip anchor="top middle" self="bottom middle" :offset="[0, 6]">Kampanya gecmisi</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-productCode="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span class="product-code-text" :title="String(props.value ?? '')">{{ props.value }}</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-variantCodes="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<div class="variant-multiline" :title="props.row.variantCodes || ''">{{ props.row.variantCodes || '' }}</div>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-variantStocks="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<div class="variant-multiline text-right" :title="props.row.variantStocks || ''">{{ props.row.variantStocks || '' }}</div>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-campaignId="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<q-select
|
||||
dense
|
||||
outlined
|
||||
options-dense
|
||||
emit-value
|
||||
map-options
|
||||
clearable
|
||||
:options="campaignSelectOptions"
|
||||
:model-value="props.row.campaignId"
|
||||
:placeholder="props.row.campaignIsMixed ? 'Karisik' : ''"
|
||||
:disable="campaignLoading"
|
||||
style="min-width: 120px"
|
||||
@update:model-value="(val) => onRowCampaignChange(props.row, val)"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-campaignRate="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span class="cell-text text-right" :title="String(props.row.campaignRate ?? '')">
|
||||
{{ formatRateDisplay(props.row.campaignRate) }}
|
||||
</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-belowBaseDiff="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span :class="['cell-text', 'text-right', { 'diff-negative': Number(props.value || 0) < 0 }]">
|
||||
{{ formatPriceMaybe(props.value) }}
|
||||
</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-stockQty="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span class="stock-qty-text">{{ formatStock(props.value) }}</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-stockEntryDate="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span class="date-cell-text">{{ formatDateDisplay(props.value) }}</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-lastCampaignDate="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span class="date-cell-text">{{ formatDateDisplay(props.value) }}</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell-brandGroupSelection="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<span class="cell-text" :title="props.row.brandGroupSelection || ''">
|
||||
{{ props.row.brandGroupSelection || '' }}
|
||||
</span>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template #body-cell="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
:class="{
|
||||
'sticky-col': isStickyCol(props.col.name),
|
||||
'sticky-boundary': isStickyBoundary(props.col.name),
|
||||
'selected-tone-cell': shouldToneSelectedCell(props.row, props.col.name)
|
||||
}"
|
||||
:style="getBodyCellStyle(props.col)"
|
||||
>
|
||||
<div v-if="editableColumnSet.has(props.col.name)" class="editable-price-cell">
|
||||
<input
|
||||
class="native-cell-input text-right price-edit-input"
|
||||
:value="formatPrice(props.row[props.col.field])"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
@change="(e) => onEditableCellChange(props.row, props.col.field, e.target.value)"
|
||||
/>
|
||||
<span class="old-price-label" :title="`Eski: ${formatPrice(getOriginalCellValue(props.row, props.col.field))}`">
|
||||
{{ formatPrice(getOriginalCellValue(props.row, props.col.field)) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="cell-text" :title="String(props.value ?? '')">{{ formatGenericCellValue(props.col, props.value) }}</span>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
|
||||
<q-banner v-if="store.error && !isGuidanceState" class="bg-red text-white q-mt-xs">
|
||||
{{ store.error }}
|
||||
</q-banner>
|
||||
|
||||
<q-dialog v-model="campaignHistoryDialogOpen" persistent>
|
||||
<q-card class="campaign-history-card">
|
||||
<q-card-section class="row items-center justify-between">
|
||||
<div>
|
||||
<div class="text-subtitle1 text-weight-bold">Kampanya Gecmisi</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
{{ campaignHistoryRow?.productCode || '-' }} | {{ campaignHistoryRow?.variantCodes || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<q-btn flat round icon="close" color="grey-8" @click="campaignHistoryDialogOpen = false" />
|
||||
</q-card-section>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<q-card-section class="q-pt-sm q-pb-none">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-btn
|
||||
color="negative"
|
||||
icon="delete"
|
||||
label="Secilenleri Sil"
|
||||
:disable="selectedCampaignHistoryCount === 0 || campaignHistoryLoading"
|
||||
:loading="campaignHistoryLoading"
|
||||
@click="confirmDeleteSelectedCampaignHistory"
|
||||
/>
|
||||
<q-space />
|
||||
<q-btn
|
||||
outline
|
||||
color="primary"
|
||||
icon="refresh"
|
||||
label="Yenile"
|
||||
:loading="campaignHistoryLoading"
|
||||
:disable="!campaignHistoryRow?.productCode"
|
||||
@click="reloadCampaignHistory()"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-sm">
|
||||
<q-inner-loading :showing="campaignHistoryLoading">
|
||||
<q-spinner-gears size="46px" color="primary" />
|
||||
</q-inner-loading>
|
||||
|
||||
<div v-if="campaignHistoryRows.length === 0" class="text-caption text-grey-7 q-pa-sm">
|
||||
Kayit bulunamadi.
|
||||
</div>
|
||||
<q-list v-else dense bordered separator class="campaign-history-list">
|
||||
<q-item v-for="r in campaignHistoryRows" :key="r.id">
|
||||
<q-item-section avatar>
|
||||
<q-checkbox
|
||||
:model-value="selectedCampaignHistoryIdSet.has(r.id)"
|
||||
dense
|
||||
@update:model-value="(val) => toggleSelectedCampaignHistoryId(r.id, val)"
|
||||
@click.stop
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
{{ r.campaign_code ? `${r.campaign_code} - ${r.campaign_title}` : '(Bos)' }}
|
||||
<span v-if="Number(r.discount_rate || 0) > 0" class="text-grey-7">
|
||||
(%{{ formatRateDisplay(r.discount_rate) }})
|
||||
</span>
|
||||
</q-item-label>
|
||||
<q-item-label caption>{{ r.at || '-' }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="priceListExportDialogOpen" persistent>
|
||||
<q-card style="min-width: 740px; max-width: 95vw;">
|
||||
<q-card-section class="row items-center justify-between">
|
||||
<div class="text-subtitle1 text-weight-bold">
|
||||
Fiyat Listesi Ciktisi
|
||||
</div>
|
||||
<q-btn flat round icon="close" color="grey-8" @click="priceListExportDialogOpen = false" />
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
|
||||
<q-card-section class="q-gutter-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-btn-toggle
|
||||
v-model="priceListExportFormat"
|
||||
dense
|
||||
unelevated
|
||||
toggle-color="primary"
|
||||
color="grey-3"
|
||||
text-color="grey-9"
|
||||
:options="[
|
||||
{ label: 'PDF', value: 'pdf', icon: 'picture_as_pdf' },
|
||||
{ label: 'Excel', value: 'excel', icon: 'grid_on' }
|
||||
]"
|
||||
/>
|
||||
<q-toggle v-model="priceListInStockOnly" label="Sadece stogu olan urunler" />
|
||||
<q-space />
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="download"
|
||||
:label="priceListExportFormat === 'pdf' ? 'PDF Olustur' : 'Excel Olustur'"
|
||||
:loading="priceListExportLoading"
|
||||
@click="runPriceListExport"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-12 col-md-4">
|
||||
<q-select
|
||||
v-model="priceListUrunIlkGrubu"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
emit-value
|
||||
map-options
|
||||
:options="topUrunIlkGrubuOptions"
|
||||
:loading="Boolean(serverFilterLoading.urunIlkGrubu)"
|
||||
label="Urun Ilk Grubu"
|
||||
@filter="onTopFilterSearchUrunIlkGrubu"
|
||||
@update:model-value="onPriceListUrunIlkGrubuChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-select
|
||||
v-model="priceListUrunAnaGrubu"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
multiple
|
||||
use-chips
|
||||
emit-value
|
||||
map-options
|
||||
:options="topUrunAnaGrubuOptions"
|
||||
:loading="Boolean(serverFilterLoading.urunAnaGrubu)"
|
||||
label="Urun Ana Grubu (max 3)"
|
||||
@filter="onTopFilterSearchUrunAnaGrubu"
|
||||
@update:model-value="onPriceListUrunAnaGrubuChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-select
|
||||
v-model="priceListUrunAltGrubu"
|
||||
dense
|
||||
outlined
|
||||
clearable
|
||||
multiple
|
||||
use-chips
|
||||
emit-value
|
||||
map-options
|
||||
:options="priceListUrunAltGrubuOptions"
|
||||
:loading="Boolean(serverFilterLoading.urunAltGrubu)"
|
||||
label="Urun Alt Grubu"
|
||||
@filter="onPriceListFilterSearchUrunAltGrubu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-toggle v-model="priceListIncludeCost" label="Maliyet fiyati" />
|
||||
<q-toggle v-model="priceListIncludeBase" label="Taban fiyatlar (USD/TRY)" />
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="text-caption text-grey-8 q-mb-xs">USD seviyeleri</div>
|
||||
<q-option-group
|
||||
v-model="priceListUSDLevels"
|
||||
type="checkbox"
|
||||
dense
|
||||
:options="priceLevelOptionsUSD"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="text-caption text-grey-8 q-mb-xs">EUR seviyeleri</div>
|
||||
<q-option-group
|
||||
v-model="priceListEURLevels"
|
||||
type="checkbox"
|
||||
dense
|
||||
:options="priceLevelOptionsEUR"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="text-caption text-grey-8 q-mb-xs">TRY seviyeleri</div>
|
||||
<q-option-group
|
||||
v-model="priceListTRYLevels"
|
||||
type="checkbox"
|
||||
dense
|
||||
:options="priceLevelOptionsTRY"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Notify, useQuasar } from 'quasar'
|
||||
import { useProductPricingStore } from 'src/stores/ProductPricingStore'
|
||||
import api, { download } from 'src/services/api'
|
||||
|
||||
const $q = useQuasar()
|
||||
const store = useProductPricingStore()
|
||||
// Variant rows explode product rows; keep this smaller than ProductPricing for responsiveness.
|
||||
const PAGE_LIMIT = 50
|
||||
const currentPage = ref(1)
|
||||
let reloadTimer = null
|
||||
const variantRows = ref([])
|
||||
|
||||
const GUIDANCE_MSG = "Calismak icin once Urun Ilk Grubu veya Urun Ana Grubu Secin ve GRUPLARI GETIR'e Basin."
|
||||
|
||||
const usdToTry = 38.25
|
||||
const eurToTry = 41.6
|
||||
const multipliers = [1, 1.03, 1.06, 1.09, 1.12, 1.15]
|
||||
const rowHeight = 31
|
||||
const headerHeight = 72
|
||||
|
||||
// Marka grubu artik Marka Siniflandirma modulunden (mk_brandgrp) gelir ve listede sadece goruntulenir.
|
||||
|
||||
const priceOptionOptions = [
|
||||
{ label: 'USD 1', value: 'usd1', currency: 'USD' },
|
||||
{ label: 'USD 2', value: 'usd2', currency: 'USD' },
|
||||
{ label: 'USD 3', value: 'usd3', currency: 'USD' },
|
||||
{ label: 'USD 4', value: 'usd4', currency: 'USD' },
|
||||
{ label: 'USD 5', value: 'usd5', currency: 'USD' },
|
||||
{ label: 'USD 6', value: 'usd6', currency: 'USD' },
|
||||
{ label: 'EUR 1', value: 'eur1', currency: 'EUR' },
|
||||
{ label: 'EUR 2', value: 'eur2', currency: 'EUR' },
|
||||
{ label: 'EUR 3', value: 'eur3', currency: 'EUR' },
|
||||
{ label: 'EUR 4', value: 'eur4', currency: 'EUR' },
|
||||
{ label: 'EUR 5', value: 'eur5', currency: 'EUR' },
|
||||
{ label: 'EUR 6', value: 'eur6', currency: 'EUR' },
|
||||
{ label: 'TRY 1', value: 'try1', currency: 'TRY' },
|
||||
{ label: 'TRY 2', value: 'try2', currency: 'TRY' },
|
||||
{ label: 'TRY 3', value: 'try3', currency: 'TRY' },
|
||||
{ label: 'TRY 4', value: 'try4', currency: 'TRY' },
|
||||
{ label: 'TRY 5', value: 'try5', currency: 'TRY' },
|
||||
{ label: 'TRY 6', value: 'try6', currency: 'TRY' }
|
||||
]
|
||||
|
||||
const campaignPricePairs = [
|
||||
{ base: 'usd1', derived: 'usd1Campaign', currency: 'USD' },
|
||||
{ base: 'usd2', derived: 'usd2Campaign', currency: 'USD' },
|
||||
{ base: 'usd3', derived: 'usd3Campaign', currency: 'USD' },
|
||||
{ base: 'usd4', derived: 'usd4Campaign', currency: 'USD' },
|
||||
{ base: 'usd5', derived: 'usd5Campaign', currency: 'USD' },
|
||||
{ base: 'usd6', derived: 'usd6Campaign', currency: 'USD' },
|
||||
{ base: 'eur1', derived: 'eur1Campaign', currency: 'EUR' },
|
||||
{ base: 'eur2', derived: 'eur2Campaign', currency: 'EUR' },
|
||||
{ base: 'eur3', derived: 'eur3Campaign', currency: 'EUR' },
|
||||
{ base: 'eur4', derived: 'eur4Campaign', currency: 'EUR' },
|
||||
{ base: 'eur5', derived: 'eur5Campaign', currency: 'EUR' },
|
||||
{ base: 'eur6', derived: 'eur6Campaign', currency: 'EUR' },
|
||||
{ base: 'try1', derived: 'try1Campaign', currency: 'TRY' },
|
||||
{ base: 'try2', derived: 'try2Campaign', currency: 'TRY' },
|
||||
{ base: 'try3', derived: 'try3Campaign', currency: 'TRY' },
|
||||
{ base: 'try4', derived: 'try4Campaign', currency: 'TRY' },
|
||||
{ base: 'try5', derived: 'try5Campaign', currency: 'TRY' },
|
||||
{ base: 'try6', derived: 'try6Campaign', currency: 'TRY' }
|
||||
]
|
||||
|
||||
const multiFilterColumns = [
|
||||
{ field: 'productCode', label: 'Urun Kodu' },
|
||||
{ field: 'variantCodes', label: 'Varyant' },
|
||||
{ field: 'campaignId', label: 'Kampanya' },
|
||||
{ field: 'brandGroupSelection', label: 'Marka Grubu' },
|
||||
{ field: 'marka', label: 'Marka' },
|
||||
{ field: 'askiliYan', label: 'Askili Yan' },
|
||||
{ field: 'kategori', label: 'Kategori' },
|
||||
{ field: 'urunIlkGrubu', label: 'Urun Ilk Grubu' },
|
||||
{ field: 'urunAnaGrubu', label: 'Urun Ana Grubu' },
|
||||
{ field: 'urunAltGrubu', label: 'Urun Alt Grubu' },
|
||||
{ field: 'icerik', label: 'Icerik' },
|
||||
{ field: 'karisim', label: 'Karisim' }
|
||||
]
|
||||
const serverBackedMultiFilterFields = new Set([
|
||||
'productCode',
|
||||
'marka',
|
||||
'askiliYan',
|
||||
'kategori',
|
||||
'urunIlkGrubu',
|
||||
'urunAnaGrubu',
|
||||
'urunAltGrubu',
|
||||
'icerik',
|
||||
'karisim'
|
||||
])
|
||||
const numberRangeFilterFields = ['stockQty']
|
||||
const dateRangeFilterFields = ['stockEntryDate', 'lastCampaignDate']
|
||||
const valueFilterFields = [
|
||||
'costPrice',
|
||||
'basePriceUsd',
|
||||
'basePriceTry',
|
||||
'usd1',
|
||||
'usd2',
|
||||
'usd3',
|
||||
'usd4',
|
||||
'usd5',
|
||||
'usd6',
|
||||
'eur1',
|
||||
'eur2',
|
||||
'eur3',
|
||||
'eur4',
|
||||
'eur5',
|
||||
'eur6',
|
||||
'try1',
|
||||
'try2',
|
||||
'try3',
|
||||
'try4',
|
||||
'try5',
|
||||
'try6'
|
||||
]
|
||||
const columnFilters = ref({
|
||||
productCode: [],
|
||||
variantCodes: [],
|
||||
campaignId: [],
|
||||
brandGroupSelection: [],
|
||||
marka: [],
|
||||
askiliYan: [],
|
||||
kategori: [],
|
||||
urunIlkGrubu: [],
|
||||
urunAnaGrubu: [],
|
||||
urunAltGrubu: [],
|
||||
icerik: [],
|
||||
karisim: []
|
||||
})
|
||||
const columnFilterSearch = ref({
|
||||
productCode: '',
|
||||
variantCodes: '',
|
||||
campaignId: '',
|
||||
brandGroupSelection: '',
|
||||
marka: '',
|
||||
askiliYan: '',
|
||||
kategori: '',
|
||||
urunIlkGrubu: '',
|
||||
urunAnaGrubu: '',
|
||||
urunAltGrubu: '',
|
||||
icerik: '',
|
||||
karisim: ''
|
||||
})
|
||||
|
||||
const serverFilterOptionMap = ref({})
|
||||
const serverFilterLoading = ref({})
|
||||
const serverFilterLastQuery = ref({})
|
||||
const serverFilterTimers = {}
|
||||
|
||||
const topUrunIlkGrubu = ref(null)
|
||||
const topUrunAnaGrubu = ref([])
|
||||
|
||||
const topUrunIlkGrubuOptions = computed(() => serverFilterOptionMap.value.urunIlkGrubu || [])
|
||||
const topUrunAnaGrubuOptions = computed(() => serverFilterOptionMap.value.urunAnaGrubu || [])
|
||||
const priceListUrunAltGrubuOptions = computed(() => serverFilterOptionMap.value.urunAltGrubu || [])
|
||||
|
||||
const priceLevelOptionsUSD = [
|
||||
{ label: 'USD 1', value: 1 },
|
||||
{ label: 'USD 2', value: 2 },
|
||||
{ label: 'USD 3', value: 3 },
|
||||
{ label: 'USD 4', value: 4 },
|
||||
{ label: 'USD 5', value: 5 },
|
||||
{ label: 'USD 6', value: 6 }
|
||||
]
|
||||
const priceLevelOptionsEUR = [
|
||||
{ label: 'EUR 1', value: 1 },
|
||||
{ label: 'EUR 2', value: 2 },
|
||||
{ label: 'EUR 3', value: 3 },
|
||||
{ label: 'EUR 4', value: 4 },
|
||||
{ label: 'EUR 5', value: 5 },
|
||||
{ label: 'EUR 6', value: 6 }
|
||||
]
|
||||
const priceLevelOptionsTRY = [
|
||||
{ label: 'TRY 1', value: 1 },
|
||||
{ label: 'TRY 2', value: 2 },
|
||||
{ label: 'TRY 3', value: 3 },
|
||||
{ label: 'TRY 4', value: 4 },
|
||||
{ label: 'TRY 5', value: 5 },
|
||||
{ label: 'TRY 6', value: 6 }
|
||||
]
|
||||
const canFetchByGroup = computed(() => {
|
||||
return Boolean(String(topUrunIlkGrubu.value || '').trim()) || (topUrunAnaGrubu.value?.length || 0) > 0
|
||||
})
|
||||
|
||||
async function fetchServerFilterOptions (field, { force = false } = {}) {
|
||||
if (!serverBackedMultiFilterFields.has(field)) return
|
||||
const q = String(columnFilterSearch.value[field] || '').trim()
|
||||
const lastQ = String(serverFilterLastQuery.value[field] || '')
|
||||
const hasCached = Array.isArray(serverFilterOptionMap.value[field]) && serverFilterOptionMap.value[field].length > 0
|
||||
if (!force && hasCached && q === lastQ) return
|
||||
if (serverFilterLoading.value[field]) return
|
||||
|
||||
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: true }
|
||||
serverFilterLastQuery.value = { ...serverFilterLastQuery.value, [field]: q }
|
||||
try {
|
||||
const params = { field, q, limit: 160 }
|
||||
// Cascade scope for Urun Ana Grubu options.
|
||||
if (field === 'urunAnaGrubu') {
|
||||
const ilk = String(topUrunIlkGrubu.value || '').trim()
|
||||
if (ilk) params.urun_ilk_grubu = [ilk]
|
||||
}
|
||||
const res = await api.get('/pricing/products/options', { params })
|
||||
const items = Array.isArray(res?.data?.items) ? res.data.items : []
|
||||
serverFilterOptionMap.value = {
|
||||
...serverFilterOptionMap.value,
|
||||
[field]: items.map((x) => ({
|
||||
label: String(x?.label ?? x?.value ?? '').trim(),
|
||||
value: String(x?.value ?? x?.label ?? '').trim()
|
||||
})).filter((x) => x.value)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[product-pricing][ui] filter options error', {
|
||||
field,
|
||||
q,
|
||||
message: String(err?.message || err || 'options failed')
|
||||
})
|
||||
serverFilterOptionMap.value = { ...serverFilterOptionMap.value, [field]: [] }
|
||||
} finally {
|
||||
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: false }
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleServerFilterOptionsFetch (field) {
|
||||
if (!serverBackedMultiFilterFields.has(field)) return
|
||||
if (serverFilterTimers[field]) clearTimeout(serverFilterTimers[field])
|
||||
serverFilterTimers[field] = setTimeout(() => {
|
||||
serverFilterTimers[field] = null
|
||||
void fetchServerFilterOptions(field)
|
||||
}, 220)
|
||||
}
|
||||
|
||||
function onFilterMenuBeforeShow (field) {
|
||||
if (!serverBackedMultiFilterFields.has(field)) return
|
||||
void fetchServerFilterOptions(field)
|
||||
}
|
||||
|
||||
function onTopFilterSearchUrunIlkGrubu (val, update) {
|
||||
update(() => {
|
||||
columnFilterSearch.value = { ...columnFilterSearch.value, urunIlkGrubu: String(val || '') }
|
||||
scheduleServerFilterOptionsFetch('urunIlkGrubu')
|
||||
})
|
||||
}
|
||||
|
||||
function onTopFilterSearchUrunAnaGrubu (val, update) {
|
||||
update(() => {
|
||||
columnFilterSearch.value = { ...columnFilterSearch.value, urunAnaGrubu: String(val || '') }
|
||||
scheduleServerFilterOptionsFetch('urunAnaGrubu')
|
||||
})
|
||||
}
|
||||
|
||||
function applyTopGroupFiltersToColumnFilters () {
|
||||
// Enforce max 3 selection for Urun Ana Grubu.
|
||||
const nextAna = Array.isArray(topUrunAnaGrubu.value) ? topUrunAnaGrubu.value.slice(0, 3) : []
|
||||
if (nextAna.length !== (topUrunAnaGrubu.value || []).length) topUrunAnaGrubu.value = nextAna
|
||||
const ilk = String(topUrunIlkGrubu.value || '').trim()
|
||||
|
||||
columnFilters.value = {
|
||||
...columnFilters.value,
|
||||
urunIlkGrubu: ilk ? [ilk] : [],
|
||||
urunAnaGrubu: nextAna
|
||||
}
|
||||
}
|
||||
|
||||
function onTopUrunIlkGrubuChange () {
|
||||
// Cascade: when Ilk Grubu changes, clear Ana Grubu selection and refetch options scoped by Ilk Grubu.
|
||||
topUrunAnaGrubu.value = []
|
||||
applyTopGroupFiltersToColumnFilters()
|
||||
void fetchServerFilterOptions('urunAnaGrubu', { force: true })
|
||||
}
|
||||
|
||||
function onTopUrunAnaGrubuChange () {
|
||||
applyTopGroupFiltersToColumnFilters()
|
||||
}
|
||||
|
||||
function onPriceListUrunIlkGrubuChange () {
|
||||
// cascade for export dialog
|
||||
priceListUrunAnaGrubu.value = []
|
||||
const ilk = String(priceListUrunIlkGrubu.value || '').trim()
|
||||
if (ilk) {
|
||||
// scope ana grubu options
|
||||
topUrunIlkGrubu.value = ilk
|
||||
void fetchServerFilterOptions('urunAnaGrubu', { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function onPriceListUrunAnaGrubuChange () {
|
||||
// enforce max 3
|
||||
const nextAna = Array.isArray(priceListUrunAnaGrubu.value) ? priceListUrunAnaGrubu.value.slice(0, 3) : []
|
||||
if (nextAna.length !== (priceListUrunAnaGrubu.value || []).length) priceListUrunAnaGrubu.value = nextAna
|
||||
}
|
||||
|
||||
function onPriceListFilterSearchUrunAltGrubu (val, update) {
|
||||
update(() => {
|
||||
columnFilterSearch.value = { ...columnFilterSearch.value, urunAltGrubu: String(val || '') }
|
||||
scheduleServerFilterOptionsFetch('urunAltGrubu')
|
||||
})
|
||||
}
|
||||
|
||||
function openPriceListExportDialog (format) {
|
||||
// format optional (default: pdf); dialog includes its own format selector.
|
||||
if (format === 'excel' || format === 'pdf') {
|
||||
priceListExportFormat.value = format
|
||||
} else {
|
||||
priceListExportFormat.value = 'pdf'
|
||||
}
|
||||
priceListExportLoading.value = false
|
||||
// default selections: mirror top group selections if present
|
||||
priceListUrunIlkGrubu.value = topUrunIlkGrubu.value
|
||||
priceListUrunAnaGrubu.value = Array.isArray(topUrunAnaGrubu.value) ? topUrunAnaGrubu.value.slice(0, 3) : []
|
||||
priceListUrunAltGrubu.value = []
|
||||
// preload alt group options
|
||||
void fetchServerFilterOptions('urunAltGrubu', { force: true })
|
||||
// mirror price option selection into legacy per-currency level selectors
|
||||
const set = selectedPriceOptionSet.value
|
||||
priceListUSDLevels.value = [1, 2, 3, 4, 5, 6].filter((lv) => set.has(`usd${lv}`))
|
||||
priceListEURLevels.value = [1, 2, 3, 4, 5, 6].filter((lv) => set.has(`eur${lv}`))
|
||||
priceListTRYLevels.value = [1, 2, 3, 4, 5, 6].filter((lv) => set.has(`try${lv}`))
|
||||
priceListExportDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function runPriceListExport () {
|
||||
priceListExportLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
in_stock_only: !!priceListInStockOnly.value,
|
||||
include_meta: true,
|
||||
include_cost: !!priceListIncludeCost.value,
|
||||
include_base: !!priceListIncludeBase.value,
|
||||
price_fields: Array.isArray(selectedPriceOptions.value) ? selectedPriceOptions.value : [],
|
||||
usd_levels: Array.isArray(priceListUSDLevels.value) ? priceListUSDLevels.value : [],
|
||||
eur_levels: Array.isArray(priceListEURLevels.value) ? priceListEURLevels.value : [],
|
||||
try_levels: Array.isArray(priceListTRYLevels.value) ? priceListTRYLevels.value : [],
|
||||
urun_ilk_grubu: String(priceListUrunIlkGrubu.value || '').trim() ? [String(priceListUrunIlkGrubu.value || '').trim()] : [],
|
||||
urun_ana_grubu: Array.isArray(priceListUrunAnaGrubu.value) ? priceListUrunAnaGrubu.value : [],
|
||||
urun_alt_grubu: Array.isArray(priceListUrunAltGrubu.value) ? priceListUrunAltGrubu.value : []
|
||||
}
|
||||
|
||||
const traceId = `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const url = priceListExportFormat.value === 'excel'
|
||||
? '/pricing/products/price-list/export-excel'
|
||||
: '/pricing/products/price-list/export-pdf'
|
||||
|
||||
const res = await api.request({
|
||||
method: 'POST',
|
||||
url,
|
||||
data: payload,
|
||||
responseType: 'blob',
|
||||
timeout: 0,
|
||||
headers: { 'X-Trace-ID': traceId }
|
||||
})
|
||||
const blob = res?.data instanceof Blob ? res.data : new Blob([res?.data || ''])
|
||||
const objUrl = URL.createObjectURL(blob)
|
||||
|
||||
if (priceListExportFormat.value === 'pdf') {
|
||||
window.open(objUrl, '_blank')
|
||||
setTimeout(() => URL.revokeObjectURL(objUrl), 120000)
|
||||
} else {
|
||||
const a = document.createElement('a')
|
||||
a.href = objUrl
|
||||
a.download = `baggi_guncel_fiyat_listesi_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(objUrl)
|
||||
}
|
||||
|
||||
priceListExportDialogOpen.value = false
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.parsedMessage || err?.message || 'Fiyat listesi olusturulamadi' })
|
||||
} finally {
|
||||
priceListExportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetGroupSelections () {
|
||||
topUrunIlkGrubu.value = null
|
||||
topUrunAnaGrubu.value = []
|
||||
applyTopGroupFiltersToColumnFilters()
|
||||
// Keep other local filters cleared too, so page is "clean render".
|
||||
store.rows = []
|
||||
variantRows.value = []
|
||||
store.error = GUIDANCE_MSG
|
||||
store.totalCount = 0
|
||||
store.totalPages = 1
|
||||
store.page = 1
|
||||
store.hasMore = false
|
||||
}
|
||||
|
||||
for (const field of Array.from(serverBackedMultiFilterFields)) {
|
||||
watch(
|
||||
() => columnFilterSearch.value[field],
|
||||
() => { scheduleServerFilterOptionsFetch(field) }
|
||||
)
|
||||
}
|
||||
const numberRangeFilters = ref({
|
||||
stockQty: { min: '', max: '' }
|
||||
})
|
||||
const dateRangeFilters = ref({
|
||||
stockEntryDate: { from: '', to: '' },
|
||||
lastCampaignDate: { from: '', to: '' }
|
||||
})
|
||||
const valueFilters = ref(Object.fromEntries(valueFilterFields.map((field) => [field, []])))
|
||||
const valueFilterSearch = ref(Object.fromEntries(valueFilterFields.map((field) => [field, ''])))
|
||||
const multiSelectFilterFieldSet = new Set(multiFilterColumns.map((x) => x.field))
|
||||
const numberRangeFilterFieldSet = new Set(numberRangeFilterFields)
|
||||
const dateRangeFilterFieldSet = new Set(dateRangeFilterFields)
|
||||
const valueSelectFilterFieldSet = new Set(valueFilterFields)
|
||||
const headerFilterFieldSet = new Set([
|
||||
...multiFilterColumns.map((x) => x.field),
|
||||
...numberRangeFilterFields,
|
||||
...dateRangeFilterFields,
|
||||
...valueFilterFields
|
||||
])
|
||||
|
||||
const mainTableRef = ref(null)
|
||||
const topScrollRef = ref(null)
|
||||
const topScrollInnerRef = ref(null)
|
||||
const tablePagination = ref({
|
||||
page: 1, // server-side paging var; q-table local paging kapali
|
||||
rowsPerPage: 0,
|
||||
sortBy: 'stockQty',
|
||||
descending: true
|
||||
})
|
||||
const selectedMap = ref({})
|
||||
const selectedPriceOptions = ref(priceOptionOptions.map((x) => x.value))
|
||||
const exportAllLoading = ref(false)
|
||||
const showSelectedOnly = ref(false)
|
||||
const leftDetailsExpanded = ref(true)
|
||||
// Keep the old calc state around (not used on this screen) to avoid touching shared helpers.
|
||||
const calcLoadingMap = ref({})
|
||||
|
||||
const campaignLoading = ref(false)
|
||||
const campaignList = ref([]) // [{id, code, title, discount_rate, ...}]
|
||||
const variantLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const campaignHistoryDialogOpen = ref(false)
|
||||
const campaignHistoryRow = ref(null)
|
||||
const campaignHistoryLoading = ref(false)
|
||||
const campaignHistoryRows = ref([])
|
||||
const selectedCampaignHistoryIds = ref([])
|
||||
|
||||
const priceListExportDialogOpen = ref(false)
|
||||
const priceListExportFormat = ref('pdf') // 'pdf' | 'excel'
|
||||
const priceListExportLoading = ref(false)
|
||||
const priceListInStockOnly = ref(true)
|
||||
const priceListUrunIlkGrubu = ref(null)
|
||||
const priceListUrunAnaGrubu = ref([])
|
||||
const priceListUrunAltGrubu = ref([])
|
||||
const priceListIncludeCost = ref(true)
|
||||
const priceListIncludeBase = ref(true)
|
||||
const priceListUSDLevels = ref([1, 2, 3, 4, 5, 6])
|
||||
const priceListEURLevels = ref([1, 2, 3, 4, 5, 6])
|
||||
const priceListTRYLevels = ref([1, 2, 3, 4, 5, 6])
|
||||
|
||||
const editableColumns = []
|
||||
const editableColumnSet = new Set(editableColumns)
|
||||
|
||||
const selectedPriceOptionSet = computed(() => new Set(selectedPriceOptions.value || []))
|
||||
const selectedPriceOptionChipOptions = computed(() => {
|
||||
const set = selectedPriceOptionSet.value
|
||||
return priceOptionOptions.filter((x) => set.has(x.value))
|
||||
})
|
||||
|
||||
const selectedCampaignHistoryIdSet = computed(() => new Set(selectedCampaignHistoryIds.value || []))
|
||||
const selectedCampaignHistoryCount = computed(() => selectedCampaignHistoryIds.value?.length || 0)
|
||||
|
||||
const campaignIdToRate = computed(() => {
|
||||
const map = new Map()
|
||||
for (const c of (Array.isArray(campaignList.value) ? campaignList.value : [])) {
|
||||
const id = Number(c?.id || 0)
|
||||
if (!(id > 0)) continue
|
||||
map.set(id, Number(c?.discount_rate || 0))
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const campaignSelectOptions = computed(() => {
|
||||
const list = Array.isArray(campaignList.value) ? campaignList.value : []
|
||||
return list
|
||||
.map((c) => {
|
||||
const id = Number(c?.id || 0)
|
||||
const code = String(c?.code || '').trim()
|
||||
const title = String(c?.title || '').trim()
|
||||
const rate = Number(c?.discount_rate || 0)
|
||||
const rateText = Number.isFinite(rate)
|
||||
? rate.toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||
: '0'
|
||||
return {
|
||||
value: id > 0 ? id : null,
|
||||
label: title ? `${code} - ${title} (%${rateText})` : `${code} (%${rateText})`,
|
||||
rate
|
||||
}
|
||||
})
|
||||
.filter((x) => Number(x.value || 0) > 0)
|
||||
})
|
||||
|
||||
function col (name, label, field, width, extra = {}) {
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
field,
|
||||
align: extra.align || 'left',
|
||||
sortable: !!extra.sortable,
|
||||
style: `width:${width}px;min-width:${width}px;max-width:${width}px;`,
|
||||
headerStyle: `width:${width}px;min-width:${width}px;max-width:${width}px;`,
|
||||
classes: extra.classes || '',
|
||||
headerClasses: extra.headerClasses || extra.classes || ''
|
||||
}
|
||||
}
|
||||
|
||||
const allColumns = [
|
||||
col('select', '', 'select', 40, { align: 'center', classes: 'text-center selection-col' }),
|
||||
col('brandGroupSelection', 'MARKA GRUBU SECIMI', 'brandGroupSelection', 76),
|
||||
col('marka', 'MARKA', 'marka', 54, { sortable: true, classes: 'ps-col' }),
|
||||
col('productCode', 'URUN KODU', 'productCode', 108, { sortable: true, classes: 'ps-col product-code-col' }),
|
||||
col('variantCodes', 'VARYANT', 'variantCodes', 112, { classes: 'ps-col variant-col' }),
|
||||
// Column is named "variantStocks" for UI, but uses stockQty for sorting/filtering (variant-level).
|
||||
col('variantStocks', 'STOK', 'stockQty', 62, { align: 'right', sortable: true, classes: 'ps-col variant-stock-col' }),
|
||||
col('campaignId', 'KAMPANYA', 'campaignId', 124, { classes: 'ps-col campaign-col' }),
|
||||
col('campaignRate', 'IND %', 'campaignRate', 58, { align: 'right', classes: 'ps-col campaign-rate-col' }),
|
||||
col('historyAction', '', 'historyAction', 40, { align: 'center', classes: 'ps-col text-center' }),
|
||||
col('stockEntryDate', 'STOK GIRIS TARIHI', 'stockEntryDate', 92, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
|
||||
col('lastCampaignDate', 'SON KAMPANYA GIRIS', 'lastCampaignDate', 114, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
|
||||
col('askiliYan', 'ASKILI YAN', 'askiliYan', 54, { sortable: true, classes: 'ps-col' }),
|
||||
col('kategori', 'KATEGORI', 'kategori', 54, { sortable: true, classes: 'ps-col' }),
|
||||
col('urunIlkGrubu', 'URUN ILK GRUBU', 'urunIlkGrubu', 66, { sortable: true, classes: 'ps-col' }),
|
||||
col('urunAnaGrubu', 'URUN ANA GRUBU', 'urunAnaGrubu', 66, { sortable: true, classes: 'ps-col' }),
|
||||
col('urunAltGrubu', 'URUN ALT GRUBU', 'urunAltGrubu', 66, { sortable: true, classes: 'ps-col' }),
|
||||
col('icerik', 'ICERIK', 'icerik', 62, { sortable: true, classes: 'ps-col' }),
|
||||
col('karisim', 'KARISIM', 'karisim', 62, { sortable: true, classes: 'ps-col' }),
|
||||
col('costPrice', 'MALIYET FIYATI', 'costPrice', 84, { align: 'right', sortable: true, classes: 'usd-col' }),
|
||||
col('basePriceUsd', 'TABAN USD', 'basePriceUsd', 84, { align: 'right', classes: 'usd-col' }),
|
||||
col('basePriceTry', 'TABAN TRY', 'basePriceTry', 88, { align: 'right', classes: 'try-col' }),
|
||||
col('usd1', 'USD 1', 'usd1', 78, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd1Campaign', 'USD 1 KMP', 'usd1Campaign', 82, { align: 'right', classes: 'usd-col campaign-price-col' }),
|
||||
col('usd2', 'USD 2', 'usd2', 78, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd2Campaign', 'USD 2 KMP', 'usd2Campaign', 82, { align: 'right', classes: 'usd-col campaign-price-col' }),
|
||||
col('usd3', 'USD 3', 'usd3', 78, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd3Campaign', 'USD 3 KMP', 'usd3Campaign', 82, { align: 'right', classes: 'usd-col campaign-price-col' }),
|
||||
col('usd4', 'USD 4', 'usd4', 78, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd4Campaign', 'USD 4 KMP', 'usd4Campaign', 82, { align: 'right', classes: 'usd-col campaign-price-col' }),
|
||||
col('usd5', 'USD 5', 'usd5', 78, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd5Campaign', 'USD 5 KMP', 'usd5Campaign', 82, { align: 'right', classes: 'usd-col campaign-price-col' }),
|
||||
col('usd6', 'USD 6', 'usd6', 78, { align: 'right', classes: 'usd-col' }),
|
||||
col('usd6Campaign', 'USD 6 KMP', 'usd6Campaign', 82, { align: 'right', classes: 'usd-col campaign-price-col' }),
|
||||
col('eur1', 'EUR 1', 'eur1', 78, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur1Campaign', 'EUR 1 KMP', 'eur1Campaign', 82, { align: 'right', classes: 'eur-col campaign-price-col' }),
|
||||
col('eur2', 'EUR 2', 'eur2', 78, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur2Campaign', 'EUR 2 KMP', 'eur2Campaign', 82, { align: 'right', classes: 'eur-col campaign-price-col' }),
|
||||
col('eur3', 'EUR 3', 'eur3', 78, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur3Campaign', 'EUR 3 KMP', 'eur3Campaign', 82, { align: 'right', classes: 'eur-col campaign-price-col' }),
|
||||
col('eur4', 'EUR 4', 'eur4', 78, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur4Campaign', 'EUR 4 KMP', 'eur4Campaign', 82, { align: 'right', classes: 'eur-col campaign-price-col' }),
|
||||
col('eur5', 'EUR 5', 'eur5', 78, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur5Campaign', 'EUR 5 KMP', 'eur5Campaign', 82, { align: 'right', classes: 'eur-col campaign-price-col' }),
|
||||
col('eur6', 'EUR 6', 'eur6', 78, { align: 'right', classes: 'eur-col' }),
|
||||
col('eur6Campaign', 'EUR 6 KMP', 'eur6Campaign', 82, { align: 'right', classes: 'eur-col campaign-price-col' }),
|
||||
col('try1', 'TRY 1', 'try1', 86, { align: 'right', classes: 'try-col' }),
|
||||
col('try1Campaign', 'TRY 1 KMP', 'try1Campaign', 94, { align: 'right', classes: 'try-col campaign-price-col' }),
|
||||
col('try2', 'TRY 2', 'try2', 86, { align: 'right', classes: 'try-col' }),
|
||||
col('try2Campaign', 'TRY 2 KMP', 'try2Campaign', 94, { align: 'right', classes: 'try-col campaign-price-col' }),
|
||||
col('try3', 'TRY 3', 'try3', 86, { align: 'right', classes: 'try-col' }),
|
||||
col('try3Campaign', 'TRY 3 KMP', 'try3Campaign', 94, { align: 'right', classes: 'try-col campaign-price-col' }),
|
||||
col('try4', 'TRY 4', 'try4', 86, { align: 'right', classes: 'try-col' }),
|
||||
col('try4Campaign', 'TRY 4 KMP', 'try4Campaign', 94, { align: 'right', classes: 'try-col campaign-price-col' }),
|
||||
col('try5', 'TRY 5', 'try5', 86, { align: 'right', classes: 'try-col' }),
|
||||
col('try5Campaign', 'TRY 5 KMP', 'try5Campaign', 94, { align: 'right', classes: 'try-col campaign-price-col' }),
|
||||
col('try6', 'TRY 6', 'try6', 86, { align: 'right', classes: 'try-col' }),
|
||||
col('try6Campaign', 'TRY 6 KMP', 'try6Campaign', 94, { align: 'right', classes: 'try-col campaign-price-col' }),
|
||||
col('belowBaseDiff', 'TABAN ALTINDA', 'belowBaseDiff', 94, { align: 'right', classes: 'ps-col diff-col' })
|
||||
]
|
||||
|
||||
const hideableLeftDetailColumnNames = new Set([
|
||||
'stockEntryDate',
|
||||
'lastCampaignDate',
|
||||
'askiliYan',
|
||||
'kategori',
|
||||
'urunIlkGrubu',
|
||||
'urunAnaGrubu',
|
||||
'urunAltGrubu',
|
||||
'icerik',
|
||||
'karisim'
|
||||
])
|
||||
const stickyColumnNamesBase = [
|
||||
'select',
|
||||
'brandGroupSelection',
|
||||
'marka',
|
||||
'productCode',
|
||||
'variantCodes',
|
||||
'variantStocks',
|
||||
'campaignId',
|
||||
'campaignRate',
|
||||
'historyAction',
|
||||
'stockEntryDate',
|
||||
'lastCampaignDate',
|
||||
'askiliYan',
|
||||
'kategori',
|
||||
'urunIlkGrubu',
|
||||
'urunAnaGrubu',
|
||||
'urunAltGrubu',
|
||||
'icerik',
|
||||
'karisim',
|
||||
'costPrice',
|
||||
'basePriceUsd',
|
||||
'basePriceTry'
|
||||
]
|
||||
const stickyBoundaryColumnName = 'basePriceTry'
|
||||
|
||||
const visibleColumns = computed(() => {
|
||||
const selected = selectedPriceOptionSet.value
|
||||
return allColumns.filter((c) => {
|
||||
// price fields
|
||||
if (/^(usd|eur|try)[1-6]$/.test(c.name)) return selected.has(c.name)
|
||||
if (/^(usd|eur|try)[1-6]Campaign$/.test(c.name)) {
|
||||
const base = c.name.replace(/Campaign$/, '')
|
||||
return selected.has(base)
|
||||
}
|
||||
if (!leftDetailsExpanded.value && hideableLeftDetailColumnNames.has(c.name)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const stickyColumnNames = computed(() => {
|
||||
const visibleNameSet = new Set(visibleColumns.value.map((col) => col.name))
|
||||
return stickyColumnNamesBase.filter((name) => visibleNameSet.has(name))
|
||||
})
|
||||
const stickyColumnNameSet = computed(() => new Set(stickyColumnNames.value))
|
||||
|
||||
const exportableColumns = computed(() => visibleColumns.value.filter((col) => col.name !== 'select' && col.name !== 'historyAction'))
|
||||
|
||||
const stickyLeftMap = computed(() => {
|
||||
const map = {}
|
||||
let left = 0
|
||||
for (const colName of stickyColumnNames.value) {
|
||||
const c = allColumns.find((x) => x.name === colName)
|
||||
if (!c) continue
|
||||
map[colName] = left
|
||||
left += extractWidth(c.style)
|
||||
}
|
||||
return map
|
||||
})
|
||||
const stickyScrollComp = computed(() => {
|
||||
const boundaryCol = allColumns.find((x) => x.name === stickyBoundaryColumnName)
|
||||
return ((stickyLeftMap.value[stickyBoundaryColumnName] || 0) + extractWidth(boundaryCol?.style)) * 1.2
|
||||
})
|
||||
const tableMinWidth = computed(() => visibleColumns.value.reduce((sum, c) => sum + extractWidth(c.style), 0))
|
||||
const tableStyle = computed(() => ({
|
||||
width: `${tableMinWidth.value}px`,
|
||||
minWidth: `${tableMinWidth.value}px`,
|
||||
tableLayout: 'fixed'
|
||||
}))
|
||||
|
||||
const rows = computed(() => variantRows.value || [])
|
||||
const tableLoading = computed(() => Boolean(store.loading) && rows.value.length === 0)
|
||||
|
||||
const isGuidanceState = computed(() => String(store.error || '').trim() === GUIDANCE_MSG)
|
||||
const showGuidanceOverlay = computed(() => isGuidanceState.value && !store.loading && rows.value.length === 0)
|
||||
const multiFilterOptionMap = computed(() => {
|
||||
const map = {}
|
||||
multiFilterColumns.forEach(({ field }) => {
|
||||
if (field === 'campaignId') {
|
||||
// Use full campaign list (not only current page), plus "(Bos)" option.
|
||||
const opts = [
|
||||
{ label: '(Bos)', value: '' },
|
||||
...campaignSelectOptions.value.map((o) => ({ label: o.label, value: String(o.value) }))
|
||||
]
|
||||
map[field] = opts
|
||||
return
|
||||
}
|
||||
const uniq = new Set()
|
||||
rows.value.forEach((row) => {
|
||||
const val = String(row?.[field] ?? '').trim()
|
||||
if (val) uniq.add(val)
|
||||
})
|
||||
map[field] = Array.from(uniq)
|
||||
.sort((a, b) => a.localeCompare(b, 'tr'))
|
||||
.map((v) => ({ label: v, value: v }))
|
||||
})
|
||||
return map
|
||||
})
|
||||
const filteredFilterOptionMap = computed(() => {
|
||||
const map = {}
|
||||
multiFilterColumns.forEach(({ field }) => {
|
||||
const search = String(columnFilterSearch.value[field] || '').trim().toLocaleLowerCase('tr')
|
||||
const options = multiFilterOptionMap.value[field] || []
|
||||
map[field] = search
|
||||
? options.filter((option) => option.label.toLocaleLowerCase('tr').includes(search))
|
||||
: options
|
||||
})
|
||||
return map
|
||||
})
|
||||
const valueFilterOptionMap = computed(() => {
|
||||
const map = {}
|
||||
valueFilterFields.forEach((field) => {
|
||||
const uniq = new Set()
|
||||
rows.value.forEach((row) => {
|
||||
uniq.add(toValueFilterKey(row?.[field]))
|
||||
})
|
||||
map[field] = Array.from(uniq)
|
||||
.sort((a, b) => Number(a) - Number(b))
|
||||
.map((v) => ({ label: formatPrice(v), value: v }))
|
||||
})
|
||||
return map
|
||||
})
|
||||
const filteredValueFilterOptionMap = computed(() => {
|
||||
const map = {}
|
||||
valueFilterFields.forEach((field) => {
|
||||
const search = String(valueFilterSearch.value[field] || '').trim().toLocaleLowerCase('tr')
|
||||
const options = valueFilterOptionMap.value[field] || []
|
||||
map[field] = search
|
||||
? options.filter((option) => option.label.toLocaleLowerCase('tr').includes(search))
|
||||
: options
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
function rowSelectionKey (row) {
|
||||
const rk = String(row?.rowKey ?? '').trim()
|
||||
if (rk) return rk
|
||||
const code = String(row?.productCode ?? '').trim()
|
||||
if (code) return code
|
||||
return String(row?.id ?? '')
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
return rows.value.filter((row) => {
|
||||
if (showSelectedOnly.value && !selectedMap.value[rowSelectionKey(row)]) return false
|
||||
for (const { field } of multiFilterColumns) {
|
||||
// Server-backed filters already reload full dataset (all pages) from backend.
|
||||
// Keep only non-server multi filters (e.g. brandGroupSelection) as local page filter.
|
||||
if (serverBackedMultiFilterFields.has(field)) continue
|
||||
const selected = columnFilters.value[field] || []
|
||||
if (selected.length <= 0) continue
|
||||
const rowVal = String(row?.[field] ?? '').trim()
|
||||
if (!selected.includes(rowVal)) return false
|
||||
}
|
||||
for (const field of valueFilterFields) {
|
||||
const selected = valueFilters.value[field] || []
|
||||
if (selected.length > 0 && !selected.includes(toValueFilterKey(row?.[field]))) return false
|
||||
}
|
||||
const stockQtyMin = parseNullableNumber(numberRangeFilters.value.stockQty?.min)
|
||||
const stockQtyMax = parseNullableNumber(numberRangeFilters.value.stockQty?.max)
|
||||
const stockQty = Number(row?.stockQty ?? 0)
|
||||
if (stockQtyMin !== null && stockQty < stockQtyMin) return false
|
||||
if (stockQtyMax !== null && stockQty > stockQtyMax) return false
|
||||
if (!matchesDateRange(String(row?.stockEntryDate || '').trim(), dateRangeFilters.value.stockEntryDate)) return false
|
||||
if (!matchesDateRange(String(row?.lastCampaignDate || '').trim(), dateRangeFilters.value.lastCampaignDate)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const visibleRowIds = computed(() => filteredRows.value.map((row) => rowSelectionKey(row)))
|
||||
const selectedRowCount = computed(() => Object.values(selectedMap.value).filter(Boolean).length)
|
||||
const selectedVisibleCount = computed(() => visibleRowIds.value.filter((id) => !!selectedMap.value[id]).length)
|
||||
const allSelectedVisible = computed(() => visibleRowIds.value.length > 0 && selectedVisibleCount.value === visibleRowIds.value.length)
|
||||
const someSelectedVisible = computed(() => selectedVisibleCount.value > 0)
|
||||
|
||||
function isHeaderFilterField (field) {
|
||||
return headerFilterFieldSet.has(field)
|
||||
}
|
||||
|
||||
function isMultiSelectFilterField (field) {
|
||||
return multiSelectFilterFieldSet.has(field)
|
||||
}
|
||||
|
||||
function isNumberRangeFilterField (field) {
|
||||
return numberRangeFilterFieldSet.has(field)
|
||||
}
|
||||
|
||||
function isDateRangeFilterField (field) {
|
||||
return dateRangeFilterFieldSet.has(field)
|
||||
}
|
||||
|
||||
function isValueSelectFilterField (field) {
|
||||
return valueSelectFilterFieldSet.has(field)
|
||||
}
|
||||
|
||||
function hasFilter (field) {
|
||||
if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length > 0
|
||||
if (isValueSelectFilterField(field)) return (valueFilters.value[field] || []).length > 0
|
||||
if (isNumberRangeFilterField(field)) {
|
||||
const filter = numberRangeFilters.value[field]
|
||||
return !!String(filter?.min || '').trim() || !!String(filter?.max || '').trim()
|
||||
}
|
||||
if (isDateRangeFilterField(field)) {
|
||||
const filter = dateRangeFilters.value[field]
|
||||
return !!String(filter?.from || '').trim() || !!String(filter?.to || '').trim()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getFilterBadgeValue (field) {
|
||||
if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length
|
||||
if (isValueSelectFilterField(field)) return (valueFilters.value[field] || []).length
|
||||
if (isNumberRangeFilterField(field)) {
|
||||
const filter = numberRangeFilters.value[field]
|
||||
return [filter?.min, filter?.max].filter((x) => String(x || '').trim()).length
|
||||
}
|
||||
if (isDateRangeFilterField(field)) {
|
||||
const filter = dateRangeFilters.value[field]
|
||||
return [filter?.from, filter?.to].filter((x) => String(x || '').trim()).length
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function clearColumnFilter (field) {
|
||||
if (isMultiSelectFilterField(field)) {
|
||||
columnFilters.value = {
|
||||
...columnFilters.value,
|
||||
[field]: []
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isValueSelectFilterField(field)) {
|
||||
valueFilters.value = {
|
||||
...valueFilters.value,
|
||||
[field]: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearRangeFilter (field) {
|
||||
if (isNumberRangeFilterField(field)) {
|
||||
numberRangeFilters.value = {
|
||||
...numberRangeFilters.value,
|
||||
[field]: { min: '', max: '' }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isDateRangeFilterField(field)) {
|
||||
dateRangeFilters.value = {
|
||||
...dateRangeFilters.value,
|
||||
[field]: { from: '', to: '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFilterOptionsForField (field) {
|
||||
if (isValueSelectFilterField(field)) return filteredValueFilterOptionMap.value[field] || []
|
||||
if (serverBackedMultiFilterFields.has(field)) {
|
||||
return serverFilterOptionMap.value[field] || []
|
||||
}
|
||||
return filteredFilterOptionMap.value[field] || []
|
||||
}
|
||||
|
||||
function isColumnFilterValueSelected (field, value) {
|
||||
if (isValueSelectFilterField(field)) return (valueFilters.value[field] || []).includes(value)
|
||||
return (columnFilters.value[field] || []).includes(value)
|
||||
}
|
||||
|
||||
function toggleColumnFilterValue (field, value) {
|
||||
const target = isValueSelectFilterField(field) ? valueFilters.value : columnFilters.value
|
||||
const current = new Set(target[field] || [])
|
||||
if (current.has(value)) current.delete(value)
|
||||
else current.add(value)
|
||||
if (isValueSelectFilterField(field)) {
|
||||
valueFilters.value = {
|
||||
...valueFilters.value,
|
||||
[field]: Array.from(current)
|
||||
}
|
||||
return
|
||||
}
|
||||
columnFilters.value = {
|
||||
...columnFilters.value,
|
||||
[field]: Array.from(current)
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllColumnFilterOptions (field) {
|
||||
const options = getFilterOptionsForField(field)
|
||||
if (isValueSelectFilterField(field)) {
|
||||
valueFilters.value = {
|
||||
...valueFilters.value,
|
||||
[field]: options.map((option) => option.value)
|
||||
}
|
||||
return
|
||||
}
|
||||
columnFilters.value = {
|
||||
...columnFilters.value,
|
||||
[field]: options.map((option) => option.value)
|
||||
}
|
||||
}
|
||||
|
||||
function extractWidth (style) {
|
||||
const m = String(style || '').match(/width:(\d+)px/)
|
||||
return m ? Number(m[1]) : 0
|
||||
}
|
||||
|
||||
function isStickyCol (colName) {
|
||||
return stickyColumnNameSet.value.has(colName)
|
||||
}
|
||||
|
||||
function isStickyBoundary (colName) {
|
||||
return colName === stickyBoundaryColumnName
|
||||
}
|
||||
|
||||
function getHeaderCellStyle (col) {
|
||||
if (!isStickyCol(col.name)) return undefined
|
||||
return { left: `${stickyLeftMap.value[col.name] || 0}px`, zIndex: 22 }
|
||||
}
|
||||
|
||||
function getBodyCellStyle (col) {
|
||||
if (!isStickyCol(col.name)) return undefined
|
||||
return { left: `${stickyLeftMap.value[col.name] || 0}px`, zIndex: 12 }
|
||||
}
|
||||
|
||||
function round2 (value) {
|
||||
return Number(Number(value || 0).toFixed(2))
|
||||
}
|
||||
|
||||
function toValueFilterKey (value) {
|
||||
return round2(parseNumber(value)).toFixed(2)
|
||||
}
|
||||
|
||||
function parseNumber (val) {
|
||||
if (typeof val === 'number') return Number.isFinite(val) ? val : 0
|
||||
const text = String(val ?? '').trim().replace(/\s/g, '')
|
||||
if (!text) return 0
|
||||
|
||||
const lastComma = text.lastIndexOf(',')
|
||||
const lastDot = text.lastIndexOf('.')
|
||||
let normalized = text
|
||||
|
||||
if (lastComma >= 0 && lastDot >= 0) {
|
||||
if (lastComma > lastDot) {
|
||||
normalized = text.replace(/\./g, '').replace(',', '.')
|
||||
} else {
|
||||
normalized = text.replace(/,/g, '')
|
||||
}
|
||||
} else if (lastComma >= 0) {
|
||||
normalized = text.replace(/\./g, '').replace(',', '.')
|
||||
} else {
|
||||
normalized = text.replace(/,/g, '')
|
||||
}
|
||||
|
||||
const n = Number(normalized)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function parseNullableNumber (val) {
|
||||
const text = String(val ?? '').trim()
|
||||
if (!text) return null
|
||||
const n = parseNumber(text)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function matchesDateRange (value, filter) {
|
||||
const from = String(filter?.from || '').trim()
|
||||
const to = String(filter?.to || '').trim()
|
||||
if (!from && !to) return true
|
||||
if (!value) return false
|
||||
if (from && value < from) return false
|
||||
if (to && value > to) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function formatPrice (val) {
|
||||
const n = parseNumber(val)
|
||||
return n.toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function formatPriceMaybe (val) {
|
||||
const n = Number(val)
|
||||
if (!Number.isFinite(n)) return ''
|
||||
return formatPrice(n)
|
||||
}
|
||||
|
||||
function formatRateDisplay (val) {
|
||||
const n = Number(val)
|
||||
if (!Number.isFinite(n) || n <= 0) return ''
|
||||
// show "20" for 20.00
|
||||
return n.toLocaleString('tr-TR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function formatGenericCellValue (col, value) {
|
||||
const name = String(col?.name || '').trim()
|
||||
if (name === 'stockQty') return formatStock(value)
|
||||
if (name === 'stockEntryDate' || name === 'lastCampaignDate') return formatDateDisplay(value)
|
||||
if (name === 'campaignRate') return formatRateDisplay(value)
|
||||
if (name === 'belowBaseDiff') return formatPriceMaybe(value)
|
||||
|
||||
// For numeric money-like columns, keep consistent formatting.
|
||||
if (typeof value === 'number' && (name.includes('Price') || name.startsWith('usd') || name.startsWith('eur') || name.startsWith('try') || name === 'costPrice')) {
|
||||
return formatPrice(value)
|
||||
}
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function formatStock (val) {
|
||||
const n = Number(val || 0)
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
const hasFraction = Math.abs(n % 1) > 0.0001
|
||||
return n.toLocaleString('tr-TR', {
|
||||
minimumFractionDigits: hasFraction ? 2 : 0,
|
||||
maximumFractionDigits: hasFraction ? 2 : 0
|
||||
})
|
||||
}
|
||||
|
||||
function formatDateDisplay (val) {
|
||||
const text = String(val || '').trim()
|
||||
if (!text) return '-'
|
||||
const [year, month, day] = text.split('-')
|
||||
if (!year || !month || !day) return text
|
||||
return `${day}.${month}.${year}`
|
||||
}
|
||||
|
||||
function formatMoney (v) {
|
||||
const n = Number(v ?? 0)
|
||||
if (!Number.isFinite(n)) return '-'
|
||||
return n.toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 6 })
|
||||
}
|
||||
|
||||
function formatMssqlStamp (row) {
|
||||
if (!row) return '-'
|
||||
const vd = String(row?.valid_date || '').trim()
|
||||
const vt = String(row?.valid_time || '').trim()
|
||||
const lud = String(row?.last_updated_date || '').trim()
|
||||
const parts = []
|
||||
if (vd) parts.push(vd)
|
||||
if (vt) parts.push(vt)
|
||||
if (lud) parts.push(`upd:${lud}`)
|
||||
return parts.length ? parts.join(' ') : '-'
|
||||
}
|
||||
|
||||
function getOriginalCellValue (row, field) {
|
||||
return row?.[`__orig_${field}`] ?? row?.[field] ?? 0
|
||||
}
|
||||
|
||||
function exportCellValue (row, field) {
|
||||
if (field === 'stockQty') return formatStock(row?.[field])
|
||||
if (field === 'stockEntryDate' || field === 'lastCampaignDate') return formatDateDisplay(row?.[field])
|
||||
if (editableColumnSet.has(field)) return String(round2(row?.[field] || 0))
|
||||
return String(row?.[field] ?? '').trim()
|
||||
}
|
||||
|
||||
function csvSafe (value) {
|
||||
let text = String(value ?? '').replaceAll('\r', ' ').replaceAll('\n', ' ').trim()
|
||||
if (text.includes(';') || text.includes('"')) {
|
||||
text = `"${text.replaceAll('"', '""')}"`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function exportCurrentView () {
|
||||
const cols = exportableColumns.value
|
||||
const list = filteredRows.value
|
||||
if (cols.length === 0 || list.length === 0) return
|
||||
|
||||
const lines = [cols.map((col) => csvSafe(col.label)).join(';')]
|
||||
for (const row of list) {
|
||||
lines.push(cols.map((col) => csvSafe(exportCellValue(row, col.field))).join(';'))
|
||||
}
|
||||
|
||||
const bom = '\uFEFF'
|
||||
const blob = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `product_pricing_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function exportAllFiltered () {
|
||||
exportAllLoading.value = true
|
||||
try {
|
||||
const filters = buildServerFilters()
|
||||
const params = {
|
||||
product_code: (filters.product_code || []).join(','),
|
||||
brand_group_selection: (filters.brand_group_selection || []).join(','),
|
||||
marka: (filters.marka || []).join(','),
|
||||
askili_yan: (filters.askili_yan || []).join(','),
|
||||
kategori: (filters.kategori || []).join(','),
|
||||
urun_ilk_grubu: (filters.urun_ilk_grubu || []).join(','),
|
||||
urun_ana_grubu: (filters.urun_ana_grubu || []).join(','),
|
||||
urun_alt_grubu: (filters.urun_alt_grubu || []).join(','),
|
||||
icerik: (filters.icerik || []).join(','),
|
||||
karisim: (filters.karisim || []).join(','),
|
||||
sort_by: tablePagination.value?.sortBy || '',
|
||||
desc: tablePagination.value?.descending ? 1 : 0,
|
||||
price_fields: (selectedPriceOptions.value || []).join(','),
|
||||
currencies: 'USD,EUR,TRY' // fallback for backward compatibility
|
||||
}
|
||||
|
||||
for (const field of valueFilterFields) {
|
||||
const values = valueFilters.value[field] || []
|
||||
if (values.length > 0) {
|
||||
params[`vf_${field}`] = values.join(',')
|
||||
}
|
||||
}
|
||||
|
||||
const stockQtyMin = String(numberRangeFilters.value.stockQty?.min || '').trim()
|
||||
const stockQtyMax = String(numberRangeFilters.value.stockQty?.max || '').trim()
|
||||
if (stockQtyMin) params.stock_qty_min = stockQtyMin
|
||||
if (stockQtyMax) params.stock_qty_max = stockQtyMax
|
||||
|
||||
const stockEntryFrom = String(dateRangeFilters.value.stockEntryDate?.from || '').trim()
|
||||
const stockEntryTo = String(dateRangeFilters.value.stockEntryDate?.to || '').trim()
|
||||
if (stockEntryFrom) params.stock_entry_from = stockEntryFrom
|
||||
if (stockEntryTo) params.stock_entry_to = stockEntryTo
|
||||
|
||||
const blob = await download('/pricing/products/export-all', params)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `product_pricing_all_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.message || 'Tum filtre export alinamadi' })
|
||||
} finally {
|
||||
exportAllLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function recalcByBasePrice (row) {
|
||||
row.basePriceTry = round2(row.basePriceUsd * usdToTry)
|
||||
let prevUsd = row.basePriceUsd
|
||||
let prevTry = row.basePriceTry
|
||||
let prevEur = round2(row.basePriceUsd * usdToTry / eurToTry)
|
||||
multipliers.forEach((multiplier, index) => {
|
||||
const nextUsd = round2(prevUsd * multiplier)
|
||||
const nextTry = round2(prevTry * multiplier)
|
||||
const nextEur = round2(prevEur * multiplier)
|
||||
row[`usd${index + 1}`] = nextUsd
|
||||
row[`eur${index + 1}`] = nextEur
|
||||
row[`try${index + 1}`] = nextTry
|
||||
prevUsd = nextUsd
|
||||
prevTry = nextTry
|
||||
prevEur = nextEur
|
||||
})
|
||||
}
|
||||
|
||||
function onEditableCellChange (row, field, val) {
|
||||
const parsed = parseNumber(val)
|
||||
store.updateCell(row, field, parsed)
|
||||
if (field === 'basePriceUsd') recalcByBasePrice(row)
|
||||
}
|
||||
|
||||
function setCalcLoading (productCode, value) {
|
||||
calcLoadingMap.value = {
|
||||
...calcLoadingMap.value,
|
||||
[productCode]: !!value
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreviewRowToUiRow (row, preview) {
|
||||
row.basePriceUsd = round2(preview?.base_price_usd)
|
||||
row.basePriceTry = round2(preview?.base_price_try)
|
||||
row.usd1 = round2(preview?.usd1)
|
||||
row.usd2 = round2(preview?.usd2)
|
||||
row.usd3 = round2(preview?.usd3)
|
||||
row.usd4 = round2(preview?.usd4)
|
||||
row.usd5 = round2(preview?.usd5)
|
||||
row.usd6 = round2(preview?.usd6)
|
||||
row.eur1 = round2(preview?.eur1)
|
||||
row.eur2 = round2(preview?.eur2)
|
||||
row.eur3 = round2(preview?.eur3)
|
||||
row.eur4 = round2(preview?.eur4)
|
||||
row.eur5 = round2(preview?.eur5)
|
||||
row.eur6 = round2(preview?.eur6)
|
||||
row.try1 = round2(preview?.try1)
|
||||
row.try2 = round2(preview?.try2)
|
||||
row.try3 = round2(preview?.try3)
|
||||
row.try4 = round2(preview?.try4)
|
||||
row.try5 = round2(preview?.try5)
|
||||
row.try6 = round2(preview?.try6)
|
||||
}
|
||||
|
||||
async function calculateRow (row) {
|
||||
if (!row?.productCode) return
|
||||
const productCode = String(row.productCode).trim()
|
||||
if (!productCode) return
|
||||
|
||||
setCalcLoading(productCode, true)
|
||||
console.info('[product-pricing][ui] calc-row:start', { product_code: productCode })
|
||||
try {
|
||||
const res = await api.post('/pricing/products/calculate-snapshots', {
|
||||
preview_only: true,
|
||||
product_codes: [productCode]
|
||||
}, {
|
||||
timeout: 180000
|
||||
})
|
||||
const list = Array.isArray(res?.data?.rows) ? res.data.rows : []
|
||||
const preview = list.find((item) => String(item?.product_code || '').trim() === productCode)
|
||||
if (!preview) {
|
||||
Notify.create({ type: 'warning', message: 'Bu urun icin hesap sonucu donmedi.' })
|
||||
return
|
||||
}
|
||||
applyPreviewRowToUiRow(row, preview)
|
||||
toggleRowSelection(rowSelectionKey(row), true)
|
||||
console.info('[product-pricing][ui] calc-row:done', { product_code: productCode })
|
||||
} catch (err) {
|
||||
console.error('[product-pricing][ui] calc-row:error', {
|
||||
product_code: productCode,
|
||||
status: err?.response?.status ?? null,
|
||||
message: err?.response?.data || err?.message || 'calc-row failed'
|
||||
})
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Hesaplama onizlemesi alinamadi' })
|
||||
} finally {
|
||||
setCalcLoading(productCode, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function openCampaignHistoryDialog (row) {
|
||||
if (!row?.productCode) return
|
||||
campaignHistoryRow.value = row
|
||||
campaignHistoryRows.value = []
|
||||
selectedCampaignHistoryIds.value = []
|
||||
campaignHistoryDialogOpen.value = true
|
||||
await reloadCampaignHistory()
|
||||
}
|
||||
|
||||
async function reloadCampaignHistory () {
|
||||
const code = String(campaignHistoryRow.value?.productCode || '').trim()
|
||||
const dim1 = Number(campaignHistoryRow.value?.dim1 || 0)
|
||||
const dim3 = campaignHistoryRow.value?.dim3 == null ? 0 : Number(campaignHistoryRow.value?.dim3 || 0)
|
||||
if (!code || !(dim1 > 0)) return
|
||||
campaignHistoryLoading.value = true
|
||||
try {
|
||||
const traceId = `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const res = await api.request({
|
||||
method: 'GET',
|
||||
url: `/pricing/wholesale-campaigns/${encodeURIComponent(code)}/campaign-history`,
|
||||
params: { dim1, dim3 },
|
||||
timeout: 180000,
|
||||
headers: { 'X-Trace-ID': traceId }
|
||||
})
|
||||
const list = Array.isArray(res?.data?.rows) ? res.data.rows : []
|
||||
campaignHistoryRows.value = list
|
||||
const idSet = new Set(list.map((r) => Number(r?.id || 0)).filter((x) => x > 0))
|
||||
selectedCampaignHistoryIds.value = (selectedCampaignHistoryIds.value || []).filter((id) => idSet.has(Number(id || 0)))
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kampanya gecmisi yuklenemedi' })
|
||||
campaignHistoryRows.value = []
|
||||
} finally {
|
||||
campaignHistoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectedCampaignHistoryId (id, val) {
|
||||
const nid = Number(id || 0)
|
||||
if (!(nid > 0)) return
|
||||
const set = new Set(selectedCampaignHistoryIds.value || [])
|
||||
if (val) set.add(nid)
|
||||
else set.delete(nid)
|
||||
selectedCampaignHistoryIds.value = Array.from(set)
|
||||
}
|
||||
|
||||
async function confirmDeleteSelectedCampaignHistory () {
|
||||
const code = String(campaignHistoryRow.value?.productCode || '').trim()
|
||||
const dim1 = Number(campaignHistoryRow.value?.dim1 || 0)
|
||||
const dim3 = campaignHistoryRow.value?.dim3 == null ? 0 : Number(campaignHistoryRow.value?.dim3 || 0)
|
||||
if (!code || !(dim1 > 0)) return
|
||||
const count = selectedCampaignHistoryIds.value?.length || 0
|
||||
if (count <= 0) return
|
||||
|
||||
await $q.dialog({
|
||||
title: 'Secilenleri Sil',
|
||||
message: `Secili kampanya gecmisi kayitlarini silmek istiyor musunuz? (${count})`,
|
||||
cancel: true,
|
||||
persistent: true,
|
||||
ok: { label: 'Sil', color: 'negative' },
|
||||
cancel: { label: 'Vazgec', color: 'grey-7', flat: true }
|
||||
}).onOk(async () => {
|
||||
await deleteSelectedCampaignHistory({ code, dim1, dim3 })
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteSelectedCampaignHistory ({ code, dim1, dim3 }) {
|
||||
const ids = (selectedCampaignHistoryIds.value || []).map((x) => Number(x || 0)).filter((x) => x > 0)
|
||||
if (ids.length === 0) return
|
||||
const traceId = `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
await api.request({
|
||||
method: 'POST',
|
||||
url: `/pricing/wholesale-campaigns/${encodeURIComponent(code)}/campaign-history/delete-selected`,
|
||||
params: { dim1, dim3 },
|
||||
data: { ids },
|
||||
timeout: 180000,
|
||||
headers: { 'X-Trace-ID': traceId }
|
||||
})
|
||||
Notify.create({ type: 'positive', message: 'Secilen kayitlar silindi.' })
|
||||
selectedCampaignHistoryIds.value = []
|
||||
await reloadCampaignHistory()
|
||||
await reloadData({ page: currentPage.value, useCache: false })
|
||||
}
|
||||
|
||||
let tableMiddleScrollEl = null
|
||||
let horizontalResizeObserver = null
|
||||
let syncingTopScroll = false
|
||||
|
||||
function getTableMiddleScrollEl () {
|
||||
return mainTableRef.value?.$el?.querySelector('.q-table__middle') || null
|
||||
}
|
||||
|
||||
function syncTopScrollWidth () {
|
||||
const top = topScrollRef.value
|
||||
const inner = topScrollInnerRef.value
|
||||
const middle = getTableMiddleScrollEl()
|
||||
if (!top || !inner || !middle) return
|
||||
const scrollWidth = Math.max(middle.scrollWidth, tableMinWidth.value, top.clientWidth)
|
||||
inner.style.width = `${scrollWidth}px`
|
||||
if (top.scrollLeft !== middle.scrollLeft) {
|
||||
top.scrollLeft = middle.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
function onTopScroll () {
|
||||
const top = topScrollRef.value
|
||||
const middle = getTableMiddleScrollEl()
|
||||
if (!top || !middle || syncingTopScroll) return
|
||||
syncingTopScroll = true
|
||||
middle.scrollLeft = top.scrollLeft
|
||||
requestAnimationFrame(() => {
|
||||
syncingTopScroll = false
|
||||
})
|
||||
}
|
||||
|
||||
function onTableMiddleScroll () {
|
||||
const top = topScrollRef.value
|
||||
const middle = getTableMiddleScrollEl()
|
||||
if (!top || !middle || syncingTopScroll) return
|
||||
syncingTopScroll = true
|
||||
top.scrollLeft = middle.scrollLeft
|
||||
requestAnimationFrame(() => {
|
||||
syncingTopScroll = false
|
||||
})
|
||||
}
|
||||
|
||||
async function bindHorizontalScrollSync () {
|
||||
await nextTick()
|
||||
const middle = getTableMiddleScrollEl()
|
||||
if (tableMiddleScrollEl && tableMiddleScrollEl !== middle) {
|
||||
tableMiddleScrollEl.removeEventListener('scroll', onTableMiddleScroll)
|
||||
}
|
||||
tableMiddleScrollEl = middle
|
||||
if (tableMiddleScrollEl) {
|
||||
tableMiddleScrollEl.removeEventListener('scroll', onTableMiddleScroll)
|
||||
tableMiddleScrollEl.addEventListener('scroll', onTableMiddleScroll, { passive: true })
|
||||
}
|
||||
if (horizontalResizeObserver) {
|
||||
horizontalResizeObserver.disconnect()
|
||||
horizontalResizeObserver = null
|
||||
}
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
horizontalResizeObserver = new ResizeObserver(() => {
|
||||
syncTopScrollWidth()
|
||||
})
|
||||
if (topScrollRef.value) horizontalResizeObserver.observe(topScrollRef.value)
|
||||
if (tableMiddleScrollEl) horizontalResizeObserver.observe(tableMiddleScrollEl)
|
||||
}
|
||||
syncTopScrollWidth()
|
||||
}
|
||||
|
||||
function onBrandGroupSelectionChange (row, val) {
|
||||
// no-op (read-only)
|
||||
}
|
||||
|
||||
function isRowSelected (rowKey) {
|
||||
const k = String(rowKey ?? '').trim()
|
||||
if (!k) return false
|
||||
return !!selectedMap.value[k]
|
||||
}
|
||||
|
||||
const selectedToneColumnNameSet = new Set([
|
||||
// "Karisim"e kadar olan sol kolonlar (fiyat kolonlarini boyamayalim)
|
||||
'brandGroupSelection',
|
||||
'marka',
|
||||
'productCode',
|
||||
'variantCodes',
|
||||
'variantStocks',
|
||||
'campaignId',
|
||||
'campaignRate',
|
||||
'stockEntryDate',
|
||||
'lastCampaignDate',
|
||||
'askiliYan',
|
||||
'kategori',
|
||||
'urunIlkGrubu',
|
||||
'urunAnaGrubu',
|
||||
'urunAltGrubu',
|
||||
'icerik',
|
||||
'karisim'
|
||||
])
|
||||
|
||||
function shouldToneSelectedCell (row, colName) {
|
||||
if (!selectedToneColumnNameSet.has(String(colName || '').trim())) return false
|
||||
if (!isRowSelected(rowSelectionKey(row))) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function onRowCheckboxChange (row, val) {
|
||||
if (!row) return
|
||||
toggleRowSelection(rowSelectionKey(row), val)
|
||||
}
|
||||
|
||||
function toggleRowSelection (rowKey, val) {
|
||||
const k = String(rowKey ?? '').trim()
|
||||
if (!k) return
|
||||
selectedMap.value = { ...selectedMap.value, [k]: !!val }
|
||||
}
|
||||
|
||||
function isRowDirty (row) {
|
||||
if (!row) return false
|
||||
const cur = row?.campaignId == null ? null : Number(row.campaignId)
|
||||
const orig = row?.__orig_campaignId == null ? null : Number(row.__orig_campaignId)
|
||||
return cur !== orig
|
||||
}
|
||||
|
||||
const selectedRows = computed(() => {
|
||||
const map = selectedMap.value || {}
|
||||
return rows.value.filter((r) => !!map[rowSelectionKey(r)])
|
||||
})
|
||||
|
||||
const selectedDirtyRows = computed(() => selectedRows.value.filter(isRowDirty))
|
||||
const selectedDirtyCount = computed(() => selectedDirtyRows.value.length)
|
||||
const saveButtonLabel = computed(() => {
|
||||
if (selectedDirtyCount.value > 0) return `Kampanyayi Kaydet (${selectedDirtyCount.value})`
|
||||
return 'Kampanyayi Kaydet'
|
||||
})
|
||||
|
||||
async function saveSelectedRows () {
|
||||
const list = selectedDirtyRows.value
|
||||
if (!Array.isArray(list) || list.length === 0) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const traceId = `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
console.info('[wholesale-campaigns][ui] save:start', { trace_id: traceId, dirty_count: list.length })
|
||||
const payload = {
|
||||
items: list.map((r) => ({
|
||||
product_code: String(r?.productCode || '').trim(),
|
||||
dim1: Number(r?.dim1 || 0),
|
||||
dim3: r?.dim3 == null ? null : Number(r.dim3),
|
||||
campaign_id: r?.campaignId == null ? null : Number(r.campaignId)
|
||||
}))
|
||||
}
|
||||
|
||||
await api.request({
|
||||
method: 'POST',
|
||||
url: '/pricing/wholesale-campaigns/save',
|
||||
data: payload,
|
||||
timeout: 180000,
|
||||
headers: { 'X-Trace-ID': traceId }
|
||||
})
|
||||
|
||||
Notify.create({ type: 'positive', message: `Kaydedildi: ${list.length}` })
|
||||
console.info('[wholesale-campaigns][ui] save:done', { trace_id: traceId, dirty_count: list.length })
|
||||
|
||||
// After persisting, clear selection state and reload from backend.
|
||||
// This avoids "Kaydet(1) but checkbox not ticked" confusion and ensures UI reflects DB.
|
||||
selectedMap.value = {}
|
||||
showSelectedOnly.value = false
|
||||
await reloadData({ page: currentPage.value, useCache: false })
|
||||
} catch (err) {
|
||||
console.error('[wholesale-campaigns][ui] save:error', {
|
||||
status: err?.response?.status ?? null,
|
||||
trace_id: err?.response?.headers?.['x-trace-id'] || null,
|
||||
message: err?.response?.data || err?.message || 'save failed'
|
||||
})
|
||||
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kaydedilemedi' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAllVisible (val) {
|
||||
const next = { ...selectedMap.value }
|
||||
visibleRowIds.value.forEach((id) => { next[id] = !!val })
|
||||
selectedMap.value = next
|
||||
}
|
||||
|
||||
function resetAll () {
|
||||
columnFilters.value = {
|
||||
productCode: [],
|
||||
variantCodes: [],
|
||||
campaignId: [],
|
||||
brandGroupSelection: [],
|
||||
marka: [],
|
||||
askiliYan: [],
|
||||
kategori: [],
|
||||
urunIlkGrubu: [],
|
||||
urunAnaGrubu: [],
|
||||
urunAltGrubu: [],
|
||||
icerik: [],
|
||||
karisim: []
|
||||
}
|
||||
columnFilterSearch.value = {
|
||||
productCode: '',
|
||||
variantCodes: '',
|
||||
campaignId: '',
|
||||
brandGroupSelection: '',
|
||||
marka: '',
|
||||
askiliYan: '',
|
||||
kategori: '',
|
||||
urunIlkGrubu: '',
|
||||
urunAnaGrubu: '',
|
||||
urunAltGrubu: '',
|
||||
icerik: '',
|
||||
karisim: ''
|
||||
}
|
||||
valueFilters.value = Object.fromEntries(valueFilterFields.map((field) => [field, []]))
|
||||
valueFilterSearch.value = Object.fromEntries(valueFilterFields.map((field) => [field, '']))
|
||||
numberRangeFilters.value = {
|
||||
stockQty: { min: '', max: '' }
|
||||
}
|
||||
dateRangeFilters.value = {
|
||||
stockEntryDate: { from: '', to: '' },
|
||||
lastCampaignDate: { from: '', to: '' }
|
||||
}
|
||||
showSelectedOnly.value = false
|
||||
selectedMap.value = {}
|
||||
}
|
||||
|
||||
function toggleShowSelectedOnly () {
|
||||
if (!showSelectedOnly.value && selectedRowCount.value === 0) return
|
||||
showSelectedOnly.value = !showSelectedOnly.value
|
||||
}
|
||||
|
||||
function isPriceOptionSelected (code) {
|
||||
return selectedPriceOptionSet.value.has(code)
|
||||
}
|
||||
|
||||
function togglePriceOption (code, checked) {
|
||||
const set = new Set(selectedPriceOptions.value || [])
|
||||
if (checked) set.add(code)
|
||||
else set.delete(code)
|
||||
selectedPriceOptions.value = priceOptionOptions.map((x) => x.value).filter((x) => set.has(x))
|
||||
// Keep price list export selectors aligned (they are still rendered in the dialog).
|
||||
priceListUSDLevels.value = [1, 2, 3, 4, 5, 6].filter((lv) => selectedPriceOptionSet.value.has(`usd${lv}`))
|
||||
priceListEURLevels.value = [1, 2, 3, 4, 5, 6].filter((lv) => selectedPriceOptionSet.value.has(`eur${lv}`))
|
||||
priceListTRYLevels.value = [1, 2, 3, 4, 5, 6].filter((lv) => selectedPriceOptionSet.value.has(`try${lv}`))
|
||||
}
|
||||
|
||||
function togglePriceOptionRow (code) {
|
||||
togglePriceOption(code, !isPriceOptionSelected(code))
|
||||
}
|
||||
|
||||
function selectAllPriceOptions () {
|
||||
selectedPriceOptions.value = priceOptionOptions.map((x) => x.value)
|
||||
}
|
||||
|
||||
function clearAllPriceOptions () {
|
||||
selectedPriceOptions.value = []
|
||||
}
|
||||
|
||||
function onPaginationChange (next) {
|
||||
const prevSortBy = tablePagination.value.sortBy
|
||||
const prevDesc = tablePagination.value.descending
|
||||
tablePagination.value = {
|
||||
...tablePagination.value,
|
||||
...(next || {}),
|
||||
page: 1,
|
||||
rowsPerPage: 0
|
||||
}
|
||||
const nextSortBy = tablePagination.value.sortBy
|
||||
const nextDesc = tablePagination.value.descending
|
||||
if (nextSortBy !== prevSortBy || nextDesc !== prevDesc) {
|
||||
currentPage.value = 1
|
||||
void reloadData({ page: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
function buildServerFilters () {
|
||||
return {
|
||||
product_code: columnFilters.value.productCode || [],
|
||||
brand_group_selection: columnFilters.value.brandGroupSelection || [],
|
||||
marka: columnFilters.value.marka || [],
|
||||
askili_yan: columnFilters.value.askiliYan || [],
|
||||
kategori: columnFilters.value.kategori || [],
|
||||
urun_ilk_grubu: columnFilters.value.urunIlkGrubu || [],
|
||||
urun_ana_grubu: columnFilters.value.urunAnaGrubu || [],
|
||||
urun_alt_grubu: columnFilters.value.urunAltGrubu || [],
|
||||
icerik: columnFilters.value.icerik || [],
|
||||
karisim: columnFilters.value.karisim || []
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReload () {
|
||||
if (reloadTimer) clearTimeout(reloadTimer)
|
||||
reloadTimer = setTimeout(() => {
|
||||
reloadTimer = null
|
||||
void reloadData({ page: 1 })
|
||||
}, 180)
|
||||
}
|
||||
|
||||
async function fetchChunk ({ page = 1, useCache = true } = {}) {
|
||||
const filters = buildServerFilters()
|
||||
const hasAnyFilter = Object.values(filters).some((v) => Array.isArray(v) && v.length > 0)
|
||||
const hasPrimaryFilter = (filters.urun_ilk_grubu?.length || 0) > 0 || (filters.urun_ana_grubu?.length || 0) > 0
|
||||
if (!hasAnyFilter) {
|
||||
// This endpoint is expensive without filters; require the user to scope down first.
|
||||
store.rows = []
|
||||
variantRows.value = []
|
||||
store.error = 'Liste cok buyuk. Lutfen en az bir filtre secin (or: Urun Ilk Grubu / Urun Ana Grubu / Urun Kodu).'
|
||||
store.totalCount = 0
|
||||
store.totalPages = 1
|
||||
store.page = 1
|
||||
store.hasMore = false
|
||||
return 0
|
||||
}
|
||||
if (!hasPrimaryFilter) {
|
||||
store.rows = []
|
||||
variantRows.value = []
|
||||
store.error = GUIDANCE_MSG
|
||||
store.totalCount = 0
|
||||
store.totalPages = 1
|
||||
store.page = 1
|
||||
store.hasMore = false
|
||||
return 0
|
||||
}
|
||||
const result = await store.fetchRows({
|
||||
limit: PAGE_LIMIT,
|
||||
page,
|
||||
append: false,
|
||||
silent: false,
|
||||
useCache,
|
||||
filters,
|
||||
sortBy: tablePagination.value.sortBy,
|
||||
descending: tablePagination.value.descending
|
||||
})
|
||||
currentPage.value = Number(result?.page) || page
|
||||
return Number(result?.fetched) || 0
|
||||
}
|
||||
|
||||
async function loadCampaignList () {
|
||||
if (campaignLoading.value) return
|
||||
campaignLoading.value = true
|
||||
try {
|
||||
const res = await api.request({
|
||||
method: 'GET',
|
||||
url: '/pricing/wholesale-campaigns',
|
||||
timeout: 30000
|
||||
})
|
||||
campaignList.value = Array.isArray(res?.data) ? res.data : []
|
||||
} catch (err) {
|
||||
console.error('[wholesale-campaigns][ui] campaigns:error', {
|
||||
status: err?.response?.status ?? null,
|
||||
message: err?.response?.data || err?.message || 'campaign list failed'
|
||||
})
|
||||
campaignList.value = []
|
||||
} finally {
|
||||
campaignLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCampaignId (val) {
|
||||
const n = Number(val)
|
||||
return Number.isFinite(n) && n > 0 ? n : null
|
||||
}
|
||||
|
||||
function applyCampaignDerived (row) {
|
||||
if (!row) return
|
||||
const cid = normalizeCampaignId(row.campaignId)
|
||||
const rate = cid ? (campaignIdToRate.value.get(cid) || Number(row?.campaignRate || 0) || 0) : 0
|
||||
row.campaignRate = rate > 0 ? rate : null
|
||||
|
||||
for (const p of campaignPricePairs) {
|
||||
const base = Number(row?.[p.base] ?? 0)
|
||||
if (!cid || !(rate > 0) || !(base > 0)) {
|
||||
row[p.derived] = null
|
||||
continue
|
||||
}
|
||||
row[p.derived] = round2(base * (1 - rate / 100))
|
||||
}
|
||||
|
||||
// Single "taban altinda" diff: compare in TRY space for all visible currencies.
|
||||
let baseTry = Number(row?.basePriceTry ?? 0)
|
||||
if (!(baseTry > 0)) {
|
||||
const baseUsd = Number(row?.basePriceUsd ?? 0)
|
||||
baseTry = baseUsd > 0 ? (baseUsd * usdToTry) : 0
|
||||
}
|
||||
let minDiff = null
|
||||
if (baseTry > 0) {
|
||||
const selected = selectedPriceOptionSet.value
|
||||
for (const p of campaignPricePairs) {
|
||||
if (!selected.has(p.base)) continue
|
||||
const v = Number(row?.[p.derived] ?? 0)
|
||||
if (!(v > 0)) continue
|
||||
const tryVal = p.currency === 'USD'
|
||||
? v * usdToTry
|
||||
: (p.currency === 'EUR' ? v * eurToTry : v)
|
||||
const diff = tryVal - baseTry
|
||||
if (minDiff == null || diff < minDiff) minDiff = diff
|
||||
}
|
||||
}
|
||||
row.belowBaseDiff = minDiff == null ? null : round2(minDiff)
|
||||
}
|
||||
|
||||
function onRowCampaignChange (row, val) {
|
||||
const cid = normalizeCampaignId(val)
|
||||
row.campaignId = cid
|
||||
row.campaignIsMixed = false
|
||||
applyCampaignDerived(row)
|
||||
toggleRowSelection(rowSelectionKey(row), true)
|
||||
}
|
||||
|
||||
async function buildVariantRowsForProductPage (baseProductRows = []) {
|
||||
const base = Array.isArray(baseProductRows) ? baseProductRows : []
|
||||
const codes = base.map((r) => String(r?.productCode || '').trim()).filter(Boolean)
|
||||
if (codes.length === 0) {
|
||||
variantRows.value = []
|
||||
return
|
||||
}
|
||||
|
||||
variantLoading.value = true
|
||||
try {
|
||||
const res = await api.request({
|
||||
method: 'GET',
|
||||
url: '/pricing/wholesale-campaigns/variant-rows',
|
||||
params: { product_code: codes.join(',') },
|
||||
timeout: 180000
|
||||
})
|
||||
const list = Array.isArray(res?.data) ? res.data : []
|
||||
const byCode = new Map()
|
||||
for (const it of list) {
|
||||
const pc = String(it?.product_code || '').trim()
|
||||
if (!pc) continue
|
||||
if (!byCode.has(pc)) byCode.set(pc, [])
|
||||
byCode.get(pc).push(it)
|
||||
}
|
||||
|
||||
const out = []
|
||||
for (const p of base) {
|
||||
const pc = String(p?.productCode || '').trim()
|
||||
if (!pc) continue
|
||||
const vs = byCode.get(pc) || []
|
||||
if (vs.length === 0) {
|
||||
out.push({
|
||||
...p,
|
||||
rowKey: `${pc}|0|0`,
|
||||
dim1: 0,
|
||||
dim3: null,
|
||||
variantCodes: '',
|
||||
variantStocks: '',
|
||||
stockQty: 0,
|
||||
campaignId: null,
|
||||
__orig_campaignId: null,
|
||||
campaignRate: null,
|
||||
lastCampaignDate: ''
|
||||
})
|
||||
continue
|
||||
}
|
||||
vs.sort((a, b) => String(a?.variant_code || '').localeCompare(String(b?.variant_code || ''), 'tr'))
|
||||
for (const v of vs) {
|
||||
const d1 = Number(v?.dim1 || 0)
|
||||
const d3 = v?.dim3 == null ? null : Number(v?.dim3 || 0)
|
||||
const d3k = d3 && d3 > 0 ? d3 : 0
|
||||
const cid = v?.campaign_id == null ? null : Number(v?.campaign_id || 0)
|
||||
const rate = Number(v?.discount_rate || 0)
|
||||
const row = {
|
||||
...p,
|
||||
rowKey: `${pc}|${d1}|${d3k}`,
|
||||
dim1: d1,
|
||||
dim3: d3k > 0 ? d3k : null,
|
||||
variantCodes: String(v?.variant_code || '').trim(),
|
||||
variantStocks: formatStock(Number(v?.stock_qty ?? 0)),
|
||||
stockQty: Number(v?.stock_qty ?? 0),
|
||||
campaignId: cid && cid > 0 ? cid : null,
|
||||
__orig_campaignId: cid && cid > 0 ? cid : null,
|
||||
campaignRate: rate > 0 ? rate : null,
|
||||
campaignIsMixed: false,
|
||||
lastCampaignDate: String(v?.campaign_last_dttm || '').trim()
|
||||
}
|
||||
applyCampaignDerived(row)
|
||||
out.push(row)
|
||||
}
|
||||
}
|
||||
variantRows.value = out
|
||||
} catch (err) {
|
||||
console.error('[wholesale-campaigns][ui] variant-rows:error', {
|
||||
status: err?.response?.status ?? null,
|
||||
message: err?.response?.data || err?.message || 'variant rows failed'
|
||||
})
|
||||
variantRows.value = []
|
||||
} finally {
|
||||
variantLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadData ({ page = 1, useCache = true } = {}) {
|
||||
const startedAt = Date.now()
|
||||
console.info('[product-pricing][ui] reload:start', {
|
||||
at: new Date(startedAt).toISOString()
|
||||
})
|
||||
try {
|
||||
await fetchChunk({ page, useCache })
|
||||
if (campaignList.value.length === 0) {
|
||||
await loadCampaignList()
|
||||
}
|
||||
await buildVariantRowsForProductPage(store.rows || [])
|
||||
} catch (err) {
|
||||
console.error('[product-pricing][ui] reload:error', {
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message: String(err?.message || err || 'reload failed')
|
||||
})
|
||||
}
|
||||
console.info('[product-pricing][ui] reload:done', {
|
||||
duration_ms: Date.now() - startedAt,
|
||||
row_count: Array.isArray(store.rows) ? store.rows.length : 0,
|
||||
has_error: Boolean(store.error)
|
||||
})
|
||||
await bindHorizontalScrollSync()
|
||||
}
|
||||
|
||||
// Full "fetch all pages" is intentionally avoided; keep server-side paging for performance.
|
||||
|
||||
function onPageChange (page) {
|
||||
const p = Number(page) > 0 ? Number(page) : 1
|
||||
if (store.loading) return
|
||||
if (p === currentPage.value && p === (store.page || 1)) return
|
||||
currentPage.value = p
|
||||
void reloadData({ page: p })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Prefetch a couple of common filters so the first open is not empty.
|
||||
void fetchServerFilterOptions('urunIlkGrubu')
|
||||
void fetchServerFilterOptions('urunAnaGrubu')
|
||||
void loadCampaignList()
|
||||
// Do not auto-fetch listing on mount; user must scope by group first.
|
||||
store.rows = []
|
||||
variantRows.value = []
|
||||
store.error = GUIDANCE_MSG
|
||||
store.totalCount = 0
|
||||
store.totalPages = 1
|
||||
store.page = 1
|
||||
store.hasMore = false
|
||||
await bindHorizontalScrollSync()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [tableMinWidth.value, rows.value.length, selectedPriceOptions.value.join(',')],
|
||||
() => {
|
||||
for (const r of rows.value) applyCampaignDerived(r)
|
||||
void bindHorizontalScrollSync()
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (reloadTimer) {
|
||||
clearTimeout(reloadTimer)
|
||||
reloadTimer = null
|
||||
}
|
||||
if (tableMiddleScrollEl) {
|
||||
tableMiddleScrollEl.removeEventListener('scroll', onTableMiddleScroll)
|
||||
tableMiddleScrollEl = null
|
||||
}
|
||||
if (horizontalResizeObserver) {
|
||||
horizontalResizeObserver.disconnect()
|
||||
horizontalResizeObserver = null
|
||||
}
|
||||
})
|
||||
|
||||
// NOTE: Listing fetch is intentionally manual via "Gruplari Getir" for performance.
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pricing-page {
|
||||
--pricing-row-height: 31px;
|
||||
--pricing-header-height: 72px;
|
||||
--pricing-table-height: calc(100vh - 210px);
|
||||
|
||||
position: relative;
|
||||
height: calc(100vh - 120px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.currency-menu-list {
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.top-actions-row {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.top-actions-row--filters {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.top-actions-row--actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* paging group is inside actions row now */
|
||||
|
||||
.toolbar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.toolbar-group--paging {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-group--paging :deep(.q-pagination) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn__content) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn--dense .q-btn__content) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn__content .q-icon) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn .q-icon) {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn__content span) {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn) {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn__content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toolbar-group :deep(.q-btn__wrapper) {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
.top-actions-row--filters,
|
||||
.top-actions-row--actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.empty-overlay-inner {
|
||||
width: min(720px, 100%);
|
||||
border: 1px dashed rgba(0, 0, 0, 0.18);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 16px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.campaign-history-card {
|
||||
width: 860px;
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.campaign-history-card :deep(.q-card__section) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.campaign-history-list {
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.top-x-scroll {
|
||||
flex: 0 0 14px;
|
||||
height: 14px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.top-x-scroll-inner {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.pane-table {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pricing-table :deep(.q-table__middle) {
|
||||
height: calc(var(--pricing-table-height) - 14px);
|
||||
min-height: calc(var(--pricing-table-height) - 14px);
|
||||
max-height: calc(var(--pricing-table-height) - 14px);
|
||||
overflow: auto !important;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.pricing-table :deep(.q-table) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
table-layout: fixed;
|
||||
font-size: 11px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin-right: var(--sticky-scroll-comp, 0px);
|
||||
}
|
||||
|
||||
.pricing-table :deep(.q-table__container) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(th),
|
||||
.pricing-table :deep(td) {
|
||||
box-sizing: border-box;
|
||||
padding: 0 1px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.pricing-table :deep(td),
|
||||
.pricing-table :deep(.q-table tbody tr) {
|
||||
height: var(--pricing-row-height) !important;
|
||||
min-height: var(--pricing-row-height) !important;
|
||||
max-height: var(--pricing-row-height) !important;
|
||||
line-height: var(--pricing-row-height);
|
||||
padding: 0 !important;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(td > div),
|
||||
.pricing-table :deep(td > .q-td) {
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
padding: 0 1px !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(th),
|
||||
.pricing-table :deep(.q-table thead tr),
|
||||
.pricing-table :deep(.q-table thead tr.header-row-fixed),
|
||||
.pricing-table :deep(.q-table thead th),
|
||||
.pricing-table :deep(.q-table thead tr.header-row-fixed > th) {
|
||||
height: var(--pricing-header-height) !important;
|
||||
min-height: var(--pricing-header-height) !important;
|
||||
max-height: var(--pricing-header-height) !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(th) {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
white-space: nowrap;
|
||||
word-break: normal;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.pricing-table :deep(.q-table thead th) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: #fff;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(.sticky-col) {
|
||||
position: sticky !important;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.pricing-table :deep(thead .sticky-col) {
|
||||
z-index: 35 !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(tbody .sticky-col) {
|
||||
z-index: 12 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(.sticky-boundary) {
|
||||
border-right: 2px solid rgba(25, 118, 210, 0.18) !important;
|
||||
box-shadow: 8px 0 12px -10px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.pricing-table :deep(tbody td:not(.sticky-col)) {
|
||||
position: relative;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(tbody td.sticky-col)::after,
|
||||
.pricing-table :deep(thead th.sticky-col)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: inherit;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.header-with-filter {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 20px;
|
||||
align-items: center;
|
||||
column-gap: 4px;
|
||||
height: 100%;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-with-filter > span {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.header-filter-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.header-filter-ghost {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.excel-filter-menu {
|
||||
min-width: 230px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.range-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.range-filter-field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.excel-filter-select :deep(.q-field__control) {
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.excel-filter-select :deep(.q-field__native),
|
||||
.excel-filter-select :deep(.q-field__input) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.excel-filter-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.excel-filter-options {
|
||||
max-height: 220px;
|
||||
margin-top: 8px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.excel-filter-option {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.excel-filter-empty {
|
||||
padding: 10px 8px;
|
||||
color: #607d8b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pricing-table :deep(th.ps-col),
|
||||
.pricing-table :deep(td.ps-col) {
|
||||
background: #fff;
|
||||
color: var(--q-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pricing-table :deep(td.ps-col .cell-text),
|
||||
.pricing-table :deep(td.ps-col .product-code-text),
|
||||
.pricing-table :deep(td.ps-col .stock-qty-text) {
|
||||
font-size: 11px;
|
||||
line-height: 1.1;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.pricing-table :deep(td.selected-tone-cell) {
|
||||
/* "Secondary" tonlu secim vurgusu (yalnizca karisima kadar olan sol kolonlar) */
|
||||
background: color-mix(in srgb, var(--q-secondary) 12%, #ffffff);
|
||||
}
|
||||
|
||||
.stock-qty-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.date-cell-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.date-warning {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.cell-danger {
|
||||
background: #c62828 !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(th.selection-col),
|
||||
.pricing-table :deep(td.selection-col) {
|
||||
background: #fff;
|
||||
color: var(--q-primary);
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(th.selection-col) {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.pricing-table :deep(.selection-col .q-checkbox__inner) {
|
||||
color: var(--q-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.pricing-table :deep(th.selection-col .q-checkbox),
|
||||
.pricing-table :deep(td.selection-col .q-checkbox) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pricing-table :deep(.selection-col .q-checkbox__bg) {
|
||||
border-color: var(--q-primary);
|
||||
}
|
||||
|
||||
.pricing-table :deep(th.usd-col),
|
||||
.pricing-table :deep(td.usd-col) {
|
||||
background: #ecf9f0;
|
||||
color: #178a3e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pricing-table :deep(th.eur-col),
|
||||
.pricing-table :deep(td.eur-col) {
|
||||
background: #fdeeee;
|
||||
color: #c62828;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pricing-table :deep(th.try-col),
|
||||
.pricing-table :deep(td.try-col) {
|
||||
background: #edf4ff;
|
||||
color: #1e63c6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Keep right-side price area more compact on wide screens */
|
||||
.pricing-table :deep(th.usd-col),
|
||||
.pricing-table :deep(th.eur-col),
|
||||
.pricing-table :deep(th.try-col),
|
||||
.pricing-table :deep(td.usd-col),
|
||||
.pricing-table :deep(td.eur-col),
|
||||
.pricing-table :deep(td.try-col) {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.pricing-table :deep(td.campaign-price-col),
|
||||
.pricing-table :deep(th.campaign-price-col) {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.cell-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.1;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.product-code-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.editable-price-cell {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.old-price-label {
|
||||
display: block;
|
||||
width: 90%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #7c3aed;
|
||||
text-align: right;
|
||||
margin: 0 auto;
|
||||
padding-right: 1px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.native-cell-input,
|
||||
.native-cell-select {
|
||||
width: 90%;
|
||||
height: 22px;
|
||||
box-sizing: border-box;
|
||||
padding: 1px 1px;
|
||||
border: 1px solid #cfd8dc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
margin: 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.price-edit-input {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.native-cell-input:focus,
|
||||
.native-cell-select:focus {
|
||||
outline: none;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
.variant-multiline {
|
||||
white-space: pre-line;
|
||||
line-height: 1.1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diff-negative {
|
||||
color: #c10015;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.price-option-chip {
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -395,6 +395,12 @@ const routes = [
|
||||
component: () => import('pages/PricingRules.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'pricing/wholesale-campaigns',
|
||||
name: 'wholesale-campaigns',
|
||||
component: () => import('pages/WholesaleCampaigns.vue'),
|
||||
meta: { permission: 'pricing:view' }
|
||||
},
|
||||
{
|
||||
path: 'costing/production-product-costing',
|
||||
name: 'production-product-costing',
|
||||
|
||||
Reference in New Issue
Block a user