Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-04-22 11:19:36 +03:00
parent e6ae925f1c
commit d2387bc221
10 changed files with 855 additions and 543 deletions

View File

@@ -6,21 +6,162 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
) )
func GetProductPricingList(ctx context.Context, limit int, afterProductCode string) ([]models.ProductPricing, error) { type ProductPricingFilters struct {
Search string
ProductCode []string
BrandGroup []string
AskiliYan []string
Kategori []string
UrunIlkGrubu []string
UrunAnaGrubu []string
UrunAltGrubu []string
Icerik []string
Karisim []string
Marka []string
}
type ProductPricingPage struct {
Rows []models.ProductPricing
TotalCount int
TotalPages int
Page int
Limit int
}
func GetProductPricingPage(ctx context.Context, page int, limit int, filters ProductPricingFilters) (ProductPricingPage, error) {
result := ProductPricingPage{
Rows: []models.ProductPricing{},
TotalCount: 0,
TotalPages: 0,
Page: page,
Limit: limit,
}
if page <= 0 {
page = 1
}
if limit <= 0 { if limit <= 0 {
limit = 500 limit = 500
} }
afterProductCode = strings.TrimSpace(afterProductCode) offset := (page - 1) * limit
paramIndex := 1
args := make([]any, 0, 64)
nextParam := func() string {
name := "@p" + strconv.Itoa(paramIndex)
paramIndex++
return name
}
whereParts := []string{
"ProductAtt42 IN ('SERI', 'AKSESUAR')",
"IsBlocked = 0",
"LEN(LTRIM(RTRIM(ProductCode))) = 13",
}
addInFilter := func(expr string, values []string) {
clean := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
clean = append(clean, v)
}
if len(clean) == 0 {
return
}
ors := make([]string, 0, len(clean))
for _, v := range clean {
p := nextParam()
ors = append(ors, expr+" = "+p)
args = append(args, v)
}
whereParts = append(whereParts, "("+strings.Join(ors, " OR ")+")")
}
brandGroupExpr := `CASE ABS(CHECKSUM(LTRIM(RTRIM(ProductCode)))) % 3
WHEN 0 THEN 'MARKA GRUBU A'
WHEN 1 THEN 'MARKA GRUBU B'
ELSE 'MARKA GRUBU C'
END`
addInFilter("LTRIM(RTRIM(ProductCode))", filters.ProductCode)
addInFilter(brandGroupExpr, filters.BrandGroup)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '')", filters.AskiliYan)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '')", filters.Kategori)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '')", filters.UrunIlkGrubu)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt01Desc)), '')", filters.UrunAnaGrubu)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '')", filters.UrunAltGrubu)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '')", filters.Icerik)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt29Desc)), '')", filters.Karisim)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '')", filters.Marka)
if q := strings.TrimSpace(filters.Search); q != "" {
p := nextParam()
args = append(args, "%"+q+"%")
whereParts = append(whereParts, "("+strings.Join([]string{
"LTRIM(RTRIM(ProductCode)) LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt01Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt29Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') LIKE " + p,
}, " OR ")+")")
}
whereSQL := strings.Join(whereParts, " AND ")
countQuery := `
SELECT COUNT(DISTINCT LTRIM(RTRIM(ProductCode)))
FROM ProductFilterWithDescription('TR')
WHERE ` + whereSQL + `;
`
var totalCount int
if err := db.MssqlDB.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil {
return result, err
}
result.TotalCount = totalCount
if totalCount == 0 {
result.TotalPages = 0
result.Page = 1
return result, nil
}
totalPages := int(math.Ceil(float64(totalCount) / float64(limit)))
if totalPages <= 0 {
totalPages = 1
}
if page > totalPages {
page = totalPages
offset = (page - 1) * limit
}
result.Page = page
result.Limit = limit
result.TotalPages = totalPages
// Stage 1: fetch only paged products first (fast path). // Stage 1: fetch only paged products first (fast path).
productQuery := ` productQuery := `
SELECT TOP (` + strconv.Itoa(limit) + `) IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes;
IF OBJECT_ID('tempdb..#stock_base') IS NOT NULL DROP TABLE #stock_base;
SELECT
f.ProductCode,
MAX(f.BrandGroupSec) AS BrandGroupSec,
MAX(f.AskiliYan) AS AskiliYan,
MAX(f.Kategori) AS Kategori,
MAX(f.UrunIlkGrubu) AS UrunIlkGrubu,
MAX(f.UrunAnaGrubu) AS UrunAnaGrubu,
MAX(f.UrunAltGrubu) AS UrunAltGrubu,
MAX(f.Icerik) AS Icerik,
MAX(f.Karisim) AS Karisim,
MAX(f.Marka) AS Marka
INTO #req_codes
FROM (
SELECT
LTRIM(RTRIM(ProductCode)) AS ProductCode, LTRIM(RTRIM(ProductCode)) AS ProductCode,
` + brandGroupExpr + ` AS BrandGroupSec,
COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '') AS AskiliYan, COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '') AS AskiliYan,
COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '') AS Kategori, COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '') AS Kategori,
COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '') AS UrunIlkGrubu, COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '') AS UrunIlkGrubu,
@@ -30,11 +171,44 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
COALESCE(LTRIM(RTRIM(ProductAtt29Desc)), '') AS Karisim, COALESCE(LTRIM(RTRIM(ProductAtt29Desc)), '') AS Karisim,
COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') AS Marka COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') AS Marka
FROM ProductFilterWithDescription('TR') FROM ProductFilterWithDescription('TR')
WHERE ProductAtt42 IN ('SERI', 'AKSESUAR') WHERE ` + whereSQL + `
AND IsBlocked = 0 ) f
AND LEN(LTRIM(RTRIM(ProductCode))) = 13 GROUP BY f.ProductCode;
AND (@p1 = '' OR LTRIM(RTRIM(ProductCode)) > @p1)
ORDER BY LTRIM(RTRIM(ProductCode)); CREATE CLUSTERED INDEX IX_req_codes_ProductCode ON #req_codes(ProductCode);
SELECT
LTRIM(RTRIM(s.ItemCode)) AS ItemCode,
SUM(s.In_Qty1 - s.Out_Qty1) AS InventoryQty1
INTO #stock_base
FROM trStock s WITH(NOLOCK)
INNER JOIN #req_codes rc
ON rc.ProductCode = LTRIM(RTRIM(s.ItemCode))
WHERE s.ItemTypeCode = 1
AND LEN(LTRIM(RTRIM(s.ItemCode))) = 13
GROUP BY LTRIM(RTRIM(s.ItemCode));
CREATE CLUSTERED INDEX IX_stock_base_ItemCode ON #stock_base(ItemCode);
SELECT
rc.ProductCode,
rc.BrandGroupSec,
rc.AskiliYan,
rc.Kategori,
rc.UrunIlkGrubu,
rc.UrunAnaGrubu,
rc.UrunAltGrubu,
rc.Icerik,
rc.Karisim,
rc.Marka
FROM #req_codes rc
LEFT JOIN #stock_base sb
ON sb.ItemCode = rc.ProductCode
ORDER BY
CAST(ROUND(ISNULL(sb.InventoryQty1, 0), 2) AS DECIMAL(18, 2)) DESC,
rc.ProductCode ASC
OFFSET ` + strconv.Itoa(offset) + ` ROWS
FETCH NEXT ` + strconv.Itoa(limit) + ` ROWS ONLY;
` `
var ( var (
@@ -43,7 +217,7 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
) )
for attempt := 1; attempt <= 3; attempt++ { for attempt := 1; attempt <= 3; attempt++ {
var err error var err error
rows, err = db.MssqlDB.QueryContext(ctx, productQuery, afterProductCode) rows, err = db.MssqlDB.QueryContext(ctx, productQuery, args...)
if err == nil { if err == nil {
rowsErr = nil rowsErr = nil
break break
@@ -60,7 +234,7 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
} }
} }
if rowsErr != nil { if rowsErr != nil {
return nil, rowsErr return result, rowsErr
} }
defer rows.Close() defer rows.Close()
@@ -69,6 +243,7 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
var item models.ProductPricing var item models.ProductPricing
if err := rows.Scan( if err := rows.Scan(
&item.ProductCode, &item.ProductCode,
&item.BrandGroupSec,
&item.AskiliYan, &item.AskiliYan,
&item.Kategori, &item.Kategori,
&item.UrunIlkGrubu, &item.UrunIlkGrubu,
@@ -78,15 +253,16 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
&item.Karisim, &item.Karisim,
&item.Marka, &item.Marka,
); err != nil { ); err != nil {
return nil, err return result, err
} }
out = append(out, item) out = append(out, item)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err return result, err
} }
if len(out) == 0 { if len(out) == 0 {
return out, nil result.Rows = out
return result, nil
} }
// Stage 2: fetch metrics only for paged product codes. // Stage 2: fetch metrics only for paged product codes.
@@ -134,6 +310,7 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
WHERE s.ItemTypeCode = 1 WHERE s.ItemTypeCode = 1
AND LEN(LTRIM(RTRIM(s.ItemCode))) = 13 AND LEN(LTRIM(RTRIM(s.ItemCode))) = 13
AND s.In_Qty1 > 0 AND s.In_Qty1 > 0
AND LTRIM(RTRIM(s.InnerProcessCode)) = 'OP'
AND LTRIM(RTRIM(s.WarehouseCode)) IN ( AND LTRIM(RTRIM(s.WarehouseCode)) IN (
'1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28', '1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28',
'1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3', '1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3',
@@ -214,7 +391,7 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
metricsRows, err := db.MssqlDB.QueryContext(ctx, metricsQuery, metricArgs...) metricsRows, err := db.MssqlDB.QueryContext(ctx, metricsQuery, metricArgs...)
if err != nil { if err != nil {
return nil, fmt.Errorf("metrics query failed: %w", err) return result, fmt.Errorf("metrics query failed: %w", err)
} }
defer metricsRows.Close() defer metricsRows.Close()
@@ -237,12 +414,12 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
&m.StockEntryDate, &m.StockEntryDate,
&m.LastPricingDate, &m.LastPricingDate,
); err != nil { ); err != nil {
return nil, err return result, err
} }
metricsByCode[strings.TrimSpace(code)] = m metricsByCode[strings.TrimSpace(code)] = m
} }
if err := metricsRows.Err(); err != nil { if err := metricsRows.Err(); err != nil {
return nil, err return result, err
} }
for i := range out { for i := range out {
@@ -254,7 +431,8 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
} }
} }
return out, nil result.Rows = out
return result, nil
} }
func isTransientMSSQLNetworkError(err error) bool { func isTransientMSSQLNetworkError(err error) bool {

View File

@@ -18,6 +18,7 @@ import (
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/lib/pq"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -497,25 +498,65 @@ func UserCreateRoute(db *sql.DB) http.HandlerFunc {
// ROLES // ROLES
for _, role := range payload.Roles { for _, role := range payload.Roles {
_, _ = tx.Exec(queries.InsertUserRole, newID, role) role = strings.TrimSpace(role)
if role == "" {
continue
}
if _, err := tx.Exec(queries.InsertUserRole, newID, role); err != nil {
log.Printf("USER ROLE INSERT ERROR user_id=%d role=%q err=%v", newID, role, err)
http.Error(w, "Rol eklenemedi", http.StatusInternalServerError)
return
}
} }
// DEPARTMENTS // DEPARTMENTS
for _, d := range payload.Departments { for _, d := range payload.Departments {
_, _ = tx.Exec(queries.InsertUserDepartment, newID, d.Code) code := strings.TrimSpace(d.Code)
if code == "" {
continue
}
if _, err := tx.Exec(queries.InsertUserDepartment, newID, code); err != nil {
log.Printf("USER DEPARTMENT INSERT ERROR user_id=%d department=%q err=%v", newID, code, err)
http.Error(w, "Departman eklenemedi", http.StatusInternalServerError)
return
}
} }
// PIYASALAR // PIYASALAR
for _, p := range payload.Piyasalar { for _, p := range payload.Piyasalar {
_, _ = tx.Exec(queries.InsertUserPiyasa, newID, p.Code) code := strings.TrimSpace(p.Code)
if code == "" {
continue
}
if _, err := tx.Exec(queries.InsertUserPiyasa, newID, code); err != nil {
log.Printf("USER PIYASA INSERT ERROR user_id=%d piyasa=%q err=%v", newID, code, err)
http.Error(w, "Piyasa eklenemedi", http.StatusInternalServerError)
return
}
} }
// NEBIM // NEBIM
for _, n := range payload.NebimUsers { for _, n := range payload.NebimUsers {
_, _ = tx.Exec(queries.InsertUserNebim, newID, n.Username) username := strings.TrimSpace(n.Username)
if username == "" {
continue
}
if _, err := tx.Exec(queries.InsertUserNebim, newID, username); err != nil {
log.Printf("USER NEBIM INSERT ERROR user_id=%d username=%q err=%v", newID, username, err)
http.Error(w, "Nebim kullanıcısı eklenemedi", http.StatusInternalServerError)
return
}
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
if pe, ok := err.(*pq.Error); ok {
log.Printf(
"USER CREATE COMMIT ERROR user_id=%d code=%s detail=%s constraint=%s table=%s err=%v",
newID, pe.Code, pe.Detail, pe.Constraint, pe.Table, err,
)
} else {
log.Printf("USER CREATE COMMIT ERROR user_id=%d err=%v", newID, err)
}
http.Error(w, "Commit başarısız", http.StatusInternalServerError) http.Error(w, "Commit başarısız", http.StatusInternalServerError)
return return
} }

View File

@@ -32,13 +32,31 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) {
limit := 500 limit := 500
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 10000 { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 500 {
limit = parsed limit = parsed
} }
} }
afterProductCode := strings.TrimSpace(r.URL.Query().Get("after_product_code")) page := 1
if raw := strings.TrimSpace(r.URL.Query().Get("page")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
page = parsed
}
}
filters := queries.ProductPricingFilters{
Search: strings.TrimSpace(r.URL.Query().Get("q")),
ProductCode: splitCSVParam(r.URL.Query().Get("product_code")),
BrandGroup: splitCSVParam(r.URL.Query().Get("brand_group_selection")),
AskiliYan: splitCSVParam(r.URL.Query().Get("askili_yan")),
Kategori: splitCSVParam(r.URL.Query().Get("kategori")),
UrunIlkGrubu: splitCSVParam(r.URL.Query().Get("urun_ilk_grubu")),
UrunAnaGrubu: splitCSVParam(r.URL.Query().Get("urun_ana_grubu")),
UrunAltGrubu: splitCSVParam(r.URL.Query().Get("urun_alt_grubu")),
Icerik: splitCSVParam(r.URL.Query().Get("icerik")),
Karisim: splitCSVParam(r.URL.Query().Get("karisim")),
Marka: splitCSVParam(r.URL.Query().Get("marka")),
}
rows, err := queries.GetProductPricingList(ctx, limit+1, afterProductCode) pageResult, err := queries.GetProductPricingPage(ctx, page, limit, filters)
if err != nil { if err != nil {
if isPricingTimeoutLike(err, ctx.Err()) { if isPricingTimeoutLike(err, ctx.Err()) {
log.Printf( log.Printf(
@@ -63,38 +81,24 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Urun fiyatlandirma listesi alinamadi: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Urun fiyatlandirma listesi alinamadi: "+err.Error(), http.StatusInternalServerError)
return return
} }
hasMore := len(rows) > limit
if hasMore {
rows = rows[:limit]
}
nextCursor := ""
if hasMore && len(rows) > 0 {
nextCursor = strings.TrimSpace(rows[len(rows)-1].ProductCode)
}
log.Printf( log.Printf(
"[ProductPricing] trace=%s success user=%s id=%d limit=%d after=%q count=%d has_more=%t next=%q duration_ms=%d", "[ProductPricing] trace=%s success user=%s id=%d page=%d limit=%d count=%d total=%d total_pages=%d duration_ms=%d",
traceID, traceID,
claims.Username, claims.Username,
claims.ID, claims.ID,
pageResult.Page,
limit, limit,
afterProductCode, len(pageResult.Rows),
len(rows), pageResult.TotalCount,
hasMore, pageResult.TotalPages,
nextCursor,
time.Since(started).Milliseconds(), time.Since(started).Milliseconds(),
) )
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
if hasMore { w.Header().Set("X-Total-Count", strconv.Itoa(pageResult.TotalCount))
w.Header().Set("X-Has-More", "true") w.Header().Set("X-Total-Pages", strconv.Itoa(pageResult.TotalPages))
} else { w.Header().Set("X-Page", strconv.Itoa(pageResult.Page))
w.Header().Set("X-Has-More", "false") _ = json.NewEncoder(w).Encode(pageResult.Rows)
}
if nextCursor != "" {
w.Header().Set("X-Next-Cursor", nextCursor)
}
_ = json.NewEncoder(w).Encode(rows)
} }
func buildPricingTraceID(r *http.Request) string { func buildPricingTraceID(r *http.Request) string {
@@ -124,3 +128,20 @@ func isPricingTimeoutLike(err error, ctxErr error) bool {
strings.Contains(e, "no connection could be made") || strings.Contains(e, "no connection could be made") ||
strings.Contains(e, "failed to respond") strings.Contains(e, "failed to respond")
} }
func splitCSVParam(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, p)
}
return out
}

View File

@@ -1,75 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { Quasar } from 'quasar'
import { markRaw } from 'vue'
import RootComponent from 'app/src/App.vue'
import createStore from 'app/src/stores/index'
import createRouter from 'app/src/router/index'
export default async function (createAppFn, quasarUserOptions) {
// Create the app instance.
// Here we inject into it the Quasar UI, the router & possibly the store.
const app = createAppFn(RootComponent)
app.use(Quasar, quasarUserOptions)
const store = typeof createStore === 'function'
? await createStore({})
: createStore
app.use(store)
const router = markRaw(
typeof createRouter === 'function'
? await createRouter({store})
: createRouter
)
// make router instance available in store
store.use(({ store }) => { store.router = router })
// Expose the app, the router and the store.
// Note that we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return {
app,
store,
router
}
}

