diff --git a/svc/main.go b/svc/main.go index 1d5117a..1788764 100644 --- a/svc/main.go +++ b/svc/main.go @@ -847,6 +847,11 @@ func main() { auditlog.Init(pgDB, 1000) log.Println("🕵️ AuditLog sistemi başlatıldı (buffer=1000)") + // ------------------------------------------------------- + // 🚀 TRANSLATION QUERY PERFORMANCE INDEXES + // ------------------------------------------------------- + routes.EnsureTranslationPerfIndexes(pgDB) + // ------------------------------------------------------- // ✉️ MAILER INIT // ------------------------------------------------------- diff --git a/svc/queries/product_pricing.go b/svc/queries/product_pricing.go index 9890b19..9565421 100644 --- a/svc/queries/product_pricing.go +++ b/svc/queries/product_pricing.go @@ -5,12 +5,20 @@ import ( "bssapp-backend/models" "context" "database/sql" + "fmt" "strings" "time" ) -func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error) { - const query = ` +func GetProductPricingList(ctx context.Context, limit int, offset int) ([]models.ProductPricing, error) { + if limit <= 0 { + limit = 500 + } + if offset < 0 { + offset = 0 + } + + query := ` WITH base_products AS ( SELECT LTRIM(RTRIM(ProductCode)) AS ProductCode, @@ -27,6 +35,13 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error) AND IsBlocked = 0 AND LEN(LTRIM(RTRIM(ProductCode))) = 13 ), + paged_products AS ( + SELECT + bp.ProductCode + FROM base_products bp + ORDER BY bp.ProductCode + OFFSET %d ROWS FETCH NEXT %d ROWS ONLY + ), latest_base_price AS ( SELECT LTRIM(RTRIM(b.ItemCode)) AS ItemCode, @@ -42,8 +57,8 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error) AND LTRIM(RTRIM(b.CurrencyCode)) = 'USD' AND EXISTS ( SELECT 1 - FROM base_products bp - WHERE bp.ProductCode = LTRIM(RTRIM(b.ItemCode)) + FROM paged_products pp + WHERE pp.ProductCode = LTRIM(RTRIM(b.ItemCode)) ) ), stock_entry_dates AS ( @@ -61,8 +76,8 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error) ) AND EXISTS ( SELECT 1 - FROM base_products bp - WHERE bp.ProductCode = LTRIM(RTRIM(s.ItemCode)) + FROM paged_products pp + WHERE pp.ProductCode = LTRIM(RTRIM(s.ItemCode)) ) GROUP BY LTRIM(RTRIM(s.ItemCode)) ), @@ -75,8 +90,8 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error) AND LEN(LTRIM(RTRIM(s.ItemCode))) = 13 AND EXISTS ( SELECT 1 - FROM base_products bp - WHERE bp.ProductCode = LTRIM(RTRIM(s.ItemCode)) + FROM paged_products pp + WHERE pp.ProductCode = LTRIM(RTRIM(s.ItemCode)) ) GROUP BY LTRIM(RTRIM(s.ItemCode)) ), @@ -89,8 +104,8 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error) AND LEN(LTRIM(RTRIM(p.ItemCode))) = 13 AND EXISTS ( SELECT 1 - FROM base_products bp - WHERE bp.ProductCode = LTRIM(RTRIM(p.ItemCode)) + FROM paged_products pp + WHERE pp.ProductCode = LTRIM(RTRIM(p.ItemCode)) ) GROUP BY LTRIM(RTRIM(p.ItemCode)) ), @@ -103,8 +118,8 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error) AND LEN(LTRIM(RTRIM(r.ItemCode))) = 13 AND EXISTS ( SELECT 1 - FROM base_products bp - WHERE bp.ProductCode = LTRIM(RTRIM(r.ItemCode)) + FROM paged_products pp + WHERE pp.ProductCode = LTRIM(RTRIM(r.ItemCode)) ) GROUP BY LTRIM(RTRIM(r.ItemCode)) ), @@ -117,29 +132,29 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error) AND LEN(LTRIM(RTRIM(d.ItemCode))) = 13 AND EXISTS ( SELECT 1 - FROM base_products bp - WHERE bp.ProductCode = LTRIM(RTRIM(d.ItemCode)) + FROM paged_products pp + WHERE pp.ProductCode = LTRIM(RTRIM(d.ItemCode)) ) GROUP BY LTRIM(RTRIM(d.ItemCode)) ), stock_totals AS ( SELECT - bp.ProductCode AS ItemCode, + pp.ProductCode AS ItemCode, CAST(ROUND( ISNULL(sb.InventoryQty1, 0) - ISNULL(pb.PickingQty1, 0) - ISNULL(rb.ReserveQty1, 0) - ISNULL(db.DispOrderQty1, 0) , 2) AS DECIMAL(18, 2)) AS StockQty - FROM base_products bp + FROM paged_products pp LEFT JOIN stock_base sb - ON sb.ItemCode = bp.ProductCode + ON sb.ItemCode = pp.ProductCode LEFT JOIN pick_base pb - ON pb.ItemCode = bp.ProductCode + ON pb.ItemCode = pp.ProductCode LEFT JOIN reserve_base rb - ON rb.ItemCode = bp.ProductCode + ON rb.ItemCode = pp.ProductCode LEFT JOIN disp_base db - ON db.ItemCode = bp.ProductCode + ON db.ItemCode = pp.ProductCode ) SELECT bp.ProductCode AS ProductCode, @@ -155,7 +170,9 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error) bp.Icerik, bp.Karisim, bp.Marka - FROM base_products bp + FROM paged_products pp + INNER JOIN base_products bp + ON bp.ProductCode = pp.ProductCode LEFT JOIN latest_base_price lp ON lp.ItemCode = bp.ProductCode AND lp.rn = 1 @@ -165,6 +182,7 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error) ON st.ItemCode = bp.ProductCode ORDER BY bp.ProductCode; ` + query = fmt.Sprintf(query, offset, limit) var ( rows *sql.Rows diff --git a/svc/routes/product_pricing.go b/svc/routes/product_pricing.go index 4e38e06..b7bc564 100644 --- a/svc/routes/product_pricing.go +++ b/svc/routes/product_pricing.go @@ -29,7 +29,20 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 180*time.Second) defer cancel() - rows, err := queries.GetProductPricingList(ctx) + limit := 500 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 10000 { + limit = parsed + } + } + offset := 0 + if raw := strings.TrimSpace(r.URL.Query().Get("offset")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 && parsed <= 1000000 { + offset = parsed + } + } + + rows, err := queries.GetProductPricingList(ctx, limit+1, offset) if err != nil { if isPricingTimeoutLike(err, ctx.Err()) { log.Printf( @@ -54,16 +67,29 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Urun fiyatlandirma listesi alinamadi: "+err.Error(), http.StatusInternalServerError) return } + hasMore := len(rows) > limit + if hasMore { + rows = rows[:limit] + } + log.Printf( - "[ProductPricing] trace=%s success user=%s id=%d count=%d duration_ms=%d", + "[ProductPricing] trace=%s success user=%s id=%d limit=%d offset=%d count=%d has_more=%t duration_ms=%d", traceID, claims.Username, claims.ID, + limit, + offset, len(rows), + hasMore, time.Since(started).Milliseconds(), ) w.Header().Set("Content-Type", "application/json; charset=utf-8") + if hasMore { + w.Header().Set("X-Has-More", "true") + } else { + w.Header().Set("X-Has-More", "false") + } _ = json.NewEncoder(w).Encode(rows) } diff --git a/svc/routes/translation_perf.go b/svc/routes/translation_perf.go new file mode 100644 index 0000000..7e982e7 --- /dev/null +++ b/svc/routes/translation_perf.go @@ -0,0 +1,41 @@ +package routes + +import ( + "database/sql" + "log" + "strings" +) + +// EnsureTranslationPerfIndexes creates helpful indexes for translation listing/search. +// It is safe to run on each startup; failures are logged and do not stop the service. +func EnsureTranslationPerfIndexes(db *sql.DB) { + if db == nil { + return + } + + statements := []string{ + `CREATE EXTENSION IF NOT EXISTS pg_trgm`, + `CREATE INDEX IF NOT EXISTS idx_mk_translator_t_key_lang ON mk_translator (t_key, lang_code)`, + `CREATE INDEX IF NOT EXISTS idx_mk_translator_status_lang_updated ON mk_translator (status, lang_code, updated_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_mk_translator_manual_status ON mk_translator (is_manual, status)`, + `CREATE INDEX IF NOT EXISTS idx_mk_translator_source_type_expr ON mk_translator ((COALESCE(NULLIF(provider_meta->>'source_type',''),'dummy')))`, + `CREATE INDEX IF NOT EXISTS idx_mk_translator_source_text_trgm ON mk_translator USING gin (source_text_tr gin_trgm_ops)`, + `CREATE INDEX IF NOT EXISTS idx_mk_translator_translated_text_trgm ON mk_translator USING gin (translated_text gin_trgm_ops)`, + } + + for _, stmt := range statements { + if _, err := db.Exec(stmt); err != nil { + log.Printf("[TranslationPerf] index_setup_warn sql=%q err=%v", summarizeSQL(stmt), err) + continue + } + log.Printf("[TranslationPerf] index_ready sql=%q", summarizeSQL(stmt)) + } +} + +func summarizeSQL(sqlText string) string { + s := strings.TrimSpace(sqlText) + if len(s) <= 100 { + return s + } + return s[:100] + "..." +} diff --git a/svc/routes/translations.go b/svc/routes/translations.go index 76e3ffd..d25d029 100644 --- a/svc/routes/translations.go +++ b/svc/routes/translations.go @@ -143,6 +143,12 @@ func GetTranslationRowsHandler(db *sql.DB) http.HandlerFunc { limit = parsed } } + offset := 0 + if raw := strings.TrimSpace(r.URL.Query().Get("offset")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 && parsed <= 1000000 { + offset = parsed + } + } clauses := []string{"1=1"} args := make([]any, 0, 8) @@ -202,6 +208,11 @@ ORDER BY t_key, lang_code if limit > 0 { query += fmt.Sprintf("LIMIT $%d", argIndex) args = append(args, limit) + argIndex++ + } + if offset > 0 { + query += fmt.Sprintf(" OFFSET $%d", argIndex) + args = append(args, offset) } rows, err := db.Query(query, args...) diff --git a/ui/.quasar/prod-spa/app.js b/ui/.quasar/prod-spa/app.js new file mode 100644 index 0000000..caeaac1 --- /dev/null +++ b/ui/.quasar/prod-spa/app.js @@ -0,0 +1,75 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " 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 + } +} diff --git a/ui/.quasar/prod-spa/client-entry.js b/ui/.quasar/prod-spa/client-entry.js new file mode 100644 index 0000000..5223e2b --- /dev/null +++ b/ui/.quasar/prod-spa/client-entry.js @@ -0,0 +1,158 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " 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) + }) + }) + diff --git a/ui/.quasar/prod-spa/client-prefetch.js b/ui/.quasar/prod-spa/client-prefetch.js new file mode 100644 index 0000000..9bbe3c5 --- /dev/null +++ b/ui/.quasar/prod-spa/client-prefetch.js @@ -0,0 +1,116 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " 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() + }) + }) +} diff --git a/ui/.quasar/prod-spa/quasar-user-options.js b/ui/.quasar/prod-spa/quasar-user-options.js new file mode 100644 index 0000000..ac1dae3 --- /dev/null +++ b/ui/.quasar/prod-spa/quasar-user-options.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +/** + * THIS FILE IS GENERATED AUTOMATICALLY. + * DO NOT EDIT. + * + * You are probably looking on adding startup/initialization code. + * Use "quasar new boot " 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} } + diff --git a/ui/src/pages/ProductPricing.vue b/ui/src/pages/ProductPricing.vue index a34e514..708950f 100644 --- a/ui/src/pages/ProductPricing.vue +++ b/ui/src/pages/ProductPricing.vue @@ -53,9 +53,10 @@ :virtual-scroll-sticky-size-start="headerHeight" :virtual-scroll-slice-size="36" :rows-per-page-options="[0]" - :pagination="{ rowsPerPage: 0 }" + v-model:pagination="tablePagination" hide-bottom :table-style="tableStyle" + @virtual-scroll="onTableVirtualScroll" > + diff --git a/ui/src/stores/ProductPricingStore.js b/ui/src/stores/ProductPricingStore.js index 8cc17c9..9f1c4cd 100644 --- a/ui/src/stores/ProductPricingStore.js +++ b/ui/src/stores/ProductPricingStore.js @@ -10,9 +10,9 @@ function toNumber (value) { return Number.isFinite(n) ? Number(n.toFixed(2)) : 0 } -function mapRow (raw, index) { +function mapRow (raw, index, offset = 0) { return { - id: index + 1, + id: offset + index + 1, productCode: toText(raw?.ProductCode), stockQty: toNumber(raw?.StockQty), stockEntryDate: toText(raw?.StockEntryDate), @@ -55,34 +55,65 @@ export const useProductPricingStore = defineStore('product-pricing-store', { state: () => ({ rows: [], loading: false, - error: '' + error: '', + hasMore: true }), actions: { - async fetchRows () { + async fetchRows (options = {}) { this.loading = true this.error = '' + const limit = Number(options?.limit) > 0 ? Number(options.limit) : 500 + const offset = Number(options?.offset) >= 0 ? Number(options.offset) : 0 + const append = Boolean(options?.append) const startedAt = Date.now() console.info('[product-pricing][frontend] request:start', { at: new Date(startedAt).toISOString(), - timeout_ms: 600000 + timeout_ms: 180000, + limit, + offset, + append }) try { const res = await api.request({ method: 'GET', url: '/pricing/products', - timeout: 600000 + params: { limit, offset }, + timeout: 180000 }) const traceId = res?.headers?.['x-trace-id'] || null + const hasMoreHeader = String(res?.headers?.['x-has-more'] || '').toLowerCase() const data = Array.isArray(res?.data) ? res.data : [] - this.rows = data.map((x, i) => mapRow(x, i)) + const mapped = data.map((x, i) => mapRow(x, i, offset)) + if (append) { + 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 + } else { + this.rows = mapped + } + this.hasMore = hasMoreHeader ? hasMoreHeader === 'true' : mapped.length === limit console.info('[product-pricing][frontend] request:success', { trace_id: traceId, duration_ms: Date.now() - startedAt, - row_count: this.rows.length + row_count: this.rows.length, + fetched_count: mapped.length, + has_more: this.hasMore }) + return { + traceId, + fetched: mapped.length, + hasMore: this.hasMore + } } catch (err) { - this.rows = [] + if (!append) this.rows = [] + this.hasMore = false const msg = err?.response?.data || err?.message || 'Urun fiyatlandirma listesi alinamadi' this.error = toText(msg) console.error('[product-pricing][frontend] request:error', { @@ -92,6 +123,7 @@ export const useProductPricingStore = defineStore('product-pricing-store', { status: err?.response?.status || null, message: this.error }) + throw err } finally { this.loading = false } diff --git a/ui/src/stores/translationStore.js b/ui/src/stores/translationStore.js index 16b2f70..57c4115 100644 --- a/ui/src/stores/translationStore.js +++ b/ui/src/stores/translationStore.js @@ -10,12 +10,27 @@ export const useTranslationStore = defineStore('translation', { }), actions: { - async fetchRows (filters = {}) { + async fetchRows (filters = {}, options = {}) { this.loading = true + const append = Boolean(options?.append) try { const res = await api.get('/language/translations', { params: filters }) const payload = res?.data || {} - this.rows = Array.isArray(payload.rows) ? payload.rows : [] + const incoming = Array.isArray(payload.rows) ? payload.rows : [] + if (append) { + const merged = [...this.rows] + const seen = new Set(this.rows.map((x) => x?.id)) + for (const row of incoming) { + const id = row?.id + if (!seen.has(id)) { + merged.push(row) + seen.add(id) + } + } + this.rows = merged + } else { + this.rows = incoming + } this.count = Number(payload.count) || this.rows.length } finally { this.loading = false