Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-05-14 02:19:59 +03:00
parent 43f965a3cf
commit 7d1304b75a
8 changed files with 726 additions and 109 deletions

View File

@@ -21,7 +21,8 @@ UI_DIR=/opt/bssapp/ui/dist
# ===============================
# DATABASES
# ===============================
POSTGRES_CONN=host=46.224.33.150 port=5432 user=postgres password=tayitkan dbname=baggib2b sslmode=disable
# Local dev: connect via SSH tunnel to the server (Windows -> 15432 -> server 127.0.0.1:5432).
POSTGRES_CONN=host=127.0.0.1 port=15432 user=postgres password=tayitkan dbname=baggib2b sslmode=disable
MSSQL_CONN=sqlserver://sa:Gil_0150@10.0.0.9:1433?database=BAGGI_V3&encrypt=disable
URETIM_MSSQL_CONN=sqlserver://sa:Gil_0150@10.0.0.9:1433?database=URETIM&encrypt=disable

View File

@@ -16,6 +16,7 @@ import (
"os"
"path"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
@@ -906,6 +907,12 @@ func main() {
log.Println("⚠️ .env / mail.env bulunamadı")
}
// Local dev convenience: on Windows we generally want to override .env with .env.local
// (e.g. SSH-tunnel PostgreSQL on 127.0.0.1:15432).
if runtime.GOOS == "windows" {
_ = godotenv.Overload(".env.local")
}
jwtSecret := os.Getenv("JWT_SECRET")
if len(jwtSecret) < 10 {
log.Fatal("❌ JWT_SECRET tanımlı değil veya çok kısa (min 10 karakter)")

View File

@@ -98,6 +98,7 @@ type ProductionHasCostDetailHeader struct {
NOnMLNo string `json:"nOnMLNo"`
UrunKodu string `json:"UrunKodu"`
UrunAdi string `json:"UrunAdi"`
UrunIlkGrubu string `json:"UrunIlkGrubu"`
UrunAnaGrubu string `json:"UrunAnaGrubu"`
UrunAltGrubu string `json:"UrunAltGrubu"`
UretimSekliID string `json:"UretimSekliID"`
@@ -131,6 +132,7 @@ type ProductionHasCostDetailEditorOption struct {
NHammaddeTuruNo string `json:"nHammaddeTuruNo"`
SHammaddeTuruAdi string `json:"sHammaddeTuruAdi"`
SAciklama3 string `json:"sAciklama3"`
MTUrtMTBolumID int `json:"mtUrtMTBolumID"`
SKodu string `json:"sKodu"`
SAciklama string `json:"sAciklama"`
SModel string `json:"sModel"`

View File

@@ -37,6 +37,34 @@ ORDER BY ProductCode;
return urunAnaGrubu, urunAltGrubu, nil
}
func GetProductIlkAnaAltGrupByUrunKodu(ctx context.Context, mssqlDB *sql.DB, urunKodu string) (urunIlkGrubu string, urunAnaGrubu string, urunAltGrubu string, err error) {
urunKodu = strings.TrimSpace(urunKodu)
if mssqlDB == nil || urunKodu == "" {
return "", "", "", nil
}
// Nebim V3: ProductFilterWithDescription exposes ProductAtt42Desc (ilk grup), ProductAtt01Desc (ana), ProductAtt02Desc (alt).
sqlText := `
SELECT TOP 1
ISNULL(ProductAtt42Desc, '') AS UrunIlkGrubu,
ISNULL(ProductAtt01Desc, '') AS UrunAnaGrubu,
ISNULL(ProductAtt02Desc, '') AS UrunAltGrubu
FROM ProductFilterWithDescription('TR')
WHERE IsBlocked = 0
AND LTRIM(RTRIM(ProductCode)) = @p1
ORDER BY ProductCode;
`
row := mssqlDB.QueryRowContext(ctx, sqlText, urunKodu)
if err := row.Scan(&urunIlkGrubu, &urunAnaGrubu, &urunAltGrubu); err != nil {
if err == sql.ErrNoRows {
return "", "", "", nil
}
return "", "", "", err
}
return urunIlkGrubu, urunAnaGrubu, urunAltGrubu, nil
}
func GetProductionProductCostingAnaGrupOptions(ctx context.Context, mssqlDB *sql.DB, search string, limit int) (*sql.Rows, error) {
search = strings.TrimSpace(search)
if limit <= 0 {
@@ -131,6 +159,7 @@ SELECT TOP (@p2)
ISNULL(B.sAdi, '') AS sAdi
FROM dbo.spUrtMTBolum B WITH (NOLOCK)
WHERE ISNULL(B.bAktif, 0) = 1
AND ISNULL(B.nUrtTipiID, 0) = 1
AND (@p1 = '' OR ISNULL(B.sAdi, '') LIKE @p3 OR CONVERT(VARCHAR(32), ISNULL(B.nUrtMTBolumID, 0)) LIKE @p3)
ORDER BY B.nUrtMTBolumID
`
@@ -160,6 +189,7 @@ SELECT
FROM dbo.mk_MaliyetParcaEslestirme M WITH (NOLOCK)
LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK)
ON B.nUrtMTBolumID = M.nUrtMTBolumID
AND ISNULL(B.nUrtTipiID, 0) = 1
OUTER APPLY (
SELECT
STUFF((
@@ -268,6 +298,97 @@ DECLARE @id INT;
return mappingID, nil
}
type ProductionProductCostingInvalidHammaddeForPart struct {
NHammaddeTuruNo int
Aciklama string
MTBolumID int
}
// FilterHammaddeTurleriForPart enforces spUrtOnMLHammaddeTuru.MTnUrtMTBolumID rules:
// - If MTnUrtMTBolumID > 0, the hammadde type is allowed only for that part (nUrtMTBolumID).
// - If MTnUrtMTBolumID is 0/NULL, it is considered global/unknown and allowed.
func FilterHammaddeTurleriForPart(
ctx context.Context,
uretimDB *sql.DB,
nUrtMTBolumID int,
nHammaddeTurleri []int,
) (allowed []int, invalid []ProductionProductCostingInvalidHammaddeForPart, err error) {
seen := make(map[int]bool, len(nHammaddeTurleri))
unique := make([]int, 0, len(nHammaddeTurleri))
for _, n := range nHammaddeTurleri {
if n <= 0 || seen[n] {
continue
}
seen[n] = true
unique = append(unique, n)
}
if len(unique) == 0 {
return []int{}, []ProductionProductCostingInvalidHammaddeForPart{}, nil
}
// Build IN list safely.
ph := make([]string, 0, len(unique))
args := make([]any, 0, len(unique))
for i, n := range unique {
ph = append(ph, fmt.Sprintf("@p%d", i+1))
args = append(args, n)
}
sqlText := fmt.Sprintf(`
SELECT
ISNULL(H.nHammaddeTuruNo, 0) AS nHammaddeTuruNo,
ISNULL(H.sAciklama, '') AS sAciklama,
ISNULL(H.MTnUrtMTBolumID, 0) AS MTnUrtMTBolumID
FROM dbo.spUrtOnMLHammaddeTuru H WITH (NOLOCK)
WHERE H.nHammaddeTuruNo IN (%s)
`, strings.Join(ph, ","))
rows, qerr := uretimDB.QueryContext(ctx, sqlText, args...)
if qerr != nil {
return nil, nil, qerr
}
defer rows.Close()
type meta struct {
aciklama string
mtBolumID int
}
metaByNo := map[int]meta{}
for rows.Next() {
var no int
var aciklama string
var mtBolum int
if scanErr := rows.Scan(&no, &aciklama, &mtBolum); scanErr != nil {
return nil, nil, scanErr
}
metaByNo[no] = meta{aciklama: strings.TrimSpace(aciklama), mtBolumID: mtBolum}
}
if rowsErr := rows.Err(); rowsErr != nil {
return nil, nil, rowsErr
}
allowed = make([]int, 0, len(unique))
invalid = make([]ProductionProductCostingInvalidHammaddeForPart, 0)
for _, n := range unique {
m, ok := metaByNo[n]
if !ok {
// Unknown in lookup; allow (we can't validate).
allowed = append(allowed, n)
continue
}
if m.mtBolumID > 0 && nUrtMTBolumID > 0 && m.mtBolumID != nUrtMTBolumID {
invalid = append(invalid, ProductionProductCostingInvalidHammaddeForPart{
NHammaddeTuruNo: n,
Aciklama: m.aciklama,
MTBolumID: m.mtBolumID,
})
continue
}
allowed = append(allowed, n)
}
return allowed, invalid, nil
}
func SetProductionProductCostingParcaMappingActive(ctx context.Context, uretimDB *sql.DB, id int, bAktif bool, user string) error {
user = strings.TrimSpace(user)
activeVal := 0
@@ -699,7 +820,8 @@ WITH RecipeMatch AS (
),
HammaddeTekil AS (
SELECT
ISNULL(NULLIF(LTRIM(RTRIM(HT.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(HT.sAciklama)), ''), N'TANIMSIZ')) AS sAciklama3,
-- Group label: DT/TP/CM2/FABRIC... Prefer sAciklama3, then sAciklama2. Never fall back to sAciklama (name).
COALESCE(NULLIF(LTRIM(RTRIM(HT.sAciklama3)), ''), NULLIF(LTRIM(RTRIM(HT.sAciklama2)), ''), N'TANIMSIZ') AS sAciklama3,
ISNULL(HT.nHammaddeTuruNo, 0) AS nHammaddeTuruNoSort,
RTRIM(CONVERT(VARCHAR(32), ISNULL(HT.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo,
-- Match URETIM's sp_pUrtOnMaliyetRecetedenKop behavior: use model code + color code instead of variant stock code.
@@ -709,6 +831,8 @@ HammaddeTekil AS (
ISNULL(HT.sAciklama, '') AS sHammaddeTuruAdi,
ISNULL(S.sBirimCinsi1, '') AS sBirim,
ISNULL(RMik.lHMiktar, 0) AS lMiktar,
ISNULL(HT.MTnUrtMTBolumID, 0) AS MTnUrtMTBolumID,
ISNULL(B.sAdi, '') AS sParcaAdi,
ROW_NUMBER() OVER (
PARTITION BY HT.nHammaddeTuruNo
ORDER BY ISNULL(S.sModel, ISNULL(S.sKodu, ''))
@@ -725,13 +849,20 @@ HammaddeTekil AS (
SELECT TOP 1
H.nHammaddeTuruNo,
H.sAciklama,
H.sAciklama3
H.sAciklama2,
H.sAciklama3,
H.MTnUrtMTBolumID
FROM dbo.spUrtOnMLHammaddeTuru H
WHERE H.nUrtMBolumID = RMik.nUrtMBolumID
-- In our data, RMik.nUrtMBolumID carries the required hammadde type number (e.g. 900/901/3300),
-- not a "bolum id". So match by hammadde type number.
WHERE H.nHammaddeTuruNo = RMik.nUrtMBolumID
ORDER BY
CASE WHEN H.nUrtMTBolumID = RMik.nUrtMTBolumID THEN 0 ELSE 1 END,
CASE WHEN H.MTnUrtMTBolumID = RMik.nUrtMTBolumID THEN 0 ELSE 1 END,
H.nHammaddeTuruNo
) HT
LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK)
ON B.nUrtMTBolumID = HT.MTnUrtMTBolumID
AND ISNULL(B.nUrtTipiID, 0) = 1
WHERE HT.nHammaddeTuruNo IS NOT NULL
)
SELECT
@@ -762,7 +893,7 @@ SELECT
0.0 AS gbpTutar,
HT.sBirim,
HT.sHammaddeTuruAdi,
HT.sHammaddeTuruAdi AS sParcaAdi
HT.sParcaAdi AS sParcaAdi
FROM HammaddeTekil HT
WHERE HT.rn = 1
ORDER BY
@@ -800,15 +931,22 @@ func GetProductionHasCostDetailHammaddeTypeOptions(
SELECT TOP (@p2)
RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo,
ISNULL(T.sAciklama, '') AS sHammaddeTuruAdi,
ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ') AS sAciklama3
FROM dbo.spUrtOnMLHammaddeTuru T
COALESCE(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), NULLIF(LTRIM(RTRIM(T.sAciklama2)), ''), N'TANIMSIZ') AS sAciklama3,
ISNULL(T.MTnUrtMTBolumID, 0) AS mtUrtMTBolumID,
ISNULL(B.sAdi, '') AS sParcaAdi
FROM dbo.spUrtOnMLHammaddeTuru T WITH (NOLOCK)
LEFT JOIN dbo.spUrtMTBolum B WITH (NOLOCK)
ON B.nUrtMTBolumID = T.MTnUrtMTBolumID
AND ISNULL(B.nUrtTipiID, 0) = 1
WHERE
ISNULL(T.bAktif, 0) = 1
AND (
@p1 = ''
OR RTRIM(CONVERT(VARCHAR(32), ISNULL(T.nHammaddeTuruNo, 0))) LIKE @p3
OR ISNULL(T.sAciklama, '') LIKE @p3
OR ISNULL(T.sAciklama2, '') LIKE @p3
OR ISNULL(T.sAciklama3, '') LIKE @p3
OR ISNULL(B.sAdi, '') LIKE @p3
)
ORDER BY
CASE

View File

@@ -596,10 +596,11 @@ func GetProductionHasCostDetailHeaderHandler(w http.ResponseWriter, r *http.Requ
logger.Info("request done", "n_urt_recete_id", item.NUrtReceteID, "urun_kodu", item.UrunKodu)
if mssqlDB != nil {
ana, alt, err := queries.GetProductAnaAltGrupByUrunKodu(ctx, mssqlDB, item.UrunKodu)
ilk, ana, alt, err := queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssqlDB, item.UrunKodu)
if err != nil {
logger.Warn("product group query error", "err", err)
} else {
item.UrunIlkGrubu = ilk
item.UrunAnaGrubu = ana
item.UrunAltGrubu = alt
}
@@ -659,10 +660,11 @@ func GetProductionHasCostDetailHeaderHandler(w http.ResponseWriter, r *http.Requ
logger.Info("request done", "n_onml_no", item.NOnMLNo, "urun_kodu", item.UrunKodu, "n_urt_recete_id", item.NUrtReceteID)
if mssqlDB != nil {
ana, alt, err := queries.GetProductAnaAltGrupByUrunKodu(ctx, mssqlDB, item.UrunKodu)
ilk, ana, alt, err := queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssqlDB, item.UrunKodu)
if err != nil {
logger.Warn("product group query error", "err", err)
} else {
item.UrunIlkGrubu = ilk
item.UrunAnaGrubu = ana
item.UrunAltGrubu = alt
}
@@ -761,7 +763,7 @@ func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *ht
list := make([]models.ProductionHasCostDetailEditorOption, 0, limit)
for rows.Next() {
var item models.ProductionHasCostDetailEditorOption
if err := rows.Scan(&item.NHammaddeTuruNo, &item.SHammaddeTuruAdi, &item.SAciklama3); err != nil {
if err := rows.Scan(&item.NHammaddeTuruNo, &item.SHammaddeTuruAdi, &item.SAciklama3, &item.MTUrtMTBolumID, &item.SParcaAdi); err != nil {
logger.Warn("hammadde scan error", "err", err)
log.Printf("⚠️ [ProductionHasCostDetailEditorOptions] hammadde scan error: %v", err)
continue
@@ -769,7 +771,6 @@ func GetProductionHasCostDetailEditorOptionsHandler(w http.ResponseWriter, r *ht
item.Kind = "hammadde"
item.Value = item.NHammaddeTuruNo
item.Label = strings.TrimSpace(item.NHammaddeTuruNo + " - " + item.SHammaddeTuruAdi)
item.SParcaAdi = item.SAciklama3
list = append(list, item)
}
if err := rows.Err(); err != nil {
@@ -1958,7 +1959,18 @@ func PostProductionProductCostingParcaMappingUpsertHandler(w http.ResponseWriter
return
}
id, err := queries.UpsertProductionProductCostingParcaMapping(ctx, uretimDB, req.UrunIlkGrubu, req.UrunAnaGrubu, req.UrunAltGrubu, req.NUrtMTBolumID, req.NHammaddeTurleri, req.BAktif, user)
allowed, invalid, ferr := queries.FilterHammaddeTurleriForPart(ctx, uretimDB, req.NUrtMTBolumID, req.NHammaddeTurleri)
if ferr != nil {
logger.Error("hammadde validation error", "err", ferr)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
return
}
// Soft-enforce: drop invalid types instead of hard failing. This prevents a "multi-part save" from failing
// when the UI reuses the same hammadde list across multiple MT bolum selections.
// We still return the ignored list so UI/logs can surface it.
ignored := invalid
id, err := queries.UpsertProductionProductCostingParcaMapping(ctx, uretimDB, req.UrunIlkGrubu, req.UrunAnaGrubu, req.UrunAltGrubu, req.NUrtMTBolumID, allowed, req.BAktif, user)
if err != nil {
logger.Error("exec error", "err", err)
http.Error(w, "Veritabani hatasi", http.StatusInternalServerError)
@@ -1966,7 +1978,10 @@ func PostProductionProductCostingParcaMappingUpsertHandler(w http.ResponseWriter
}
logger.Info("request done", "id", id, "user", user, "bAktif", req.BAktif)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": id})
if len(ignored) > 0 {
logger.Warn("hammadde types ignored due to MT bolum mismatch", "nUrtMTBolumID", req.NUrtMTBolumID, "ignored_count", len(ignored))
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": id, "ignored": ignored})
}
type productionProductCostingSetActiveRequest struct {

View File

@@ -0,0 +1,135 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* 1. DO NOT edit this file directly as it won't do anything.
* 2. EDIT the original quasar.config file INSTEAD.
* 3. DO NOT git commit this file. It should be ignored.
*
* This file is still here because there was an error in
* the original quasar.config file and this allows you to
* investigate the Node.js stack error.
*
* After you fix the original file, this file will be
* deleted automatically.
**/
// quasar.config.js
import { defineConfig } from "@quasar/app-webpack/wrappers";
var quasar_config_default = defineConfig(() => {
const apiBaseUrl = (process.env.VITE_API_BASE_URL || "/api").trim();
return {
/* =====================================================
APP INFO
===================================================== */
productName: "Baggi BSS",
productDescription: "Baggi Tekstil Business Support System",
/* =====================================================
BOOT FILES
===================================================== */
boot: ["dayjs", "locale", "resizeObserverGuard"],
/* =====================================================
GLOBAL CSS
===================================================== */
css: ["app.css"],
/* =====================================================
ICONS / FONTS
===================================================== */
extras: [
"roboto-font",
"material-icons"
],
/* =====================================================
BUILD (PRODUCTION)
===================================================== */
build: {
vueRouterMode: "hash",
env: {
VITE_API_BASE_URL: apiBaseUrl
},
esbuildTarget: {
browser: ["es2022", "firefox115", "chrome115", "safari14"],
node: "node20"
},
// Cache & performance
gzip: true,
preloadChunks: true
},
/* =====================================================
DEV SERVER (LOCAL)
===================================================== */
devServer: {
server: { type: "http" },
port: 9e3,
open: true,
client: {
overlay: {
errors: true,
warnings: false,
runtimeErrors: false
}
},
// DEV proxy (CORS'suz)
proxy: [
{
context: ["/api"],
target: "http://localhost:8080",
changeOrigin: true,
secure: false,
ws: true,
timeout: 0,
proxyTimeout: 0
}
]
},
/* =====================================================
QUASAR FRAMEWORK
===================================================== */
framework: {
config: {
notify: {
position: "top",
timeout: 2500
}
},
lang: "tr",
plugins: [
"Loading",
"Dialog",
"Notify"
]
},
animations: [],
/* =====================================================
SSR / PWA (DISABLED)
===================================================== */
ssr: {
prodPort: 3e3,
middlewares: ["render"],
pwa: false
},
pwa: {
workboxMode: "GenerateSW"
},
/* =====================================================
MOBILE / DESKTOP
===================================================== */
capacitor: {
hideSplashscreen: true
},
electron: {
preloadScripts: ["electron-preload"],
inspectPort: 5858,
bundler: "packager",
builder: {
appId: "baggisowtfaresystem"
}
},
bex: {
extraScripts: []
}
};
});
export {
quasar_config_default as default
};

View File

@@ -44,7 +44,15 @@
@click="toggleHeaderInfo"
/>
<q-btn icon="arrow_back" label="Geri" dense flat color="grey-8" class="pcd-toolbar-btn" @click="goBack" />
<q-btn label="Yenile" icon="refresh" dense color="primary" class="pcd-toolbar-btn" :loading="detailLoading" @click="fetchDetail" />
<q-btn
label="Yenile"
icon="refresh"
dense
color="primary"
class="pcd-toolbar-btn"
:loading="detailLoading"
@click="fetchDetail({ clearDraft: true, hydrateDraft: false })"
/>
<q-btn
label="Toplu Fiyat Cagir"
icon="playlist_add_check"
@@ -107,7 +115,6 @@
<div class="col-12 col-md-3">
<q-select
v-if="!isNoCostDetail"
v-model="detailHeader.UretimSekliID"
:options="productionTypes"
option-value="id"
@@ -120,29 +127,6 @@
class="pcd-emphasis-field-alt"
@update:model-value="onUretimSekliChange"
/>
<q-input
v-else
dense
filled
readonly
label="Uretim Sekli"
:model-value="detailHeader.UretimSekli || '-'"
class="pcd-emphasis-field-alt"
/>
</div>
<div class="col-12 col-md-6">
<div class="row q-col-gutter-xs">
<div class="col-4">
<q-input dense filled readonly label="USD Kuru" :model-value="formatMoney(exchangeRates.usdRate)" class="pcd-emphasis-field-alt" />
</div>
<div class="col-4">
<q-input dense filled readonly label="EUR Kuru" :model-value="formatMoney(exchangeRates.eurRate)" class="pcd-emphasis-field-alt" />
</div>
<div class="col-4">
<q-input dense filled readonly label="GBP Kuru" :model-value="formatMoney(exchangeRates.gbpRate)" class="pcd-emphasis-field-alt" />
</div>
</div>
</div>
<div class="col-12 col-md-4">
@@ -160,6 +144,9 @@
<div class="col-12 col-md-3">
<q-input dense filled readonly label="UrunAdi" :model-value="detailHeader.UrunAdi || '-'" />
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="Urun Ilk Grubu" :model-value="detailHeader.UrunIlkGrubu || '-'" />
</div>
<div class="col-12 col-md-3">
<q-input dense filled readonly label="Urun Ana Grubu" :model-value="detailHeader.UrunAnaGrubu || '-'" />
</div>
@@ -180,9 +167,9 @@
<q-input dense filled readonly label="nUrtReceteID" :model-value="detailHeader.nUrtReceteID || '-'" />
</div>
<div v-if="!isNoCostDetail && partSummary && partSummary.length > 0" class="col-12">
<div v-if="partSummary && partSummary.length > 0" class="col-12">
<div class="pcd-part-summary-card">
<div class="pcd-part-summary-title">Parça Bazlı Maliyet Özetleri</div>
<div class="pcd-part-summary-title">Parça Bazlı Maliyet Özellikleri</div>
<q-markup-table dense flat bordered separator="cell" class="pcd-part-summary-table">
<thead>
<tr>
@@ -359,7 +346,7 @@
<template #body-cell-sParcaAdi="props">
<q-td :props="props">
{{ props.value || props.row.sAciklama3 || '-' }}
{{ props.value || '-' }}
</q-td>
</template>
@@ -826,14 +813,18 @@ const flatDetailRows = computed(() => detailGroups.value.flatMap(grp => Array.is
// no-cost: required parca slots (from Maliyet Parca Eslestirme)
const requiredParcaMappings = ref([])
const requiredAttentionRowKeys = ref({})
const requiredHammaddeMetaCache = ref({}) // hammaddeNo -> { groupName, parcaAdi }
// Bump this when draft payload semantics change, to avoid hydrating stale rows after backend logic updates.
const DRAFT_STORAGE_VERSION = 'v2'
const draftStorageKey = computed(() => {
if (isNoCostDetail.value) {
if (!recipeCode.value) return ''
return `pcd-costing:no-cost:${String(productCode.value || '').trim()}:${String(recipeCode.value || '').trim()}`
return `pcd-costing:${DRAFT_STORAGE_VERSION}:no-cost:${String(productCode.value || '').trim()}:${String(recipeCode.value || '').trim()}`
}
if (!onMLNo.value) return ''
return `pcd-costing:has-cost:${String(onMLNo.value).trim()}`
return `pcd-costing:${DRAFT_STORAGE_VERSION}:has-cost:${String(onMLNo.value).trim()}`
})
const currentHeaderSnapshot = computed(() => JSON.stringify({
@@ -1051,6 +1042,27 @@ function isCMGroupName (value) {
return normalizedValue.includes('CM1') || normalizedValue.includes('CM2')
}
function isKnownGroupName (value) {
const v = String(value || '').trim().toUpperCase()
if (!v) return false
return v === 'DT' || v === 'TP' || v === 'FABRIC' || v === 'CM1' || v === 'CM2' ||
v.includes(' DT') || v.includes(' TP') || v.includes('FABRIC') || v.includes('CM1') || v.includes('CM2')
}
function normalizeLegacyParcaAndGroup (seed = {}) {
const parca = String(seed?.sParcaAdi || '').trim()
const group = String(seed?.sAciklama3 || '').trim()
if (!parca && !group) return seed
// Some legacy rows were saved with parca/group swapped. Fix it at the UI layer:
// - Parca: CEKET/PANTOLON/YELEK...
// - Group: DT/TP/CM1/CM2/FABRIC...
if (isKnownGroupName(parca) && !isKnownGroupName(group)) {
return { ...seed, sParcaAdi: group, sAciklama3: parca }
}
return seed
}
function createEmptyExchangeRates () {
return {
rateDate: '',
@@ -1062,6 +1074,7 @@ function createEmptyExchangeRates () {
}
function createRowEditorForm (seed = {}) {
seed = normalizeLegacyParcaAndGroup(seed)
const defaultCurrency = normalizePriceCurrency(seed?.inputPricePrBr || seed?.fiyat_doviz || detailHeader.value?.sDovizCinsi) || 'USD'
const cmPriceTypeId = normalizeCMPriceTypeId(seed?.cmPriceTypeId ?? seed?.cm_price_type_id, seed?.sAciklama3 ?? seed?.sParcaAdi)
return {
@@ -1070,10 +1083,10 @@ function createRowEditorForm (seed = {}) {
nStokID: String(seed?.nStokID || '').trim(),
sModel: String(seed?.sModel || '').trim(),
nOnMLDetNo: String(seed?.nOnMLDetNo || '').trim(),
sParcaAdi: String(seed?.sParcaAdi || seed?.sAciklama3 || '').trim(),
sParcaAdi: String(seed?.sParcaAdi || '').trim(),
nHammaddeTuruNo: String(seed?.nHammaddeTuruNo || '').trim(),
sHammaddeTuruAdi: String(seed?.sHammaddeTuruAdi || '').trim(),
sAciklama3: String(seed?.sAciklama3 || seed?.sParcaAdi || '').trim(),
sAciklama3: String(seed?.sAciklama3 || '').trim(),
sKodu: String(seed?.sKodu || '').trim(),
sAciklama: String(seed?.sAciklama || '').trim(),
sRenk: String(seed?.sRenk || seed?.ColorCode || '').trim(),
@@ -1828,7 +1841,7 @@ function normalizeDetailRows (items, groupName = '') {
function normalizeDetailGroups (groups) {
const list = Array.isArray(groups) ? groups : []
return list.map(grp => {
const out = list.map(grp => {
const groupName = String(grp?.sAciklama3 || '').trim()
const items = normalizeDetailRows(grp?.items, groupName).map(row => ({
...row,
@@ -1836,16 +1849,52 @@ function normalizeDetailGroups (groups) {
cmPriceTypeId: normalizeCMPriceTypeId(row?.cmPriceTypeId ?? row?.cm_price_type_id, groupName || row?.sAciklama3)
}))
// USD TUTAR (DESC) sıralama
if (isNoCostDetail.value) {
items.sort((a, b) => {
const ha = parseInt(String(a?.nHammaddeTuruNo || '0'), 10) || 0
const hb = parseInt(String(b?.nHammaddeTuruNo || '0'), 10) || 0
if (ha !== hb) return ha - hb
const ka = String(a?.sKodu || '').trim()
const kb = String(b?.sKodu || '').trim()
return ka.localeCompare(kb, 'tr')
})
} else {
items.sort((a, b) => {
const valA = resolveRowUSDTutar(a)
const valB = resolveRowUSDTutar(b)
return valB - valA
})
}
return {
...grp,
items
}
})
return sortDetailGroups(out)
}
function groupOrderIndex (name) {
const v = String(name || '').trim().toUpperCase()
if (!v) return 999
if (v.includes('CM2')) return 0
if (v.includes('FABRIC')) return 1
if (v === 'TP' || v.includes(' TP')) return 2
if (v === 'DT' || v.includes(' DT')) return 3
if (v.includes('CM1')) return 4
return 999
}
function sortDetailGroups (groups) {
const list = Array.isArray(groups) ? [...groups] : []
list.sort((a, b) => {
const ga = String(a?.sAciklama3 || '').trim()
const gb = String(b?.sAciklama3 || '').trim()
const ia = groupOrderIndex(ga)
const ib = groupOrderIndex(gb)
if (ia !== ib) return ia - ib
return ga.localeCompare(gb, 'tr')
})
return list
}
function recalculateDetailRow (row, options = {}) {
@@ -1977,11 +2026,7 @@ function resolveElHeight (refVal) {
function updateStickyTop () {
const stackH = resolveElHeight(stickyStackRef.value)
// Quasar default header height is usually around 50px
// If we are in a sub-layout or context where top header is not 50px, this might need adjustment
const layoutHeader = document.querySelector('.q-header')
const layoutHeaderH = layoutHeader ? layoutHeader.offsetHeight : 50
subHeaderTop.value = (stackH || 0) + layoutHeaderH
subHeaderTop.value = (stackH || 0) + 50
}
function toggleHeaderInfo () {
@@ -2052,7 +2097,9 @@ async function fetchExchangeRatesForCostDate (targetDate = costDate.value) {
}
}
async function fetchDetail () {
async function fetchDetail (options = {}) {
const hydrateDraft = options.hydrateDraft !== false
const clearDraft = options.clearDraft === true
if (isNoCostDetail.value && !recipeCode.value) {
detailError.value = 'Recete kodu bulunamadi'
detailGroups.value = []
@@ -2102,6 +2149,9 @@ async function fetchDetail () {
lineHistoryLastRecipeMatchStage.value = ''
try {
if (clearDraft) {
clearLocalDraft()
}
const detailParams = buildDetailFetchParams()
slog.info('production-product-costing.detail', 'fetch-detail:start', {
trace_id: traceId.value,
@@ -2123,11 +2173,13 @@ async function fetchDetail () {
detailGroups.value = normalizeDetailGroups(groupsData)
initialHeaderSnapshot.value = currentHeaderSnapshot.value
// Optional: hydrate local draft after base data load.
if (hydrateDraft) {
tryHydrateFromLocalDraft()
}
// ensure required placeholder rows exist (based on mapping screen)
try {
const mappings = await fetchRequiredParcaMappings()
ensureNoCostRequiredRowsFromMappings(mappings)
await ensureNoCostRequiredRowsFromMappings(mappings)
} catch (err) {
slog.error('production-product-costing.detail', 'required-mapping:error', {
trace_id: traceId.value,
@@ -2526,7 +2578,8 @@ async function bootstrapRowEditorOptions () {
if (selectedHammadde) {
rowEditorForm.value.sHammaddeTuruAdi = String(selectedHammadde.sHammaddeTuruAdi || rowEditorForm.value.sHammaddeTuruAdi || '').trim()
rowEditorForm.value.sAciklama3 = String(selectedHammadde.sAciklama3 || rowEditorForm.value.sAciklama3 || 'TANIMSIZ').trim() || 'TANIMSIZ'
rowEditorForm.value.sParcaAdi = String(selectedHammadde.sParcaAdi || selectedHammadde.sAciklama3 || rowEditorForm.value.sParcaAdi || '').trim()
// Parca adi should come from spUrtMTBolum.sAdi (backend sends as sParcaAdi). Never fall back to group label (DT/TP/...).
rowEditorForm.value.sParcaAdi = String(selectedHammadde.sParcaAdi || rowEditorForm.value.sParcaAdi || '').trim()
}
const selectedItem = itemRows.find(opt => String(opt?.value || '') === itemSearch)
if (selectedItem) {
@@ -2558,7 +2611,8 @@ function onRowEditorHammaddeChange (value) {
if (!selected) return
rowEditorForm.value.sHammaddeTuruAdi = String(selected.sHammaddeTuruAdi || '').trim()
rowEditorForm.value.sAciklama3 = String(selected.sAciklama3 || 'TANIMSIZ').trim() || 'TANIMSIZ'
rowEditorForm.value.sParcaAdi = String(selected.sParcaAdi || selected.sAciklama3 || '').trim()
// Parca adi should come from spUrtMTBolum.sAdi (backend sends as sParcaAdi). Never fall back to group label (DT/TP/...).
rowEditorForm.value.sParcaAdi = String(selected.sParcaAdi || '').trim()
if (!isCMGroupName(rowEditorForm.value.sAciklama3)) {
rowEditorForm.value.cmPriceTypeChecked = false
}
@@ -2603,7 +2657,7 @@ function onRowEditorColorChange (value) {
}
function buildRowFromEditorForm () {
const form = rowEditorForm.value
const form = normalizeLegacyParcaAndGroup(rowEditorForm.value)
const existingRow = flatDetailRows.value.find(row => row.__rowKey === rowEditorTargetRowKey.value)
const cmPriceTypeId = normalizeCMPriceTypeId(form.cmPriceTypeChecked ? 2 : 1, form.sAciklama3)
if (!existingRow) {
@@ -2618,8 +2672,9 @@ function buildRowFromEditorForm () {
nStokID: String(form.nStokID || '').trim(),
sModel: String(form.sModel || '').trim(),
nOnMLDetNo: String(form.nOnMLDetNo || '').trim(),
sParcaAdi: String(form.sParcaAdi || form.sAciklama3 || '').trim(),
sAciklama3: String(form.sAciklama3 || form.sParcaAdi || 'TANIMSIZ').trim() || 'TANIMSIZ',
// Keep Parca Adi and Parca Grubu distinct. sAciklama3 is the group key (DT/TP/CM2/FABRIC).
sParcaAdi: String(form.sParcaAdi || '').trim(),
sAciklama3: String(form.sAciklama3 || 'TANIMSIZ').trim() || 'TANIMSIZ',
nHammaddeTuruNo: String(form.nHammaddeTuruNo || '').trim(),
sHammaddeTuruAdi: String(form.sHammaddeTuruAdi || '').trim(),
sKodu: String(form.sKodu || '').trim(),
@@ -2662,7 +2717,14 @@ function applyEditorRowToGroups (nextRow) {
nextGroups[targetIndex] = {
...nextGroups[targetIndex],
items: [...(Array.isArray(nextGroups[targetIndex].items) ? nextGroups[targetIndex].items : []), nextRow]
.sort((left, right) => parseInt(String(left?.nOnMLDetNo || '0'), 10) - parseInt(String(right?.nOnMLDetNo || '0'), 10))
.sort((left, right) => {
if (isNoCostDetail.value) {
const ha = parseInt(String(left?.nHammaddeTuruNo || '0'), 10) || 0
const hb = parseInt(String(right?.nHammaddeTuruNo || '0'), 10) || 0
if (ha !== hb) return ha - hb
}
return parseInt(String(left?.nOnMLDetNo || '0'), 10) - parseInt(String(right?.nOnMLDetNo || '0'), 10)
})
}
} else {
nextGroups.push({
@@ -2673,7 +2735,7 @@ function applyEditorRowToGroups (nextRow) {
})
}
detailGroups.value = nextGroups
detailGroups.value = sortDetailGroups(nextGroups)
syncAllGroupsOpen()
schedulePersistLocalDraft()
}
@@ -2695,49 +2757,143 @@ function normalizeGroupName (value) {
}
async function fetchRequiredParcaMappings () {
const ilk = String(detailHeader.value?.UrunIlkGrubu || '').trim()
const ana = String(detailHeader.value?.UrunAnaGrubu || '').trim()
const alt = String(detailHeader.value?.UrunAltGrubu || '').trim()
if (!ana || !alt) return []
if (!ilk || !ana || !alt) return []
const data = await get('/pricing/production-product-costing/maliyet-parca-eslestirme', {
trace_id: traceId.value,
only_active: 1,
urun_ilk_grubu: ilk,
urun_ana_grubu: ana,
urun_alt_grubu: alt
})
return Array.isArray(data) ? data : []
}
function ensureNoCostRequiredRowsFromMappings (mappings) {
async function resolveHammaddeMetaForRequired (hNo) {
const key = String(hNo || '').trim()
if (!key) return null
const cached = requiredHammaddeMetaCache.value?.[key]
if (cached) return cached
try {
const rows = await get('/pricing/production-product-costing/detail-editor-options', {
kind: 'hammadde',
search: key,
limit: 25,
trace_id: traceId.value
})
const list = Array.isArray(rows) ? rows : []
const hit = list.find(x => String(x?.nHammaddeTuruNo ?? x?.value ?? '').trim() === key)
const meta = hit
? {
// In URETIM lookups, DT/TP/CM2/FABRIC is often stored in sAciklama2 (and sometimes in sAciklama3).
groupName: String(hit?.sAciklama3 || hit?.sAciklama2 || '').trim(),
// Hammadde type description is usually sAciklama (e.g. "CKT KUMAS").
// NOTE: detail-editor-options returns `sHammaddeTuruAdi` already without the number. Do not fall back to `label`,
// otherwise we end up rendering "3300 - 3300 - CKT ASKI".
hammaddeAdi: String(hit?.sHammaddeTuruAdi || hit?.sAciklama || '').trim(),
// Part info: spUrtOnMLHammaddeTuru.nUrtMTBolumID -> spUrtMTBolum.sAdi
mtBolumID: parseInt(String(hit?.mtUrtMTBolumID ?? hit?.MTUrtMTBolumID ?? hit?.nUrtMTBolumID ?? hit?.NUrtMTBolumID ?? '0'), 10) || 0,
parcaAdi: String(hit?.sParcaAdi || '').trim()
}
: null
requiredHammaddeMetaCache.value = { ...(requiredHammaddeMetaCache.value || {}), [key]: meta || { groupName: '', hammaddeAdi: '', mtBolumID: 0, parcaAdi: '' } }
return meta
} catch {
requiredHammaddeMetaCache.value = { ...(requiredHammaddeMetaCache.value || {}), [key]: { groupName: '', hammaddeAdi: '', mtBolumID: 0, parcaAdi: '' } }
return null
}
}
async function ensureNoCostRequiredRowsFromMappings (mappings) {
const list = Array.isArray(mappings) ? mappings : []
requiredParcaMappings.value = list
if (list.length === 0) return
// Add missing placeholder rows (qty=1, price=0) to remind user
list.forEach(mapping => {
const groupName = normalizeGroupName(mapping?.parcaBolumAdi || mapping?.mtBolumAdi || mapping?.sAciklama3)
const hList = Array.isArray(mapping?.nHammaddeTurleri) ? mapping.nHammaddeTurleri : []
hList.forEach(hNoRaw => {
const hNo = normalizeHammaddeNo(hNoRaw)
if (!hNo) return
// Defensive: The mapping payload may contain duplicate hammadde numbers across rows (or even inside a single row).
// IMPORTANT: Placeholders are keyed by (nUrtMTBolumID + nHammaddeTuruNo). The same hammadde type can be required
// for multiple parts (Ceket/Pantolon/Yelek...), so de-duping only by hNo is incorrect.
const processedRequiredKeys = new Set()
const exists = flatDetailRows.value.some(r =>
normalizeGroupName(r?.sAciklama3) === groupName &&
normalizeHammaddeNo(r?.nHammaddeTuruNo) === hNo
)
if (exists) return
// Add missing placeholder rows (qty=1, price=0) to remind user
for (const mapping of list) {
// Parca adi (CEKET/PANTOLON/YELEK...) comes from the MT bolum description (joined in backend as parcaBolumAdi).
// sAciklama3 is reserved for the group header (DT/TP/CM2/FABRIC).
const mappingParcaAdi = normalizeGroupName(mapping?.parcaBolumAdi || mapping?.mtBolumAdi)
const mappingMtBolumID = parseInt(String(mapping?.nUrtMTBolumID ?? mapping?.NUrtMTBolumID ?? '0'), 10) || 0
const hList = Array.isArray(mapping?.nHammaddeTurleri) ? mapping.nHammaddeTurleri : []
for (const hNoRaw of hList) {
const hNo = normalizeHammaddeNo(hNoRaw)
if (!hNo) continue
const reqKey = `${mappingMtBolumID}:${hNo}`
if (processedRequiredKeys.has(reqKey)) continue
processedRequiredKeys.add(reqKey)
// Prefer the hammadde lookup's groupName (DT/TP/CM2/FABRIC)
// so placeholder rows land under the correct group headers.
const meta = await resolveHammaddeMetaForRequired(hNo)
const groupName = normalizeGroupName(meta?.groupName || '')
const hammaddeAdi = String(meta?.hammaddeAdi || '').trim()
const effectiveGroupName = groupName || 'TANIMSIZ'
const mtBolumID = (meta?.mtBolumID > 0 ? meta.mtBolumID : mappingMtBolumID) || 0
const metaParcaCandidate = normalizeGroupName((meta?.parcaAdi || '').trim())
// Defensive: if backend/lookup accidentally returns group label (DT/TP/...) as part name, ignore it.
const desiredParcaAdi = normalizeGroupName((metaParcaCandidate && !isKnownGroupName(metaParcaCandidate)) ? metaParcaCandidate : mappingParcaAdi)
const anyMatch = flatDetailRows.value.find(r => {
if (normalizeHammaddeNo(r?.nHammaddeTuruNo) !== hNo) return false
const rowMtBolumID = parseInt(String(r?.nUrtMTBolumID ?? r?.NUrtMTBolumID ?? '0'), 10) || 0
if (rowMtBolumID > 0 && mtBolumID > 0) return rowMtBolumID === mtBolumID
// If we don't have row's nUrtMTBolumID (legacy rows / older placeholders), fall back to matching by part name.
// Also accept legacy placeholders where sParcaAdi accidentally contains the group label (DT/TP/...), so we can move/fix them.
const rowParca = normalizeGroupName(r?.sParcaAdi)
return rowParca === desiredParcaAdi || isKnownGroupName(rowParca)
})
if (anyMatch) {
// If we previously created a placeholder under a wrong group name, move it instead of duplicating.
if (anyMatch?.requiredPlaceholder) {
const currentGroup = normalizeGroupName(anyMatch?.sAciklama3)
const currentParcaAdi = normalizeGroupName(anyMatch?.sParcaAdi || '')
const currentHammaddeAdi = String(anyMatch?.sHammaddeTuruAdi || '').trim()
if (currentGroup !== effectiveGroupName || currentParcaAdi !== desiredParcaAdi || (hammaddeAdi && currentHammaddeAdi !== hammaddeAdi)) {
const moved = recalculateDetailRow({
...anyMatch,
sAciklama3: effectiveGroupName,
sParcaAdi: desiredParcaAdi,
nUrtMTBolumID: mtBolumID || anyMatch?.nUrtMTBolumID || anyMatch?.NUrtMTBolumID || 0,
sHammaddeTuruAdi: hammaddeAdi || String(anyMatch?.sHammaddeTuruAdi || '').trim(),
requiredPlaceholder: true,
draftChanged: true
}, {
preserveInputs: true,
priceType: 'REQ',
updateState: 'required',
markChanged: true
})
applyEditorRowToGroups(moved)
}
}
continue
}
newRowSequence.value += 1
const rowKey = `req-auto-row-${newRowSequence.value}`
const placeholder = recalculateDetailRow({
__rowKey: rowKey,
isNew: true,
nUrtMTBolumID: mtBolumID,
nOnMLNo: detailHeader.value?.nOnMLNo || onMLNo.value || '',
nOnMLDetNo: '',
sParcaAdi: groupName,
sAciklama3: groupName,
// Group header key
sAciklama3: effectiveGroupName,
// Parca adi column value (CEKET/PANTOLON/YELEK...)
sParcaAdi: desiredParcaAdi,
nHammaddeTuruNo: hNo,
sHammaddeTuruAdi: '',
// Make "Hammadde Turu" render like existing rows: "no - aciklama"
sHammaddeTuruAdi: hammaddeAdi,
sKodu: '',
sAciklama: '',
sRenk: '',
@@ -2765,8 +2921,8 @@ function ensureNoCostRequiredRowsFromMappings (mappings) {
})
applyEditorRowToGroups(placeholder)
})
})
}
}
}
function computeMissingRequiredSlots () {
@@ -3061,6 +3217,9 @@ watch(
}
.pcd-sticky-stack {
position: sticky;
top: 50px;
z-index: 1000;
background: #fff;
margin-bottom: 0;
border-bottom: 1px solid #ddd;
@@ -3253,6 +3412,9 @@ watch(
}
.pcd-sub-header {
position: sticky !important;
top: var(--pcd-subheader-top) !important;
z-index: 990 !important;
display: flex !important;
align-items: center;
justify-content: space-between;
@@ -3296,6 +3458,9 @@ watch(
}
.pcd-detail-table :deep(.q-table thead tr:first-child th) {
position: sticky !important;
top: calc(var(--pcd-subheader-top) + 42px) !important;
z-index: 980 !important;
background: #f8f9fa !important;
opacity: 1 !important;
}

View File

@@ -1,6 +1,6 @@
<template>
<q-page v-if="canReadOrder" class="pcmm-page q-pa-md">
<div class="pcmm-top">
<div class="pcmm-top sticky-top">
<div class="pcmm-header row items-center q-col-gutter-md">
<div class="col">
<div class="text-h6">Maliyet Parca Eslestirme</div>
@@ -15,12 +15,12 @@
icon="refresh"
label="Yenile"
:loading="loading"
@click="refreshAll"
@click="hardResetAndRefresh"
/>
</div>
</div>
<q-separator class="q-my-md" />
<q-separator class="q-my-sm" />
</div>
<div class="pcmm-table-wrap">
@@ -37,6 +37,7 @@
no-data-label="Kayit bulunamadi"
:rows-per-page-options="[0]"
hide-bottom
sticky-header
>
<template #header-cell="props">
<q-th :props="props">
@@ -239,8 +240,9 @@
map-options
class="pcmm-multi-select"
behavior="menu"
:disable="(bolumByKey[props.row.__key] || []).length === 0"
@filter="onFilterHammadde"
@update:model-value="(val) => { updateHammaddeSelection(props.row.__key, val); markDirty(props.row) }"
@update:model-value="(val) => { updateHammaddeSelection(props.row.__key, pruneHammaddeSelection(props.row.__key, val)); markDirty(props.row) }"
style="min-width: 320px"
>
<template #before-options>
@@ -265,10 +267,11 @@
</template>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item v-bind="scope.itemProps" :disable="isHammaddeOptionDisabled(props.row.__key, scope.opt)">
<q-item-section avatar>
<q-checkbox
:model-value="scope.selected"
:disable="isHammaddeOptionDisabled(props.row.__key, scope.opt)"
tabindex="-1"
@update:model-value="() => scope.toggleOption(scope.opt)"
@click.stop
@@ -294,7 +297,7 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import { get, post, del, extractApiErrorDetail } from 'src/services/api'
import { usePermission } from 'src/composables/usePermission'
@@ -305,6 +308,8 @@ const { canRead } = usePermission()
const canReadOrder = canRead('order')
const traceId = `pcd-mtbolum-map-${crypto?.randomUUID?.() || String(Date.now())}`
const prevBodyOverflow = ref(null)
const COLUMN_FILTERS_KEY = 'pcmm.mtbolum.columnFilters.v1'
const loading = ref(false)
const saving = ref(false)
@@ -361,6 +366,29 @@ function normalizeSearch (value) {
const columnFilters = reactive({})
function loadSavedColumnFilters () {
try {
const raw = localStorage.getItem(COLUMN_FILTERS_KEY)
if (!raw) return
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return
// Replace-in-place for Vue reactivity.
for (const k of Object.keys(columnFilters)) delete columnFilters[k]
for (const [k, v] of Object.entries(parsed)) {
const text = String(v?.text ?? '')
const selected = Array.isArray(v?.selected) ? v.selected.map(x => String(x ?? '')) : []
columnFilters[k] = { text, selected }
}
} catch {}
}
function persistColumnFilters () {
try {
localStorage.setItem(COLUMN_FILTERS_KEY, JSON.stringify(columnFilters))
} catch {}
}
function getColumnFilter (name) {
if (!columnFilters[name]) {
columnFilters[name] = { text: '', selected: [] }
@@ -377,6 +405,7 @@ function clearColumnFilter (name) {
const cf = getColumnFilter(name)
cf.text = ''
cf.selected = []
persistColumnFilters()
}
function clearAllColumnFilters () {
@@ -384,6 +413,7 @@ function clearAllColumnFilters () {
if (col.name === 'copy_select' || col.name === 'save_select') continue
clearColumnFilter(col.name)
}
persistColumnFilters()
}
function getColumnComparableValue (row, colName) {
@@ -429,6 +459,15 @@ const rows = computed(() => {
return result
})
function hardResetAndRefresh () {
// reset view state (filters + selections + dirty)
clearAllColumnFilters()
copySelectedKeys.value = []
saveSelectedKeyMap.value = {}
clearDirty()
refreshAll()
}
function markDirty (row) {
const key = String(row?.__key || '').trim()
if (!key) return
@@ -532,6 +571,7 @@ function selectAllHammadde (rowKey) {
const key = String(rowKey || '').trim()
if (!key) return
const all = (Array.isArray(hammaddeOptions.value) ? hammaddeOptions.value : [])
.filter(opt => !isHammaddeOptionDisabled(key, opt))
.map(o => Number(o?.value))
.filter(n => Number.isFinite(n) && n > 0)
updateHammaddeSelection(key, all)
@@ -575,6 +615,12 @@ function updateBolumSelection (key, newValue) {
...(bolumByKey.value || {}),
[k]: normalizeIntList(newValue)
}
// When part selection changes, prune hammadde selection to what is valid for the selected parts.
const currentHam = hammaddeByKey.value?.[k] || []
const nextHam = pruneHammaddeSelection(k, currentHam)
if (String(nextHam) !== String(currentHam)) {
hammaddeByKey.value = { ...(hammaddeByKey.value || {}), [k]: nextHam }
}
}
function updateHammaddeSelection (key, newValue) {
@@ -586,6 +632,27 @@ function updateHammaddeSelection (key, newValue) {
}
}
function isHammaddeOptionDisabled (rowKey, opt) {
const mtIds = normalizeIntList(bolumByKey.value?.[rowKey] || [])
if (mtIds.length === 0) return true
const mtBolumID = Number(opt?.mtBolumID || 0)
// If lookup doesn't specify a part, allow everywhere (mtBolumID=0).
if (!Number.isFinite(mtBolumID) || mtBolumID <= 0) return false
return !mtIds.includes(mtBolumID)
}
function pruneHammaddeSelection (rowKey, list) {
const values = normalizeIntList(list || [])
if (values.length === 0) return []
const allowed = values.filter(v => {
const opt = (Array.isArray(hammaddeOptions.value) ? hammaddeOptions.value : []).find(o => Number(o?.value) === Number(v))
// If option is not currently in the option list (search/paging), keep it; backend will still validate on save.
if (!opt) return true
return !isHammaddeOptionDisabled(rowKey, opt)
})
return allowed
}
// label resolution now handled by options' `label` field + selected-item slot (see UserDetail.vue "Piyasalar").
async function fetchMappings () {
loading.value = true
@@ -736,6 +803,8 @@ async function fetchHammaddeOptions (search) {
hammaddeOptions.value = Array.isArray(data)
? data.map(x => ({
value: Number(String(x?.nHammaddeTuruNo ?? x?.value ?? '').trim()),
// Part ownership: spUrtOnMLHammaddeTuru.MTnUrtMTBolumID (backend exposes as mtUrtMTBolumID / mtUrtMTBolumID).
mtBolumID: Number(x?.mtUrtMTBolumID ?? x?.MTUrtMTBolumID ?? 0),
label: (() => {
const v = Number(String(x?.nHammaddeTuruNo ?? x?.value ?? '').trim())
const name = String(x?.sHammaddeTuruAdi || x?.label || '').trim()
@@ -833,8 +902,6 @@ async function saveKeys (keys) {
clearDirty()
// after saving, clear save selection to avoid accidental re-save
saveSelectedKeyMap.value = {}
// after saving, also clear column filters to avoid carrying search context
clearAllColumnFilters()
await refreshAll()
} catch (e) {
const detail = await extractApiErrorDetail(e)
@@ -845,6 +912,25 @@ async function saveKeys (keys) {
}
onMounted(async () => {
// This page is designed to scroll only inside the q-table body.
// Prevent global/body scrolling while the page is mounted.
try {
prevBodyOverflow.value = document?.body?.style?.overflow ?? ''
document.body.style.overflow = 'hidden'
} catch {}
loadSavedColumnFilters()
// Persist on changes (typing/selecting) with a small debounce.
let persistTimer = null
watch(
() => columnFilters,
() => {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => persistColumnFilters(), 250)
},
{ deep: true }
)
try {
await Promise.all([
fetchMTBolumOptions(''),
@@ -861,11 +947,33 @@ onMounted(async () => {
})
}
})
onBeforeUnmount(() => {
try {
if (prevBodyOverflow.value !== null) {
document.body.style.overflow = prevBodyOverflow.value || ''
}
} catch {}
})
</script>
<style scoped>
.pcmm-page {
background: #fafafa;
display: flex;
flex-direction: column;
/* Constrain to viewport so the table body can scroll. Quasar header is 56px in this app. */
height: calc(100vh - 56px);
min-height: 0;
overflow: hidden;
/* Small safe gap under q-header */
padding-top: 10px;
}
.sticky-top {
flex: 0 0 auto;
z-index: 10;
background: #fafafa;
}
.pcmm-header {
@@ -875,10 +983,16 @@ onMounted(async () => {
.pcmm-top {
flex: 0 0 auto;
padding-bottom: 0;
margin-top: 0;
}
.pcmm-table-wrap {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.pcmm-form {
@@ -887,8 +1001,30 @@ onMounted(async () => {
}
.pcmm-table {
max-width: 1200px;
width: 100%;
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.pcmm-table :deep(.q-table__container) {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.pcmm-table :deep(.q-table__middle) {
flex: 1 1 auto;
min-height: 0;
overflow: auto !important;
}
.pcmm-table :deep(.q-table) {
table-layout: auto;
}
/* Allow multi-select chips to wrap and grow vertically (PowerBI-like) */
@@ -909,17 +1045,35 @@ onMounted(async () => {
max-width: 100%;
}
.pcmm-table :deep(.q-table__top) {
background: #fafafa;
position: sticky;
top: 0;
z-index: 20; /* Increased to be above thead th */
padding-top: 4px;
padding-bottom: 4px;
margin-bottom: 0;
}
.pcmm-table :deep(.q-table thead th) {
font-size: 11px;
padding: 3px 4px;
white-space: normal !important;
vertical-align: top !important;
line-height: 1.15;
background: #f0f0f0;
}
/* Keep q-table top controls visible while scrolling (like sticky headers). */
.pcmm-table :deep(.q-table__top) {
background: #fafafa;
/* Keep header fixed to the top of the scrollable middle area (directly under the top bar). */
.pcmm-table :deep(.q-table__middle thead tr th) {
position: sticky;
top: 0;
z-index: 10;
}
/* Make sure body rows never paint over the sticky header while scrolling. */
.pcmm-table :deep(.q-table__middle tbody td) {
background-clip: padding-box;
}
.pcmm-header-cell {