View File

@@ -1,158 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { createApp } from 'vue'
import '@quasar/extras/roboto-font/roboto-font.css'
import '@quasar/extras/material-icons/material-icons.css'
// We load Quasar stylesheet file
import 'quasar/dist/quasar.sass'
import 'src/css/app.css'
import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js'
const publicPath = `/`
async function start ({
app,
router
, store
}, bootFiles) {
let hasRedirected = false
const getRedirectUrl = url => {
try { return router.resolve(url).href }
catch (err) {}
return Object(url) === url
? null
: url
}
const redirect = url => {
hasRedirected = true
if (typeof url === 'string' && /^https?:\/\//.test(url)) {
window.location.href = url
return
}
const href = getRedirectUrl(url)
// continue if we didn't fail to resolve the url
if (href !== null) {
window.location.href = href
window.location.reload()
}
}
const urlPath = window.location.href.replace(window.location.origin, '')
for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) {
try {
await bootFiles[i]({
app,
router,
store,
ssrContext: null,
redirect,
urlPath,
publicPath
})
}
catch (err) {
if (err && err.url) {
redirect(err.url)
return
}
console.error('[Quasar] boot error:', err)
return
}
}
if (hasRedirected === true) return
app.use(router)
app.mount('#q-app')
}
createQuasarApp(createApp, quasarUserOptions)
.then(app => {
// eventually remove this when Cordova/Capacitor/Electron support becomes old
const [ method, mapFn ] = Promise.allSettled !== void 0
? [
'allSettled',
bootFiles => bootFiles.map(result => {
if (result.status === 'rejected') {
console.error('[Quasar] boot error:', result.reason)
return
}
return result.value.default
})
]
: [
'all',
bootFiles => bootFiles.map(entry => entry.default)
]
return Promise[ method ]([
import(/* webpackMode: "eager" */ 'boot/dayjs'),
import(/* webpackMode: "eager" */ 'boot/locale'),
import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard')
]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
start(app, boot)
})
})

