Compare commits

...

17 Commits

Author SHA1 Message Date
M_Kececi
1054a15547 Merge remote-tracking branch 'origin/master' 2026-06-19 15:44:21 +03:00
M_Kececi
da9d7c2fd5 Merge remote-tracking branch 'origin/master' 2026-06-19 15:09:55 +03:00
M_Kececi
539ca4b587 Merge remote-tracking branch 'origin/master' 2026-06-19 14:46:32 +03:00
M_Kececi
10f90cbaf3 Merge remote-tracking branch 'origin/master' 2026-06-19 14:22:04 +03:00
M_Kececi
13c9df1e96 Merge remote-tracking branch 'origin/master' 2026-06-19 13:56:21 +03:00
M_Kececi
462fb4058d Merge remote-tracking branch 'origin/master' 2026-06-19 13:10:10 +03:00
M_Kececi
a2f70160bc Merge remote-tracking branch 'origin/master' 2026-06-19 12:59:06 +03:00
M_Kececi
3732004a29 Merge remote-tracking branch 'origin/master' 2026-06-19 12:49:49 +03:00
M_Kececi
c8c37b4e69 Merge remote-tracking branch 'origin/master' 2026-06-19 12:21:43 +03:00
M_Kececi
ad8d459491 Merge remote-tracking branch 'origin/master' 2026-06-19 12:03:46 +03:00
M_Kececi
9b57e0846e Merge remote-tracking branch 'origin/master' 2026-06-19 00:59:02 +03:00
M_Kececi
55e36366c3 Merge remote-tracking branch 'origin/master' 2026-06-19 00:49:59 +03:00
M_Kececi
7512e7fe7c Merge remote-tracking branch 'origin/master' 2026-06-19 00:22:44 +03:00
M_Kececi
81d1af61be Merge remote-tracking branch 'origin/master' 2026-06-18 23:57:25 +03:00
M_Kececi
b59889bbdb Merge remote-tracking branch 'origin/master' 2026-06-18 23:44:36 +03:00
M_Kececi
ef33a56a49 Merge remote-tracking branch 'origin/master' 2026-06-18 18:47:35 +03:00
M_Kececi
49863d7569 Merge remote-tracking branch 'origin/master' 2026-06-18 18:47:30 +03:00
25 changed files with 3176 additions and 286 deletions

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS mk_order_price_list_user_price_group (
user_id BIGINT NOT NULL,
price_group TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, price_group)
);
CREATE INDEX IF NOT EXISTS ix_order_price_list_user_price_group_user
ON mk_order_price_list_user_price_group (user_id);
ALTER TABLE mk_order_price_list_user_price_group
DROP CONSTRAINT IF EXISTS ck_order_price_list_user_price_group;
ALTER TABLE mk_order_price_list_user_price_group
ADD CONSTRAINT ck_order_price_list_user_price_group
CHECK (price_group IN (
'usd1','usd2','usd3','usd4','usd5','usd6',
'eur1','eur2','eur3','eur4','eur5','eur6',
'try1','try2','try3','try4','try5','try6'
));

View File

