1293 lines
42 KiB
Vue
1293 lines
42 KiB
Vue
<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>
|