View File

@@ -1,116 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import App from 'app/src/App.vue'
let appPrefetch = typeof App.preFetch === 'function'
? App.preFetch
: (
// Class components return the component options (and the preFetch hook) inside __c property
App.__c !== void 0 && typeof App.__c.preFetch === 'function'
? App.__c.preFetch
: false
)
function getMatchedComponents (to, router) {
const route = to
? (to.matched ? to : router.resolve(to).route)
: router.currentRoute.value
if (!route) { return [] }
const matched = route.matched.filter(m => m.components !== void 0)
if (matched.length === 0) { return [] }
return Array.prototype.concat.apply([], matched.map(m => {
return Object.keys(m.components).map(key => {
const comp = m.components[key]
return {
path: m.path,
c: comp
}
})
}))
}
export function addPreFetchHooks ({ router, store, publicPath }) {
// Add router hook for handling preFetch.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
router.beforeResolve((to, from, next) => {
const
urlPath = window.location.href.replace(window.location.origin, ''),
matched = getMatchedComponents(to, router),
prevMatched = getMatchedComponents(from, router)
let diffed = false
const preFetchList = matched
.filter((m, i) => {
return diffed || (diffed = (
!prevMatched[i] ||
prevMatched[i].c !== m.c ||
m.path.indexOf('/:') > -1 // does it has params?
))
})
.filter(m => m.c !== void 0 && (
typeof m.c.preFetch === 'function'
// Class components return the component options (and the preFetch hook) inside __c property
|| (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function')
))
.map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch)
if (appPrefetch !== false) {
preFetchList.unshift(appPrefetch)
appPrefetch = false
}
if (preFetchList.length === 0) {
return next()
}
let hasRedirected = false
const redirect = url => {
hasRedirected = true
next(url)
}
const proceed = () => {
if (hasRedirected === false) { next() }
}
preFetchList.reduce(
(promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({
store,
currentRoute: to,
previousRoute: from,
redirect,
urlPath,
publicPath
})),
Promise.resolve()
)
.then(proceed)
.catch(e => {
console.error(e)
proceed()
})
})
}

View File

@@ -1,23 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import lang from 'quasar/lang/tr.js'
import {Loading,Dialog,Notify} from 'quasar'
export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} }

View File

