Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-05-06 11:08:31 +03:00
parent 05a2a03a6a
commit 77fe2b04b6
38 changed files with 12676 additions and 8 deletions
@@ -0,0 +1,744 @@
<template>
<q-page v-if="canReadOrder" class="pcmm-page q-pa-md">
<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">
V3 Urun Ilk Grubu (42. ozellik) + Urun Ana/Alt Grup + URETIM Parca Bolum + Hammadde Turleri eslestirmesi (URETIM mk_ tablolarinda tutulur)
</div>
</div>
<div class="col-auto">
<q-btn
color="primary"
icon="refresh"
label="Yenile"
:loading="loading"
@click="refreshAll"
/>
</div>
</div>
<q-separator class="q-my-md" />
<q-table
class="ol-table pcmm-table"
flat
bordered
dense
separator="cell"
row-key="__key"
:rows="mappings"
:columns="columns"
:loading="loading"
no-data-label="Kayit bulunamadi"
:rows-per-page-options="[0]"
hide-bottom
>
<template #top-right>
<div class="row items-center q-gutter-sm">
<q-input
v-model="filters.search"
dense
filled
clearable
debounce="250"
label="Ara (Ana/Alt)"
style="min-width: 220px"
@update:model-value="fetchSheet"
@clear="onSearchCleared"
/>
<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: {{ mappings.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"
@filter="onFilterHammadde"
@update:model-value="(val) => { updateHammaddeSelection(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">
<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>
</q-table>
</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, onMounted, ref } 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 loading = ref(false)
const saving = ref(false)
const mappings = ref([])
const copySelectedKeys = ref([]) // ordered
const saveSelectedKeyMap = ref({}) // key -> true
const filters = ref({
search: ''
})
const mtBolumOptions = ref([])
const hammaddeOptions = ref([])
const mtBolumLoading = ref(false)
const hammaddeLoading = ref(false)
const bolumByKey = ref({})
const hammaddeByKey = ref({})
const columns = [
{ name: 'copy_select', label: 'Kopya', field: 'copy_select', align: 'center' },
{ name: 'save_select', label: 'Sec', 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 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 : [])
.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)
}
}
function updateHammaddeSelection (key, newValue) {
const k = String(key || '').trim()
if (!k) return
hammaddeByKey.value = {
...(hammaddeByKey.value || {}),
[k]: normalizeIntList(newValue)
}
}
// 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 [combos, existing] = await Promise.all([
get('/pricing/production-product-costing/options/urun-ana-alt-combos', {
trace_id: traceId,
search: String(filters.value.search || '').trim(),
limit: 5000
}),
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 rows = (Array.isArray(combos) ? 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
}
}
function onSearchCleared () {
filters.value.search = ''
copySelectedKeys.value = []
saveSelectedKeyMap.value = {}
clearDirty()
fetchSheet()
}
async function refreshAll () {
await Promise.all([
fetchMTBolumOptions(''),
fetchHammaddeOptions('')
])
await fetchSheet()
}
async function fetchMTBolumOptions (search) {
mtBolumLoading.value = true
try {
const data = await get('/pricing/production-product-costing/options/mtbolum', {
trace_id: traceId,
search: 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: search || '',
limit: 200
})
hammaddeOptions.value = Array.isArray(data)
? data.map(x => ({
value: Number(String(x?.nHammaddeTuruNo ?? x?.value ?? '').trim()),
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)
: []
} finally {
hammaddeLoading.value = false
}
}
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 () => {
await Promise.all([
fetchMTBolumOptions(''),
fetchHammaddeOptions(''),
fetchSheet()
])
})
</script>
<style scoped>
.pcmm-page {
background: #fafafa;
}
.pcmm-header {
max-width: 1200px;
margin: 0 auto;
}
.pcmm-form {
max-width: 1200px;
margin: 0 auto;
}
.pcmm-table {
max-width: 1200px;
margin: 0 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%;
}
</style>