Files
bssapp/ui/src/pages/ProductionProductCostingMTBolumMapping.vue
T

1293 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<q-page v-if="canReadOrder" class="pcmm-page q-pa-md">
<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>
<div class="text-caption text-grey-7">
NEBIM_V3 urun gruplari + TEDARIK URETIM PROGRAMI parca bolum + hammadde turleri eslestirmesi
</div>
</div>
<div class="col-auto">
<q-btn
color="primary"
icon="refresh"
label="Yenile"
:loading="loading"
@click="hardResetAndRefresh"
/>
</div>
</div>
<q-separator class="q-my-sm" />
</div>
<div class="pcmm-table-wrap">
<q-table
class="ol-table pcmm-table"
flat
bordered
dense
separator="cell"
row-key="__key"
:rows="rows"
:columns="columns"
:loading="loading"
no-data-label="Kayit bulunamadi"
:rows-per-page-options="[0]"
hide-bottom
sticky-header
>
<template #header-cell="props">
<q-th :props="props">
<div class="pcmm-header-cell">
<div class="pcmm-head-wrap-2">{{ props.col.label }}</div>
<q-btn
v-if="props.col.name !== 'copy_select' && props.col.name !== 'save_select'"
dense
flat
round
size="sm"
icon="filter_alt"
:color="isColumnFilterActive(props.col.name) ? 'primary' : 'grey-6'"
>
<q-menu class="pcmm-filter-menu" fit>
<div class="pcmm-filter-menu-content">
<div class="text-caption text-weight-bold q-mb-sm">{{ props.col.label }}</div>
<q-input
v-model="getColumnFilter(props.col.name).text"
dense
outlined
clearable
label="Icerir"
/>
<q-select
v-model="getColumnFilter(props.col.name).selected"
class="q-mt-sm"
dense
outlined
multiple
use-chips
use-input
emit-value
map-options
:options="getColumnDistinctOptions(props.col.name)"
label="Deger Sec"
/>
<div class="row justify-end q-gutter-sm q-mt-sm">
<q-btn dense flat color="grey-7" label="Temizle" @click="clearColumnFilter(props.col.name)" />
</div>
</div>
</q-menu>
</q-btn>
</div>
</q-th>
</template>
<template #top-left>
<div class="row items-center q-gutter-sm">
<q-select
v-model="commonHammaddeSelected"
dense
filled
multiple
use-chips
clearable
emit-value
map-options
option-label="label"
option-value="value"
:options="commonHammaddeOptions"
:disable="loading || saving || commonHammaddeOptions.length === 0"
class="pcmm-multi-select"
style="min-width: 420px"
>
<template #selected-item="scope">
<q-chip
class="q-mr-xs"
dense
removable
@remove="scope.removeAtIndex(scope.index)"
>
{{ scope.opt.label }}
</q-chip>
</template>
<template #hint>
Ekrandaki satirlarda bulunan hammadde turleri (distinct)
</template>
</q-select>
<q-btn
color="negative"
icon="delete_sweep"
label="Butun Parcalardan Kaldir"
:disable="loading || saving || (commonHammaddeSelected || []).length === 0"
@click="confirmRemoveCommonHammadde"
/>
<q-btn
label="Kolon Filtreleri"
icon="filter_alt_off"
color="grey-7"
flat
:disable="loading || saving"
@click="clearAllColumnFilters"
/>
</div>
</template>
<template #top-right>
<div class="row items-center q-gutter-sm">
<q-btn
color="secondary"
icon="content_copy"
label="Kopyala"
:disable="loading || saving || !canCopySelected"
@click="copySelectedToSelected"
/>
<q-btn
color="secondary"
icon="save"
label="Secilenleri Kaydet"
:loading="saving"
:disable="loading || saving || !canSaveSelected"
@click="saveSelected"
/>
<q-btn
color="primary"
icon="save"
label="Degisenleri Kaydet"
:loading="saving"
:disable="loading || saving || dirtyCount === 0"
@click="saveAll"
/>
<div class="text-caption text-grey-7 q-pl-sm">
Satir: {{ rows.length }} | Degisen: {{ dirtyCount }} | Kopya: {{ copySelectedCount }} | Secili: {{ saveSelectedCount }}
</div>
</div>
</template>
<template #body-cell-copy_select="props">
<q-td :props="props" style="width: 54px">
<div class="row items-center no-wrap">
<q-checkbox
:model-value="isCopySelected(props.row.__key)"
@update:model-value="(v) => toggleCopySelected(props.row.__key, v)"
/>
<q-badge
v-if="copyRoleLabel(props.row.__key)"
color="grey-8"
class="q-ml-xs"
style="max-width: 110px"
>
{{ copyRoleLabel(props.row.__key) }}
<q-tooltip anchor="center left" self="center right">
{{ copyRoleLabel(props.row.__key) }}
</q-tooltip>
</q-badge>
</div>
</q-td>
</template>
<template #body-cell-save_select="props">
<q-td :props="props" style="width: 54px">
<q-checkbox
:model-value="isSaveSelected(props.row.__key)"
@update:model-value="(v) => toggleSaveSelected(props.row.__key, v)"
/>
</q-td>
</template>
<template #body-cell-parcaBolumAdi="props">
<q-td :props="props">
<q-select
:model-value="bolumByKey[props.row.__key] || []"
dense
filled
multiple
use-chips
clearable
use-input
input-debounce="0"
:options="mtBolumOptions"
option-label="label"
option-value="value"
emit-value
map-options
class="pcmm-multi-select"
behavior="menu"
@filter="onFilterMTBolum"
@update:model-value="(val) => { updateBolumSelection(props.row.__key, val); markDirty(props.row) }"
style="min-width: 260px"
>
<template #before-options>
<q-item clickable @click="selectAllMTBolum(props.row.__key)">
<q-item-section>Tumunu Sec</q-item-section>
</q-item>
<q-item clickable @click="clearMTBolum(props.row.__key)">
<q-item-section>Temizle</q-item-section>
</q-item>
<q-separator />
</template>
<template #selected-item="scope">
<q-chip
class="q-mr-xs"
dense
removable
@remove="scope.removeAtIndex(scope.index)"
>
{{ scope.opt.label }}
</q-chip>
</template>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<q-checkbox
:model-value="scope.selected"
tabindex="-1"
@update:model-value="() => scope.toggleOption(scope.opt)"
@click.stop
/>
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
</q-td>
</template>
<template #body-cell-nHammaddeTurleri="props">
<q-td :props="props">
<q-select
:model-value="hammaddeByKey[props.row.__key] || []"
dense
filled
multiple
use-chips
clearable
use-input
input-debounce="0"
:options="hammaddeOptions"
option-label="label"
option-value="value"
emit-value
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, pruneHammaddeSelection(props.row.__key, val)); markDirty(props.row) }"
style="min-width: 320px"
>
<template #before-options>
<q-item clickable @click="selectAllHammadde(props.row.__key)">
<q-item-section>Tumunu Sec</q-item-section>
</q-item>
<q-item clickable @click="clearHammadde(props.row.__key)">
<q-item-section>Temizle</q-item-section>
</q-item>
<q-separator />
</template>
<template #selected-item="scope">
<q-chip
class="q-mr-xs"
dense
removable
@remove="scope.removeAtIndex(scope.index)"
>
{{ scope.opt.label }}
</q-chip>
</template>
<template #option="scope">
<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
/>
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
</q-td>
</template>
</q-table>
</div>
</q-page>
<q-page v-else class="q-pa-md flex flex-center">
<div class="text-negative text-subtitle1">
Bu module erisim yetkiniz yok.
</div>
</q-page>
</template>
<script setup>
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'
import { slog } from 'src/utils/slog'
const $q = useQuasar()
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)
const mappings = ref([])
const copySelectedKeys = ref([]) // ordered
const saveSelectedKeyMap = ref({}) // key -> true
const allCombos = ref([])
const mtBolumOptions = ref([])
const hammaddeOptions = ref([])
const mtBolumLoading = ref(false)
const hammaddeLoading = ref(false)
const bolumByKey = ref({})
const hammaddeByKey = ref({})
const commonHammaddeSelected = ref([])
const hammaddeLabelCache = ref({}) // no -> "NO - ACIKLAMA"
const columns = [
{ name: 'copy_select', label: '', field: 'copy_select', align: 'center' },
{ name: 'save_select', label: '', field: 'save_select', align: 'center' },
{ name: 'urunIlkGrubu', label: 'Urun Ilk Grubu', field: 'urunIlkGrubu', align: 'left', sortable: true },
{ name: 'urunAnaGrubu', label: 'Urun Ana Grubu', field: 'urunAnaGrubu', align: 'left', sortable: true },
{ name: 'urunAltGrubu', label: 'Urun Alt Grubu', field: 'urunAltGrubu', align: 'left', sortable: true },
{ name: 'parcaBolumAdi', label: 'Parca Bolum', field: row => (Array.isArray(row?.nUrtMTBolumIDs) ? row.nUrtMTBolumIDs.join(', ') : ''), align: 'left' },
{ name: 'nHammaddeTurleri', label: 'Hammadde Turleri', field: row => (Array.isArray(row?.nHammaddeTurleri) ? row.nHammaddeTurleri.join(', ') : ''), align: 'left' }
]
const dirtyMap = ref({})
const dirtyCount = computed(() => Object.keys(dirtyMap.value || {}).length)
const copySelectedCount = computed(() => (Array.isArray(copySelectedKeys.value) ? copySelectedKeys.value.length : 0))
const canCopySelected = computed(() => copySelectedCount.value >= 2)
const saveSelectedCount = computed(() => Object.keys(saveSelectedKeyMap.value || {}).length)
const canSaveSelected = computed(() => saveSelectedCount.value > 0)
function findHammaddeLabel (no) {
const n = Number(no || 0)
if (!(n > 0)) return ''
const cached = String(hammaddeLabelCache.value?.[String(n)] || '').trim()
if (cached) return cached
const opt = (Array.isArray(hammaddeOptions.value) ? hammaddeOptions.value : []).find(o => Number(o?.value) === n)
const raw = String(opt?.label || '').trim()
if (raw) {
// Ensure "NO - ACIKLAMA" format. Some option sources may only provide the description.
if (/^\d+\s*-\s*/.test(raw)) return raw
if (/^\d+\s*$/.test(raw)) return `${n}`
return `${n} - ${raw}`
}
return `${n}`
}
function normalizeSearch (value) {
const s = String(value ?? '').trim()
if (!s) return ''
// Case-insensitive + Turkish/English character folding.
// Goal: treat "İ/i/I/ı" as the same, and fold Turkish letters to their ASCII counterparts.
// This is intentionally simple and predictable for server-side LIKE/search endpoints.
return s
.toLowerCase()
.replace(/ı/g, 'i')
.replace(/İ/g, 'i')
.replace(/i̇/g, 'i') // defensive: dotted i from some lowercasing paths
.replace(/ğ/g, 'g')
.replace(/ü/g, 'u')
.replace(/ş/g, 's')
.replace(/ö/g, 'o')
.replace(/ç/g, 'c')
}
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: [] }
}
return columnFilters[name]
}
function isColumnFilterActive (name) {
const cf = getColumnFilter(name)
return !!String(cf.text || '').trim() || (Array.isArray(cf.selected) && cf.selected.length > 0)
}
function clearColumnFilter (name) {
const cf = getColumnFilter(name)
cf.text = ''
cf.selected = []
persistColumnFilters()
}
function clearAllColumnFilters () {
for (const col of columns) {
if (col.name === 'copy_select' || col.name === 'save_select') continue
clearColumnFilter(col.name)
}
persistColumnFilters()
}
function getColumnComparableValue (row, colName) {
if (colName === 'parcaBolumAdi') {
return Array.isArray(row?.nUrtMTBolumIDs) ? row.nUrtMTBolumIDs.join(', ') : ''
}
if (colName === 'nHammaddeTurleri') {
return Array.isArray(row?.nHammaddeTurleri) ? row.nHammaddeTurleri.join(', ') : ''
}
return String(row?.[colName] ?? '').trim()
}
function getColumnDistinctOptions (colName) {
const set = new Set()
for (const row of mappings.value) {
const val = getColumnComparableValue(row, colName)
if (val) set.add(val)
}
return Array.from(set)
.sort((a, b) => a.localeCompare(b, 'tr'))
.map(v => ({ label: v, value: v }))
}
const rows = computed(() => {
let result = mappings.value
for (const col of columns) {
if (col.name === 'copy_select' || col.name === 'save_select') continue
const cf = getColumnFilter(col.name)
const text = normalizeSearch(cf.text)
const selected = Array.isArray(cf.selected) ? cf.selected : []
if (!text && selected.length === 0) continue
result = result.filter((row) => {
const value = getColumnComparableValue(row, col.name)
const valueNorm = normalizeSearch(value)
if (text && !valueNorm.includes(text)) return false
if (selected.length > 0 && !selected.includes(value)) return false
return true
})
}
return result
})
const commonHammaddeOptions = computed(() => {
const listRows = Array.isArray(rows.value) ? rows.value : []
if (listRows.length === 0) return []
const keys = listRows.map(r => String(r?.__key || '').trim()).filter(Boolean)
if (keys.length === 0) return []
// DISTINCT across visible rows (union).
const set = new Set()
for (const k of keys) {
const ham = normalizeIntList(hammaddeByKey.value?.[k] || [])
ham.forEach(n => { if (n > 0) set.add(n) })
}
return Array.from(set)
.sort((a, b) => a - b)
.map(n => ({ value: n, label: findHammaddeLabel(n) }))
})
watch(commonHammaddeOptions, (opts) => {
// Ensure labels for large/rare nos that won't be in the first 200 option fetch.
try {
const nos = (Array.isArray(opts) ? opts : []).map(o => Number(o?.value || 0)).filter(n => n > 0)
ensureHammaddeLabelsForNos(nos)
} catch {
// ignore
}
}, { immediate: true })
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
dirtyMap.value = { ...(dirtyMap.value || {}), [key]: true }
}
function clearDirty () {
dirtyMap.value = {}
}
function isCopySelected (rowKey) {
const key = String(rowKey || '').trim()
if (!key) return false
return (Array.isArray(copySelectedKeys.value) ? copySelectedKeys.value : []).includes(key)
}
function toggleCopySelected (rowKey, enabled) {
const key = String(rowKey || '').trim()
if (!key) return
const current = Array.isArray(copySelectedKeys.value) ? [...copySelectedKeys.value] : []
const idx = current.indexOf(key)
if (enabled) {
if (idx === -1) current.push(key)
} else {
if (idx >= 0) current.splice(idx, 1)
}
copySelectedKeys.value = current
}
function copyRoleLabel (rowKey) {
const key = String(rowKey || '').trim()
if (!key) return ''
const keys = Array.isArray(copySelectedKeys.value) ? copySelectedKeys.value : []
const idx = keys.indexOf(key)
if (idx === 0) return 'Kopyalanacak'
if (idx > 0) return 'Yapistirilacak'
return ''
}
function isSaveSelected (rowKey) {
const key = String(rowKey || '').trim()
if (!key) return false
return !!saveSelectedKeyMap.value?.[key]
}
function toggleSaveSelected (rowKey, enabled) {
const key = String(rowKey || '').trim()
if (!key) return
const next = { ...(saveSelectedKeyMap.value || {}) }
if (enabled) next[key] = true
else delete next[key]
saveSelectedKeyMap.value = next
}
function copySelectedToSelected () {
const keys = Array.isArray(copySelectedKeys.value) ? copySelectedKeys.value : []
if (keys.length < 2) return
const srcKey = String(keys[0] || '').trim()
if (!srcKey) return
const srcBolum = bolumByKey.value?.[srcKey] || []
const srcHam = hammaddeByKey.value?.[srcKey] || []
for (let i = 1; i < keys.length; i++) {
const key = String(keys[i] || '').trim()
if (!key) continue
updateBolumSelection(key, srcBolum)
updateHammaddeSelection(key, srcHam)
const row = (Array.isArray(mappings.value) ? mappings.value : []).find(r => String(r?.__key || '') === key)
if (row) markDirty(row)
}
$q.notify({ type: 'positive', message: 'Kopyala / Yapistir tamamlandi' })
}
async function saveSelected () {
const keys = Object.keys(saveSelectedKeyMap.value || {})
await saveKeys(keys)
}
function selectAllMTBolum (rowKey) {
const key = String(rowKey || '').trim()
if (!key) return
const all = (Array.isArray(mtBolumOptions.value) ? mtBolumOptions.value : [])
.map(o => Number(o?.value))
.filter(n => Number.isFinite(n) && n > 0)
updateBolumSelection(key, all)
const row = (Array.isArray(mappings.value) ? mappings.value : []).find(r => String(r?.__key || '') === key)
if (row) markDirty(row)
}
function clearMTBolum (rowKey) {
const key = String(rowKey || '').trim()
if (!key) return
updateBolumSelection(key, [])
const row = (Array.isArray(mappings.value) ? mappings.value : []).find(r => String(r?.__key || '') === key)
if (row) markDirty(row)
}
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)
const row = (Array.isArray(mappings.value) ? mappings.value : []).find(r => String(r?.__key || '') === key)
if (row) markDirty(row)
}
function clearHammadde (rowKey) {
const key = String(rowKey || '').trim()
if (!key) return
updateHammaddeSelection(key, [])
const row = (Array.isArray(mappings.value) ? mappings.value : []).find(r => String(r?.__key || '') === key)
if (row) markDirty(row)
}
function normalizeIntList (list) {
const arr = Array.isArray(list) ? list : []
const nums = arr
.map(x => Number(String(x).trim()))
.filter(n => Number.isFinite(n) && n > 0)
return Array.from(new Set(nums))
}
function initEditableStateFromRows (rows) {
const bolum = {}
const ham = {}
;(Array.isArray(rows) ? rows : []).forEach(r => {
const key = String(r?.__key || '').trim()
if (!key) return
bolum[key] = normalizeIntList(r?.nUrtMTBolumIDs || [])
ham[key] = normalizeIntList(r?.nHammaddeTurleri || [])
})
bolumByKey.value = bolum
hammaddeByKey.value = ham
}
function updateBolumSelection (key, newValue) {
const k = String(key || '').trim()
if (!k) return
bolumByKey.value = {
...(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) {
const k = String(key || '').trim()
if (!k) return
const nextList = normalizeIntList(newValue)
hammaddeByKey.value = { ...(hammaddeByKey.value || {}), [k]: nextList }
// Keep table field in sync for column filtering/sorting/export behavior.
const idx = (Array.isArray(mappings.value) ? mappings.value : []).findIndex(r => String(r?.__key || '') === k)
if (idx >= 0) {
const copy = [...mappings.value]
copy[idx] = { ...(copy[idx] || {}), nHammaddeTurleri: [...nextList] }
mappings.value = copy
}
}
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
}
function confirmRemoveCommonHammadde () {
const selected = normalizeIntList(commonHammaddeSelected.value || [])
if (selected.length === 0) return
const listRows = Array.isArray(rows.value) ? rows.value : []
const keys = listRows.map(r => String(r?.__key || '').trim()).filter(Boolean)
if (keys.length === 0) return
// Affected "parcalar": only rows that currently contain at least one selected hammadde.
const affectedRows = listRows.filter(r => {
const k = String(r?.__key || '').trim()
if (!k) return false
const current = normalizeIntList(hammaddeByKey.value?.[k] || [])
return current.some(v => selected.includes(v))
})
if (affectedRows.length === 0) {
$q.notify({ type: 'info', message: 'Secilen hammadde turleri bu ekrandaki satirlarda bulunmuyor.', position: 'top-right' })
return
}
const affected = affectedRows
.map(r => `${String(r?.urunIlkGrubu || '').trim() || '-'} | ${String(r?.urunAnaGrubu || '').trim() || '-'} | ${String(r?.urunAltGrubu || '').trim() || '-'}`)
.filter(Boolean)
const removedLabels = selected.map(n => findHammaddeLabel(n))
const htmlList = affected.slice(0, 30).map(x => `<div>${x}</div>`).join('')
const more = affected.length > 30 ? `<div class="text-grey-7 q-mt-sm">(+${affected.length - 30} satir daha)</div>` : ''
$q.dialog({
title: 'Butun Parcalardan Kaldir',
message: `
<div class="q-mb-sm"><b>Kaldirilacak hammadde turleri:</b></div>
<div class="q-mb-sm">${removedLabels.map(x => `<div>${x}</div>`).join('')}</div>
<div class="q-mb-sm"><b>Etkilenecek parcalar:</b></div>
<div style="max-height: 240px; overflow:auto; border: 1px solid #eee; padding: 8px;">${htmlList}${more}</div>
<div class="q-mt-sm text-grey-7">Onaylarsaniz secilen hammadde turleri sadece bu satirlardan kaldirilacak ve Degisenleri Kaydet ile kaydedilebilecek.</div>
`,
html: true,
cancel: true,
persistent: true,
ok: { label: 'Onayla', color: 'negative' },
cancelLabel: 'Geri Don'
}).onOk(() => {
const affectedKeys = affectedRows.map(r => String(r?.__key || '').trim()).filter(Boolean)
for (const k of affectedKeys) {
const current = normalizeIntList(hammaddeByKey.value?.[k] || [])
const next = current.filter(v => !selected.includes(v))
if (String(next) === String(current)) continue
updateHammaddeSelection(k, next)
const row = listRows.find(r => String(r?.__key || '') === k) || (Array.isArray(mappings.value) ? mappings.value : []).find(r => String(r?.__key || '') === k)
if (row) markDirty(row)
}
commonHammaddeSelected.value = []
$q.notify({ type: 'positive', message: 'Secilen hammadde turleri etkilenen satirlardan kaldirildi (taslak).', position: 'top-right' })
})
}
// label resolution now handled by options' `label` field + selected-item slot (see UserDetail.vue "Piyasalar").
async function fetchMappings () {
loading.value = true
try {
const data = await get('/pricing/production-product-costing/maliyet-parca-eslestirme', {
trace_id: traceId,
only_active: 1
})
return Array.isArray(data) ? data : []
} catch (e) {
const detail = await extractApiErrorDetail(e)
$q.notify({ type: 'negative', message: detail || 'Liste okunamadi' })
return []
} finally {
loading.value = false
}
}
async function fetchSheet () {
loading.value = true
try {
// Search changes should not carry over row-level selections from a previous sheet.
copySelectedKeys.value = []
saveSelectedKeyMap.value = {}
const existing = await fetchMappings()
const existingByKey = new Map()
;(Array.isArray(existing) ? existing : []).forEach(x => {
const k = `${String(x?.urunIlkGrubu || '').trim()}|${String(x?.urunAltGrubu || '').trim()}|${String(x?.urunAnaGrubu || '').trim()}`
const prev = existingByKey.get(k)
const nextList = Array.isArray(prev) ? prev : (prev ? [prev] : [])
nextList.push(x)
existingByKey.set(k, nextList)
})
const combos = Array.isArray(allCombos.value) ? allCombos.value : []
const rows = combos.map((c, idx) => {
const ilk = String(c?.urunIlkGrubu || '').trim()
const ana = String(c?.urunAnaGrubu || '').trim()
const alt = String(c?.urunAltGrubu || '').trim()
const k = `${ilk}|${alt}|${ana}`
const hitList = existingByKey.get(k)
const list = Array.isArray(hitList) ? hitList : []
const mtIds = list
.map(x => String(x?.nUrtMTBolumID || '').trim())
.filter(Boolean)
const uniqMt = Array.from(new Set(mtIds))
// If multiple mappings exist, we merge hammadde types (union) for editing convenience.
const hSet = new Set()
list.forEach(x => {
if (Array.isArray(x?.nHammaddeTurleri)) {
x.nHammaddeTurleri.forEach(v => {
v = String(v || '').trim()
if (v) hSet.add(v)
})
}
})
return {
__key: k,
__rowIndex: idx,
__existingIDs: list.map(x => Number(x?.id || 0)).filter(n => n > 0),
urunIlkGrubu: ilk,
urunAnaGrubu: ana,
urunAltGrubu: alt,
nUrtMTBolumIDs: uniqMt,
nHammaddeTurleri: Array.from(hSet)
}
})
mappings.value = rows
initEditableStateFromRows(rows)
clearDirty()
} catch (e) {
const detail = await extractApiErrorDetail(e)
$q.notify({ type: 'negative', message: detail || 'Carsaf yuklenemedi' })
} finally {
loading.value = false
}
}
// Column filters handle "search" now. Use clearAllColumnFilters() from the toolbar.
async function refreshAll () {
await Promise.all([
fetchMTBolumOptions(''),
fetchHammaddeOptions(''),
fetchCombos()
])
await fetchSheet()
}
async function fetchCombos () {
try {
const data = await get('/pricing/production-product-costing/options/urun-ana-alt-combos', {
trace_id: traceId,
search: '',
limit: 5000
})
allCombos.value = Array.isArray(data) ? data : []
} catch (e) {
const detail = await extractApiErrorDetail(e)
allCombos.value = []
$q.notify({ type: 'negative', message: detail || 'Urun combo listesi okunamadi' })
slog.error('production-product-costing.mtbolum-map', 'fetchCombos:error', {
trace_id: traceId,
detail: detail || String(e?.message || e || '')
})
}
}
async function fetchMTBolumOptions (search) {
mtBolumLoading.value = true
try {
const data = await get('/pricing/production-product-costing/options/mtbolum', {
trace_id: traceId,
search: normalizeSearch(search),
limit: 200
})
mtBolumOptions.value = Array.isArray(data)
? data.map(x => ({
value: Number(x?.value ?? x?.nUrtMTBolumID ?? x?.id ?? 0),
label: (() => {
const v = Number(x?.value ?? x?.nUrtMTBolumID ?? x?.id ?? 0)
const name = String(x?.label || x?.sAdi || '').trim()
if (Number.isFinite(v) && v > 0 && name) return `${v} - ${name}`
if (Number.isFinite(v) && v > 0) return String(v)
return name
})()
}))
.filter(x => Number.isFinite(x.value) && x.value > 0)
.sort((a, b) => a.value - b.value)
: []
} finally {
mtBolumLoading.value = false
}
}
async function fetchHammaddeOptions (search) {
hammaddeLoading.value = true
try {
const data = await get('/pricing/production-product-costing/detail-editor-options', {
trace_id: traceId,
kind: 'hammadde',
search: normalizeSearch(search),
limit: 200
})
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()
if (Number.isFinite(v) && v > 0 && name) return `${v} - ${name}`
if (Number.isFinite(v) && v > 0) return String(v)
return name
})()
}))
.filter(x => Number.isFinite(x.value) && x.value > 0)
.sort((a, b) => a.value - b.value)
: []
// Prime cache with currently loaded options.
try {
const next = { ...(hammaddeLabelCache.value || {}) }
;(Array.isArray(hammaddeOptions.value) ? hammaddeOptions.value : []).forEach(opt => {
const no = Number(opt?.value || 0)
const label = String(opt?.label || '').trim()
if (no > 0 && label) next[String(no)] = label
})
hammaddeLabelCache.value = next
} catch {
// ignore
}
} finally {
hammaddeLoading.value = false
}
}
async function ensureHammaddeLabelsForNos (nos) {
const list = normalizeIntList(nos || [])
if (list.length === 0) return
const missing = list.filter(n => {
const cached = String(hammaddeLabelCache.value?.[String(n)] || '').trim()
if (cached) return false
const opt = (Array.isArray(hammaddeOptions.value) ? hammaddeOptions.value : []).find(o => Number(o?.value) === n)
return !String(opt?.label || '').trim()
})
if (missing.length === 0) return
try {
const data = await post('/pricing/production-product-costing/options/hammadde-by-nos', {
nHammaddeTuruNos: missing
}, { trace_id: traceId })
const rows = Array.isArray(data) ? data : []
const next = { ...(hammaddeLabelCache.value || {}) }
rows.forEach(r => {
const no = Number(r?.nHammaddeTuruNo || 0)
const name = String(r?.sAciklama || '').trim()
if (no > 0 && name) next[String(no)] = `${no} - ${name}`
})
hammaddeLabelCache.value = next
} catch (e) {
// Non-blocking: fallback will show only the number
slog.error('production-product-costing.mtbolum-map', 'hammadde-by-nos:error', {
trace_id: traceId,
detail: await extractApiErrorDetail(e)
})
}
}
function onFilterMTBolum (val, update) {
update(async () => {
await fetchMTBolumOptions(val)
})
}
function onFilterHammadde (val, update) {
update(async () => {
await fetchHammaddeOptions(val)
})
}
async function saveAll () {
const dirtyKeys = Object.keys(dirtyMap.value || {})
await saveKeys(dirtyKeys)
}
async function saveKeys (keys) {
const list = Array.isArray(keys) ? keys.map(k => String(k || '').trim()).filter(Boolean) : []
if (list.length === 0) return
saving.value = true
try {
const rowsToSave = mappings.value.filter(r => list.includes(String(r.__key || '').trim()))
for (const row of rowsToSave) {
const key = String(row.__key || '').trim()
const mtIds = normalizeIntList(bolumByKey.value?.[key] || [])
const hList = normalizeIntList(hammaddeByKey.value?.[key] || [])
// If hammadde cleared OR no bölüm selected: delete all existing mappings for this combo
const existingIDs = Array.isArray(row.__existingIDs) ? row.__existingIDs : []
if (existingIDs.length > 0 && (mtIds.length === 0 || hList.length === 0)) {
for (const id of existingIDs) {
await del('/pricing/production-product-costing/maliyet-parca-eslestirme', { trace_id: traceId, id })
}
row.__existingIDs = []
bolumByKey.value = { ...(bolumByKey.value || {}), [key]: [] }
hammaddeByKey.value = { ...(hammaddeByKey.value || {}), [key]: [] }
continue
}
if (mtIds.length > 0 && hList.length > 0) {
const existing = await fetchMappings()
const ilk = String(row.urunIlkGrubu || '').trim()
const ana = String(row.urunAnaGrubu || '').trim()
const alt = String(row.urunAltGrubu || '').trim()
const existingForCombo = (Array.isArray(existing) ? existing : []).filter(x =>
String(x?.urunIlkGrubu || '').trim() === ilk &&
String(x?.urunAnaGrubu || '').trim() === ana &&
String(x?.urunAltGrubu || '').trim() === alt
)
const selectedSet = new Set(mtIds.map(String))
for (const ex of existingForCombo) {
const exMt = String(ex?.nUrtMTBolumID || '').trim()
if (exMt && !selectedSet.has(exMt) && Number(ex?.id || 0) > 0) {
await del('/pricing/production-product-costing/maliyet-parca-eslestirme', { trace_id: traceId, id: Number(ex.id) })
}
}
const idsAfter = []
for (const mtId of mtIds) {
const payload = {
urunIlkGrubu: ilk,
urunAnaGrubu: ana,
urunAltGrubu: alt,
nUrtMTBolumID: mtId,
nHammaddeTurleri: hList,
bAktif: true
}
const resp = await post('/pricing/production-product-costing/maliyet-parca-eslestirme/upsert', payload, { trace_id: traceId })
if (resp?.id) idsAfter.push(Number(resp.id))
}
row.__existingIDs = idsAfter
}
}
$q.notify({ type: 'positive', message: 'Degisiklikler kaydedildi' })
clearDirty()
// after saving, clear save selection to avoid accidental re-save
saveSelectedKeyMap.value = {}
await refreshAll()
} catch (e) {
const detail = await extractApiErrorDetail(e)
$q.notify({ type: 'negative', message: detail || 'Kaydetme basarisiz' })
} finally {
saving.value = false
}
}
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(''),
fetchHammaddeOptions(''),
fetchCombos()
])
await fetchSheet()
} catch (e) {
const detail = await extractApiErrorDetail(e)
$q.notify({ type: 'negative', message: detail || 'Sayfa yuklenemedi' })
slog.error('production-product-costing.mtbolum-map', 'mounted:error', {
trace_id: traceId,
detail: detail || String(e?.message || e || '')
})
}
})
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 {
max-width: 1200px;
margin: 0 auto;
}
.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 {
max-width: 1200px;
margin: 0 auto;
}
.pcmm-table {
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) */
.pcmm-table :deep(.pcmm-multi-select .q-field__control) {
height: auto !important;
min-height: 38px;
}
.pcmm-table :deep(.pcmm-multi-select .q-field__native) {
align-items: flex-start;
}
.pcmm-table :deep(.pcmm-multi-select .q-select__chips) {
flex-wrap: wrap;
}
.pcmm-table :deep(.pcmm-multi-select .q-chip) {
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 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 {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 4px;
width: 100%;
}
.pcmm-head-wrap-2 {
min-width: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
line-height: 1.15;
white-space: normal;
word-break: break-word;
}
.pcmm-filter-menu {
min-width: 300px;
}
.pcmm-filter-menu-content {
padding: 10px;
}
</style>