Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-04-16 17:46:39 +03:00
parent 307282928c
commit f9728b8a4c
13 changed files with 906 additions and 109 deletions

View File

@@ -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
// -------------------------------------------------------

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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] + "..."
}

View File

@@ -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...)

View File

@@ -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 <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

@@ -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 <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

@@ -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 <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

@@ -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 <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

@@ -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"
>
<template #header="props">
<q-tr :props="props" class="header-row-fixed">
@@ -309,10 +310,13 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useProductPricingStore } from 'src/stores/ProductPricingStore'
const store = useProductPricingStore()
const FETCH_LIMIT = 500
const currentOffset = ref(0)
const loadingMore = ref(false)
const usdToTry = 38.25
const eurToTry = 41.6
@@ -381,6 +385,12 @@ const headerFilterFieldSet = new Set([
])
const mainTableRef = ref(null)
const tablePagination = ref({
page: 1,
rowsPerPage: 0,
sortBy: 'productCode',
descending: false
})
const selectedMap = ref({})
const selectedCurrencies = ref(['USD', 'EUR', 'TRY'])
const showSelectedOnly = ref(false)
@@ -570,6 +580,7 @@ const selectedRowCount = computed(() => Object.values(selectedMap.value).filter(
const selectedVisibleCount = computed(() => visibleRowIds.value.filter((id) => !!selectedMap.value[id]).length)
const allSelectedVisible = computed(() => visibleRowIds.value.length > 0 && selectedVisibleCount.value === visibleRowIds.value.length)
const someSelectedVisible = computed(() => selectedVisibleCount.value > 0)
const hasMoreRows = computed(() => Boolean(store.hasMore))
function isHeaderFilterField (field) {
return headerFilterFieldSet.has(field)
@@ -840,12 +851,52 @@ function clearAllCurrencies () {
selectedCurrencies.value = []
}
async function fetchChunk ({ reset = false } = {}) {
const offset = reset ? 0 : currentOffset.value
const result = await store.fetchRows({
limit: FETCH_LIMIT,
offset,
append: !reset
})
const fetched = Number(result?.fetched) || 0
currentOffset.value = offset + fetched
return fetched
}
async function loadMoreRows () {
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()
console.info('[product-pricing][ui] reload:start', {
at: new Date(startedAt).toISOString()
})
await store.fetchRows()
currentOffset.value = 0
await fetchChunk({ reset: true })
await ensureEnoughVisibleRows(120, 6)
console.info('[product-pricing][ui] reload:done', {
duration_ms: Date.now() - startedAt,
row_count: Array.isArray(store.rows) ? store.rows.length : 0,
@@ -857,6 +908,19 @@ async function reloadData () {
onMounted(async () => {
await reloadData()
})
watch(
[
columnFilters,
numberRangeFilters,
dateRangeFilters,
showSelectedOnly,
() => tablePagination.value.sortBy,
() => tablePagination.value.descending
],
() => { void ensureEnoughVisibleRows(80, 4) },
{ deep: true }
)
</script>
<style scoped>

View File

@@ -1,69 +1,76 @@
<template>
<q-page v-if="canUpdateLanguage" class="q-pa-md">
<div class="row q-col-gutter-sm items-end q-mb-md">
<div class="col-12 col-md-4">
<q-input
v-model="filters.q"
dense
outlined
clearable
label="Kelime ara"
<template>
<q-page v-if="canUpdateLanguage" class="q-pa-md translation-page">
<div class="translation-toolbar sticky-toolbar">
<div class="row q-col-gutter-sm items-end q-mb-md">
<div class="col-12 col-md-4">
<q-input
v-model="filters.q"
dense
outlined
clearable
label="Kelime ara"
/>
</div>
<div class="col-auto">
<q-btn color="primary" icon="search" label="Getir" @click="loadRows" />
</div>
<div class="col-auto">
<q-btn
color="secondary"
icon="sync"
label="YENİ KELİMELERİ GETİR"
:loading="store.saving"
@click="syncSources"
/>
</div>
<div class="col-auto">
<q-toggle v-model="autoTranslate" dense color="primary" label="Oto Çeviri" />
</div>
</div>
<div class="row q-gutter-sm q-mb-sm">
<q-btn
color="accent"
icon="g_translate"
label="Seçilenleri Çevir"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="translateSelectedRows"
/>
</div>
<div class="col-auto">
<q-btn color="primary" icon="search" label="Getir" @click="loadRows" />
</div>
<div class="col-auto">
<q-btn
color="secondary"
icon="sync"
label="YENİ KELİMELERİ GETİR"
icon="done_all"
label="Seçilenleri Onayla"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="syncSources"
@click="bulkApproveSelected"
/>
<q-btn
color="primary"
icon="save"
label="Seçilenleri Toplu Güncelle"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="bulkSaveSelected"
/>
</div>
<div class="col-auto">
<q-toggle v-model="autoTranslate" dense color="primary" label="Oto Çeviri" />
</div>
</div>
<div class="row q-gutter-sm q-mb-sm">
<q-btn
color="accent"
icon="g_translate"
label="Seçilenleri Çevir"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="translateSelectedRows"
/>
<q-btn
color="secondary"
icon="done_all"
label="Seçilenleri Onayla"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="bulkApproveSelected"
/>
<q-btn
color="primary"
icon="save"
label="Seçilenleri Toplu Güncelle"
:disable="selectedKeys.length === 0"
:loading="store.saving"
@click="bulkSaveSelected"
/>
</div>
<q-table
ref="tableRef"
class="translation-table"
flat
bordered
dense
virtual-scroll
:virtual-scroll-sticky-size-start="56"
row-key="t_key"
:loading="store.loading || store.saving"
:rows="pivotRows"
:columns="columns"
:rows-per-page-options="[0]"
:pagination="{ rowsPerPage: 0 }"
v-model:pagination="tablePagination"
hide-bottom
@virtual-scroll="onVirtualScroll"
>
<template #body-cell-actions="props">
<q-td :props="props">
@@ -91,7 +98,9 @@
<template #body-cell-source_text_tr="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'source_text_tr')">
<q-input v-model="rowDraft(props.row.t_key).source_text_tr" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<div class="source-text-label" :title="rowDraft(props.row.t_key).source_text_tr">
{{ rowDraft(props.row.t_key).source_text_tr }}
</div>
</q-td>
</template>
@@ -111,37 +120,79 @@
<template #body-cell-en="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'en')">
<q-input v-model="rowDraft(props.row.t_key).en" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).en"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
<template #body-cell-de="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'de')">
<q-input v-model="rowDraft(props.row.t_key).de" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).de"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
<template #body-cell-es="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'es')">
<q-input v-model="rowDraft(props.row.t_key).es" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).es"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
<template #body-cell-it="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'it')">
<q-input v-model="rowDraft(props.row.t_key).it" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).it"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
<template #body-cell-ru="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'ru')">
<q-input v-model="rowDraft(props.row.t_key).ru" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).ru"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
<template #body-cell-ar="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'ar')">
<q-input v-model="rowDraft(props.row.t_key).ar" dense outlined @blur="queueAutoSave(props.row.t_key)" />
<q-input
v-model="rowDraft(props.row.t_key).ar"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td>
</template>
</q-table>
@@ -155,7 +206,7 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import { usePermission } from 'src/composables/usePermission'
import { useTranslationStore } from 'src/stores/translationStore'
@@ -169,6 +220,18 @@ const filters = ref({
q: ''
})
const autoTranslate = ref(false)
const tableRef = ref(null)
const FETCH_LIMIT = 1400
const loadedOffset = ref(0)
const hasMoreRows = ref(true)
const loadingMore = ref(false)
const tablePagination = ref({
page: 1,
rowsPerPage: 0,
sortBy: 'source_text_tr',
descending: false
})
let filterReloadTimer = null
const sourceTypeOptions = [
{ label: 'dummy', value: 'dummy' },
@@ -179,15 +242,14 @@ const sourceTypeOptions = [
const columns = [
{ name: 'actions', label: 'Güncelle', field: 'actions', align: 'left' },
{ name: 'select', label: 'Seç', field: 'select', align: 'left' },
{ name: 't_key', label: 'Key', field: 't_key', align: 'left', sortable: true },
{ name: 'source_text_tr', label: 'Türkçe kaynak', field: 'source_text_tr', align: 'left' },
{ name: 'source_type', label: 'Veri tipi', field: 'source_type', align: 'left' },
{ name: 'en', label: 'English', field: 'en', align: 'left' },
{ name: 'de', label: 'Deutch', field: 'de', align: 'left' },
{ name: 'es', label: 'Espanol', field: 'es', align: 'left' },
{ name: 'it', label: 'Italiano', field: 'it', align: 'left' },
{ name: 'ru', label: 'Русский', field: 'ru', align: 'left' },
{ name: 'ar', label: 'العربية', field: 'ar', align: 'left' }
{ name: 'source_text_tr', label: 'Türkçe Metin', field: 'source_text_tr', align: 'left', style: 'min-width: 340px' },
{ name: 'source_type', label: 'Kaynak', field: 'source_type', align: 'left', style: 'min-width: 140px' },
{ name: 'en', label: 'İngilizce', field: 'en', align: 'left', style: 'min-width: 220px' },
{ name: 'de', label: 'Almanca', field: 'de', align: 'left', style: 'min-width: 220px' },
{ name: 'es', label: 'İspanyolca', field: 'es', align: 'left', style: 'min-width: 220px' },
{ name: 'it', label: 'İtalyanca', field: 'it', align: 'left', style: 'min-width: 220px' },
{ name: 'ru', label: 'Rusça', field: 'ru', align: 'left', style: 'min-width: 220px' },
{ name: 'ar', label: 'Arapça', field: 'ar', align: 'left', style: 'min-width: 220px' }
]
const draftByKey = ref({})
@@ -242,10 +304,33 @@ const pivotRows = computed(() => {
return Array.from(byKey.values()).sort((a, b) => a.t_key.localeCompare(b.t_key))
})
function snapshotDrafts () {
function snapshotDrafts (options = {}) {
const preserveDirty = Boolean(options?.preserveDirty)
const draft = {}
const original = {}
for (const row of pivotRows.value) {
const existingDraft = draftByKey.value[row.t_key]
const existingOriginal = originalByKey.value[row.t_key]
const keepExisting = preserveDirty &&
existingDraft &&
existingOriginal &&
(
existingDraft.source_text_tr !== existingOriginal.source_text_tr ||
existingDraft.source_type !== existingOriginal.source_type ||
existingDraft.en !== existingOriginal.en ||
existingDraft.de !== existingOriginal.de ||
existingDraft.es !== existingOriginal.es ||
existingDraft.it !== existingOriginal.it ||
existingDraft.ru !== existingOriginal.ru ||
existingDraft.ar !== existingOriginal.ar
)
if (keepExisting) {
draft[row.t_key] = { ...existingDraft }
original[row.t_key] = { ...existingOriginal }
continue
}
draft[row.t_key] = {
source_text_tr: row.source_text_tr || '',
source_type: row.source_type || 'dummy',
@@ -280,8 +365,9 @@ function rowDraft (key) {
}
function buildFilters () {
const query = String(filters.value.q || '').trim()
return {
q: filters.value.q || undefined
q: query || undefined
}
}
@@ -349,10 +435,30 @@ function queueAutoSave (key) {
autoSaveTimers.set(key, timer)
}
async function fetchRowsChunk (append = false) {
const params = {
...buildFilters(),
limit: FETCH_LIMIT,
offset: append ? loadedOffset.value : 0
}
await store.fetchRows(params, { append })
const incomingCount = Number(store.count) || 0
if (append) {
loadedOffset.value += incomingCount
} else {
loadedOffset.value = incomingCount
}
hasMoreRows.value = incomingCount === FETCH_LIMIT
snapshotDrafts({ preserveDirty: append })
}
async function loadRows () {
try {
await store.fetchRows(buildFilters())
snapshotDrafts()
loadedOffset.value = 0
hasMoreRows.value = true
await fetchRowsChunk(false)
} catch (err) {
console.error('[translation-sync][ui] loadRows:error', {
message: err?.message || 'Çeviri satırları yüklenemedi'
@@ -364,6 +470,42 @@ async function loadRows () {
}
}
async function loadMoreRows () {
if (!hasMoreRows.value || loadingMore.value || store.loading || store.saving) return
loadingMore.value = true
try {
await fetchRowsChunk(true)
} finally {
loadingMore.value = false
}
}
async function ensureEnoughVisibleRows (minRows = 120, maxBatches = 4) {
let guard = 0
while (hasMoreRows.value && pivotRows.value.length < minRows && guard < maxBatches) {
await loadMoreRows()
guard++
}
}
function onVirtualScroll (details) {
const to = Number(details?.to || 0)
if (!Number.isFinite(to)) return
if (to >= pivotRows.value.length - 15) {
void loadMoreRows()
}
}
function scheduleFilterReload () {
if (filterReloadTimer) {
clearTimeout(filterReloadTimer)
}
filterReloadTimer = setTimeout(() => {
filterReloadTimer = null
void loadRows()
}, 350)
}
async function ensureMissingLangRows (key, draft, langs) {
const missingLangs = []
if (!langs.en && String(draft.en || '').trim() !== '') missingLangs.push('en')
@@ -618,11 +760,81 @@ async function syncSources () {
}
onMounted(() => {
loadRows()
void loadRows()
})
onBeforeUnmount(() => {
if (filterReloadTimer) {
clearTimeout(filterReloadTimer)
filterReloadTimer = null
}
})
watch(
() => filters.value.q,
() => { scheduleFilterReload() }
)
watch(
[() => tablePagination.value.sortBy, () => tablePagination.value.descending],
() => { void ensureEnoughVisibleRows(120, 4) }
)
</script>
<style scoped>
.translation-page {
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.translation-toolbar {
background: #fff;
padding-top: 6px;
}
.sticky-toolbar {
position: sticky;
top: 0;
z-index: 35;
}
.translation-table {
flex: 1;
min-height: 0;
}
.translation-table :deep(.q-table__middle) {
max-height: calc(100vh - 280px);
overflow: auto;
}
.translation-table :deep(.q-table thead tr th) {
position: sticky;
top: 0;
z-index: 30;
background: #fff;
}
.translation-table :deep(.q-table tbody td) {
vertical-align: top;
padding: 6px;
}
.translation-table :deep(.q-field__native) {
line-height: 1.35;
word-break: break-word;
}
.source-text-label {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.4;
max-height: 11.2em;
overflow: auto;
}
.cell-dirty {
background: #fff3cd;
}
@@ -631,3 +843,4 @@ onMounted(() => {
background: #d9f7e8;
}
</style>

View File

@@ -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
}

View File

@@ -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