@@ -360,6 +360,36 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"system", "update",
wrapV3(routes.SavePricingFirstGroupMailMappingHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/system/order-price-list-mail-mappings/lookups", "GET",
"system", "update",
wrapV3(routes.GetOrderPriceListFirstGroupMailMappingLookupsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/system/order-price-list-mail-mappings", "GET",
"system", "update",
wrapV3(routes.GetOrderPriceListFirstGroupMailMappingsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/system/order-price-list-mail-mappings/{group}", "PUT",
"system", "update",
wrapV3(routes.SaveOrderPriceListFirstGroupMailMappingHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/system/order-price-list-user-price-groups/lookups", "GET",
"system", "update",
wrapV3(routes.GetOrderPriceListPriceGroupLookupsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/system/order-price-list-user-price-groups", "GET",
"system", "update",
wrapV3(routes.GetOrderPriceListUserPriceGroupRowsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/system/order-price-list-user-price-groups/{id}", "PUT",
"system", "update",
wrapV3(routes.SaveUserOrderPriceListPriceGroupsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/language/translations", "GET",
"language", "update",
@@ -424,6 +454,21 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"system", "update",
wrapV3(routes.SaveUserPermissionsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/users/{id}/order-price-list-price-groups", "GET",
"user", "update",
wrapV3(routes.GetUserOrderPriceListPriceGroupsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/users/order-price-list-price-groups/lookups", "GET",
"user", "update",
wrapV3(routes.GetOrderPriceListPriceGroupLookupsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/users/{id}/order-price-list-price-groups", "PUT",
"user", "update",
wrapV3(routes.SaveUserOrderPriceListPriceGroupsHandler(pgDB)),
)
// ✅ permissions/routes (system:view)
bindV3(r, pgDB,
@@ -795,6 +840,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"order", "view",
wrapV3(http.HandlerFunc(routes.GetProductPricingFilterOptionsHandler)),
)
bindV3(r, pgDB,
"/api/order/price-list/my-price-groups", "GET",
"order", "view",
wrapV3(routes.GetMyOrderPriceListPriceGroupsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/order/price-list/campaigns", "GET",
"order", "view",
@@ -815,6 +865,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"order", "view",
wrapV3(http.HandlerFunc(routes.ExportProductPriceListPDFHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/order/price-list/export-notify", "POST",
"order", "view",
wrapV3(http.HandlerFunc(routes.NotifyOrderPriceListExportHandler(pgDB, ml))),
)
bindV3(r, pgDB,
"/api/product-size-match/rules", "GET",
"order", "view",

View File

@@ -18,10 +18,11 @@ type UserDetail struct {
HasPassword bool `json:"has_password"` // 🔐 SADECE DURUM
// ===== İLİŞKİLER =====
Roles []string `json:"roles"`
Departments []DeptOption `json:"departments"`
Piyasalar []DeptOption `json:"piyasalar"`
NebimUsers []NebimOption `json:"nebim_users"`
Roles []string `json:"roles"`
Departments []DeptOption `json:"departments"`
Piyasalar []DeptOption `json:"piyasalar"`
NebimUsers []NebimOption `json:"nebim_users"`
OrderPriceListPriceGroups []string `json:"order_price_list_price_groups"`
}
// ======================================================
@@ -35,10 +36,11 @@ type UserWrite struct {
Mobile string `json:"mobile"`
Address string `json:"address"`
Roles []string `json:"roles"`
Departments []DeptOption `json:"departments"`
Piyasalar []DeptOption `json:"piyasalar"`
NebimUsers []NebimOption `json:"nebim_users"`
Roles []string `json:"roles"`
Departments []DeptOption `json:"departments"`
Piyasalar []DeptOption `json:"piyasalar"`
NebimUsers []NebimOption `json:"nebim_users"`
OrderPriceListPriceGroups []string `json:"order_price_list_price_groups"`
}
// ======================================================

View File

@@ -45,3 +45,26 @@ const InsertPricingFirstGroupMailMapping = `
INSERT INTO mk_pricing_first_group_mail (urun_ilk_grubu, mail_id)
VALUES ($1, $2)
`
const GetOrderPriceListFirstGroupMailMappingRows = `
SELECT
f.urun_ilk_grubu,
m.id::text,
m.email,
COALESCE(NULLIF(m.display_name, ''), m.email) AS display_name
FROM mk_order_price_list_first_group_mail f
JOIN mk_mail m
ON m.id = f.mail_id
AND m.is_active = true
ORDER BY f.urun_ilk_grubu, m.email
`
const DeleteOrderPriceListFirstGroupMailsByGroup = `
DELETE FROM mk_order_price_list_first_group_mail
WHERE urun_ilk_grubu = $1
`
const InsertOrderPriceListFirstGroupMailMapping = `
INSERT INTO mk_order_price_list_first_group_mail (urun_ilk_grubu, mail_id)
VALUES ($1, $2)
`

View File

@@ -1,25 +1,26 @@
package queries
// GetProductVariantDimsForPricing:
// Pull variant dimension combos from Nebim stock tables (same source as product-stock-query UI).
// Pull variant dimension combos from Nebim's variant master table.
// We intentionally keep it small: only the keys we need to write dim-aware prices into PG sdprc.
//
// Note: Column semantics depend on your Nebim setup. We treat ItemDim1Code/ItemDim3Code as the
// primary variant dimensions used by the e-commerce sdprc dim filters.
// Note: Column semantics depend on your Nebim setup. Here ColorCode and ItemDim2Code are the
// user-visible/key dimensions; PG stores them as mmitem_dim.val1 and mmitem_dim.val3.
// ItemDim1Code is kept only as size context for mmitem_dim seeding.
const GetProductVariantDimsForPricing = `
DECLARE @ProductCode NVARCHAR(50) = @p1;
SELECT DISTINCT
LTRIM(RTRIM(ISNULL(S.ColorCode,''))) AS ColorCode,
LTRIM(RTRIM(ISNULL(S.ItemDim1Code,''))) AS ItemDim1Code,
LTRIM(RTRIM(ISNULL(S.ItemDim3Code,''))) AS ItemDim3Code
FROM trStock S WITH(NOLOCK)
WHERE S.ItemTypeCode = 1
AND S.ItemCode = @ProductCode
AND LEN(S.ItemCode) = 13
LTRIM(RTRIM(ISNULL(V.ColorCode,''))) AS ColorCode,
LTRIM(RTRIM(ISNULL(V.ItemDim1Code,''))) AS ItemDim1Code,
LTRIM(RTRIM(ISNULL(V.ItemDim2Code,''))) AS ItemDim3Code
FROM prItemVariant V WITH(NOLOCK)
WHERE V.ItemTypeCode = 1
AND V.ItemCode = @ProductCode
AND LEN(V.ItemCode) = 13
AND LEN(@ProductCode) = 13
ORDER BY
LTRIM(RTRIM(ISNULL(S.ColorCode,''))),
LTRIM(RTRIM(ISNULL(S.ItemDim1Code,''))),
LTRIM(RTRIM(ISNULL(S.ItemDim3Code,'')));
LTRIM(RTRIM(ISNULL(V.ColorCode,''))),
LTRIM(RTRIM(ISNULL(V.ItemDim1Code,''))),
LTRIM(RTRIM(ISNULL(V.ItemDim2Code,'')));
`

View File

@@ -1,8 +1,9 @@
package queries
// GetWholesaleCampaignVariantStockByProducts:
// Returns per-product variant keys (ColorCode/ItemDim1Code/ItemDim3Code) and available stock qty.
// Returns per-product variant keys (ColorCode/ItemDim1Code/ItemDim2Code-as-ItemDim3Code) and available stock qty.
// We aggregate across warehouses/stores; semantics align with product-stock-query's "Kullanilabilir_Envanter".
// In this Nebim setup, the second user-visible/key variant maps to ItemDim2Code, while PG stores it in mmitem_dim.val3.
const GetWholesaleCampaignVariantStockByProducts = `
DECLARE @Codes NVARCHAR(MAX) = @p1;
@@ -16,11 +17,50 @@ DECLARE @Codes NVARCHAR(MAX) = @p1;
CROSS APPLY D.XmlData.nodes('/i') AS X(C)
WHERE LTRIM(RTRIM(X.C.value('.', 'NVARCHAR(50)'))) <> ''
),
VARIANT_MASTER AS (
SELECT
V.ItemCode,
LTRIM(RTRIM(ISNULL(V.ColorCode,''))) AS ColorCode,
LTRIM(RTRIM(ISNULL(V.ItemDim2Code,''))) AS ItemDim3Code,
MAX(LTRIM(RTRIM(ISNULL(V.ItemDim1Code,'')))) AS ItemDim1Code
FROM prItemVariant V WITH(NOLOCK)
JOIN INP ON INP.ItemCode = V.ItemCode
WHERE V.ItemTypeCode = 1
AND LEN(V.ItemCode) = 13
GROUP BY
V.ItemCode, V.ColorCode, V.ItemDim2Code
),
VARIANT_STOCK AS (
SELECT
S.ItemCode,
LTRIM(RTRIM(ISNULL(S.ColorCode,''))) AS ColorCode,
LTRIM(RTRIM(ISNULL(S.ItemDim2Code,''))) AS ItemDim3Code,
MAX(LTRIM(RTRIM(ISNULL(S.ItemDim1Code,'')))) AS ItemDim1Code
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.ItemDim2Code
),
VARIANT AS (
SELECT
X.ItemCode,
X.ColorCode,
X.ItemDim3Code,
MAX(X.ItemDim1Code) AS ItemDim1Code
FROM (
SELECT ItemCode, ColorCode, ItemDim3Code, ItemDim1Code FROM VARIANT_MASTER
UNION ALL
SELECT ItemCode, ColorCode, ItemDim3Code, ItemDim1Code FROM VARIANT_STOCK
) X
GROUP BY X.ItemCode, X.ColorCode, X.ItemDim3Code
),
STOCK AS (
SELECT
S.ItemCode,
LTRIM(RTRIM(ISNULL(S.ColorCode,''))) AS ColorCode,
LTRIM(RTRIM(ISNULL(S.ItemDim3Code,''))) AS ItemDim3Code,
LTRIM(RTRIM(ISNULL(S.ItemDim2Code,''))) AS ItemDim3Code,
MAX(LTRIM(RTRIM(ISNULL(S.ItemDim1Code,'')))) AS ItemDim1Code,
SUM(S.In_Qty1 - S.Out_Qty1) AS InventoryQty1
FROM trStock S WITH(NOLOCK)
@@ -28,13 +68,13 @@ STOCK AS (
WHERE S.ItemTypeCode = 1
AND LEN(S.ItemCode) = 13
GROUP BY
S.ItemCode, S.ColorCode, S.ItemDim3Code
S.ItemCode, S.ColorCode, S.ItemDim2Code
),
PICK AS (
SELECT
P.ItemCode,
LTRIM(RTRIM(ISNULL(P.ColorCode,''))) AS ColorCode,
LTRIM(RTRIM(ISNULL(P.ItemDim3Code,''))) AS ItemDim3Code,
LTRIM(RTRIM(ISNULL(P.ItemDim2Code,''))) AS ItemDim3Code,
MAX(LTRIM(RTRIM(ISNULL(P.ItemDim1Code,'')))) AS ItemDim1Code,
SUM(P.Qty1) AS PickingQty1
FROM PickingStates P
@@ -42,13 +82,13 @@ PICK AS (
WHERE P.ItemTypeCode = 1
AND LEN(P.ItemCode) = 13
GROUP BY
P.ItemCode, P.ColorCode, P.ItemDim3Code
P.ItemCode, P.ColorCode, P.ItemDim2Code
),
RESERVE AS (
SELECT
R.ItemCode,
LTRIM(RTRIM(ISNULL(R.ColorCode,''))) AS ColorCode,
LTRIM(RTRIM(ISNULL(R.ItemDim3Code,''))) AS ItemDim3Code,
LTRIM(RTRIM(ISNULL(R.ItemDim2Code,''))) AS ItemDim3Code,
MAX(LTRIM(RTRIM(ISNULL(R.ItemDim1Code,'')))) AS ItemDim1Code,
SUM(R.Qty1) AS ReserveQty1
FROM ReserveStates R
@@ -56,13 +96,13 @@ RESERVE AS (
WHERE R.ItemTypeCode = 1
AND LEN(R.ItemCode) = 13
GROUP BY
R.ItemCode, R.ColorCode, R.ItemDim3Code
R.ItemCode, R.ColorCode, R.ItemDim2Code
),
DISP AS (
SELECT
D.ItemCode,
LTRIM(RTRIM(ISNULL(D.ColorCode,''))) AS ColorCode,
LTRIM(RTRIM(ISNULL(D.ItemDim3Code,''))) AS ItemDim3Code,
LTRIM(RTRIM(ISNULL(D.ItemDim2Code,''))) AS ItemDim3Code,
MAX(LTRIM(RTRIM(ISNULL(D.ItemDim1Code,'')))) AS ItemDim1Code,
SUM(D.Qty1) AS DispOrderQty1
FROM DispOrderStates D
@@ -70,30 +110,28 @@ DISP AS (
WHERE D.ItemTypeCode = 1
AND LEN(D.ItemCode) = 13
GROUP BY
D.ItemCode, D.ColorCode, D.ItemDim3Code
D.ItemCode, D.ColorCode, D.ItemDim2Code
)
SELECT
S.ItemCode AS ItemCode,
S.ColorCode AS ColorCode,
S.ItemDim1Code AS ItemDim1Code,
S.ItemDim3Code AS ItemDim3Code,
V.ItemCode AS ItemCode,
V.ColorCode AS ColorCode,
V.ItemDim1Code AS ItemDim1Code,
V.ItemDim3Code AS ItemDim3Code,
CAST(ROUND(
S.InventoryQty1
ISNULL(S.InventoryQty1,0)
- ISNULL(PK.PickingQty1,0)
- ISNULL(RS.ReserveQty1,0)
- ISNULL(DP.DispOrderQty1,0),
2
) AS FLOAT) AS StockQty
FROM STOCK S
FROM VARIANT V
LEFT JOIN STOCK S
ON S.ItemCode=V.ItemCode AND S.ColorCode=V.ColorCode AND S.ItemDim3Code=V.ItemDim3Code
LEFT JOIN PICK PK
ON PK.ItemCode=S.ItemCode AND PK.ColorCode=S.ColorCode AND PK.ItemDim3Code=S.ItemDim3Code
ON PK.ItemCode=V.ItemCode AND PK.ColorCode=V.ColorCode AND PK.ItemDim3Code=V.ItemDim3Code
LEFT JOIN RESERVE RS
ON RS.ItemCode=S.ItemCode AND RS.ColorCode=S.ColorCode AND RS.ItemDim3Code=S.ItemDim3Code
ON RS.ItemCode=V.ItemCode AND RS.ColorCode=V.ColorCode AND RS.ItemDim3Code=V.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;
ON DP.ItemCode=V.ItemCode AND DP.ColorCode=V.ColorCode AND DP.ItemDim3Code=V.ItemDim3Code
ORDER BY V.ItemCode, V.ColorCode, V.ItemDim3Code;
`

View File

@@ -48,6 +48,15 @@ CREATE TABLE IF NOT EXISTS mk_pricing_first_group_mail (
)
`,
`CREATE INDEX IF NOT EXISTS ix_pricing_first_group_mail_group ON mk_pricing_first_group_mail (urun_ilk_grubu)`,
`
CREATE TABLE IF NOT EXISTS mk_order_price_list_first_group_mail (
urun_ilk_grubu TEXT NOT NULL,
mail_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (urun_ilk_grubu, mail_id)
)
`,
`CREATE INDEX IF NOT EXISTS ix_order_price_list_first_group_mail_group ON mk_order_price_list_first_group_mail (urun_ilk_grubu)`,
}
for _, s := range stmts {
@@ -68,7 +77,7 @@ func GetCostingFirstGroupMailMappingLookupsHandler(pg *sql.DB) http.HandlerFunc
return
}
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
http.Error(w, "mapping table bootstrap error", http.StatusInternalServerError)
http.Error(w, "mapping table bootstrap error: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -130,6 +139,11 @@ func GetPricingFirstGroupMailMappingLookupsHandler(pg *sql.DB) http.HandlerFunc
return GetCostingFirstGroupMailMappingLookupsHandler(pg)
}
func GetOrderPriceListFirstGroupMailMappingLookupsHandler(pg *sql.DB) http.HandlerFunc {
// same lookups as costing/pricing
return GetCostingFirstGroupMailMappingLookupsHandler(pg)
}
func GetCostingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -140,7 +154,7 @@ func GetCostingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
return
}
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
http.Error(w, "mapping table bootstrap error", http.StatusInternalServerError)
http.Error(w, "mapping table bootstrap error: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -263,7 +277,7 @@ func GetPricingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
return
}
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
http.Error(w, "mapping table bootstrap error", http.StatusInternalServerError)
http.Error(w, "mapping table bootstrap error: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -492,3 +506,195 @@ func SavePricingFirstGroupMailMappingHandler(pg *sql.DB) http.HandlerFunc {
})
}
}
func GetOrderPriceListFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc {
return getFirstGroupMailMappingsByQuery(pg, queries.GetOrderPriceListFirstGroupMailMappingRows)
}
func SaveOrderPriceListFirstGroupMailMappingHandler(pg *sql.DB) http.HandlerFunc {
return saveFirstGroupMailMappingByQueries(
pg,
queries.DeleteOrderPriceListFirstGroupMailsByGroup,
queries.InsertOrderPriceListFirstGroupMailMapping,
)
}
func getFirstGroupMailMappingsByQuery(pg *sql.DB, mappingQuery string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
mssql := db.GetDB()
if mssql == nil {
http.Error(w, "mssql connection not available", http.StatusServiceUnavailable)
return
}
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
http.Error(w, "mapping table bootstrap error: "+err.Error(), http.StatusInternalServerError)
return
}
traceID := utils.TraceIDFromRequest(r)
ctx := utils.ContextWithTraceID(r.Context(), traceID)
allCodes := make([]string, 0, 512)
titleByCode := make(map[string]string, 512)
fgRows, err := queries.ListProductFirstGroupCodeDescOptions(ctx, mssql, "", 5000)
if err != nil {
http.Error(w, "first group lookup error", http.StatusInternalServerError)
return
}
defer fgRows.Close()
for fgRows.Next() {
var code string
var title string
if err := fgRows.Scan(&code, &title); err != nil {
http.Error(w, "first group scan error", http.StatusInternalServerError)
return
}
code = strings.TrimSpace(code)
title = strings.TrimSpace(title)
if code != "" {
allCodes = append(allCodes, code)
if _, ok := titleByCode[code]; !ok {
titleByCode[code] = title
}
}
}
if err := fgRows.Err(); err != nil {
http.Error(w, "first group rows error", http.StatusInternalServerError)
return
}
allCodes = normalizeIDList(allCodes)
rows, err := pg.Query(mappingQuery)
if err != nil {
http.Error(w, "mapping query error", http.StatusInternalServerError)
return
}
defer rows.Close()
byGroup := map[string]*models.FirstGroupMailMappingRow{}
for _, code := range allCodes {
byGroup[code] = &models.FirstGroupMailMappingRow{
UrunIlkGrubu: code,
GroupCode: code,
GroupTitle: titleByCode[code],
MailIDs: make([]string, 0, 8),
Mails: make([]models.FirstGroupMailOption, 0, 8),
}
}
for rows.Next() {
var group sql.NullString
var mailID sql.NullString
var email sql.NullString
var displayName sql.NullString
if err := rows.Scan(&group, &mailID, &email, &displayName); err != nil {
http.Error(w, "mapping scan error", http.StatusInternalServerError)
return
}
code := strings.TrimSpace(group.String)
if code == "" {
continue
}
row, ok := byGroup[code]
if !ok {
row = &models.FirstGroupMailMappingRow{
UrunIlkGrubu: code,
GroupCode: code,
GroupTitle: titleByCode[code],
MailIDs: make([]string, 0, 8),
Mails: make([]models.FirstGroupMailOption, 0, 8),
}
byGroup[code] = row
allCodes = append(allCodes, code)
}
if mailID.Valid && strings.TrimSpace(mailID.String) != "" {
id := strings.TrimSpace(mailID.String)
row.MailIDs = append(row.MailIDs, id)
label := strings.TrimSpace(displayName.String)
if label == "" {
label = strings.TrimSpace(email.String)
}
row.Mails = append(row.Mails, models.FirstGroupMailOption{ID: id, Label: label})
}
}
if err := rows.Err(); err != nil {
http.Error(w, "mapping rows error", http.StatusInternalServerError)
return
}
allCodes = normalizeIDList(allCodes)
out := make([]models.FirstGroupMailMappingRow, 0, len(allCodes))
for _, code := range allCodes {
if r := byGroup[code]; r != nil {
r.MailIDs = normalizeIDList(r.MailIDs)
if strings.TrimSpace(r.GroupTitle) == "" {
r.GroupTitle = titleByCode[code]
}
out = append(out, *r)
}
}
_ = json.NewEncoder(w).Encode(out)
}
}
func saveFirstGroupMailMappingByQueries(pg *sql.DB, deleteQuery string, insertQuery string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
group := strings.TrimSpace(mux.Vars(r)["group"])
if group == "" {
http.Error(w, "invalid urun_ilk_grubu", http.StatusBadRequest)
return
}
var payload FirstGroupMailSavePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
mailIDs := normalizeIDList(payload.MailIDs)
for _, mailID := range mailIDs {
var mailExists bool
if err := pg.QueryRow(queries.ExistsActiveMailByID, mailID).Scan(&mailExists); err != nil {
http.Error(w, "mail validate error", http.StatusInternalServerError)
return
}
if !mailExists {
http.Error(w, "mail not found: "+mailID, http.StatusBadRequest)
return
}
}
tx, err := pg.Begin()
if err != nil {
http.Error(w, "transaction start error", http.StatusInternalServerError)
return
}
defer tx.Rollback()
if _, err := tx.Exec(deleteQuery, group); err != nil {
http.Error(w, "mapping delete error", http.StatusInternalServerError)
return
}
for _, mailID := range mailIDs {
if _, err := tx.Exec(insertQuery, group, mailID); err != nil {
http.Error(w, "mapping insert error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(); err != nil {
http.Error(w, "transaction commit error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"urun_ilk_grubu": group,
"mail_ids": mailIDs,
})
}
}

View File

@@ -555,6 +555,17 @@ func UserCreateRoute(db *sql.DB) http.HandlerFunc {
}
}
if err := ensureOrderPriceListUserPriceGroupSchema(db); err != nil {
log.Printf("USER CREATE PRICE GROUP SCHEMA ERROR user_id=%d err=%v", newID, err)
http.Error(w, "Fiyat grubu tablosu hazirlanamadi", http.StatusInternalServerError)
return
}
if err := saveOrderPriceListUserPriceGroupsTx(tx, newID, payload.OrderPriceListPriceGroups); err != nil {
log.Printf("USER CREATE PRICE GROUP SAVE ERROR user_id=%d err=%v", newID, err)
http.Error(w, "Fiyat gruplari eklenemedi", http.StatusInternalServerError)
return
}
if err := tx.Commit(); err != nil {
if pe, ok := err.(*pq.Error); ok {
log.Printf(

View File

@@ -0,0 +1,192 @@
package routes
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"html"
"log"
"net/http"
"strings"
"time"
"bssapp-backend/auth"
"bssapp-backend/internal/mailer"
"github.com/lib/pq"
)
type orderPriceListExportNotifyRequest struct {
Format string `json:"format"`
RowCount int `json:"row_count"`
PriceFields []string `json:"price_fields"`
ProductCodes []string `json:"product_codes"`
CampaignLabels []string `json:"campaign_labels"`
FirstGroups []string `json:"first_groups"`
UrunIlkGrubu string `json:"urun_ilk_grubu"`
UrunAnaGrubu string `json:"urun_ana_grubu"`
}
func NotifyOrderPriceListExportHandler(pg *sql.DB, ml *mailer.GraphMailer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if ml == nil {
http.Error(w, "mailer unavailable", http.StatusServiceUnavailable)
return
}
if err := ensureFirstGroupMailMappingTables(pg); err != nil {
http.Error(w, "mapping table bootstrap error", http.StatusInternalServerError)
return
}
var req orderPriceListExportNotifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
groups := normalizeExportNotifyList(req.FirstGroups, 100)
if len(groups) == 0 {
groups = normalizeExportNotifyList([]string{req.UrunIlkGrubu}, 100)
}
recipients, err := loadOrderPriceListRecipients(pg, groups)
if err != nil {
http.Error(w, "recipient lookup error", http.StatusInternalServerError)
return
}
if len(recipients) == 0 {
log.Printf("[order-price-list-export-notify] no recipients groups=%v", groups)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "sent": false, "reason": "no_recipients"})
return
}
actor := strings.TrimSpace(claims.Username)
if actor == "" {
actor = fmt.Sprintf("user-%d", claims.ID)
}
format := strings.ToUpper(strings.TrimSpace(req.Format))
if format == "" {
format = "CIKTI"
}
now := time.Now()
htmlBody := buildOrderPriceListExportNotifyHTML(req, actor, format, groups, now)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := ml.Send(ctx, mailer.Message{
To: recipients,
Subject: fmt.Sprintf("Fiyat Listesi Ciktisi Alindi | %s | %s", actor, now.Format("02.01.2006 15:04")),
BodyHTML: htmlBody,
}); err != nil {
log.Printf("[order-price-list-export-notify] send failed user=%s format=%s err=%v", actor, format, err)
http.Error(w, "mail send error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "sent": true, "recipient_count": len(recipients)})
}
}
func loadOrderPriceListRecipients(pg *sql.DB, groups []string) ([]string, error) {
groups = normalizeExportNotifyList(groups, 100)
if len(groups) == 0 {
return nil, nil
}
rows, err := pg.Query(`
SELECT DISTINCT TRIM(m.email) AS email
FROM mk_order_price_list_first_group_mail f
JOIN mk_mail m
ON m.id = f.mail_id
WHERE m.is_active = true
AND COALESCE(TRIM(m.email), '') <> ''
AND UPPER(TRIM(f.urun_ilk_grubu)) = ANY($1)
ORDER BY email
`, pq.Array(upperExportNotifyList(groups)))
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, 16)
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, err
}
email = strings.TrimSpace(email)
if email != "" {
out = append(out, email)
}
}
return out, rows.Err()
}
func buildOrderPriceListExportNotifyHTML(req orderPriceListExportNotifyRequest, actor string, format string, groups []string, now time.Time) string {
return fmt.Sprintf(`
<h3>Fiyat Listesi Ciktisi Alindi</h3>
<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;border:1px solid #ddd">
<tr><td><b>Islem Yapan</b></td><td>%s</td></tr>
<tr><td><b>Cikti Tipi</b></td><td>%s</td></tr>
<tr><td><b>Tarih</b></td><td>%s</td></tr>
<tr><td><b>Satir Sayisi</b></td><td>%d</td></tr>
<tr><td><b>Secili Fiyat Gruplari</b></td><td>%s</td></tr>
<tr><td><b>Urun Ilk Gruplari</b></td><td>%s</td></tr>
<tr><td><b>Urun Ana Grubu</b></td><td>%s</td></tr>
<tr><td><b>Urun Kodlari</b></td><td>%s</td></tr>
<tr><td><b>Kampanya Filtreleri</b></td><td>%s</td></tr>
</table>`,
html.EscapeString(actor),
html.EscapeString(format),
html.EscapeString(now.Format("02.01.2006 15:04")),
req.RowCount,
html.EscapeString(strings.Join(normalizeExportNotifyList(req.PriceFields, 24), ", ")),
html.EscapeString(strings.Join(groups, ", ")),
html.EscapeString(orderNotifyDash(req.UrunAnaGrubu)),
html.EscapeString(strings.Join(normalizeExportNotifyList(req.ProductCodes, 50), ", ")),
html.EscapeString(strings.Join(normalizeExportNotifyList(req.CampaignLabels, 20), ", ")),
)
}
func normalizeExportNotifyList(in []string, limit int) []string {
out := make([]string, 0, len(in))
seen := make(map[string]struct{}, len(in))
for _, raw := range in {
v := strings.TrimSpace(raw)
if v == "" || v == "-" {
continue
}
key := strings.ToUpper(v)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, v)
if limit > 0 && len(out) >= limit {
break
}
}
return out
}
func upperExportNotifyList(in []string) []string {
out := make([]string, 0, len(in))
for _, v := range normalizeExportNotifyList(in, 0) {
out = append(out, strings.ToUpper(strings.TrimSpace(v)))
}
return out
}
func orderNotifyDash(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return "-"
}
return v
}

View File

@@ -0,0 +1,278 @@
package routes
import (
"bssapp-backend/auth"
"database/sql"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/lib/pq"
)
type orderPriceListPriceGroupOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
type orderPriceListUserPriceGroupRow struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
FullName string `json:"full_name"`
Email string `json:"email"`
PriceGroups []string `json:"price_groups"`
}
type orderPriceListUserPriceGroupPayload struct {
PriceGroups []string `json:"price_groups"`
}
var orderPriceListAllPriceGroups = []orderPriceListPriceGroupOption{
{Value: "usd1", Label: "USD 1"},
{Value: "usd2", Label: "USD 2"},
{Value: "usd3", Label: "USD 3"},
{Value: "usd4", Label: "USD 4"},
{Value: "usd5", Label: "USD 5"},
{Value: "usd6", Label: "USD 6"},
{Value: "eur1", Label: "EUR 1"},
{Value: "eur2", Label: "EUR 2"},
{Value: "eur3", Label: "EUR 3"},
{Value: "eur4", Label: "EUR 4"},
{Value: "eur5", Label: "EUR 5"},
{Value: "eur6", Label: "EUR 6"},
{Value: "try1", Label: "TRY 1"},
{Value: "try2", Label: "TRY 2"},
{Value: "try3", Label: "TRY 3"},
{Value: "try4", Label: "TRY 4"},
{Value: "try5", Label: "TRY 5"},
{Value: "try6", Label: "TRY 6"},
}
func ensureOrderPriceListUserPriceGroupSchema(db *sql.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS mk_order_price_list_user_price_group (
user_id BIGINT NOT NULL,
price_group TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, price_group)
)`,
`CREATE INDEX IF NOT EXISTS ix_order_price_list_user_price_group_user ON mk_order_price_list_user_price_group (user_id)`,
`ALTER TABLE mk_order_price_list_user_price_group DROP CONSTRAINT IF EXISTS ck_order_price_list_user_price_group`,
`ALTER TABLE mk_order_price_list_user_price_group ADD CONSTRAINT ck_order_price_list_user_price_group CHECK (price_group IN ('usd1','usd2','usd3','usd4','usd5','usd6','eur1','eur2','eur3','eur4','eur5','eur6','try1','try2','try3','try4','try5','try6'))`,
}
for _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
return err
}
}
return nil
}
func normalizeOrderPriceListPriceGroups(groups []string) []string {
allowed := map[string]bool{}
order := []string{}
for _, opt := range orderPriceListAllPriceGroups {
allowed[opt.Value] = true
order = append(order, opt.Value)
}
set := map[string]bool{}
for _, item := range groups {
v := strings.ToLower(strings.TrimSpace(item))
if allowed[v] {
set[v] = true
}
}
out := make([]string, 0, len(set))
for _, v := range order {
if set[v] {
out = append(out, v)
}
}
return out
}
func loadOrderPriceListUserPriceGroups(db *sql.DB, userID int64) ([]string, error) {
if err := ensureOrderPriceListUserPriceGroupSchema(db); err != nil {
return nil, err
}
rows, err := db.Query(`
SELECT price_group
FROM mk_order_price_list_user_price_group
WHERE user_id = $1
ORDER BY
CASE SUBSTRING(price_group, 1, 3)
WHEN 'usd' THEN 1
WHEN 'eur' THEN 2
WHEN 'try' THEN 3
ELSE 9
END,
CAST(SUBSTRING(price_group, 4) AS INT)
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var group string
if err := rows.Scan(&group); err != nil {
return nil, err
}
out = append(out, strings.ToLower(strings.TrimSpace(group)))
}
return normalizeOrderPriceListPriceGroups(out), rows.Err()
}
func saveOrderPriceListUserPriceGroupsTx(tx *sql.Tx, userID int64, groups []string) error {
if _, err := tx.Exec(`DELETE FROM mk_order_price_list_user_price_group WHERE user_id = $1`, userID); err != nil {
return err
}
for _, group := range normalizeOrderPriceListPriceGroups(groups) {
if _, err := tx.Exec(`
INSERT INTO mk_order_price_list_user_price_group (user_id, price_group, created_at, updated_at)
VALUES ($1, $2, $3, $3)
ON CONFLICT (user_id, price_group)
DO UPDATE SET updated_at = EXCLUDED.updated_at
`, userID, group, time.Now()); err != nil {
return err
}
}
return nil
}
func GetOrderPriceListPriceGroupLookupsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := ensureOrderPriceListUserPriceGroupSchema(db); err != nil {
log.Printf("[order-price-list-price-groups] schema error: %v", err)
http.Error(w, "price group schema error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"price_groups": orderPriceListAllPriceGroups})
}
}
func GetMyOrderPriceListPriceGroupsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
groups, err := loadOrderPriceListUserPriceGroups(db, int64(claims.ID))
if err != nil {
log.Printf("[order-price-list-price-groups] my groups error user=%d err=%v", claims.ID, err)
http.Error(w, "price groups lookup error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"price_groups": groups,
"restricted": len(groups) > 0,
"all_groups": orderPriceListAllPriceGroups,
})
}
}
func GetUserOrderPriceListPriceGroupsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
if err != nil || id <= 0 {
http.Error(w, "invalid user id", http.StatusBadRequest)
return
}
groups, err := loadOrderPriceListUserPriceGroups(db, id)
if err != nil {
log.Printf("[order-price-list-price-groups] user groups error user=%d err=%v", id, err)
http.Error(w, "price groups lookup error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"price_groups": groups})
}
}
func SaveUserOrderPriceListPriceGroupsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
if err != nil || id <= 0 {
http.Error(w, "invalid user id", http.StatusBadRequest)
return
}
var payload orderPriceListUserPriceGroupPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if err := ensureOrderPriceListUserPriceGroupSchema(db); err != nil {
http.Error(w, "price group schema error", http.StatusInternalServerError)
return
}
tx, err := db.Begin()
if err != nil {
http.Error(w, "transaction error", http.StatusInternalServerError)
return
}
defer tx.Rollback()
if err := saveOrderPriceListUserPriceGroupsTx(tx, id, payload.PriceGroups); err != nil {
log.Printf("[order-price-list-price-groups] save error user=%d err=%v", id, err)
http.Error(w, "price groups save error", http.StatusInternalServerError)
return
}
if err := tx.Commit(); err != nil {
http.Error(w, "commit error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}
}
func GetOrderPriceListUserPriceGroupRowsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := ensureOrderPriceListUserPriceGroupSchema(db); err != nil {
http.Error(w, "price group schema error", http.StatusInternalServerError)
return
}
rows, err := db.Query(`
SELECT u.id, u.username, COALESCE(u.full_name, ''), COALESCE(u.email, ''),
COALESCE(array_agg(m.price_group ORDER BY
CASE SUBSTRING(m.price_group, 1, 3)
WHEN 'usd' THEN 1
WHEN 'eur' THEN 2
WHEN 'try' THEN 3
ELSE 9
END,
CAST(SUBSTRING(m.price_group, 4) AS INT)
) FILTER (WHERE m.price_group IS NOT NULL), ARRAY[]::text[]) AS price_groups
FROM mk_dfusr u
LEFT JOIN mk_order_price_list_user_price_group m ON m.user_id = u.id
WHERE COALESCE(u.is_active, TRUE) = TRUE
GROUP BY u.id, u.username, u.full_name, u.email
ORDER BY u.username
`)
if err != nil {
log.Printf("[order-price-list-price-groups] rows error: %v", err)
http.Error(w, "price groups rows error", http.StatusInternalServerError)
return
}
defer rows.Close()
out := []orderPriceListUserPriceGroupRow{}
for rows.Next() {
var row orderPriceListUserPriceGroupRow
if err := rows.Scan(&row.UserID, &row.Username, &row.FullName, &row.Email, pq.Array(&row.PriceGroups)); err != nil {
http.Error(w, "price groups scan error", http.StatusInternalServerError)
return
}
row.PriceGroups = normalizeOrderPriceListPriceGroups(row.PriceGroups)
out = append(out, row)
}
_ = json.NewEncoder(w).Encode(out)
}
}

View File

@@ -545,7 +545,7 @@ func csvEscape(value string) string {
}
func csvFloat(value float64) string {
return fmt.Sprintf("%.2f", value)
return strings.ReplaceAll(fmt.Sprintf("%.2f", value), ".", ",")
}
func exportPriceFieldTitle(field string) string {

View File

@@ -384,6 +384,25 @@ WHERE is_active = TRUE
Dim3 sql.NullInt64
}
hasDim3Combo := func(dims []dimCombo) bool {
for _, d := range dims {
if d.Dim3.Valid && d.Dim3.Int64 > 0 {
return true
}
}
return false
}
filterDim3Combos := func(dims []dimCombo) []dimCombo {
out := make([]dimCombo, 0, len(dims))
for _, d := range dims {
if d.Dim3.Valid && d.Dim3.Int64 > 0 {
out = append(out, d)
}
}
return out
}
type sdprcWriteRow struct {
Currency string `json:"currency"`
SdprcGrpID int `json:"sdprcgrp_id"`
@@ -562,28 +581,25 @@ DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
}
// Resolve to PG dim ids. For this installation we align with mmitem_dim key:
// - dim1 = color
// - dim3 = itemdim3 (optional)
// - dim3 = ItemDim2Code/yaka (optional)
// Size (ItemDim1Code) is not part of the key here.
d1 := int64(0)
if id, ok := resolveDimvalFromToken(pgTx, "dimval1", colorCode); ok {
d1 = id
resolvedDim1++
} else if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim3Code); ok {
d1 = id
resolvedDim1++
} else if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim1Code); ok {
d1 = id
resolvedDim1++
}
if d1 <= 0 {
continue
}
var d3 sql.NullInt64
// dim3 corresponds to mmitem_dim.val3 (ItemDim3Code).
if id, ok := resolveDimvalFromToken(pgTx, "dimval1", dim3Code); ok {
// dim3 corresponds to mmitem_dim.val3.
if id, ok := resolveDimvalFromToken(pgTx, "dimval3", dim3Code); ok {
d3 = sql.NullInt64{Int64: id, Valid: true}
resolvedDim3++
}
if strings.TrimSpace(dim3Code) != "" && !d3.Valid {
continue
}
key := fmt.Sprintf("%d|%d", d1, func() int64 {
if d3.Valid {
return d3.Int64
@@ -619,16 +635,13 @@ DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
if c.Dim1 <= 0 {
continue
}
v2 := int64(0)
var v2any any = nil
if c.Dim3.Valid && c.Dim3.Int64 > 0 {
v2 = c.Dim3.Int64
v2any = v2
}
// If we managed to resolve an "ItemDim3Code" id too, store it in val3 and mark mmdim_id=3.
// Active key: val1=color, val3=ItemDim2Code/yaka. val2 is size and is not part of price/campaign key.
v3 := int64(0)
if extraVal3 != nil {
if vv, ok := extraVal3[fmt.Sprintf("%d|%d", c.Dim1, v2)]; ok && vv > 0 {
if c.Dim3.Valid && c.Dim3.Int64 > 0 {
v3 = c.Dim3.Int64
} else if extraVal3 != nil {
if vv, ok := extraVal3[fmt.Sprintf("%d|0", c.Dim1)]; ok && vv > 0 {
v3 = vv
}
}
@@ -687,13 +700,17 @@ WHERE mmitem_id = $1
if !v1.Valid || v1.Int64 <= 0 {
continue
}
// Variant key in this installation: (val1=color, val3=itemdim3_if_any). Ignore val2 (size).
// Variant key in this installation: (val1=color, val3=ItemDim2Code/yaka_if_any). Ignore val2 (size).
d1 := v1.Int64
_ = mmdimID
_ = v2
var d3 sql.NullInt64
if v3.Valid && v3.Int64 > 0 {
if mmdimID.Valid && mmdimID.Int64 == 3 {
if !v3.Valid || v3.Int64 <= 1000 {
continue
}
}
if v3.Valid && v3.Int64 > 1000 {
d3 = sql.NullInt64{Int64: v3.Int64, Valid: true}
}
@@ -1071,18 +1088,41 @@ VALUES (
_ = upsertDimCombosCache(code, dims) // best-effort cache fill
}
// 2) Last resort: MSSQL stock tokens, then seed mmitem_dim. Do not use
// mk_mmitem_dim_combo as a write source; stale cache rows can create wrong keys.
if len(dims) == 0 {
d, err := loadDimsFromMssqlStock(code)
if err != nil {
logger.Error("save:pg:dims:mssql:error", "product_code", code, "err", err)
} else {
dims = d
_ = upsertDimCombosCache(code, dims)
// If PG doesn't have mmitem_dim rows for this product yet, try to seed them.
ensureMMItemDimRows(mmItemID, dims, nil)
// 2) Merge MSSQL variant master combos. PG may be partially populated; missing
// colors/dim3 combos still need to be seeded before sdprc/zbggcampaign writes.
if d, err := loadDimsFromMssqlStock(code); err != nil {
logger.Error("save:pg:dims:mssql:error", "product_code", code, "err", err)
} else if len(d) > 0 {
if hasDim3Combo(d) {
dims = filterDim3Combos(dims)
}
seenDims := make(map[string]struct{}, len(dims)+len(d))
merged := make([]dimCombo, 0, len(dims)+len(d))
dim3Value := func(v sql.NullInt64) int64 {
if v.Valid {
return v.Int64
}
return 0
}
for _, c := range dims {
k := fmt.Sprintf("%d|%d", c.Dim1, dim3Value(c.Dim3))
if _, ok := seenDims[k]; ok {
continue
}
seenDims[k] = struct{}{}
merged = append(merged, c)
}
for _, c := range d {
k := fmt.Sprintf("%d|%d", c.Dim1, dim3Value(c.Dim3))
if _, ok := seenDims[k]; ok {
continue
}
seenDims[k] = struct{}{}
merged = append(merged, c)
}
dims = merged
_ = upsertDimCombosCache(code, dims)
ensureMMItemDimRows(mmItemID, d, nil)
}
}

View File

@@ -184,6 +184,12 @@ func handleUserGet(db *sql.DB, w http.ResponseWriter, userID int64) {
}
}
if groups, err := loadOrderPriceListUserPriceGroups(db, userID); err == nil {
u.OrderPriceListPriceGroups = groups
} else {
log.Printf("WARN [UserDetail] order price list price groups lookup failed user_id=%d err=%v", userID, err)
}
// --------------------------------------------------
// 🟢 RESPONSE
// --------------------------------------------------
@@ -326,6 +332,17 @@ func handleUserUpdate(db *sql.DB, w http.ResponseWriter, r *http.Request, userID
}
}
if err := ensureOrderPriceListUserPriceGroupSchema(db); err != nil {
log.Printf("ERROR [UserDetail] price group schema failed user_id=%d err=%v", userID, err)
http.Error(w, "Fiyat grubu tablosu hazirlanamadi", http.StatusInternalServerError)
return
}
if err := saveOrderPriceListUserPriceGroupsTx(tx, userID, payload.OrderPriceListPriceGroups); err != nil {
log.Printf("ERROR [UserDetail] price groups save failed user_id=%d err=%v", userID, err)
http.Error(w, "Fiyat gruplari guncellenemedi", http.StatusInternalServerError)
return
}
if err := tx.Commit(); err != nil {
log.Printf("❌ [UserDetail] commit failed user_id=%d err=%v", userID, err)
http.Error(w, "Commit başarısız", http.StatusInternalServerError)
@@ -384,6 +401,7 @@ func handleUserDelete(db *sql.DB, w http.ResponseWriter, r *http.Request, userID
`DELETE FROM dfusr_dprt WHERE dfusr_id = $1`,
`DELETE FROM dfusr_piyasa WHERE dfusr_id = $1`,
`DELETE FROM dfusr_nebim_user WHERE dfusr_id = $1`,
`DELETE FROM mk_order_price_list_user_price_group WHERE user_id = $1`,
}
isUndefinedTable := func(err error) bool {

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"log"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -420,6 +421,31 @@ type wholesaleVariantRow struct {
CampaignLast string `json:"campaign_last_dttm"`
}
func buildNebimVariantDisplayCode(colorCode string, dim3Code string) string {
colorCode = strings.TrimSpace(colorCode)
dim3Code = strings.TrimSpace(dim3Code)
if colorCode != "" && dim3Code != "" {
return colorCode + "-" + dim3Code
}
if colorCode != "" {
return colorCode
}
return dim3Code
}
func chooseDisplayDimToken(raw string, resolvedID int64, reverse map[int64]string) string {
raw = strings.TrimSpace(raw)
if raw != "" {
return raw
}
if resolvedID > 0 {
if tok := strings.TrimSpace(reverse[resolvedID]); tok != "" {
return tok
}
}
return raw
}
type wholesaleCampaignHistoryRow struct {
ID int64 `json:"id"`
CampaignID *int64 `json:"campaign_id"`
@@ -736,6 +762,7 @@ DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
ItemID int64
Dim1 int64
Dim3Key int64
HasMSSQL bool
}
// Build base variant keys from PG's authoritative table (mmitem_dim).
@@ -745,7 +772,21 @@ DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
}
tmpMap := make(map[string]tmpRow, 4096)
hasMMItemDim := make(map[int64]bool, len(itemIDs))
dimIDs := make([]int64, 0, 8192)
dim1IDs := make([]int64, 0, 8192)
dim3IDs := make([]int64, 0, 8192)
itemDim1Candidates := make(map[int64][]int64, len(itemIDs))
itemDim3Candidates := make(map[int64][]int64, len(itemIDs))
addCandidate := func(dst map[int64][]int64, itemID int64, id int64) {
if itemID <= 0 || id <= 0 {
return
}
for _, existing := range dst[itemID] {
if existing == id {
return
}
}
dst[itemID] = append(dst[itemID], id)
}
if len(itemIDs) > 0 {
rows, err := pg.QueryContext(ctx, `
SELECT mmitem_id, mmdim_id, val1, val2, val3
@@ -772,13 +813,19 @@ WHERE mmitem_id = ANY($1::bigint[])
if !v1.Valid || v1.Int64 <= 0 {
continue
}
// Variant key in this installation: (val1=color, val3=itemdim3_if_any). Ignore val2 (size).
// Variant key in this installation: (val1=color, val3=ItemDim2Code/yaka_if_any). Ignore val2 (size).
d1 := v1.Int64
_ = mmdimID
_ = v2
addCandidate(itemDim1Candidates, itemID, d1)
d3k := int64(0)
if v3.Valid && v3.Int64 > 0 {
if mmdimID.Valid && mmdimID.Int64 == 3 {
if !v3.Valid || v3.Int64 <= 1000 {
continue
}
}
if v3.Valid && v3.Int64 > 1000 {
d3k = v3.Int64
addCandidate(itemDim3Candidates, itemID, d3k)
}
code := strings.TrimSpace(itemToCode[itemID])
@@ -797,21 +844,26 @@ WHERE mmitem_id = ANY($1::bigint[])
Dim1: d1,
Dim3Key: d3k,
}
dimIDs = append(dimIDs, d1)
dim1IDs = append(dim1IDs, d1)
if d3k > 0 {
dimIDs = append(dimIDs, d3k)
dim3IDs = append(dim3IDs, d3k)
}
}
rows.Close()
}
// Resolve dim ids -> tokens for a readable VariantCode.
idToToken := map[int64]string{}
if len(dimIDs) > 0 {
// Resolve dim ids -> tokens for a fallback readable VariantCode.
// MSSQL/Nebim tokens override this below; PG ids are only storage keys.
idToDim1Token := map[int64]string{}
idToDim3Token := map[int64]string{}
loadReverseTokens := func(column string, ids []int64, out map[int64]string) {
if len(ids) == 0 {
return
}
// uniq
uniq := make([]int64, 0, len(dimIDs))
seen := make(map[int64]struct{}, len(dimIDs))
for _, id := range dimIDs {
uniq := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
@@ -821,35 +873,38 @@ WHERE mmitem_id = ANY($1::bigint[])
seen[id] = struct{}{}
uniq = append(uniq, id)
}
if len(uniq) > 0 {
rows, err := pg.QueryContext(ctx, `
if len(uniq) == 0 {
return
}
rows, err := pg.QueryContext(ctx, `
SELECT DISTINCT ON (dim_id) dim_id, token
FROM mk_dim_token_map
WHERE dim_column = 'dimval1'
AND dim_id = ANY($1::bigint[])
WHERE dim_column = $1
AND dim_id = ANY($2::bigint[])
ORDER BY dim_id, updated_at DESC;
`, pq.Array(uniq))
if err == nil {
for rows.Next() {
var id int64
var tok string
_ = rows.Scan(&id, &tok)
tok = strings.TrimSpace(tok)
if tok != "" {
idToToken[id] = tok
}
`, column, pq.Array(uniq))
if err == nil {
for rows.Next() {
var id int64
var tok string
_ = rows.Scan(&id, &tok)
tok = strings.TrimSpace(tok)
if tok != "" {
out[id] = tok
}
rows.Close()
}
rows.Close()
}
}
loadReverseTokens("dimval1", dim1IDs, idToDim1Token)
loadReverseTokens("dimval3", dim3IDs, idToDim3Token)
for k, v := range tmpMap {
t1 := strings.TrimSpace(idToToken[v.Dim1])
t1 := strings.TrimSpace(idToDim1Token[v.Dim1])
if t1 == "" {
t1 = fmt.Sprintf("%d", v.Dim1)
}
if v.Dim3Key > 0 {
t3 := strings.TrimSpace(idToToken[v.Dim3Key])
t3 := strings.TrimSpace(idToDim3Token[v.Dim3Key])
if t3 == "" {
t3 = fmt.Sprintf("%d", v.Dim3Key)
}
@@ -859,6 +914,120 @@ ORDER BY dim_id, updated_at DESC;
}
tmpMap[k] = v
}
canonicalToken := func(column string, id int64) string {
if id <= 0 {
return ""
}
target := idToDim1Token
if column == "dimval3" {
target = idToDim3Token
}
if tok := strings.TrimSpace(target[id]); tok != "" {
return tok
}
var tok string
if err := pg.QueryRowContext(ctx, `
SELECT token
FROM mk_dim_token_map
WHERE dim_column = $1
AND dim_id = $2
ORDER BY
CASE WHEN token ~ '^[0-9]{3}$' THEN 0 ELSE 1 END,
length(token),
updated_at DESC
LIMIT 1
`, column, id).Scan(&tok); err == nil {
tok = strings.TrimSpace(tok)
if tok != "" {
target[id] = tok
return tok
}
}
return ""
}
sortDimIDs := func(ids []int64) []int64 {
out := append([]int64(nil), ids...)
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
sortTokens := func(tokens []string) []string {
out := append([]string(nil), tokens...)
sort.Slice(out, func(i, j int) bool {
li := strings.TrimLeft(out[i], "0")
lj := strings.TrimLeft(out[j], "0")
if li == "" {
li = "0"
}
if lj == "" {
lj = "0"
}
ni, ei := strconv.ParseInt(li, 10, 64)
nj, ej := strconv.ParseInt(lj, 10, 64)
if ei == nil && ej == nil && ni != nj {
return ni < nj
}
return out[i] < out[j]
})
return out
}
addToken := func(dst map[int64][]string, itemID int64, token string) {
token = strings.ToUpper(normalizeDimParam(token))
if itemID <= 0 || token == "" {
return
}
for _, existing := range dst[itemID] {
if existing == token {
return
}
}
dst[itemID] = append(dst[itemID], token)
}
buildInferredMap := func(column string, tokenByItem map[int64][]string, idsByItem map[int64][]int64) map[string]int64 {
out := make(map[string]int64, 128)
for itemID, tokens := range tokenByItem {
sortedTokens := sortTokens(tokens)
sortedIDs := sortDimIDs(idsByItem[itemID])
if len(sortedTokens) == 0 || len(sortedIDs) == 0 {
continue
}
limit := len(sortedTokens)
if len(sortedIDs) < limit {
limit = len(sortedIDs)
}
for i := 0; i < limit; i++ {
token := sortedTokens[i]
id := sortedIDs[i]
if token == "" || id <= 0 {
continue
}
key := fmt.Sprintf("%s|%d|%s", column, itemID, token)
if _, exists := out[key]; !exists {
out[key] = id
}
}
}
return out
}
persistDimToken := func(column string, token string, id int64) {
token = strings.ToUpper(normalizeDimParam(token))
if column == "" || token == "" || id <= 0 {
return
}
_, _ = 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)
}
type msVariantRow struct {
ItemCode string
ColorCode string
Dim1Code string
Dim3Code string
Qty sql.NullFloat64
}
// MSSQL: stock list for selected products; map to (mmitem_id, dim1, dim3_key) via token->id mapping.
joined := strings.Join(codes, ",")
@@ -867,11 +1036,15 @@ ORDER BY dim_id, updated_at DESC;
http.Error(w, "variant stock query error: "+err.Error(), http.StatusInternalServerError)
return
}
defer msRows.Close()
msVariants := make([]msVariantRow, 0, 1024)
colorTokensByItem := make(map[int64][]string, len(itemIDs))
dim3TokensByItem := make(map[int64][]string, len(itemIDs))
itemHasMssqlDim3 := make(map[int64]bool, len(itemIDs))
for msRows.Next() {
var itemCode, colorCode, dim1Code, dim3Code string
var qty sql.NullFloat64
if err := msRows.Scan(&itemCode, &colorCode, &dim1Code, &dim3Code, &qty); err != nil {
msRows.Close()
http.Error(w, "variant stock scan error", http.StatusInternalServerError)
return
}
@@ -883,40 +1056,95 @@ ORDER BY dim_id, updated_at DESC;
if itemID <= 0 {
continue
}
msVariants = append(msVariants, msVariantRow{
ItemCode: itemCode,
ColorCode: colorCode,
Dim1Code: dim1Code,
Dim3Code: dim3Code,
Qty: qty,
})
addToken(colorTokensByItem, itemID, colorCode)
addToken(dim3TokensByItem, itemID, dim3Code)
if strings.TrimSpace(dim3Code) != "" {
itemHasMssqlDim3[itemID] = true
}
}
if err := msRows.Err(); err != nil {
msRows.Close()
http.Error(w, "variant stock rows error", http.StatusInternalServerError)
return
}
msRows.Close()
// Map Nebim tokens to PG integer ids (dimval1 namespace).
// This app uses key: dim1=<color>, dim3=<itemdim3> to match mmitem_dim (val1,val3).
inferredDim1 := buildInferredMap("dimval1", colorTokensByItem, itemDim1Candidates)
inferredDim3 := buildInferredMap("dimval3", dim3TokensByItem, itemDim3Candidates)
for key, row := range tmpMap {
if itemHasMssqlDim3[row.ItemID] && row.Dim3Key == 0 {
delete(tmpMap, key)
}
}
resolveProductDimID := func(itemID int64, column string, token string, inferred map[string]int64) (int64, bool) {
token = strings.ToUpper(normalizeDimParam(token))
if token == "" {
return 0, false
}
if id := inferred[fmt.Sprintf("%s|%d|%s", column, itemID, token)]; id > 0 {
persistDimToken(column, token, id)
return id, true
}
if id, ok := resolveDimID(column, token); ok {
return id, true
}
return 0, false
}
for _, ms := range msVariants {
itemCode := ms.ItemCode
colorCode := ms.ColorCode
dim1Code := ms.Dim1Code
dim3Code := ms.Dim3Code
qty := ms.Qty
itemID := codeToItem[itemCode]
if itemID <= 0 {
continue
}
// Map Nebim tokens to PG integer ids. Color and yaka must use separate token namespaces,
// because the same visible token (for example "001") can exist in both dimensions.
d1 := int64(0)
if id, ok := resolveDimID("dimval1", colorCode); ok {
if id, ok := resolveProductDimID(itemID, "dimval1", colorCode, inferredDim1); ok {
d1 = id
}
if d1 <= 0 {
continue
}
d3k := int64(0)
if id, ok := resolveDimID("dimval1", dim3Code); ok {
if id, ok := resolveProductDimID(itemID, "dimval3", dim3Code, inferredDim3); ok {
d3k = id
}
if strings.TrimSpace(dim3Code) != "" && d3k <= 0 {
continue
}
key := fmt.Sprintf("%d|%d|%d", itemID, d1, d3k)
prev, ok := tmpMap[key]
if !ok {
// If PG does not have mmitem_dim rows for this item yet, seed it from MSSQL and include it.
if !hasMMItemDim[itemID] {
var v2 any = nil
if d3k > 0 {
v2 = d3k
}
v3 := int64(0)
if id, ok := resolveDimID("dimval1", dim3Code); ok {
v3 = id
}
mmdimID := int64(2)
var v3any any = nil
if v3 > 0 {
mmdimID = 3
v3any = v3
}
_, _ = pg.ExecContext(ctx, `
// Seed missing MSSQL combos even when the product already has some mmitem_dim rows.
// PG remains the storage key source, but MSSQL may reveal new/missing color or dim3 combos.
var v2 any = nil
if sizeID, ok := resolveDimID("dimval1", dim1Code); ok && sizeID > 0 {
v2 = sizeID
}
v3 := int64(0)
if id, ok := resolveProductDimID(itemID, "dimval3", dim3Code, inferredDim3); ok {
v3 = id
}
mmdimID := int64(2)
var v3any any = nil
if v3 > 0 {
mmdimID = 3
v3any = v3
}
_, _ = pg.ExecContext(ctx, `
INSERT INTO mmitem_dim (mmitem_id, mmdim_id, val1, val2, val3, is_active, qty)
SELECT $1, $2, $3, $4, $5, TRUE, 0
WHERE NOT EXISTS (
@@ -930,26 +1158,25 @@ WHERE NOT EXISTS (
LIMIT 1
);
`, itemID, mmdimID, d1, v2, v3any)
hasMMItemDim[itemID] = true
hasMMItemDim[itemID] = true
code := strings.TrimSpace(itemToCode[itemID])
if code != "" {
tmpMap[key] = tmpRow{
ProductCode: code,
VariantCode: "",
StockQty: 0,
ItemID: itemID,
Dim1: d1,
Dim3Key: d3k,
}
// Keep dim token cache for VariantCode formatting.
dimIDs = append(dimIDs, d1)
if d3k > 0 {
dimIDs = append(dimIDs, d3k)
}
prev = tmpMap[key]
ok = true
code := strings.TrimSpace(itemToCode[itemID])
if code != "" {
tmpMap[key] = tmpRow{
ProductCode: code,
VariantCode: "",
StockQty: 0,
ItemID: itemID,
Dim1: d1,
Dim3Key: d3k,
}
// Keep dim token cache for VariantCode formatting.
dim1IDs = append(dim1IDs, d1)
if d3k > 0 {
dim3IDs = append(dim3IDs, d3k)
}
prev = tmpMap[key]
ok = true
}
if !ok {
continue
@@ -960,6 +1187,10 @@ WHERE NOT EXISTS (
q = qty.Float64
}
prev.StockQty += q
displayColor := chooseDisplayDimToken(colorCode, d1, map[int64]string{d1: canonicalToken("dimval1", d1)})
displayDim3 := chooseDisplayDimToken(dim3Code, d3k, map[int64]string{d3k: canonicalToken("dimval3", d3k)})
prev.VariantCode = buildNebimVariantDisplayCode(displayColor, displayDim3)
prev.HasMSSQL = true
tmpMap[key] = prev
_ = colorCode // display-only
}
@@ -968,6 +1199,15 @@ WHERE NOT EXISTS (
for _, v := range tmpMap {
tmp = append(tmp, v)
}
sort.SliceStable(tmp, func(i, j int) bool {
if tmp[i].ProductCode != tmp[j].ProductCode {
return tmp[i].ProductCode < tmp[j].ProductCode
}
if tmp[i].HasMSSQL != tmp[j].HasMSSQL {
return tmp[i].HasMSSQL
}
return strings.TrimSpace(tmp[i].VariantCode) < strings.TrimSpace(tmp[j].VariantCode)
})
// Bulk load campaign assignment for each (mmitem_id, dim1, dim3_key)
type keyRec struct {

View File

@@ -413,6 +413,16 @@ const menuItems = [
label: 'Fiyatlandırma Mail Eşleştirme',
to: '/app/pricing-mail-mapping',
permission: 'system:update'
},
{
label: 'Fiyat Listesi Mail Eşleştirme',
to: '/app/order-price-list-mail-mapping',
permission: 'system:update'
},
{
label: 'Kullanıcı Fiyat Eşleştirme',
to: '/app/order-price-list-user-price-groups',
permission: 'system:update'
}
]

View File

@@ -70,67 +70,80 @@
</div>
<div class="row items-center q-gutter-xs top-actions-row top-actions-row--actions">
<q-btn-dropdown dense color="secondary" outline icon="view_module" label="Fiyat Secimi" :auto-close="false" :disable="pageBusy">
<q-list dense class="currency-menu-list">
<q-item clickable @click="selectAllPrices">
<q-item-section>Tumunu Sec</q-item-section>
</q-item>
<q-item clickable @click="clearAllPrices">
<q-item-section>Tumunu Temizle</q-item-section>
</q-item>
<q-separator />
<q-item v-for="option in priceOptions" :key="option.value" clickable @click="togglePriceOption(option.value)">
<q-item-section avatar>
<q-checkbox
dense
:model-value="selectedPriceSet.has(option.value)"
:disable="pageBusy"
@click.stop
@update:model-value="() => togglePriceOption(option.value)"
/>
</q-item-section>
<q-item-section>{{ option.label }}</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<div class="toolbar-group">
<q-btn-dropdown dense color="secondary" outline icon="view_module" label="Fiyat Secimi" :auto-close="false" :disable="pageBusy">
<q-list dense class="currency-menu-list">
<q-item clickable @click="selectAllPrices">
<q-item-section>Tumunu Sec</q-item-section>
</q-item>
<q-item clickable @click="clearAllPrices">
<q-item-section>Tumunu Temizle</q-item-section>
</q-item>
<q-separator />
<q-item v-for="option in allowedPriceOptions" :key="option.value" clickable @click="togglePriceOption(option.value)">
<q-item-section avatar>
<q-checkbox
dense
:model-value="selectedPriceSet.has(option.value)"
:disable="pageBusy"
@click.stop
@update:model-value="() => togglePriceOption(option.value)"
/>
</q-item-section>
<q-item-section>{{ option.label }}</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
dense
flat
color="grey-8"
icon="view_sidebar"
:label="leftDetailsExpanded ? 'Detaylari Gizle' : 'Detaylari Goster'"
:disable="pageBusy"
@click="leftDetailsExpanded = !leftDetailsExpanded"
/>
<q-btn
dense
flat
color="grey-8"
icon="view_sidebar"
:label="leftDetailsExpanded ? 'Detaylari Gizle' : 'Detaylari Goster'"
:disable="pageBusy"
@click="leftDetailsExpanded = !leftDetailsExpanded"
/>
<q-toggle
v-model="showInStockOnly"
dense
color="primary"
label="Sadece stogu olanlar"
:disable="pageBusy"
/>
</div>
<q-btn-dropdown dense color="primary" outline icon="download" label="Cikti Al" :disable="pageBusy || filteredRows.length === 0">
<q-list dense style="min-width: 220px;">
<q-item clickable @click="exportVisibleExcel">
<q-item-section avatar><q-icon name="grid_on" /></q-item-section>
<q-item-section>Excel'e Aktar</q-item-section>
</q-item>
<q-item clickable @click="printVisibleRows">
<q-item-section avatar><q-icon name="picture_as_pdf" /></q-item-section>
<q-item-section>PDF / Yazdir</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<div class="toolbar-group">
<q-btn-dropdown dense color="primary" outline icon="download" label="Cikti Al" :disable="pageBusy || filteredRows.length === 0">
<q-list dense style="min-width: 220px;">
<q-item clickable @click="exportVisibleExcel">
<q-item-section avatar><q-icon name="grid_on" /></q-item-section>
<q-item-section>Excel'e Aktar</q-item-section>
</q-item>
<q-item clickable @click="printVisibleRows">
<q-item-section avatar><q-icon name="picture_as_pdf" /></q-item-section>
<q-item-section>PDF / Yazdir</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<q-space />
<q-pagination
v-model="currentPage"
color="primary"
:max="Math.max(1, totalPages || 1)"
:max-pages="8"
boundary-links
direction-links
:disable="pageBusy"
@update:model-value="onPageChange"
/>
<div class="text-caption text-grey-8">
Sayfa {{ currentPage }} / {{ Math.max(1, totalPages || 1) }} - {{ filteredRows.length }} satir
<div class="toolbar-group toolbar-group--paging">
<q-pagination
v-model="currentPage"
color="primary"
:max="Math.max(1, totalPages || 1)"
:max-pages="8"
boundary-links
direction-links
:disable="pageBusy"
@update:model-value="onPageChange"
/>
<div class="text-caption text-grey-8">
Sayfa {{ currentPage }} / {{ Math.max(1, totalPages || 1) }} - {{ filteredRows.length }} satir
</div>
</div>
</div>
</div>
@@ -152,7 +165,7 @@
>
<div
class="top-x-scroll-inner"
:style="{ width: `${tableMinWidth}px` }"
:style="{ width: `${tableScrollWidth}px` }"
/>
</div>
<q-table
@@ -315,6 +328,137 @@
</div>
</q-menu>
</q-btn>
<q-btn
v-else-if="col.name === 'variantCodes'"
flat
dense
round
size="8px"
icon="filter_alt"
:color="selectedVariantCodes.length > 0 ? 'primary' : 'grey-7'"
:disable="pageBusy"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="selectedVariantCodes.length > 0" color="primary" floating rounded>
{{ selectedVariantCodes.length }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
v-model="variantFilterSearch"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Varyant ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tumunu Sec" :disable="pageBusy || filteredVariantOptions.length === 0" @click="selectAllVariantOptions" />
<q-btn flat dense size="sm" label="Temizle" :disable="pageBusy || selectedVariantCodes.length === 0" @click="clearVariantOptions" />
</div>
<q-virtual-scroll
v-if="filteredVariantOptions.length > 0"
class="excel-filter-options"
:items="filteredVariantOptions"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`variant-${option.value}`"
dense
clickable
:disable="pageBusy"
class="excel-filter-option"
@click="toggleVariantValue(option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="selectedVariantCodeSet.has(option.value)"
:disable="pageBusy"
@update:model-value="() => toggleVariantValue(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>
</q-menu>
</q-btn>
<q-btn
v-else-if="isLocalFilterableColumn(col.name)"
flat
dense
round
size="8px"
icon="filter_alt"
:color="getColumnFilterValues(col.name).length > 0 ? 'primary' : 'grey-7'"
:disable="pageBusy"
class="header-filter-btn"
@click.stop
>
<q-badge v-if="getColumnFilterValues(col.name).length > 0" color="primary" floating rounded>
{{ getColumnFilterValues(col.name).length }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<div class="excel-filter-menu">
<q-input
:model-value="columnFilterSearch[col.name] || ''"
dense
outlined
clearable
class="excel-filter-select"
placeholder="Filtre ara"
@update:model-value="(val) => setColumnFilterSearch(col.name, val)"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tumunu Sec" :disable="pageBusy || getFilteredColumnOptions(col).length === 0" @click="selectAllColumnOptions(col)" />
<q-btn flat dense size="sm" label="Temizle" :disable="pageBusy || getColumnFilterValues(col.name).length === 0" @click="clearColumnOptions(col.name)" />
</div>
<q-virtual-scroll
v-if="getFilteredColumnOptions(col).length > 0"
class="excel-filter-options"
:items="getFilteredColumnOptions(col)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`${col.name}-${option.value}`"
dense
clickable
:disable="pageBusy"
class="excel-filter-option"
@click="toggleColumnFilterValue(col.name, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="getColumnFilterSet(col.name).has(option.value)"
:disable="pageBusy"
@update:model-value="() => toggleColumnFilterValue(col.name, option.value)"
@click.stop
/>
</q-item-section>
<q-item-section>
<q-item-label>{{ option.label || '-' }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-virtual-scroll>
<div v-else class="excel-filter-empty">Sonuc yok</div>
</div>
</q-menu>
</q-btn>
<span v-else class="header-filter-ghost"></span>
</div>
</q-th>
@@ -340,10 +484,14 @@
<q-img
v-if="props.row.imageUrl"
:src="props.row.imageUrl"
class="product-thumb"
class="product-thumb cursor-pointer"
fit="cover"
no-spinner
@click.stop="openProductCard(props.row)"
/>
<div v-else class="product-thumb-placeholder cursor-pointer" @click.stop="openProductCard(props.row)">
<q-icon name="image_not_supported" size="24px" color="grey-6" />
</div>
</q-td>
</template>
@@ -353,8 +501,11 @@
:class="[props.col.classes, { 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }]"
:style="getBodyCellStyle(props.col)"
>
<q-badge v-if="props.row.campaignLabel" color="primary" outline :label="props.row.campaignLabel" />
<span v-else class="text-grey-6">-</span>
<div class="campaign-cell-content">
<span v-if="props.row.campaignLabel" class="campaign-text" :title="props.row.campaignLabel">
{{ props.row.campaignLabel }}
</span>
</div>
</q-td>
</template>
@@ -364,7 +515,9 @@
:class="[props.col.classes, { 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }]"
:style="getBodyCellStyle(props.col)"
>
{{ props.row.campaignRate ? formatPrice(props.row.campaignRate) : '' }}
<span class="cell-text campaign-rate-text" :title="String(props.row.campaignRate ?? '')">
{{ props.row.campaignRate ? formatPrice(props.row.campaignRate) : '' }}
</span>
</q-td>
</template>
@@ -374,11 +527,139 @@
:class="['text-right', props.col.classes, { 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }]"
:style="getBodyCellStyle(props.col)"
>
{{ formatPrice(props.row[name]) }}
<span :class="['cell-text', 'price-cell-text', { 'campaign-price-text': name.endsWith('Campaign') }]" :title="formatPrice(props.row[name])">
{{ formatPrice(props.row[name]) }}
</span>
</q-td>
</template>
</q-table>
</div>
<q-dialog v-model="productCardDialog" maximized @hide="onProductCardDialogHide">
<q-card class="product-card-dialog">
<q-card-section class="row items-center q-pb-sm">
<div class="text-h6">Urun Karti</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-separator />
<q-card-section class="q-pt-md">
<div class="product-card-content">
<div class="product-card-images">
<q-carousel
v-if="productCardImages.length"
v-model="productCardSlide"
animated
swipeable
navigation
arrows
height="100%"
class="product-card-carousel rounded-borders"
>
<q-carousel-slide
v-for="(img, idx) in productCardImages"
:key="'img-' + idx"
:name="idx"
class="column no-wrap flex-center"
>
<div class="dialog-image-stage cursor-pointer" @click="openProductImageFullscreen(img)">
<q-img :src="img" fit="contain" class="dialog-image" />
</div>
</q-carousel-slide>
</q-carousel>
<div v-else class="dialog-image-empty">
<q-icon name="image_not_supported" size="36px" color="grey-6" />
</div>
</div>
<div class="product-card-fields">
<div class="field-row field-row-head"><span class="k">Urun</span><span class="v">{{ productCardData.productCode || '-' }} / {{ productCardData.variantCodes || '-' }}</span></div>
<div class="field-row"><span class="k">Urun Kodu</span><span class="v">{{ productCardData.productCode || '-' }}</span></div>
<div class="field-row"><span class="k">Varyant</span><span class="v">{{ productCardData.variantCodes || '-' }}</span></div>
<div class="field-row"><span class="k">Marka</span><span class="v">{{ productCardData.marka || '-' }}</span></div>
<div class="field-row"><span class="k">Marka Grubu</span><span class="v">{{ productCardData.brandGroupSelection || '-' }}</span></div>
<div class="field-row"><span class="k">Kategori</span><span class="v">{{ productCardData.kategori || '-' }}</span></div>
<div class="field-row"><span class="k">Urun Ilk Grubu</span><span class="v">{{ productCardData.urunIlkGrubu || '-' }}</span></div>
<div class="field-row"><span class="k">Urun Ana Grubu</span><span class="v">{{ productCardData.urunAnaGrubu || '-' }}</span></div>
<div class="field-row"><span class="k">Urun Alt Grubu</span><span class="v">{{ productCardData.urunAltGrubu || '-' }}</span></div>
<div class="field-row"><span class="k">Icerik</span><span class="v">{{ productCardData.icerik || '-' }}</span></div>
<div class="field-row"><span class="k">Karisim</span><span class="v">{{ productCardData.karisim || '-' }}</span></div>
<div class="field-row"><span class="k">Kampanya</span><span class="v">{{ productCardData.campaignLabel || '-' }}</span></div>
<div class="field-row"><span class="k">Stok</span><span class="v">{{ formatStock(productCardData.stockQty || 0) }}</span></div>
<div class="product-card-section">
<div class="product-card-section-title">Beden Stoklari</div>
<q-inner-loading :showing="productCardStockLoading">
<q-spinner size="24px" color="primary" />
</q-inner-loading>
<div v-if="productCardSizeRows.length" class="size-stock-grid">
<div v-for="item in productCardSizeRows" :key="item.size" class="size-stock-cell">
<span class="size-label">{{ item.size }}</span>
<span class="size-qty">{{ formatStock(item.qty) }}</span>
</div>
</div>
<div v-else-if="!productCardStockLoading" class="product-card-empty-text">Beden stogu bulunamadi.</div>
</div>
</div>
<div class="product-card-price-panel">
<div class="product-card-section product-card-price-section">
<div class="product-card-section-title">Fiyat Bilgileri</div>
<div class="price-info-header">
<span>Fiyat</span>
<span>Liste</span>
<span>Kampanyali</span>
</div>
<div v-if="productCardPriceRows.length" class="price-info-grid">
<div v-for="item in productCardPriceRows" :key="item.key" :class="['price-info-row', `price-info-row-${item.currency}`, { 'has-campaign-price': item.hasCampaignPrice }]">
<span class="price-label">{{ item.label }}</span>
<span class="price-value">{{ item.price || '-' }}</span>
<span class="price-campaign">{{ item.campaignPrice || '-' }}</span>
</div>
</div>
<div v-else class="product-card-empty-text">Secili fiyat kolonu yok.</div>
</div>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="productImageFullscreenDialog" maximized>
<q-card class="image-fullscreen-dialog">
<q-card-section class="row items-center q-pb-sm">
<div class="text-h6">Urun Fotografi</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-separator />
<q-card-section class="image-fullscreen-body">
<q-carousel
v-if="fullscreenImages.length"
v-model="productImageFullscreenSlide"
animated
swipeable
navigation
arrows
height="calc(100vh - 120px)"
class="image-fullscreen-carousel"
>
<q-carousel-slide
v-for="(img, idx) in fullscreenImages"
:key="'full-img-' + idx"
:name="idx"
class="column no-wrap flex-center"
>
<div class="image-fullscreen-stage">
<q-img :src="img" fit="contain" class="image-fullscreen-img" />
</div>
</q-carousel-slide>
</q-carousel>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
@@ -390,10 +671,18 @@ import api from 'src/services/api'
const PAGE_LIMIT = 250
const GUIDANCE_MSG = 'Liste icin filtre secin.'
const priceOptions = ['USD', 'EUR', 'TRY'].flatMap((cur) => [1, 2, 3, 4, 5, 6].map((lv) => ({
const allPriceOptions = ['USD', 'EUR', 'TRY'].flatMap((cur) => [1, 2, 3, 4, 5, 6].map((lv) => ({
label: `${cur} ${lv}`,
value: `${cur.toLowerCase()}${lv}`
})))
const allowedPriceGroupValues = ref([])
const priceGroupRestricted = ref(false)
const allowedPriceOptions = computed(() => {
if (!priceGroupRestricted.value) return allPriceOptions
const allowed = new Set(allowedPriceGroupValues.value || [])
return allPriceOptions.filter((x) => allowed.has(x.value))
})
const priceOptions = allPriceOptions
const campaignPairs = priceOptions.map((x) => ({ base: x.value, derived: `${x.value}Campaign` }))
const priceColumnNames = campaignPairs.flatMap((p) => [p.base, p.derived])
@@ -401,9 +690,14 @@ const topUrunIlkGrubu = ref(null)
const topUrunAnaGrubu = ref(null)
const selectedProductCodes = ref([])
const selectedCampaignLabels = ref([])
const selectedVariantCodes = ref([])
const campaignFilterSearch = ref('')
const selectedPriceOptions = ref(priceOptions.map((x) => x.value))
const variantFilterSearch = ref('')
const columnFilters = ref({})
const columnFilterSearch = ref({})
const selectedPriceOptions = ref(['usd5', 'try5'])
const leftDetailsExpanded = ref(true)
const showInStockOnly = ref(false)
const rows = ref([])
const loading = ref(false)
@@ -418,13 +712,39 @@ const serverFilterLoading = ref({})
const serverFilterLastQuery = ref({})
const filterSearch = ref({ productCode: '', urunIlkGrubu: '', urunAnaGrubu: '' })
const imageCache = new Map()
const imageListCache = new Map()
const variantCodeCollator = new Intl.Collator('tr', { numeric: true, sensitivity: 'base' })
const mainTableRef = ref(null)
const topScrollRef = ref(null)
let syncingScroll = false
const productCardDialog = ref(false)
const productCardData = ref({})
const productCardImages = ref([])
const productCardSlide = ref(0)
const productCardStockLoading = ref(false)
const productCardSizeRows = ref([])
const productImageFullscreenDialog = ref(false)
const productImageFullscreenSlide = ref(0)
const fullscreenImages = computed(() => productCardImages.value || [])
const selectedPriceSet = computed(() => new Set(selectedPriceOptions.value || []))
const productCardPriceRows = computed(() => {
const row = productCardData.value || {}
return priceOptions
.filter((option) => selectedPriceSet.value.has(option.value))
.map((option) => ({
key: option.value,
label: option.label,
currency: String(option.value || '').slice(0, 3).toLowerCase(),
price: formatPrice(row?.[option.value]),
campaignPrice: formatPrice(row?.[`${option.value}Campaign`]),
hasCampaignPrice: Number(row?.[`${option.value}Campaign`] || 0) > 0
}))
})
const selectedProductCodeSet = computed(() => new Set(selectedProductCodes.value || []))
const selectedCampaignLabelSet = computed(() => new Set(selectedCampaignLabels.value || []))
const selectedVariantCodeSet = computed(() => new Set(selectedVariantCodes.value || []))
const pageBusy = computed(() => loading.value || renderPending.value)
const canFetch = computed(() => Boolean(topUrunIlkGrubu.value || topUrunAnaGrubu.value || selectedProductCodes.value.length > 0))
const showGuidanceOverlay = computed(() => !loading.value && rows.value.length === 0 && error.value === GUIDANCE_MSG)
@@ -447,6 +767,21 @@ const filteredCampaignOptions = computed(() => {
const list = campaignOptions.value
return q ? list.filter((x) => x.label.toLocaleLowerCase('tr').includes(q)) : list
})
const variantOptions = computed(() => {
const uniq = new Set()
for (const row of rows.value || []) {
const val = toText(row?.variantCodes)
if (val) uniq.add(val)
}
return Array.from(uniq)
.sort((a, b) => variantCodeCollator.compare(a, b))
.map((value) => ({ label: value, value }))
})
const filteredVariantOptions = computed(() => {
const q = toText(variantFilterSearch.value).toLocaleLowerCase('tr')
const list = variantOptions.value
return q ? list.filter((x) => x.label.toLocaleLowerCase('tr').includes(q)) : list
})
function toText (value) {
return String(value ?? '').trim()
@@ -474,6 +809,59 @@ function formatStock (value) {
return n.toLocaleString('tr-TR', { maximumFractionDigits: 2 })
}
function parseStockNumber (value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
const text = String(value ?? '').trim()
if (!text) return 0
const normalized = text.replace(/\./g, '').replace(',', '.')
const n = Number.parseFloat(normalized)
return Number.isFinite(n) ? n : 0
}
function normalizeCardToken (value) {
return String(value ?? '').trim().toUpperCase()
}
function parseVariantTokens (variantCode) {
const parts = String(variantCode || '').split('-').map((x) => normalizeCardToken(x)).filter(Boolean)
return {
color: parts[0] || '',
dim2: parts.length > 1 ? parts.slice(1).join('-') : ''
}
}
function stockRowText (row, ...keys) {
for (const key of keys) {
const value = String(row?.[key] ?? '').trim()
if (value) return value
}
return ''
}
function matchesProductCardVariant (stockRow, cardRow) {
const tokens = parseVariantTokens(cardRow?.variantCodes)
if (!tokens.color && !tokens.dim2) return true
const color = normalizeCardToken(stockRowText(stockRow, 'Renk_Kodu', 'ColorCode', 'colorCode'))
const dim2 = normalizeCardToken(stockRowText(stockRow, 'Yaka', 'ItemDim2Code', 'itemDim2Code', 'Renk2'))
if (tokens.color && color !== tokens.color) return false
if (tokens.dim2 && dim2 !== tokens.dim2) return false
return true
}
function buildSizeStockRows (stockRows, cardRow) {
const totals = new Map()
for (const item of stockRows || []) {
if (!matchesProductCardVariant(item, cardRow)) continue
const size = stockRowText(item, 'Beden', 'Size', 'ItemDim1Code', 'itemDim1Code')
if (!size) continue
const qty = parseStockNumber(item?.Kullanilabilir_Envanter ?? item?.StockQty ?? item?.qty)
totals.set(size, (totals.get(size) || 0) + qty)
}
return Array.from(totals.entries())
.map(([size, qty]) => ({ size, qty }))
.sort((a, b) => variantCodeCollator.compare(a.size, b.size))
}
function mapProductRow (raw, index) {
const row = {
id: index + 1,
@@ -537,7 +925,7 @@ function buildRows (products, variants) {
out.push(row)
continue
}
list.sort((a, b) => toText(a?.variant_code).localeCompare(toText(b?.variant_code), 'tr'))
list.sort((a, b) => variantCodeCollator.compare(toText(a?.variant_code), toText(b?.variant_code)))
for (const v of list) {
const d1 = Number(v?.dim1 || 0)
const d3 = v?.dim3 == null ? 0 : Number(v?.dim3 || 0)
@@ -585,6 +973,20 @@ async function fetchServerFilterOptions (field, q = '') {
}
}
async function fetchMyPriceGroups () {
try {
const res = await api.get('/order/price-list/my-price-groups')
priceGroupRestricted.value = !!res?.data?.restricted
allowedPriceGroupValues.value = Array.isArray(res?.data?.price_groups) ? res.data.price_groups : []
normalizeSelectedPriceOptions()
} catch (err) {
console.warn('[order-price-list][ui] price-groups lookup failed', err?.response?.data || err?.message || err)
priceGroupRestricted.value = false
allowedPriceGroupValues.value = []
normalizeSelectedPriceOptions()
}
}
function onTopFilterSearchUrunIlkGrubu (val, update) {
update(() => {
filterSearch.value.urunIlkGrubu = toText(val)
@@ -651,6 +1053,79 @@ function clearCampaignOptions () {
selectedCampaignLabels.value = []
}
function toggleVariantValue (value) {
const v = toText(value)
if (!v) return
const set = new Set(selectedVariantCodes.value || [])
if (set.has(v)) set.delete(v)
else set.add(v)
selectedVariantCodes.value = Array.from(set).sort((a, b) => variantCodeCollator.compare(a, b))
}
function selectAllVariantOptions () {
const set = new Set(selectedVariantCodes.value || [])
for (const option of filteredVariantOptions.value) {
const v = toText(option.value)
if (v) set.add(v)
}
selectedVariantCodes.value = Array.from(set).sort((a, b) => variantCodeCollator.compare(a, b))
}
function clearVariantOptions () {
selectedVariantCodes.value = []
}
function isLocalFilterableColumn (name) {
if (!name || ['image', 'productCode', 'variantCodes', 'campaignLabel'].includes(name)) return false
return true
}
function getColumnFilterValues (name) {
const list = columnFilters.value?.[name]
return Array.isArray(list) ? list : []
}
function getColumnFilterSet (name) {
return new Set(getColumnFilterValues(name))
}
function setColumnFilterSearch (name, value) {
columnFilterSearch.value = { ...columnFilterSearch.value, [name]: toText(value) }
}
function getColumnOptions (col) {
const uniq = new Set()
for (const row of rows.value || []) {
uniq.add(exportCell(row, col))
}
return Array.from(uniq)
.sort((a, b) => String(a).localeCompare(String(b), 'tr', { numeric: true, sensitivity: 'base' }))
.map((value) => ({ label: value || '-', value }))
}
function getFilteredColumnOptions (col) {
const q = toText(columnFilterSearch.value?.[col.name]).toLocaleLowerCase('tr')
const list = getColumnOptions(col)
return q ? list.filter((x) => String(x.label || '').toLocaleLowerCase('tr').includes(q)) : list
}
function toggleColumnFilterValue (name, value) {
const set = new Set(getColumnFilterValues(name))
if (set.has(value)) set.delete(value)
else set.add(value)
columnFilters.value = { ...columnFilters.value, [name]: Array.from(set) }
}
function selectAllColumnOptions (col) {
const set = new Set(getColumnFilterValues(col.name))
for (const option of getFilteredColumnOptions(col)) set.add(option.value)
columnFilters.value = { ...columnFilters.value, [col.name]: Array.from(set) }
}
function clearColumnOptions (name) {
columnFilters.value = { ...columnFilters.value, [name]: [] }
}
function onTopUrunIlkGrubuChange () {
topUrunAnaGrubu.value = null
void fetchServerFilterOptions('urunAnaGrubu', '')
@@ -703,7 +1178,7 @@ async function reloadData ({ page = 1 } = {}) {
}
rows.value = buildRows(products, variants)
error.value = ''
void loadImagesForRows(rows.value.slice(0, 120))
void loadImagesForRows(rows.value)
await nextTick()
} catch (err) {
rows.value = []
@@ -724,10 +1199,18 @@ async function loadImagesForRows (list) {
seen.add(key)
targets.push({ row, key })
}
await Promise.all(targets.map(async ({ row, key }) => {
const concurrency = 12
let cursor = 0
let loaded = 0
const workers = Array.from({ length: Math.min(concurrency, targets.length) }, async () => {
for (;;) {
const target = targets[cursor]
cursor += 1
if (!target) return
const { row, key } = target
if (imageCache.has(key)) {
row.imageUrl = imageCache.get(key)
return
continue
}
try {
const res = await api.get('/product-images', {
@@ -739,19 +1222,109 @@ async function loadImagesForRows (list) {
timeout: 15000
})
const first = Array.isArray(res?.data) ? res.data[0] : null
const url = toText(first?.thumb_url || first?.content_url || first?.full_url)
const url = resolveProductImageUrl(first)
imageCache.set(key, url)
row.imageUrl = url
imageListCache.set(key, Array.isArray(res?.data) ? res.data : [])
} catch {
imageCache.set(key, '')
}
}))
loaded += 1
if (loaded % 12 === 0) rows.value = [...rows.value]
}
})
await Promise.all(workers)
rows.value = [...rows.value]
}
function normalizeUploadsPath (storagePath) {
const raw = toText(storagePath)
if (!raw) return ''
const normalized = raw.replace(/\\/g, '/')
const idx = normalized.toLowerCase().indexOf('/uploads/')
if (idx >= 0) return normalized.slice(idx)
if (normalized.toLowerCase().startsWith('uploads/')) return `/${normalized}`
return ''
}
function resolveProductImageUrl (item) {
if (!item || typeof item !== 'object') return ''
const imageId = Number(item.id || item.ID || 0)
if (Number.isFinite(imageId) && imageId > 0) return `/api/product-images/${imageId}/content`
const thumbUrl = toText(item.thumb_url || item.thumbUrl)
if (thumbUrl) return thumbUrl
const fullUrl = toText(item.full_url || item.fullUrl)
if (fullUrl) return fullUrl
const contentUrl = toText(item.content_url || item.ContentURL)
if (contentUrl) return contentUrl.startsWith('/api/') ? contentUrl : contentUrl
const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage)
if (uploadsPath) return uploadsPath
const fileName = toText(item.file_name || item.FileName)
return fileName ? `/uploads/image/${fileName}` : ''
}
async function fetchImageListForRow (row) {
const key = `${row.productCode}|${row.dim1 || 0}|${row.dim3 || 0}`
if (imageListCache.has(key)) return imageListCache.get(key) || []
const res = await api.get('/product-images', {
params: {
code: row.productCode,
dim1_id: row.dim1 || '',
dim3_id: row.dim3 || ''
},
timeout: 15000
})
const list = Array.isArray(res?.data) ? res.data : []
imageListCache.set(key, list)
return list
}
async function openProductCard (row) {
if (!row) return
productCardData.value = { ...row }
productCardSizeRows.value = []
productCardDialog.value = true
productCardSlide.value = 0
productCardStockLoading.value = true
try {
const [list, stockRes] = await Promise.all([
fetchImageListForRow(row),
api.get('/product-stock-query', { params: { code: row.productCode }, timeout: 30000 })
])
const images = list.map(resolveProductImageUrl).filter(Boolean)
if (row.imageUrl && !images.includes(row.imageUrl)) images.unshift(row.imageUrl)
productCardImages.value = Array.from(new Set(images))
productCardSizeRows.value = buildSizeStockRows(Array.isArray(stockRes?.data) ? stockRes.data : [], row)
} catch {
productCardImages.value = row.imageUrl ? [row.imageUrl] : []
productCardSizeRows.value = []
} finally {
productCardStockLoading.value = false
}
}
function openProductImageFullscreen (src) {
const value = toText(src)
if (!value) return
const idx = Math.max(0, fullscreenImages.value.findIndex((x) => toText(x) === value))
productImageFullscreenSlide.value = idx
productImageFullscreenDialog.value = true
}
function onProductCardDialogHide () {
productImageFullscreenDialog.value = false
productCardStockLoading.value = false
productCardSizeRows.value = []
}
function resetSelections () {
topUrunIlkGrubu.value = null
topUrunAnaGrubu.value = null
selectedProductCodes.value = []
selectedCampaignLabels.value = []
selectedVariantCodes.value = []
columnFilters.value = {}
columnFilterSearch.value = {}
rows.value = []
error.value = GUIDANCE_MSG
currentPage.value = 1
@@ -764,20 +1337,33 @@ function onPageChange (page) {
}
function togglePriceOption (value) {
if (!allowedPriceOptions.value.some((x) => x.value === value)) return
const set = new Set(selectedPriceOptions.value || [])
if (set.has(value)) set.delete(value)
else set.add(value)
selectedPriceOptions.value = priceOptions.map((x) => x.value).filter((x) => set.has(x))
selectedPriceOptions.value = allowedPriceOptions.value.map((x) => x.value).filter((x) => set.has(x))
}
function selectAllPrices () {
selectedPriceOptions.value = priceOptions.map((x) => x.value)
selectedPriceOptions.value = allowedPriceOptions.value.map((x) => x.value)
}
function clearAllPrices () {
selectedPriceOptions.value = []
}
function normalizeSelectedPriceOptions () {
const allowedValues = allowedPriceOptions.value.map((x) => x.value)
const allowed = new Set(allowedValues)
const current = (selectedPriceOptions.value || []).filter((x) => allowed.has(x))
if (current.length > 0 || allowedValues.length === 0) {
selectedPriceOptions.value = current
return
}
const preferred = ['usd5', 'try5'].filter((x) => allowed.has(x))
selectedPriceOptions.value = preferred.length ? preferred : allowedValues.slice(0, 2)
}
function col (name, label, field, width, extra = {}) {
return {
name,
@@ -793,34 +1379,31 @@ function col (name, label, field, width, extra = {}) {
}
const allColumns = [
col('image', '', 'imageUrl', 58, { align: 'center', classes: 'image-col sticky-col' }),
col('image', '', 'imageUrl', 108, { align: 'center', classes: 'image-col sticky-col' }),
col('brandGroupSelection', 'MARKA GRUBU', 'brandGroupSelection', 86, { classes: 'ps-col sticky-col' }),
col('marka', 'MARKA', 'marka', 62, { sortable: true, classes: 'ps-col sticky-col' }),
col('marka', 'MARKA', 'marka', 72, { sortable: true, classes: 'ps-col sticky-col' }),
col('productCode', 'URUN KODU', 'productCode', 112, { sortable: true, classes: 'ps-col product-code-col sticky-col' }),
col('variantCodes', 'VARYANT', 'variantCodes', 112, { classes: 'ps-col variant-col sticky-col' }),
col('variantStocks', 'STOK', 'stockQty', 62, { align: 'right', sortable: true, classes: 'ps-col variant-stock-col sticky-col' }),
col('campaignLabel', 'KAMPANYA', 'campaignLabel', 150, { classes: 'ps-col campaign-col sticky-col' }),
col('campaignRate', 'IND %', 'campaignRate', 58, { align: 'right', classes: 'ps-col campaign-rate-col sticky-col' }),
col('stockEntryDate', 'STOK GIRIS', 'stockEntryDate', 86, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
col('lastPricingDate', 'SON FIYAT', 'lastPricingDate', 86, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
col('lastCampaignDate', 'SON KAMPANYA', 'lastCampaignDate', 98, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
col('askiliYan', 'ASKILI YAN', 'askiliYan', 58, { sortable: true, classes: 'ps-col' }),
col('kategori', 'KATEGORI', 'kategori', 58, { sortable: true, classes: 'ps-col' }),
col('urunIlkGrubu', 'URUN ILK GRUBU', 'urunIlkGrubu', 70, { sortable: true, classes: 'ps-col' }),
col('urunAnaGrubu', 'URUN ANA GRUBU', 'urunAnaGrubu', 74, { sortable: true, classes: 'ps-col' }),
col('urunAltGrubu', 'URUN ALT GRUBU', 'urunAltGrubu', 74, { sortable: true, classes: 'ps-col' }),
col('icerik', 'ICERIK', 'icerik', 66, { sortable: true, classes: 'ps-col' }),
col('karisim', 'KARISIM', 'karisim', 66, { sortable: true, classes: 'ps-col' }),
col('variantCodes', 'VARYANT', 'variantCodes', 82, { align: 'center', classes: 'ps-col variant-col sticky-col center-col' }),
col('variantStocks', 'STOK', 'stockQty', 64, { align: 'center', sortable: true, classes: 'ps-col variant-stock-col sticky-col center-col' }),
col('campaignLabel', 'KAMPANYA', 'campaignLabel', 118, { align: 'center', classes: 'ps-col campaign-col sticky-col center-col' }),
col('campaignRate', 'IND %', 'campaignRate', 56, { align: 'center', classes: 'ps-col campaign-rate-col sticky-col center-col' }),
col('askiliYan', 'ASKILI YAN', 'askiliYan', 58, { sortable: true, classes: 'ps-col center-col' }),
col('kategori', 'KATEGORI', 'kategori', 72, { sortable: true, classes: 'ps-col center-col' }),
col('urunIlkGrubu', 'URUN ILK GRUBU', 'urunIlkGrubu', 72, { sortable: true, classes: 'ps-col center-col' }),
col('urunAnaGrubu', 'URUN ANA GRUBU', 'urunAnaGrubu', 84, { sortable: true, classes: 'ps-col center-col' }),
col('urunAltGrubu', 'URUN ALT GRUBU', 'urunAltGrubu', 84, { sortable: true, classes: 'ps-col center-col' }),
col('icerik', 'ICERIK', 'icerik', 92, { sortable: true, classes: 'ps-col' }),
col('karisim', 'KARISIM', 'karisim', 88, { sortable: true, classes: 'ps-col karisim-wrap-col' }),
...campaignPairs.flatMap((p) => [
col(p.base, p.base.toUpperCase().replace(/([A-Z]+)(\d)/, '$1 $2'), p.base, 78, { align: 'right', classes: `${p.base.slice(0, 3)}-col` }),
col(p.derived, `${p.base.toUpperCase().replace(/([A-Z]+)(\d)/, '$1 $2')} KMP`, p.derived, 88, { align: 'right', classes: `${p.base.slice(0, 3)}-col campaign-price-col` })
])
]
const hideableLeftDetailColumnNames = new Set([
'stockEntryDate',
'lastPricingDate',
'lastCampaignDate',
const compactHiddenColumnNames = new Set([
'variantStocks',
'campaignLabel',
'campaignRate',
'askiliYan',
'kategori',
'urunIlkGrubu',
@@ -833,16 +1416,37 @@ const hideableLeftDetailColumnNames = new Set([
const visibleColumns = computed(() => allColumns.filter((c) => {
if (/^(usd|eur|try)[1-6]$/.test(c.name)) return selectedPriceSet.value.has(c.name)
if (/^(usd|eur|try)[1-6]Campaign$/.test(c.name)) return selectedPriceSet.value.has(c.name.replace(/Campaign$/, ''))
if (!leftDetailsExpanded.value && hideableLeftDetailColumnNames.has(c.name)) return false
if (!leftDetailsExpanded.value && compactHiddenColumnNames.has(c.name)) return false
return true
}))
const filteredRows = computed(() => {
const campaignSet = selectedCampaignLabelSet.value
if (campaignSet.size === 0) return rows.value || []
return (rows.value || []).filter((row) => campaignSet.has(toText(row?.campaignLabel)))
const variantSet = selectedVariantCodeSet.value
const localFilters = columnFilters.value || {}
let list = rows.value || []
if (showInStockOnly.value) {
list = list.filter((row) => Number(row?.stockQty ?? 0) > 0)
}
if (campaignSet.size > 0) {
list = list.filter((row) => campaignSet.has(toText(row?.campaignLabel)))
}
if (variantSet.size > 0) {
list = list.filter((row) => variantSet.has(toText(row?.variantCodes)))
}
const active = Object.entries(localFilters).filter(([, values]) => Array.isArray(values) && values.length > 0)
if (active.length > 0) {
const byName = new Map(allColumns.map((c) => [c.name, c]))
list = list.filter((row) => active.every(([name, values]) => {
const col = byName.get(name)
if (!col) return true
return new Set(values).has(exportCell(row, col))
}))
}
return list
})
const tableMinWidth = computed(() => visibleColumns.value.reduce((sum, c) => sum + extractWidth(c.style), 0))
const tableScrollWidth = computed(() => tableMinWidth.value + stickyScrollComp.value + 48)
const tableStyle = computed(() => ({
width: `${tableMinWidth.value}px`,
minWidth: `${tableMinWidth.value}px`,
@@ -850,9 +1454,30 @@ const tableStyle = computed(() => ({
}))
const stickyColumnNames = computed(() => {
const visible = new Set(visibleColumns.value.map((x) => x.name))
return ['image', 'brandGroupSelection', 'marka', 'productCode', 'variantCodes', 'variantStocks', 'campaignLabel', 'campaignRate'].filter((x) => visible.has(x))
const expanded = [
'image',
'brandGroupSelection',
'marka',
'productCode',
'variantCodes',
'variantStocks',
'campaignLabel',
'campaignRate',
'askiliYan',
'kategori',
'urunIlkGrubu',
'urunAnaGrubu',
'urunAltGrubu',
'icerik',
'karisim'
]
const compact = ['image', 'brandGroupSelection', 'marka', 'productCode', 'variantCodes']
return (leftDetailsExpanded.value ? expanded : compact).filter((x) => visible.has(x))
})
const stickyBoundaryColumnName = computed(() => {
const list = stickyColumnNames.value
return list.length ? list[list.length - 1] : ''
})
const stickyBoundaryColumnName = 'campaignRate'
const stickyColumnNameSet = computed(() => new Set(stickyColumnNames.value))
const stickyLeftMap = computed(() => {
const map = {}
@@ -866,8 +1491,9 @@ const stickyLeftMap = computed(() => {
return map
})
const stickyScrollComp = computed(() => {
const boundaryCol = allColumns.find((x) => x.name === stickyBoundaryColumnName)
return ((stickyLeftMap.value[stickyBoundaryColumnName] || 0) + extractWidth(boundaryCol?.style)) * 1.2
const boundaryName = stickyBoundaryColumnName.value
const boundaryCol = allColumns.find((x) => x.name === boundaryName)
return ((stickyLeftMap.value[boundaryName] || 0) + extractWidth(boundaryCol?.style)) * 1.2
})
function isStickyCol (name) {
@@ -875,7 +1501,7 @@ function isStickyCol (name) {
}
function isStickyBoundary (name) {
return name === stickyBoundaryColumnName
return name === stickyBoundaryColumnName.value
}
function getHeaderCellStyle (col) {
@@ -903,13 +1529,30 @@ function exportCell (row, col) {
return toText(row[col.field])
}
function isExcelNumericColumn (col) {
return priceColumnNames.includes(col.name) || col.name === 'campaignRate'
}
function excelNumericCell (value) {
const n = Number(value)
if (!Number.isFinite(n) || n === 0) return '<td></td>'
const display = n.toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const raw = n.toFixed(2)
return `<td style="mso-number-format:'0.00';text-align:right;" x:num="${raw}">${escapeHtml(display)}</td>`
}
function exportExcelCellHtml (row, col) {
if (priceColumnNames.includes(col.name)) return excelNumericCell(row[col.field])
if (col.name === 'campaignRate') return excelNumericCell(row.campaignRate)
return `<td>${escapeHtml(exportCell(row, col))}</td>`
}
function exportVisibleExcel () {
const cols = visibleColumns.value
const cols = visibleColumns.value.filter((c) => c.name !== 'image')
const body = filteredRows.value.map((row) => `<tr>${cols.map((c) => {
if (c.name === 'image' && row.imageUrl) return `<td><img src="${row.imageUrl}" width="54" height="54"></td>`
return `<td>${escapeHtml(exportCell(row, c))}</td>`
return exportExcelCellHtml(row, c)
}).join('')}</tr>`).join('')
const html = `<!doctype html><html><head><meta charset="utf-8"></head><body><table border="1"><thead><tr>${cols.map((c) => `<th>${escapeHtml(c.label || 'Gorsel')}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table></body></html>`
const html = `<!doctype html><html xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="utf-8"></head><body><table border="1"><thead><tr>${cols.map((c) => `<th>${escapeHtml(c.label)}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table></body></html>`
const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -919,13 +1562,14 @@ function exportVisibleExcel () {
a.click()
a.remove()
URL.revokeObjectURL(url)
void notifyExportTaken('excel')
}
function printVisibleRows () {
const cols = visibleColumns.value
const body = filteredRows.value.map((row) => `<tr>${cols.map((c) => {
if (c.name === 'image' && row.imageUrl) return `<td><img src="${row.imageUrl}" class="thumb"></td>`
return `<td class="${priceColumnNames.includes(c.name) ? 'num' : ''}">${escapeHtml(exportCell(row, c))}</td>`
return `<td class="${isExcelNumericColumn(c) ? 'num' : ''}">${escapeHtml(exportCell(row, c))}</td>`
}).join('')}</tr>`).join('')
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Fiyat Listesi</title><style>
@page { size: A3 landscape; margin: 8mm; }
@@ -935,13 +1579,31 @@ function printVisibleRows () {
th { background: #957116; color: #fff; }
th, td { border: 1px solid #ccc; padding: 3px; vertical-align: middle; }
.num { text-align: right; }
.thumb { width: 42px; height: 42px; object-fit: cover; }
.thumb { width: 100px; height: 100px; object-fit: cover; }
</style></head><body><h1>Fiyat Listesi</h1><table><thead><tr>${cols.map((c) => `<th>${escapeHtml(c.label || 'Gorsel')}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table><script>window.onload=function(){window.print()}<\/script></body></html>`
const win = window.open('', '_blank')
if (!win) return
win.document.open()
win.document.write(html)
win.document.close()
void notifyExportTaken('pdf')
}
async function notifyExportTaken (format) {
try {
await api.post('/order/price-list/export-notify', {
format,
row_count: filteredRows.value.length,
price_fields: [...selectedPriceOptions.value],
product_codes: [...selectedProductCodes.value],
campaign_labels: [...selectedCampaignLabels.value],
first_groups: Array.from(new Set(filteredRows.value.map((row) => toText(row.urunIlkGrubu)).filter(Boolean))).sort((a, b) => a.localeCompare(b, 'tr')),
urun_ilk_grubu: topUrunIlkGrubu.value || '',
urun_ana_grubu: topUrunAnaGrubu.value || ''
}, { timeout: 30000 })
} catch (err) {
console.warn('[order-price-list][ui] export notify failed', err?.response?.data || err?.message || err)
}
}
function getTableMiddleEl () {
@@ -992,7 +1654,12 @@ watch([tableMinWidth, rows], async () => {
bindTableScrollSync()
})
watch(allowedPriceOptions, () => {
normalizeSelectedPriceOptions()
})
onMounted(() => {
void fetchMyPriceGroups()
void fetchServerFilterOptions('urunIlkGrubu', '')
void fetchServerFilterOptions('urunAnaGrubu', '')
void fetchServerFilterOptions('productCode', '')
@@ -1006,8 +1673,8 @@ onMounted(() => {
height: calc(100vh - 58px);
display: flex;
flex-direction: column;
--pricing-row-height: 31px;
--pricing-header-height: 72px;
--pricing-row-height: 108px;
--pricing-header-height: 88px;
--pricing-table-height: calc(100vh - 156px);
}
@@ -1015,12 +1682,59 @@ onMounted(() => {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
align-items: flex-start;
flex: 1 1 auto;
min-width: 0;
}
.top-actions-row {
width: 100%;
justify-content: flex-end;
justify-content: flex-start;
}
.top-bar {
position: sticky;
top: 0;
z-index: 60;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
background: #fff;
padding-top: 2px;
padding-bottom: 4px;
}
.top-actions-row--filters,
.top-actions-row--actions {
justify-content: flex-start;
}
.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 :deep(.q-btn) {
min-height: 32px;
white-space: nowrap;
}
.toolbar-group :deep(.q-btn__content) {
gap: 6px;
padding: 0;
}
.toolbar-group :deep(.q-btn__wrapper) {
padding: 4px 10px;
}
.table-wrap {
@@ -1032,30 +1746,58 @@ onMounted(() => {
border-radius: 4px;
display: flex;
flex-direction: column;
--top-scroll-height: 22px;
}
.product-thumb {
width: 46px;
height: 46px;
width: 100px;
height: 100px;
border-radius: 4px;
background: #f4f4f4;
}
.product-thumb-placeholder {
width: 100px;
height: 100px;
border-radius: 4px;
background: #f4f4f4;
border: 1px dashed rgba(0, 0, 0, 0.16);
display: flex;
align-items: center;
justify-content: center;
}
.image-cell {
padding: 2px 4px !important;
padding: 4px !important;
}
.top-x-scroll {
flex: 0 0 14px;
height: 14px;
flex: 0 0 var(--top-scroll-height);
height: var(--top-scroll-height);
overflow-x: auto;
overflow-y: hidden;
background: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: #f8fafc;
border-bottom: 1px solid rgba(0, 0, 0, 0.14);
scrollbar-gutter: stable;
z-index: 20;
}
.top-x-scroll-inner {
height: 1px;
height: 18px;
}
.top-x-scroll::-webkit-scrollbar {
height: 14px;
}
.top-x-scroll::-webkit-scrollbar-track {
background: #edf1f5;
}
.top-x-scroll::-webkit-scrollbar-thumb {
background: rgba(25, 118, 210, 0.42);
border: 3px solid #edf1f5;
border-radius: 999px;
}
.pane-table {
@@ -1064,9 +1806,9 @@ onMounted(() => {
}
.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);
height: calc(var(--pricing-table-height) - var(--top-scroll-height));
min-height: calc(var(--pricing-table-height) - var(--top-scroll-height));
max-height: calc(var(--pricing-table-height) - var(--top-scroll-height));
overflow: auto !important;
scrollbar-gutter: stable both-edges;
overscroll-behavior: contain;
@@ -1074,6 +1816,19 @@ onMounted(() => {
.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(td),
@@ -1081,11 +1836,25 @@ onMounted(() => {
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);
line-height: 1.25;
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;
}
.pricing-table :deep(td) {
overflow: hidden !important;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle !important;
}
.pricing-table :deep(th),
.pricing-table :deep(.q-table thead tr),
.pricing-table :deep(.q-table thead tr.header-row-fixed),
@@ -1099,9 +1868,10 @@ onMounted(() => {
.pricing-table :deep(th) {
padding-top: 0;
padding-bottom: 0;
white-space: nowrap;
word-break: normal;
text-overflow: ellipsis;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
overflow: hidden;
text-align: center;
font-size: 10px;
font-weight: 800;
@@ -1156,8 +1926,90 @@ onMounted(() => {
font-weight: 700;
}
.order-price-list-table :deep(.campaign-price-col) {
background: #f6fbf7;
.pricing-table :deep(td.ps-col),
.pricing-table :deep(td.usd-col),
.pricing-table :deep(td.eur-col),
.pricing-table :deep(td.try-col) {
vertical-align: middle !important;
}
.pricing-table :deep(th.usd-col),
.pricing-table :deep(td.usd-col) {
background: #fff;
color: #16803a;
font-weight: 800;
}
.pricing-table :deep(th.eur-col),
.pricing-table :deep(td.eur-col) {
background: #fff;
color: #b91c1c;
font-weight: 800;
}
.pricing-table :deep(th.try-col),
.pricing-table :deep(td.try-col) {
background: #fff;
color: #185abc;
font-weight: 800;
}
.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: 12px;
}
.pricing-table :deep(th.usd-col.campaign-price-col),
.pricing-table :deep(td.usd-col.campaign-price-col) {
background: #dff6e7;
color: #0f6b2f;
font-weight: 900;
letter-spacing: 0;
}
.pricing-table :deep(th.eur-col.campaign-price-col),
.pricing-table :deep(td.eur-col.campaign-price-col) {
background: #fde2e2;
color: #a61717;
font-weight: 900;
letter-spacing: 0;
}
.pricing-table :deep(th.try-col.campaign-price-col),
.pricing-table :deep(td.try-col.campaign-price-col) {
background: #e2edff;
color: #174ea6;
font-weight: 900;
letter-spacing: 0;
}
.pricing-table :deep(td.center-col) {
text-align: center !important;
}
.pricing-table :deep(td.karisim-wrap-col) {
white-space: normal !important;
text-overflow: clip !important;
word-break: break-word;
overflow-wrap: anywhere;
vertical-align: top !important;
text-align: left !important;
font-size: 9px;
line-height: 1.15;
padding: 6px 4px !important;
align-content: flex-start;
}
.pricing-table :deep(td.karisim-wrap-col .q-td__content),
.pricing-table :deep(td.karisim-wrap-col > div) {
align-items: flex-start !important;
justify-content: flex-start !important;
white-space: normal !important;
overflow: hidden;
}
.header-with-filter {
@@ -1175,15 +2027,66 @@ onMounted(() => {
width: 100%;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
font-weight: 800;
line-height: 1.15;
line-height: 1.12;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
.cell-text {
display: block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.1;
padding-top: 0;
}
.price-cell-text {
width: 100%;
text-align: right;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.campaign-price-text {
font-weight: 900;
}
.campaign-cell-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 22px;
}
.campaign-text {
display: block;
max-width: 100%;
color: #c62828;
font-weight: 900;
line-height: 1.12;
text-align: center;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
}
.campaign-rate-text {
width: 100%;
color: #c62828;
font-weight: 900;
text-align: center;
font-variant-numeric: tabular-nums;
}
.header-filter-btn {
width: 20px;
height: 20px;
@@ -1259,4 +2162,325 @@ onMounted(() => {
padding: 16px 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.product-card-dialog {
--pc-media-h: calc(100vh - 180px);
--pc-media-w: min(28vw, 440px);
background: #f9f8f5;
height: 100vh;
display: flex;
flex-direction: column;
}
:deep(.product-card-dialog > .q-card__section:last-child) {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.product-card-content {
display: grid;
grid-template-columns: minmax(360px, 420px) minmax(360px, 440px) minmax(320px, 420px);
gap: 14px;
align-items: stretch;
justify-content: center;
height: 100%;
}
.product-card-images {
grid-column: 2;
grid-row: 1;
height: var(--pc-media-h);
min-width: 0;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
}
.product-card-carousel {
width: 100%;
height: 100%;
max-width: 100%;
background: linear-gradient(180deg, #f6f1e6 0%, #efe6d3 100%);
border: 1px solid #e4dac7;
}
.dialog-image {
width: 100%;
height: 100%;
}
.dialog-image-stage {
width: 100%;
max-width: 100%;
height: 100%;
overflow: hidden;
border-radius: 10px;
background: linear-gradient(180deg, #f6f1e6 0%, #efe6d3 100%);
display: flex;
align-items: center;
justify-content: center;
}
.dialog-image-empty {
width: 100%;
max-width: 100%;
height: var(--pc-media-h);
border: 1px dashed #c5b28d;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: #faf6ee;
}
.product-card-fields {
grid-column: 1;
grid-row: 1;
border: 1px solid #e4dac7;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #fdfaf4 100%);
padding: 12px;
height: var(--pc-media-h);
overflow: auto;
}
.product-card-price-panel {
grid-column: 3;
grid-row: 1;
min-width: 0;
height: var(--pc-media-h);
overflow: hidden;
}
.field-row {
display: grid;
grid-template-columns: 150px 1fr;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #efe5d5;
font-size: 13px;
}
.field-row.field-row-head {
background: #f8f3e9;
border: 1px solid #e6dccb;
border-radius: 8px;
padding: 8px 10px;
margin-bottom: 8px;
}
.field-row:last-child {
border-bottom: none;
}
.field-row .k {
color: #6b5a33;
font-weight: 700;
}
.field-row .v {
color: #1f1f1f;
word-break: break-word;
}
.product-card-section {
position: relative;
margin-top: 12px;
border: 1px solid #e6dccb;
border-radius: 8px;
background: #fffdf8;
padding: 10px;
}
.product-card-price-section {
height: 100%;
margin-top: 0;
overflow: auto;
background: linear-gradient(180deg, #fffdf8 0%, #fff7ec 100%);
}
.product-card-section-title {
font-size: 13px;
font-weight: 800;
color: #5e4a22;
margin-bottom: 8px;
}
.price-info-grid {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.price-info-header {
display: grid;
grid-template-columns: 74px 1fr 1fr;
gap: 8px;
margin-bottom: 6px;
padding: 0 8px;
color: #6b5a33;
font-size: 11px;
font-weight: 800;
text-align: right;
}
.price-info-header span:first-child {
text-align: left;
}
.price-info-row {
display: grid;
grid-template-columns: 74px 1fr 1fr;
gap: 8px;
align-items: center;
min-height: 34px;
padding: 6px 8px;
border: 1px solid #f0e5d2;
border-radius: 6px;
background: #fff;
font-size: 13px;
}
.price-label {
font-weight: 800;
color: #6b5a33;
}
.price-value,
.price-campaign {
min-height: 26px;
padding: 5px 7px;
border-radius: 5px;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 800;
background: #fff;
}
.price-campaign {
color: #8a8a8a;
background: #f4f4f4;
font-weight: 800;
}
.price-info-row-usd .price-value {
color: #16803a;
}
.price-info-row-eur .price-value {
color: #b91c1c;
}
.price-info-row-try .price-value {
color: #185abc;
}
.price-info-row-usd.has-campaign-price .price-campaign {
background: #dff6e7;
color: #0f6b2f;
font-weight: 900;
}
.price-info-row-eur.has-campaign-price .price-campaign {
background: #fde2e2;
color: #a61717;
font-weight: 900;
}
.price-info-row-try.has-campaign-price .price-campaign {
background: #e2edff;
color: #174ea6;
font-weight: 900;
}
.size-stock-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(62px, 1fr));
gap: 6px;
}
.size-stock-cell {
min-height: 42px;
border: 1px solid #eadfca;
border-radius: 6px;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4px;
}
.size-label {
font-size: 11px;
font-weight: 800;
color: #6b5a33;
}
.size-qty {
font-size: 13px;
font-weight: 800;
color: #1f1f1f;
font-variant-numeric: tabular-nums;
}
.product-card-empty-text {
color: #7a6d55;
font-size: 12px;
padding: 6px 0;
}
.image-fullscreen-dialog {
background: #f4f0e2;
}
.image-fullscreen-body {
height: calc(100vh - 72px);
display: flex;
align-items: center;
justify-content: center;
}
.image-fullscreen-carousel {
width: min(98vw, 1500px);
}
.image-fullscreen-stage {
width: min(96vw, 1400px);
height: calc(100vh - 120px);
border-radius: 10px;
background: linear-gradient(180deg, #f1e7d3 0%, #e9dcc4 100%);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.image-fullscreen-img {
width: 100%;
height: 100%;
}
.q-btn,
.q-icon,
.cursor-pointer {
cursor: pointer !important;
}
@media (max-width: 1024px) {
.product-card-content {
grid-template-columns: 1fr;
}
.product-card-images,
.product-card-fields,
.product-card-price-panel {
grid-column: 1;
grid-row: auto;
height: auto;
min-height: 320px;
}
}
</style>

View File

@@ -0,0 +1,172 @@
<template>
<q-page v-if="canUpdateSystem" class="q-pa-md">
<div class="row justify-end q-mb-md">
<q-btn
color="primary"
icon="save"
label="Degisiklikleri Kaydet"
:loading="store.saving"
:disable="!hasChanges"
@click="saveChanges"
/>
</div>
<q-table
flat
bordered
dense
row-key="group_code"
:loading="store.loading"
:rows="store.rows"
:columns="columns"
:rows-per-page-options="[0]"
:pagination="{ rowsPerPage: 0 }"
>
<template #body-cell-mail_selector="props">
<q-td :props="props">
<q-select
:model-value="editableByGroup[props.row.group_code] || []"
:options="mailOptionsByGroup[props.row.group_code] || allMailOptions"
option-value="id"
option-label="label"
emit-value
map-options
multiple
use-chips
use-input
input-debounce="0"
clearable
dense
outlined
label="Mail ara ve sec"
@filter="(val, update) => filterMailOptions(props.row.group_code, val, update)"
@update:model-value="(val) => updateRowSelection(props.row.group_code, val)"
/>
</q-td>
</template>
</q-table>
</q-page>
<q-page v-else class="q-pa-md flex flex-center">
<div class="text-negative text-subtitle1">
Bu module erisim yetkiniz yok.
</div>
</q-page>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useQuasar } from 'quasar'
import { usePermission } from 'src/composables/usePermission'
import { useOrderPriceListMailMappingStore } from 'src/stores/orderPriceListMailMappingStore'
const $q = useQuasar()
const store = useOrderPriceListMailMappingStore()
const { canUpdate } = usePermission()
const canUpdateSystem = canUpdate('system')
const editableByGroup = ref({})
const originalByGroup = ref({})
const mailOptionsByGroup = ref({})
const columns = [
{ name: 'group_code', label: 'Urun Ilk Grup Kodu', field: 'group_code', align: 'left' },
{ name: 'group_title', label: 'Urun Ilk Grup Aciklama', field: 'group_title', align: 'left' },
{ name: 'mail_selector', label: 'Fiyat Listesi Mail Eslestirme', field: 'mail_selector', align: 'left' }
]
const allMailOptions = computed(() =>
(store.mails || []).map((m) => ({ id: m.id, label: m.display_name || m.email }))
)
const changedGroups = computed(() => {
return (store.rows || [])
.map((r) => String(r.group_code || r.urun_ilk_grubu || '').trim())
.filter(Boolean)
.filter((g) => {
const current = normalizeList(editableByGroup.value[g] || [])
const original = normalizeList(originalByGroup.value[g] || [])
return !isEqualList(current, original)
})
})
const hasChanges = computed(() => changedGroups.value.length > 0)
function normalizeList (list) {
return Array.from(
new Set(
(Array.isArray(list) ? list : [])
.map((x) => String(x).trim())
.filter(Boolean)
)
).sort()
}
function isEqualList (a, b) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false
}
return true
}
function initEditableState () {
const editable = {}
const original = {}
;(store.rows || []).forEach((row) => {
const g = String(row.group_code || row.urun_ilk_grubu || '').trim()
const selected = normalizeList(row.mail_ids || [])
editable[g] = [...selected]
original[g] = [...selected]
})
editableByGroup.value = editable
originalByGroup.value = original
mailOptionsByGroup.value = {}
}
function updateRowSelection (group, newValue) {
const g = String(group || '').trim()
editableByGroup.value = { ...editableByGroup.value, [g]: normalizeList(newValue) }
}
function filterMailOptions (group, search, update) {
const g = String(group || '').trim()
update(() => {
const q = String(search || '').trim().toLowerCase()
const filtered = !q
? allMailOptions.value
: allMailOptions.value.filter((opt) => String(opt.label || '').toLowerCase().includes(q))
mailOptionsByGroup.value = { ...mailOptionsByGroup.value, [g]: filtered }
})
}
async function init () {
try {
await Promise.all([store.fetchLookups(), store.fetchRows()])
initEditableState()
} catch (err) {
$q.notify({ type: 'negative', message: err?.message || 'Fiyat listesi mail eslestirmeleri yuklenemedi' })
}
}
async function saveChanges () {
if (!hasChanges.value) return
try {
for (const g of changedGroups.value) {
await store.saveGroupMails(g, editableByGroup.value[g] || [])
}
await store.fetchRows()
initEditableState()
$q.notify({ type: 'positive', message: 'Degisiklikler kaydedildi' })
} catch (err) {
$q.notify({ type: 'negative', message: err?.message || 'Kayit hatasi' })
}
}
onMounted(() => { init() })
</script>

View File

@@ -0,0 +1,163 @@
<template>
<q-page v-if="canUpdateSystem" class="q-pa-md">
<div class="row items-center justify-between q-mb-md">
<div>
<div class="text-h6">Kullanici Fiyat Grubu Eslestirme</div>
<div class="text-caption text-grey-7">Fiyat Listesi ekraninda kullanicinin gorebilecegi fiyat gruplarini belirler.</div>
</div>
<q-btn
color="primary"
icon="save"
label="Degisiklikleri Kaydet"
:loading="store.saving"
:disable="!hasChanges"
@click="saveChanges"
/>
</div>
<q-table
flat
bordered
dense
row-key="user_id"
:loading="store.loading"
:rows="store.rows"
:columns="columns"
:rows-per-page-options="[0]"
:pagination="{ rowsPerPage: 0 }"
>
<template #body-cell-price_groups="props">
<q-td :props="props">
<q-select
:model-value="editableByUser[props.row.user_id] || []"
:options="store.priceGroupOptions"
option-value="value"
option-label="label"
emit-value
map-options
multiple
use-chips
clearable
dense
outlined
label="Fiyat gruplari"
@update:model-value="(val) => updateRowSelection(props.row.user_id, val)"
>
<template #before-options>
<q-item clickable @click="selectAll(props.row.user_id)">
<q-item-section>Tumunu Sec</q-item-section>
</q-item>
<q-item clickable @click="clearAll(props.row.user_id)">
<q-item-section>Temizle</q-item-section>
</q-item>
<q-separator />
</template>
</q-select>
</q-td>
</template>
</q-table>
</q-page>
<q-page v-else class="q-pa-md flex flex-center">
<div class="text-negative text-subtitle1">
Bu module erisim yetkiniz yok.
</div>
</q-page>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useQuasar } from 'quasar'
import { usePermission } from 'src/composables/usePermission'
import { useOrderPriceListUserPriceGroupStore } from 'src/stores/orderPriceListUserPriceGroupStore'
const $q = useQuasar()
const store = useOrderPriceListUserPriceGroupStore()
const { canUpdate } = usePermission()
const canUpdateSystem = canUpdate('system')
const editableByUser = ref({})
const originalByUser = ref({})
const columns = [
{ name: 'username', label: 'Kullanici Kodu', field: 'username', align: 'left', sortable: true },
{ name: 'full_name', label: 'Ad Soyad', field: 'full_name', align: 'left', sortable: true },
{ name: 'email', label: 'E-Posta', field: 'email', align: 'left' },
{ name: 'price_groups', label: 'Gorebilecegi Fiyat Gruplari', field: 'price_groups', align: 'left' }
]
const changedUsers = computed(() => {
return (store.rows || [])
.map((r) => Number(r.user_id || 0))
.filter(Boolean)
.filter((id) => !isEqualList(normalizeList(editableByUser.value[id] || []), normalizeList(originalByUser.value[id] || [])))
})
const hasChanges = computed(() => changedUsers.value.length > 0)
function normalizeList (list) {
const allowed = new Set((store.priceGroupOptions || []).map((x) => x.value))
return Array.from(new Set((Array.isArray(list) ? list : []).map((x) => String(x).trim()).filter((x) => allowed.has(x)))).sort()
}
function isEqualList (a, b) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false
}
return true
}
function initEditableState () {
const editable = {}
const original = {}
for (const row of store.rows || []) {
const id = Number(row.user_id || 0)
if (!id) continue
const selected = normalizeList(row.price_groups || [])
editable[id] = [...selected]
original[id] = [...selected]
}
editableByUser.value = editable
originalByUser.value = original
}
function updateRowSelection (userId, newValue) {
const id = Number(userId || 0)
if (!id) return
editableByUser.value = { ...editableByUser.value, [id]: normalizeList(newValue) }
}
function selectAll (userId) {
updateRowSelection(userId, store.priceGroupOptions.map((x) => x.value))
}
function clearAll (userId) {
updateRowSelection(userId, [])
}
async function init () {
try {
await Promise.all([store.fetchLookups(), store.fetchRows()])
initEditableState()
} catch (err) {
$q.notify({ type: 'negative', message: err?.message || 'Kullanici fiyat grubu eslestirmeleri yuklenemedi' })
}
}
async function saveChanges () {
if (!hasChanges.value) return
try {
for (const id of changedUsers.value) {
await store.saveUserPriceGroups(id, editableByUser.value[id] || [])
}
await store.fetchRows()
initEditableState()
$q.notify({ type: 'positive', message: 'Degisiklikler kaydedildi' })
} catch (err) {
$q.notify({ type: 'negative', message: err?.message || 'Kayit hatasi' })
}
}
onMounted(() => { init() })
</script>

View File

@@ -106,6 +106,13 @@
</q-item>
</q-list>
</q-btn-dropdown>
<q-toggle
v-model="showInStockOnly"
dense
color="primary"
label="Sadece stogu olanlar"
:disable="pageBusy"
/>
</div>
<div class="toolbar-group">
@@ -1245,6 +1252,7 @@ const selectedCurrencies = ref(['USD', 'EUR', 'TRY'])
const exportAllLoading = ref(false)
const showSelectedOnly = ref(false)
const leftDetailsExpanded = ref(true)
const showInStockOnly = ref(false)
const calcLoadingMap = ref({})
const bulkCalcLoading = ref(false)
const saving = ref(false)
@@ -1528,6 +1536,7 @@ function rowSelectionKey (row) {
const filteredRows = computed(() => {
return rows.value.filter((row) => {
if (showSelectedOnly.value && !selectedMap.value[rowSelectionKey(row)]) return false
if (showInStockOnly.value && Number(row?.stockQty ?? 0) <= 0) 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.
@@ -1816,10 +1825,20 @@ function getOriginalCellValue (row, field) {
return row?.[`__orig_${field}`] ?? row?.[field] ?? 0
}
function exportDecimalValue (value) {
const n = parseNumber(value)
if (!Number.isFinite(n)) return ''
return n.toFixed(2).replace('.', ',')
}
function isExportDecimalField (field) {
return editableColumnSet.has(field) || field === 'costPrice' || field === 'basePriceUsd' || field === 'basePriceTry'
}
function exportCellValue (row, field) {
if (field === 'stockQty') return formatStock(row?.[field])
if (field === 'stockEntryDate' || field === 'lastCostingDate' || field === 'lastPricingDate') return formatDateDisplay(row?.[field])
if (editableColumnSet.has(field)) return String(round2(row?.[field] || 0))
if (isExportDecimalField(field)) return exportDecimalValue(row?.[field])
return String(row?.[field] ?? '').trim()
}

View File

@@ -277,6 +277,34 @@
/>
</div>
<div class="col-12">
<div class="text-caption text-grey-7 q-mb-xs">Fiyat Listesi Fiyat Gruplari</div>
<q-select
v-model="form.order_price_list_price_groups"
:options="orderPriceListPriceGroupOptions"
option-label="label"
option-value="value"
emit-value
map-options
multiple
use-chips
clearable
dense
filled
behavior="menu"
>
<template #before-options>
<q-item clickable @click="selectAllOrderPriceGroups">
<q-item-section>Tumunu Sec</q-item-section>
</q-item>
<q-item clickable @click="clearOrderPriceGroups">
<q-item-section>Temizle</q-item-section>
</q-item>
<q-separator />
</template>
</q-select>
</div>
</div>
</q-card-section>
</q-card>
@@ -320,6 +348,7 @@ const {
departmentOptions,
piyasaOptions,
nebimUserOptions,
orderPriceListPriceGroupOptions,
sendingPasswordMail,
lastPasswordMailSentAt
} = storeToRefs(store)
@@ -373,6 +402,16 @@ function clearPiyasalar () {
form.value.piyasalar = []
}
function selectAllOrderPriceGroups () {
form.value.order_price_list_price_groups = (orderPriceListPriceGroupOptions.value || [])
.map((o) => o.value)
.filter(Boolean)
}
function clearOrderPriceGroups () {
form.value.order_price_list_price_groups = []
}
/* ================= LIFECYCLE ================= */
watch(
() => userId.value,

View File

@@ -106,6 +106,13 @@
</q-item>
</q-list>
</q-btn-dropdown>
<q-toggle
v-model="showInStockOnly"
dense
color="primary"
label="Sadece stogu olanlar"
:disable="pageBusy"
/>
<div class="row items-center q-gutter-xs">
<q-chip
@@ -884,6 +891,7 @@ const currentPage = ref(1)
let reloadTimer = null
const variantRows = ref([])
const variantRowsCache = new Map()
const variantCodeCollator = new Intl.Collator('tr', { numeric: true, sensitivity: 'base' })
const VARIANT_ROWS_CACHE_LIMIT = 16
const GUIDANCE_MSG = "Calismak icin once Urun Ilk Grubu veya Urun Ana Grubu Secin ve GRUPLARI GETIR'e Basin."
@@ -1301,6 +1309,7 @@ const selectedPriceOptions = ref(priceOptionOptions.map((x) => x.value))
const exportAllLoading = ref(false)
const showSelectedOnly = ref(false)
const leftDetailsExpanded = ref(true)
const showInStockOnly = ref(false)
// Keep the old calc state around (not used on this screen) to avoid touching shared helpers.
const calcLoadingMap = ref({})
@@ -1601,6 +1610,7 @@ function rowSelectionKey (row) {
const filteredRows = computed(() => {
return rows.value.filter((row) => {
if (showSelectedOnly.value && !selectedMap.value[rowSelectionKey(row)]) return false
if (showInStockOnly.value && Number(row?.stockQty ?? 0) <= 0) 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.
@@ -1916,10 +1926,27 @@ function getOriginalCellValue (row, field) {
return row?.[`__orig_${field}`] ?? row?.[field] ?? 0
}
function exportDecimalValue (value) {
const n = parseNumber(value)
if (!Number.isFinite(n)) return ''
return n.toFixed(2).replace('.', ',')
}
function isExportDecimalField (field) {
return field === 'campaignRate' ||
field === 'belowBaseDiff' ||
field === 'costPrice' ||
field === 'basePriceUsd' ||
field === 'basePriceTry' ||
/^usd\d$/i.test(field) ||
/^eur\d$/i.test(field) ||
/^try\d$/i.test(field)
}
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))
if (isExportDecimalField(field)) return exportDecimalValue(row?.[field])
return String(row?.[field] ?? '').trim()
}
@@ -2678,7 +2705,7 @@ async function buildVariantRowsForProductPage (baseProductRows = []) {
})
continue
}
vs.sort((a, b) => String(a?.variant_code || '').localeCompare(String(b?.variant_code || ''), 'tr'))
vs.sort((a, b) => variantCodeCollator.compare(String(a?.variant_code || ''), String(b?.variant_code || '')))
for (const v of vs) {
const d1 = Number(v?.dim1 || 0)
const d3 = v?.dim3 == null ? null : Number(v?.dim3 || 0)

View File

@@ -251,6 +251,18 @@ const routes = [
component: () => import('../pages/PricingMailMapping.vue'),
meta: { permission: 'system:update' }
},
{
path: 'order-price-list-mail-mapping',
name: 'order-price-list-mail-mapping',
component: () => import('../pages/OrderPriceListMailMapping.vue'),
meta: { permission: 'system:update' }
},
{
path: 'order-price-list-user-price-groups',
name: 'order-price-list-user-price-groups',
component: () => import('../pages/OrderPriceListUserPriceGroupMapping.vue'),
meta: { permission: 'system:update' }
},
{
path: 'language/translations',
name: 'translation-table',

View File

@@ -26,14 +26,16 @@ export const useUserDetailStore = defineStore('userDetail', {
roles: [],
departments: null,
piyasalar: [],
nebim_users: null
nebim_users: null,
order_price_list_price_groups: []
},
/* ================= LOOKUPS ================= */
roleOptions: [],
departmentOptions: [],
piyasaOptions: [],
nebimUserOptions: []
nebimUserOptions: [],
orderPriceListPriceGroupOptions: []
}),
actions: {
@@ -52,7 +54,8 @@ export const useUserDetailStore = defineStore('userDetail', {
roles: [],
departments: null,
piyasalar: [],
nebim_users: null
nebim_users: null,
order_price_list_price_groups: []
}
this.error = null
this.hasPassword = false
@@ -113,6 +116,7 @@ export const useUserDetailStore = defineStore('userDetail', {
departments: departmentCodes.map(code => ({ code })),
piyasalar: (this.form.piyasalar || []).map(code => ({ code })),
order_price_list_price_groups: this.form.order_price_list_price_groups || [],
nebim_users: nebimUsernames.map(username => {
const opt = (this.nebimUserOptions || []).find(x => x.value === username)
@@ -146,6 +150,7 @@ export const useUserDetailStore = defineStore('userDetail', {
this.form.departments = (data.departments || []).map(x => x.code)[0] || null
this.form.piyasalar = (data.piyasalar || []).map(x => x.code)
this.form.nebim_users = (data.nebim_users || []).map(x => x.username)[0] || null
this.form.order_price_list_price_groups = data.order_price_list_price_groups || []
this.hasPassword = !!data.has_password
} catch (e) {
@@ -237,17 +242,22 @@ export const useUserDetailStore = defineStore('userDetail', {
===================================================== */
async fetchLookups () {
// token otomatik
const [roles, depts, piyasalar, nebims] = await Promise.all([
const [roles, depts, piyasalar, nebims, priceGroups] = await Promise.all([
api.get('/lookups/roles'),
api.get('/lookups/departments'),
api.get('/lookups/piyasalar'),
api.get('/lookups/nebim-users')
api.get('/lookups/nebim-users'),
api.get('/users/order-price-list-price-groups/lookups')
])
this.roleOptions = roles?.data || roles || []
this.departmentOptions = depts?.data || depts || []
this.piyasaOptions = piyasalar?.data || piyasalar || []
this.nebimUserOptions = nebims?.data || nebims || []
this.orderPriceListPriceGroupOptions = (priceGroups?.data?.price_groups || []).map(x => ({
label: x.label || x.value,
value: x.value
}))
}
}
})

View File

@@ -0,0 +1,47 @@
import { defineStore } from 'pinia'
import api from 'src/services/api'
export const useOrderPriceListMailMappingStore = defineStore('orderPriceListMailMapping', {
state: () => ({
loading: false,
saving: false,
firstGroups: [],
mails: [],
rows: []
}),
actions: {
async fetchLookups () {
this.loading = true
try {
const res = await api.get('/system/order-price-list-mail-mappings/lookups')
const payload = res?.data || {}
this.firstGroups = Array.isArray(payload.first_groups) ? payload.first_groups : []
this.mails = Array.isArray(payload.mails) ? payload.mails : []
} finally {
this.loading = false
}
},
async fetchRows () {
this.loading = true
try {
const res = await api.get('/system/order-price-list-mail-mappings')
this.rows = Array.isArray(res?.data) ? res.data : []
} finally {
this.loading = false
}
},
async saveGroupMails (urunIlkGrubu, mailIds) {
this.saving = true
try {
await api.put(`/system/order-price-list-mail-mappings/${encodeURIComponent(String(urunIlkGrubu || '').trim())}`, {
mail_ids: Array.isArray(mailIds) ? mailIds : []
})
} finally {
this.saving = false
}
}
}
})

View File

@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import api from 'src/services/api'
export const useOrderPriceListUserPriceGroupStore = defineStore('orderPriceListUserPriceGroup', {
state: () => ({
loading: false,
saving: false,
rows: [],
priceGroupOptions: []
}),
actions: {
async fetchLookups () {
const res = await api.get('/system/order-price-list-user-price-groups/lookups')
this.priceGroupOptions = (res?.data?.price_groups || []).map((x) => ({
label: x.label || x.value,
value: x.value
}))
},
async fetchRows () {
this.loading = true
try {
const res = await api.get('/system/order-price-list-user-price-groups')
this.rows = Array.isArray(res?.data) ? res.data : []
} finally {
this.loading = false
}
},
async saveUserPriceGroups (userId, priceGroups) {
this.saving = true
try {
await api.put(`/system/order-price-list-user-price-groups/${encodeURIComponent(String(userId))}`, {
price_groups: Array.isArray(priceGroups) ? priceGroups : []
})
} finally {
this.saving = false
}
}
}
})