@@ -35,6 +35,26 @@
/> />
<q-btn flat color="grey-7" icon="restart_alt" label="Filtreleri Sifirla" @click="resetAll" /> <q-btn flat color="grey-7" icon="restart_alt" label="Filtreleri Sifirla" @click="resetAll" />
<q-btn color="primary" icon="refresh" label="Veriyi Yenile" :loading="store.loading" @click="reloadData" /> <q-btn color="primary" icon="refresh" label="Veriyi Yenile" :loading="store.loading" @click="reloadData" />
<q-btn
color="primary"
outline
icon="edit_note"
label="Secili Olanlari Toplu Degistir"
:disable="selectedRowCount === 0"
@click="bulkDialogOpen = true"
/>
<q-pagination
v-model="currentPage"
color="primary"
:max="Math.max(1, store.totalPages || 1)"
:max-pages="8"
boundary-links
direction-links
@update:model-value="onPageChange"
/>
<div class="text-caption text-grey-8">
Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu
</div>
</div> </div>
</div> </div>
@@ -44,19 +64,15 @@
class="pane-table pricing-table" class="pane-table pricing-table"
flat flat
dense dense
row-key="id" row-key="productCode"
:rows="filteredRows" :rows="filteredRows"
:columns="visibleColumns" :columns="visibleColumns"
:loading="store.loading" :loading="tableLoading"
virtual-scroll
:virtual-scroll-item-size="rowHeight"
:virtual-scroll-sticky-size-start="headerHeight"
:virtual-scroll-slice-size="36"
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
v-model:pagination="tablePagination" :pagination="tablePagination"
hide-bottom hide-bottom
:table-style="tableStyle" :table-style="tableStyle"
@virtual-scroll="onTableVirtualScroll" @update:pagination="onPaginationChange"
> >
<template #header="props"> <template #header="props">
<q-tr :props="props" class="header-row-fixed"> <q-tr :props="props" class="header-row-fixed">
@@ -139,6 +155,54 @@
Sonuc yok Sonuc yok
</div> </div>
</div> </div>
<div v-else-if="isValueSelectFilterField(col.field)" class="excel-filter-menu">
<q-input
v-model="valueFilterSearch[col.field]"
dense
outlined
clearable
use-input
class="excel-filter-select"
placeholder="Deger ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tumunu Sec" @click="selectAllColumnFilterOptions(col.field)" />
<q-btn flat dense size="sm" label="Temizle" @click="clearColumnFilter(col.field)" />
</div>
<q-virtual-scroll
v-if="getFilterOptionsForField(col.field).length > 0"
class="excel-filter-options"
:items="getFilterOptionsForField(col.field)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
:key="`${col.field}-${option.value}`"
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue(col.field, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected(col.field, option.value)"
@update:model-value="() => toggleColumnFilterValue(col.field, option.value)"
@click.stop
/>
</q-item-section>
<q-item-section>
<q-item-label>{{ option.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-virtual-scroll>
<div v-else class="excel-filter-empty">
Sonuc yok
</div>
</div>
<div v-else-if="isNumberRangeFilterField(col.field)" class="excel-filter-menu"> <div v-else-if="isNumberRangeFilterField(col.field)" class="excel-filter-menu">
<div class="range-filter-grid"> <div class="range-filter-grid">
<q-input <q-input
@@ -216,8 +280,25 @@
<q-checkbox <q-checkbox
size="sm" size="sm"
color="primary" color="primary"
:model-value="!!selectedMap[props.row.id]" :model-value="isRowSelected(props.row.productCode)"
@update:model-value="(val) => toggleRowSelection(props.row.id, val)" @update:model-value="(val) => onRowCheckboxChange(props.row, val)"
@click.stop
/>
</q-td>
</template>
<template #body-cell-calcAction="props">
<q-td
:props="props"
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
:style="getBodyCellStyle(props.col)"
>
<q-btn
dense
size="sm"
color="primary"
label="Hesapla"
@click="calculateRow(props.row)"
/> />
</q-td> </q-td>
</template> </template>
@@ -306,17 +387,52 @@
<q-banner v-if="store.error" class="bg-red text-white q-mt-xs"> <q-banner v-if="store.error" class="bg-red text-white q-mt-xs">
Hata: {{ store.error }} Hata: {{ store.error }}
</q-banner> </q-banner>
<q-dialog v-model="bulkDialogOpen">
<q-card style="min-width: 420px; max-width: 95vw;">
<q-card-section class="text-subtitle1 text-weight-bold">
Secili Olanlari Toplu Degistir
</q-card-section>
<q-card-section class="q-gutter-sm">
<q-select
v-model="bulkField"
:options="bulkFieldOptions"
option-value="value"
option-label="label"
emit-value
map-options
dense
outlined
label="Alan"
/>
<q-input
v-model="bulkValue"
dense
outlined
label="Deger"
inputmode="decimal"
/>
<div class="text-caption text-grey-8">
Uygulanacak satir sayisi: {{ selectedRowCount }}
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Iptal" v-close-popup />
<q-btn color="primary" label="Uygula" @click="applyBulkUpdate" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page> </q-page>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useProductPricingStore } from 'src/stores/ProductPricingStore' import { useProductPricingStore } from 'src/stores/ProductPricingStore'
const store = useProductPricingStore() const store = useProductPricingStore()
const FETCH_LIMIT = 500 const PAGE_LIMIT = 500
const nextCursor = ref('') const currentPage = ref(1)
const loadingMore = ref(false) let reloadTimer = null
const usdToTry = 38.25 const usdToTry = 38.25
const eurToTry = 41.6 const eurToTry = 41.6
@@ -338,6 +454,8 @@ const currencyOptions = [
const multiFilterColumns = [ const multiFilterColumns = [
{ field: 'productCode', label: 'Urun Kodu' }, { field: 'productCode', label: 'Urun Kodu' },
{ field: 'brandGroupSelection', label: 'Marka Grubu Secimi' },
{ field: 'marka', label: 'Marka' },
{ field: 'askiliYan', label: 'Askili Yan' }, { field: 'askiliYan', label: 'Askili Yan' },
{ field: 'kategori', label: 'Kategori' }, { field: 'kategori', label: 'Kategori' },
{ field: 'urunIlkGrubu', label: 'Urun Ilk Grubu' }, { field: 'urunIlkGrubu', label: 'Urun Ilk Grubu' },
@@ -346,10 +464,48 @@ const multiFilterColumns = [
{ field: 'icerik', label: 'Icerik' }, { field: 'icerik', label: 'Icerik' },
{ field: 'karisim', label: 'Karisim' } { field: 'karisim', label: 'Karisim' }
] ]
const serverBackedMultiFilterFields = new Set([
'productCode',
'brandGroupSelection',
'marka',
'askiliYan',
'kategori',
'urunIlkGrubu',
'urunAnaGrubu',
'urunAltGrubu',
'icerik',
'karisim'
])
const numberRangeFilterFields = ['stockQty'] const numberRangeFilterFields = ['stockQty']
const dateRangeFilterFields = ['stockEntryDate', 'lastPricingDate'] const dateRangeFilterFields = ['stockEntryDate', 'lastPricingDate']
const valueFilterFields = [
'costPrice',
'expenseForBasePrice',
'basePriceUsd',
'basePriceTry',
'usd1',
'usd2',
'usd3',
'usd4',
'usd5',
'usd6',
'eur1',
'eur2',
'eur3',
'eur4',
'eur5',
'eur6',
'try1',
'try2',
'try3',
'try4',
'try5',
'try6'
]
const columnFilters = ref({ const columnFilters = ref({
productCode: [], productCode: [],
brandGroupSelection: [],
marka: [],
askiliYan: [], askiliYan: [],
kategori: [], kategori: [],
urunIlkGrubu: [], urunIlkGrubu: [],
@@ -360,6 +516,8 @@ const columnFilters = ref({
}) })
const columnFilterSearch = ref({ const columnFilterSearch = ref({
productCode: '', productCode: '',
brandGroupSelection: '',
marka: '',
askiliYan: '', askiliYan: '',
kategori: '', kategori: '',
urunIlkGrubu: '', urunIlkGrubu: '',
@@ -375,23 +533,30 @@ const dateRangeFilters = ref({
stockEntryDate: { from: '', to: '' }, stockEntryDate: { from: '', to: '' },
lastPricingDate: { from: '', to: '' } lastPricingDate: { from: '', to: '' }
}) })
const valueFilters = ref(Object.fromEntries(valueFilterFields.map((field) => [field, []])))
const valueFilterSearch = ref(Object.fromEntries(valueFilterFields.map((field) => [field, ''])))
const multiSelectFilterFieldSet = new Set(multiFilterColumns.map((x) => x.field)) const multiSelectFilterFieldSet = new Set(multiFilterColumns.map((x) => x.field))
const numberRangeFilterFieldSet = new Set(numberRangeFilterFields) const numberRangeFilterFieldSet = new Set(numberRangeFilterFields)
const dateRangeFilterFieldSet = new Set(dateRangeFilterFields) const dateRangeFilterFieldSet = new Set(dateRangeFilterFields)
const valueSelectFilterFieldSet = new Set(valueFilterFields)
const headerFilterFieldSet = new Set([ const headerFilterFieldSet = new Set([
...multiFilterColumns.map((x) => x.field), ...multiFilterColumns.map((x) => x.field),
...numberRangeFilterFields, ...numberRangeFilterFields,
...dateRangeFilterFields ...dateRangeFilterFields,
...valueFilterFields
]) ])
const mainTableRef = ref(null) const mainTableRef = ref(null)
const tablePagination = ref({ const tablePagination = ref({
page: 1, page: 1, // server-side paging var; q-table local paging kapali
rowsPerPage: 0, rowsPerPage: 0,
sortBy: 'productCode', sortBy: 'stockQty',
descending: false descending: true
}) })
const selectedMap = ref({}) const selectedMap = ref({})
const bulkDialogOpen = ref(false)
const bulkField = ref('expenseForBasePrice')
const bulkValue = ref('')
const selectedCurrencies = ref(['USD', 'EUR', 'TRY']) const selectedCurrencies = ref(['USD', 'EUR', 'TRY'])
const showSelectedOnly = ref(false) const showSelectedOnly = ref(false)
@@ -437,7 +602,10 @@ function col (name, label, field, width, extra = {}) {
const allColumns = [ const allColumns = [
col('select', '', 'select', 40, { align: 'center', classes: 'text-center selection-col' }), col('select', '', 'select', 40, { align: 'center', classes: 'text-center selection-col' }),
col('brandGroupSelection', 'MARKA GRUBU SECIMI', 'brandGroupSelection', 76),
col('marka', 'MARKA', 'marka', 54, { sortable: true, classes: 'ps-col' }),
col('productCode', 'URUN KODU', 'productCode', 108, { sortable: true, classes: 'ps-col product-code-col' }), col('productCode', 'URUN KODU', 'productCode', 108, { sortable: true, classes: 'ps-col product-code-col' }),
col('calcAction', 'HESAPLA', 'calcAction', 72, { align: 'center', classes: 'ps-col' }),
col('stockQty', 'STOK ADET', 'stockQty', 72, { align: 'right', sortable: true, classes: 'ps-col stock-col' }), col('stockQty', 'STOK ADET', 'stockQty', 72, { align: 'right', sortable: true, classes: 'ps-col stock-col' }),
col('stockEntryDate', 'STOK GIRIS TARIHI', 'stockEntryDate', 92, { align: 'center', sortable: true, classes: 'ps-col date-col' }), col('stockEntryDate', 'STOK GIRIS TARIHI', 'stockEntryDate', 92, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
col('lastPricingDate', 'SON FIYATLANDIRMA TARIHI', 'lastPricingDate', 108, { align: 'center', sortable: true, classes: 'ps-col date-col' }), col('lastPricingDate', 'SON FIYATLANDIRMA TARIHI', 'lastPricingDate', 108, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
@@ -448,8 +616,6 @@ const allColumns = [
col('urunAltGrubu', 'URUN ALT GRUBU', 'urunAltGrubu', 66, { sortable: true, classes: 'ps-col' }), col('urunAltGrubu', 'URUN ALT GRUBU', 'urunAltGrubu', 66, { sortable: true, classes: 'ps-col' }),
col('icerik', 'ICERIK', 'icerik', 62, { sortable: true, classes: 'ps-col' }), col('icerik', 'ICERIK', 'icerik', 62, { sortable: true, classes: 'ps-col' }),
col('karisim', 'KARISIM', 'karisim', 62, { sortable: true, classes: 'ps-col' }), col('karisim', 'KARISIM', 'karisim', 62, { sortable: true, classes: 'ps-col' }),
col('marka', 'MARKA', 'marka', 54, { sortable: true, classes: 'ps-col' }),
col('brandGroupSelection', 'MARKA GRUBU SECIMI', 'brandGroupSelection', 76),
col('costPrice', 'MALIYET FIYATI', 'costPrice', 74, { align: 'right', sortable: true, classes: 'usd-col' }), col('costPrice', 'MALIYET FIYATI', 'costPrice', 74, { align: 'right', sortable: true, classes: 'usd-col' }),
col('expenseForBasePrice', 'TABAN FIYAT MASRAF', 'expenseForBasePrice', 86, { align: 'right', classes: 'usd-col' }), col('expenseForBasePrice', 'TABAN FIYAT MASRAF', 'expenseForBasePrice', 86, { align: 'right', classes: 'usd-col' }),
col('basePriceUsd', 'TABAN USD', 'basePriceUsd', 74, { align: 'right', classes: 'usd-col' }), col('basePriceUsd', 'TABAN USD', 'basePriceUsd', 74, { align: 'right', classes: 'usd-col' }),
@@ -476,7 +642,10 @@ const allColumns = [
const stickyColumnNames = [ const stickyColumnNames = [
'select', 'select',
'brandGroupSelection',
'marka',
'productCode', 'productCode',
'calcAction',
'stockQty', 'stockQty',
'stockEntryDate', 'stockEntryDate',
'lastPricingDate', 'lastPricingDate',
@@ -487,8 +656,6 @@ const stickyColumnNames = [
'urunAltGrubu', 'urunAltGrubu',
'icerik', 'icerik',
'karisim', 'karisim',
'marka',
'brandGroupSelection',
'costPrice', 'costPrice',
'expenseForBasePrice', 'expenseForBasePrice',
'basePriceUsd', 'basePriceUsd',
@@ -531,6 +698,17 @@ const tableStyle = computed(() => ({
})) }))
const rows = computed(() => store.rows || []) const rows = computed(() => store.rows || [])
const tableLoading = computed(() => Boolean(store.loading) && rows.value.length === 0)
const bulkFieldOptions = computed(() => {
return editableColumns
.map((name) => {
const colDef = allColumns.find((c) => c.field === name)
return {
value: name,
label: colDef?.label || name
}
})
})
const multiFilterOptionMap = computed(() => { const multiFilterOptionMap = computed(() => {
const map = {} const map = {}
multiFilterColumns.forEach(({ field }) => { multiFilterColumns.forEach(({ field }) => {
@@ -556,13 +734,52 @@ const filteredFilterOptionMap = computed(() => {
}) })
return map return map
}) })
const valueFilterOptionMap = computed(() => {
const map = {}
valueFilterFields.forEach((field) => {
const uniq = new Set()
rows.value.forEach((row) => {
uniq.add(toValueFilterKey(row?.[field]))
})
map[field] = Array.from(uniq)
.sort((a, b) => Number(a) - Number(b))
.map((v) => ({ label: formatPrice(v), value: v }))
})
return map
})
const filteredValueFilterOptionMap = computed(() => {
const map = {}
valueFilterFields.forEach((field) => {
const search = String(valueFilterSearch.value[field] || '').trim().toLocaleLowerCase('tr')
const options = valueFilterOptionMap.value[field] || []
map[field] = search
? options.filter((option) => option.label.toLocaleLowerCase('tr').includes(search))
: options
})
return map
})
function rowSelectionKey (row) {
const code = String(row?.productCode ?? '').trim()
if (code) return code
return String(row?.id ?? '')
}
const filteredRows = computed(() => { const filteredRows = computed(() => {
return rows.value.filter((row) => { return rows.value.filter((row) => {
if (showSelectedOnly.value && !selectedMap.value[row.id]) return false if (showSelectedOnly.value && !selectedMap.value[rowSelectionKey(row)]) return false
for (const mf of multiFilterColumns) { for (const { field } of multiFilterColumns) {
const selected = columnFilters.value[mf.field] || [] // Server-backed filters already reload full dataset (all pages) from backend.
if (selected.length > 0 && !selected.includes(String(row?.[mf.field] ?? '').trim())) return false // Keep only non-server multi filters (e.g. brandGroupSelection) as local page filter.
if (serverBackedMultiFilterFields.has(field)) continue
const selected = columnFilters.value[field] || []
if (selected.length <= 0) continue
const rowVal = String(row?.[field] ?? '').trim()
if (!selected.includes(rowVal)) return false
}
for (const field of valueFilterFields) {
const selected = valueFilters.value[field] || []
if (selected.length > 0 && !selected.includes(toValueFilterKey(row?.[field]))) return false
} }
const stockQtyMin = parseNullableNumber(numberRangeFilters.value.stockQty?.min) const stockQtyMin = parseNullableNumber(numberRangeFilters.value.stockQty?.min)
const stockQtyMax = parseNullableNumber(numberRangeFilters.value.stockQty?.max) const stockQtyMax = parseNullableNumber(numberRangeFilters.value.stockQty?.max)
@@ -575,12 +792,11 @@ const filteredRows = computed(() => {
}) })
}) })
const visibleRowIds = computed(() => filteredRows.value.map((row) => row.id)) const visibleRowIds = computed(() => filteredRows.value.map((row) => rowSelectionKey(row)))
const selectedRowCount = computed(() => Object.values(selectedMap.value).filter(Boolean).length) const selectedRowCount = computed(() => Object.values(selectedMap.value).filter(Boolean).length)
const selectedVisibleCount = computed(() => visibleRowIds.value.filter((id) => !!selectedMap.value[id]).length) const selectedVisibleCount = computed(() => visibleRowIds.value.filter((id) => !!selectedMap.value[id]).length)
const allSelectedVisible = computed(() => visibleRowIds.value.length > 0 && selectedVisibleCount.value === visibleRowIds.value.length) const allSelectedVisible = computed(() => visibleRowIds.value.length > 0 && selectedVisibleCount.value === visibleRowIds.value.length)
const someSelectedVisible = computed(() => selectedVisibleCount.value > 0) const someSelectedVisible = computed(() => selectedVisibleCount.value > 0)
const hasMoreRows = computed(() => Boolean(store.hasMore))
function isHeaderFilterField (field) { function isHeaderFilterField (field) {
return headerFilterFieldSet.has(field) return headerFilterFieldSet.has(field)
@@ -598,8 +814,13 @@ function isDateRangeFilterField (field) {
return dateRangeFilterFieldSet.has(field) return dateRangeFilterFieldSet.has(field)
} }
function isValueSelectFilterField (field) {
return valueSelectFilterFieldSet.has(field)
}
function hasFilter (field) { function hasFilter (field) {
if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length > 0 if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length > 0
if (isValueSelectFilterField(field)) return (valueFilters.value[field] || []).length > 0
if (isNumberRangeFilterField(field)) { if (isNumberRangeFilterField(field)) {
const filter = numberRangeFilters.value[field] const filter = numberRangeFilters.value[field]
return !!String(filter?.min || '').trim() || !!String(filter?.max || '').trim() return !!String(filter?.min || '').trim() || !!String(filter?.max || '').trim()
@@ -613,6 +834,7 @@ function hasFilter (field) {
function getFilterBadgeValue (field) { function getFilterBadgeValue (field) {
if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length
if (isValueSelectFilterField(field)) return (valueFilters.value[field] || []).length
if (isNumberRangeFilterField(field)) { if (isNumberRangeFilterField(field)) {
const filter = numberRangeFilters.value[field] const filter = numberRangeFilters.value[field]
return [filter?.min, filter?.max].filter((x) => String(x || '').trim()).length return [filter?.min, filter?.max].filter((x) => String(x || '').trim()).length
@@ -625,11 +847,19 @@ function getFilterBadgeValue (field) {
} }
function clearColumnFilter (field) { function clearColumnFilter (field) {
if (!isMultiSelectFilterField(field)) return if (isMultiSelectFilterField(field)) {
columnFilters.value = { columnFilters.value = {
...columnFilters.value, ...columnFilters.value,
[field]: [] [field]: []
} }
return
}
if (isValueSelectFilterField(field)) {
valueFilters.value = {
...valueFilters.value,
[field]: []
}
}
} }
function clearRangeFilter (field) { function clearRangeFilter (field) {
@@ -649,17 +879,27 @@ function clearRangeFilter (field) {
} }
function getFilterOptionsForField (field) { function getFilterOptionsForField (field) {
if (isValueSelectFilterField(field)) return filteredValueFilterOptionMap.value[field] || []
return filteredFilterOptionMap.value[field] || [] return filteredFilterOptionMap.value[field] || []
} }
function isColumnFilterValueSelected (field, value) { function isColumnFilterValueSelected (field, value) {
if (isValueSelectFilterField(field)) return (valueFilters.value[field] || []).includes(value)
return (columnFilters.value[field] || []).includes(value) return (columnFilters.value[field] || []).includes(value)
} }
function toggleColumnFilterValue (field, value) { function toggleColumnFilterValue (field, value) {
const current = new Set(columnFilters.value[field] || []) const target = isValueSelectFilterField(field) ? valueFilters.value : columnFilters.value
const current = new Set(target[field] || [])
if (current.has(value)) current.delete(value) if (current.has(value)) current.delete(value)
else current.add(value) else current.add(value)
if (isValueSelectFilterField(field)) {
valueFilters.value = {
...valueFilters.value,
[field]: Array.from(current)
}
return
}
columnFilters.value = { columnFilters.value = {
...columnFilters.value, ...columnFilters.value,
[field]: Array.from(current) [field]: Array.from(current)
@@ -668,6 +908,13 @@ function toggleColumnFilterValue (field, value) {
function selectAllColumnFilterOptions (field) { function selectAllColumnFilterOptions (field) {
const options = getFilterOptionsForField(field) const options = getFilterOptionsForField(field)
if (isValueSelectFilterField(field)) {
valueFilters.value = {
...valueFilters.value,
[field]: options.map((option) => option.value)
}
return
}
columnFilters.value = { columnFilters.value = {
...columnFilters.value, ...columnFilters.value,
[field]: options.map((option) => option.value) [field]: options.map((option) => option.value)
@@ -701,6 +948,10 @@ function round2 (value) {
return Number(Number(value || 0).toFixed(2)) return Number(Number(value || 0).toFixed(2))
} }
function toValueFilterKey (value) {
return round2(parseNumber(value)).toFixed(2)
}
function parseNumber (val) { function parseNumber (val) {
if (typeof val === 'number') return Number.isFinite(val) ? val : 0 if (typeof val === 'number') return Number.isFinite(val) ? val : 0
const text = String(val ?? '').trim().replace(/\s/g, '') const text = String(val ?? '').trim().replace(/\s/g, '')
@@ -789,12 +1040,27 @@ function onEditableCellChange (row, field, val) {
if (field === 'expenseForBasePrice' || field === 'basePriceUsd') recalcByBasePrice(row) if (field === 'expenseForBasePrice' || field === 'basePriceUsd') recalcByBasePrice(row)
} }
function calculateRow (row) {
if (!row) return
recalcByBasePrice(row)
toggleRowSelection(rowSelectionKey(row), true)
}
function onBrandGroupSelectionChange (row, val) { function onBrandGroupSelectionChange (row, val) {
store.updateBrandGroupSelection(row, val) store.updateBrandGroupSelection(row, val)
} }
function toggleRowSelection (rowId, val) { function isRowSelected (rowKey) {
selectedMap.value = { ...selectedMap.value, [rowId]: !!val } return !!selectedMap.value[rowKey]
}
function onRowCheckboxChange (row, val) {
if (!row) return
toggleRowSelection(rowSelectionKey(row), val)
}
function toggleRowSelection (rowKey, val) {
selectedMap.value = { ...selectedMap.value, [rowKey]: !!val }
} }
function toggleSelectAllVisible (val) { function toggleSelectAllVisible (val) {
@@ -803,9 +1069,25 @@ function toggleSelectAllVisible (val) {
selectedMap.value = next selectedMap.value = next
} }
function applyBulkUpdate () {
const field = String(bulkField.value || '').trim()
if (!field || !editableColumnSet.has(field)) return
const parsed = parseNumber(bulkValue.value)
rows.value.forEach((row) => {
if (!isRowSelected(rowSelectionKey(row))) return
store.updateCell(row, field, parsed)
if (field === 'expenseForBasePrice' || field === 'basePriceUsd') {
recalcByBasePrice(row)
}
})
bulkDialogOpen.value = false
}
function resetAll () { function resetAll () {
columnFilters.value = { columnFilters.value = {
productCode: [], productCode: [],
brandGroupSelection: [],
marka: [],
askiliYan: [], askiliYan: [],
kategori: [], kategori: [],
urunIlkGrubu: [], urunIlkGrubu: [],
@@ -816,6 +1098,8 @@ function resetAll () {
} }
columnFilterSearch.value = { columnFilterSearch.value = {
productCode: '', productCode: '',
brandGroupSelection: '',
marka: '',
askiliYan: '', askiliYan: '',
kategori: '', kategori: '',
urunIlkGrubu: '', urunIlkGrubu: '',
@@ -824,6 +1108,8 @@ function resetAll () {
icerik: '', icerik: '',
karisim: '' karisim: ''
} }
valueFilters.value = Object.fromEntries(valueFilterFields.map((field) => [field, []]))
valueFilterSearch.value = Object.fromEntries(valueFilterFields.map((field) => [field, '']))
numberRangeFilters.value = { numberRangeFilters.value = {
stockQty: { min: '', max: '' } stockQty: { min: '', max: '' }
} }
@@ -863,53 +1149,57 @@ function clearAllCurrencies () {
selectedCurrencies.value = [] selectedCurrencies.value = []
} }
async function fetchChunk ({ reset = false } = {}) { function onPaginationChange (next) {
const afterProductCode = reset ? '' : nextCursor.value tablePagination.value = {
...tablePagination.value,
...(next || {}),
page: 1,
rowsPerPage: 0
}
}
function buildServerFilters () {
return {
product_code: columnFilters.value.productCode || [],
brand_group_selection: columnFilters.value.brandGroupSelection || [],
marka: columnFilters.value.marka || [],
askili_yan: columnFilters.value.askiliYan || [],
kategori: columnFilters.value.kategori || [],
urun_ilk_grubu: columnFilters.value.urunIlkGrubu || [],
urun_ana_grubu: columnFilters.value.urunAnaGrubu || [],
urun_alt_grubu: columnFilters.value.urunAltGrubu || [],
icerik: columnFilters.value.icerik || [],
karisim: columnFilters.value.karisim || []
}
}
function scheduleReload () {
if (reloadTimer) clearTimeout(reloadTimer)
reloadTimer = setTimeout(() => {
reloadTimer = null
void reloadData({ page: 1 })
}, 180)
}
async function fetchChunk ({ page = 1 } = {}) {
const result = await store.fetchRows({ const result = await store.fetchRows({
limit: FETCH_LIMIT, limit: PAGE_LIMIT,
afterProductCode, page,
append: !reset append: false,
silent: false,
filters: buildServerFilters()
}) })
const fetched = Number(result?.fetched) || 0 currentPage.value = Number(result?.page) || page
nextCursor.value = String(result?.nextCursor || '') return Number(result?.fetched) || 0
return fetched
} }
async function loadMoreRows () { async function reloadData ({ page = 1 } = {}) {
if (loadingMore.value || store.loading || !hasMoreRows.value) return
loadingMore.value = true
try {
await fetchChunk({ reset: false })
} finally {
loadingMore.value = false
}
}
function onTableVirtualScroll (details) {
const to = Number(details?.to || 0)
if (!Number.isFinite(to)) return
if (to >= filteredRows.value.length - 25) {
void loadMoreRows()
}
}
async function ensureEnoughVisibleRows (minRows = 80, maxBatches = 4) {
let guard = 0
while (hasMoreRows.value && filteredRows.value.length < minRows && guard < maxBatches) {
await loadMoreRows()
guard++
}
}
async function reloadData () {
const startedAt = Date.now() const startedAt = Date.now()
console.info('[product-pricing][ui] reload:start', { console.info('[product-pricing][ui] reload:start', {
at: new Date(startedAt).toISOString() at: new Date(startedAt).toISOString()
}) })
try { try {
nextCursor.value = '' await fetchChunk({ page })
await fetchChunk({ reset: true })
await ensureEnoughVisibleRows(120, 6)
} catch (err) { } catch (err) {
console.error('[product-pricing][ui] reload:error', { console.error('[product-pricing][ui] reload:error', {
duration_ms: Date.now() - startedAt, duration_ms: Date.now() - startedAt,
@@ -924,20 +1214,28 @@ async function reloadData () {
selectedMap.value = {} selectedMap.value = {}
} }
function onPageChange (page) {
const p = Number(page) > 0 ? Number(page) : 1
if (store.loading) return
if (p === currentPage.value && p === (store.page || 1)) return
currentPage.value = p
void reloadData({ page: p })
}
onMounted(async () => { onMounted(async () => {
await reloadData() await reloadData({ page: currentPage.value })
})
onBeforeUnmount(() => {
if (reloadTimer) {
clearTimeout(reloadTimer)
reloadTimer = null
}
}) })
watch( watch(
[ [columnFilters],
columnFilters, () => { scheduleReload() },
numberRangeFilters,
dateRangeFilters,
showSelectedOnly,
() => tablePagination.value.sortBy,
() => tablePagination.value.descending
],
() => { void ensureEnoughVisibleRows(80, 4) },
{ deep: true } { deep: true }
) )
</script> </script>

View File

@@ -77,33 +77,175 @@ function mapRow (raw, index, baseIndex = 0) {
} }
} }
function cloneRows (rows = []) {
return rows.map((r) => ({ ...r }))
}
function normalizeFilterList (list) {
if (!Array.isArray(list)) return []
return list.map((x) => toText(x)).filter(Boolean).sort()
}
function normalizeFilters (filters = {}) {
const keys = ['product_code', 'brand_group_selection', 'askili_yan', 'kategori', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'icerik', 'karisim', 'marka']
const out = {}
for (const key of keys) out[key] = normalizeFilterList(filters[key])
const q = toText(filters.q)
if (q) out.q = q
return out
}
function makeCacheKey (limit, page, filters) {
return JSON.stringify({
limit: Number(limit) || 500,
page: Number(page) || 1,
filters: normalizeFilters(filters)
})
}
export const useProductPricingStore = defineStore('product-pricing-store', { export const useProductPricingStore = defineStore('product-pricing-store', {
state: () => ({ state: () => ({
rows: [], rows: [],
loading: false, loading: false,
error: '', error: '',
hasMore: true hasMore: true,
page: 1,
totalPages: 1,
totalCount: 0,
pageCache: {},
cacheOrder: [],
prefetchInFlight: {}
}), }),
actions: { actions: {
cachePut (key, value) {
this.pageCache[key] = value
this.cacheOrder = this.cacheOrder.filter((x) => x !== key)
this.cacheOrder.push(key)
while (this.cacheOrder.length > 24) {
const oldest = this.cacheOrder.shift()
if (oldest) delete this.pageCache[oldest]
}
},
cacheGet (key) {
return this.pageCache[key] || null
},
applyPageResult (payload = {}, requestedPage = 1) {
const data = Array.isArray(payload?.rows) ? payload.rows : []
this.rows = cloneRows(data)
this.totalCount = Number.isFinite(payload?.totalCount) ? payload.totalCount : 0
this.totalPages = Math.max(1, Number(payload?.totalPages || 1))
this.page = Math.max(1, Number(payload?.page || requestedPage))
this.hasMore = this.page < this.totalPages
},
async prefetchPage (options = {}) {
const limit = Number(options?.limit) > 0 ? Number(options.limit) : 500
const page = Number(options?.page) > 0 ? Number(options.page) : 1
const filters = normalizeFilters(options?.filters || {})
const key = makeCacheKey(limit, page, filters)
if (this.pageCache[key]) return
if (this.prefetchInFlight[key]) {
await this.prefetchInFlight[key]
return
}
const run = async () => {
try {
const params = { limit, page }
for (const k of Object.keys(filters)) {
if (k === 'q') {
params.q = filters.q
continue
}
if (Array.isArray(filters[k]) && filters[k].length > 0) {
params[k] = filters[k].join(',')
}
}
const res = await api.request({
method: 'GET',
url: '/pricing/products',
params,
timeout: 180000
})
const totalCount = Number(res?.headers?.['x-total-count'] || 0)
const totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 1))
const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page))
const data = Array.isArray(res?.data) ? res.data : []
const mapped = data.map((x, i) => mapRow(x, i, 0))
this.cachePut(key, {
rows: mapped,
totalCount: Number.isFinite(totalCount) ? totalCount : 0,
totalPages: Number.isFinite(totalPages) ? totalPages : 1,
page: currentPage
})
} catch {
}
}
this.prefetchInFlight[key] = run()
try {
await this.prefetchInFlight[key]
} finally {
delete this.prefetchInFlight[key]
}
},
async fetchRows (options = {}) { async fetchRows (options = {}) {
const silent = Boolean(options?.silent)
if (!silent) {
this.loading = true this.loading = true
this.error = '' this.error = ''
}
const limit = Number(options?.limit) > 0 ? Number(options.limit) : 500 const limit = Number(options?.limit) > 0 ? Number(options.limit) : 500
const afterProductCode = toText(options?.afterProductCode) const page = Number(options?.page) > 0 ? Number(options.page) : 1
const append = Boolean(options?.append) const append = Boolean(options?.append)
const baseIndex = append ? this.rows.length : 0 const baseIndex = append ? this.rows.length : 0
const filters = normalizeFilters(options?.filters || {})
const cacheKey = makeCacheKey(limit, page, filters)
const startedAt = Date.now() const startedAt = Date.now()
console.info('[product-pricing][frontend] request:start', { console.info('[product-pricing][frontend] request:start', {
at: new Date(startedAt).toISOString(), at: new Date(startedAt).toISOString(),
timeout_ms: 180000, timeout_ms: 180000,
limit, limit,
after_product_code: afterProductCode || null, page,
append append
}) })
try { try {
const params = { limit } if (options?.useCache !== false) {
if (afterProductCode) params.after_product_code = afterProductCode const inFlight = this.prefetchInFlight[cacheKey]
if (inFlight) {
await inFlight
}
const cached = this.cacheGet(cacheKey)
if (cached) {
this.applyPageResult(cached, page)
console.info('[product-pricing][frontend] request:cache-hit', {
page: this.page,
total_pages: this.totalPages,
row_count: this.rows.length,
duration_ms: Date.now() - startedAt
})
return {
traceId: null,
fetched: this.rows.length,
hasMore: this.hasMore,
page: this.page,
totalPages: this.totalPages,
totalCount: this.totalCount
}
}
}
const params = { limit, page }
for (const key of Object.keys(filters)) {
if (key === 'q') {
params.q = filters.q
continue
}
const list = filters[key]
if (Array.isArray(list) && list.length > 0) params[key] = list.join(',')
}
const res = await api.request({ const res = await api.request({
method: 'GET', method: 'GET',
url: '/pricing/products', url: '/pricing/products',
@@ -111,44 +253,48 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
timeout: 180000 timeout: 180000
}) })
const traceId = res?.headers?.['x-trace-id'] || null const traceId = res?.headers?.['x-trace-id'] || null
const hasMoreHeader = String(res?.headers?.['x-has-more'] || '').toLowerCase() const totalCount = Number(res?.headers?.['x-total-count'] || 0)
const nextCursorHeader = toText(res?.headers?.['x-next-cursor']) const totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 1))
const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page))
const data = Array.isArray(res?.data) ? res.data : [] const data = Array.isArray(res?.data) ? res.data : []
const mapped = data.map((x, i) => mapRow(x, i, baseIndex)) const mapped = data.map((x, i) => mapRow(x, i, baseIndex))
const fallbackNextCursor = mapped.length > 0 const payload = {
? toText(mapped[mapped.length - 1]?.productCode) rows: mapped,
: '' totalCount: Number.isFinite(totalCount) ? totalCount : 0,
const nextCursor = nextCursorHeader || fallbackNextCursor totalPages: Number.isFinite(totalPages) ? totalPages : 1,
if (append) { page: Number.isFinite(currentPage) ? currentPage : page
const merged = [...this.rows]
const seen = new Set(this.rows.map((x) => x?.productCode))
for (const row of mapped) {
const key = row?.productCode
if (key && seen.has(key)) continue
merged.push(row)
if (key) seen.add(key)
} }
this.rows = merged this.cachePut(cacheKey, payload)
} else { this.applyPageResult(payload, page)
this.rows = mapped
// Background prefetch for next page to reduce perceived wait on page change.
if (this.page < this.totalPages) {
void this.prefetchPage({
limit,
page: this.page + 1,
filters
})
} }
this.hasMore = hasMoreHeader ? hasMoreHeader === 'true' : mapped.length === limit
console.info('[product-pricing][frontend] request:success', { console.info('[product-pricing][frontend] request:success', {
trace_id: traceId, trace_id: traceId,
duration_ms: Date.now() - startedAt, duration_ms: Date.now() - startedAt,
row_count: this.rows.length, row_count: this.rows.length,
fetched_count: mapped.length, fetched_count: mapped.length,
has_more: this.hasMore, has_more: this.hasMore,
next_cursor: nextCursor || null page: this.page,
total_pages: this.totalPages,
total_count: this.totalCount
}) })
return { return {
traceId, traceId,
fetched: mapped.length, fetched: mapped.length,
hasMore: this.hasMore, hasMore: this.hasMore,
nextCursor page: this.page,
totalPages: this.totalPages,
totalCount: this.totalCount
} }
} catch (err) { } catch (err) {
if (!append) this.rows = [] this.rows = []
this.hasMore = false this.hasMore = false
const msg = err?.response?.data || err?.message || 'Urun fiyatlandirma listesi alinamadi' const msg = err?.response?.data || err?.message || 'Urun fiyatlandirma listesi alinamadi'
this.error = toText(msg) this.error = toText(msg)
@@ -161,7 +307,7 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
}) })
throw err throw err
} finally { } finally {
this.loading = false if (!silent) this.loading = false
} }
}, },