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

@@ -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
items.sort((a, b) => {
const valA = resolveRowUSDTutar(a)
const valB = resolveRowUSDTutar(b)
return valB - valA
})
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.
tryHydrateFromLocalDraft()
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>
@@ -9,18 +9,18 @@
</div>
</div>
<div class="col-auto">
<q-btn
color="primary"
icon="refresh"
label="Yenile"
:loading="loading"
@click="refreshAll"
/>
</div>
<div class="col-auto">
<q-btn
color="primary"
icon="refresh"
label="Yenile"
:loading="loading"
@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 {