提交
This commit is contained in:
1 parent
ca6aca2b14
commit
f70dc26206
63 files changed
+589912
-9127
No files matched your search
@@ -15,51 +15,22 @@
|
||||
>
|
||||
<p v-if="selectableStatusHint" class="contract-selector-hint">{{ selectableStatusHint }}</p>
|
||||
|
||||
<div class="filter-block contract-selector-filter">
|
||||
<el-form ref="queryFormRef" :model="queryParams" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="合同编号" prop="contractNo">
|
||||
<el-input
|
||||
v-model="queryParams.contractNo"
|
||||
placeholder="搜索合同编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="合同名称" prop="contractName">
|
||||
<el-input
|
||||
v-model="queryParams.contractName"
|
||||
placeholder="搜索合同名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户名称" prop="customerName">
|
||||
<el-input
|
||||
v-model="queryParams.customerName"
|
||||
placeholder="搜索客户名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<div class="filter-actions-start" />
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" :loading="loading" @click="resetQuery">
|
||||
<el-icon><RefreshLeft /></el-icon>重置
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="loading" @click="handleQuery">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="panel dialog-list-panel contract-selector-panel">
|
||||
<div class="contract-selector-quick-search">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
clearable
|
||||
placeholder="合同编号 / 产品名称 / 产品拼音首字母"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="panel dialog-list-panel">
|
||||
<div class="list-body list-wrap dialog-list-body-only">
|
||||
<ListTable table-key="business-contract-selector"
|
||||
<ListTable
|
||||
table-key="business-contract-selector"
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="contractList"
|
||||
@@ -74,7 +45,15 @@
|
||||
>
|
||||
<el-table-column label="" min-width="48" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-checkbox
|
||||
v-if="multiple"
|
||||
:model-value="selectedIdSet.has(String(row.id))"
|
||||
:disabled="!isContractSelectable(row)"
|
||||
@click.stop
|
||||
@change="checked => handleCheckboxChange(row, !!checked)"
|
||||
/>
|
||||
<el-radio
|
||||
v-else
|
||||
v-model="selectedId"
|
||||
:label="String(row.id)"
|
||||
:disabled="!isContractSelectable(row)"
|
||||
@@ -89,7 +68,7 @@
|
||||
<el-table-column
|
||||
prop="contractNo"
|
||||
label="合同编号"
|
||||
min-width="150"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
@@ -106,15 +85,7 @@
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="customerName"
|
||||
label="客户名称"
|
||||
min-width="150"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="产品信息" min-width="280" align="left" header-align="center">
|
||||
<el-table-column label="产品信息" min-width="260" align="left" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.products?.length" class="contract-selector-products">
|
||||
<div
|
||||
@@ -128,34 +99,36 @@
|
||||
<span v-else class="contract-selector-products__empty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contractType" label="合同类型" min-width="110" align="center" header-align="center">
|
||||
<el-table-column
|
||||
prop="contractType"
|
||||
label="合同类型"
|
||||
min-width="110"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<DictTag dict-type="crm_contract_type" :value="row.contractType" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已关联的运输计划数" min-width="140" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ planCountMap[String(row.id)] ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" min-width="100" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<DictTag dict-type="crm_contract_status" :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="!hideAmount"
|
||||
prop="amount"
|
||||
label="合同金额"
|
||||
min-width="120"
|
||||
align="right"
|
||||
prop="customerName"
|
||||
label="客户名称"
|
||||
min-width="150"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span class="money-display">{{ formatMoneyDisplay(row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="signDate" label="签订日期" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column label="已关联的运输计划数" min-width="140" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ planCountMap[String(row.id)] ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
|
||||
<ListPagination
|
||||
@@ -176,7 +149,7 @@
|
||||
<el-button
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:disabled="!currentRow"
|
||||
:disabled="multiple ? selectedIdSet.size < 1 : !currentRow"
|
||||
:loading="detailLoading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
@@ -190,15 +163,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, TableInstance } from 'element-plus'
|
||||
import { Check, Close, Search } from '@element-plus/icons-vue'
|
||||
import type { TableInstance } from 'element-plus'
|
||||
import { getContract, listContract } from '@/api/crm/contract'
|
||||
import type { ContractProductForm, ContractQuery, ContractVO } from '@/api/crm/contract/types'
|
||||
import { planApi } from '@/api/pl'
|
||||
import DictTag from '@/components/DictTag/index.vue'
|
||||
import ListPagination from '@/components/ListPagination/index.vue'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { formatMoneyDisplay } from '@/utils/money-format'
|
||||
import { formatPlanProductListText } from '@/utils/pl-display'
|
||||
|
||||
const ContractDetailDialog = defineAsyncComponent(
|
||||
@@ -211,10 +183,19 @@ const props = withDefaults(
|
||||
hideAmount?: boolean
|
||||
/** 仅这些状态的合同可被选中;未配置时列表内均可选 */
|
||||
selectableStatuses?: string[]
|
||||
/** 限定客户:只展示该客户合同 */
|
||||
customerId?: string
|
||||
/** 多选模式(运输计划供方/需方) */
|
||||
multiple?: boolean
|
||||
/** 多选模式下已选合同 ID(打开时预勾选) */
|
||||
selectedIds?: string[]
|
||||
}>(),
|
||||
{
|
||||
hideAmount: false,
|
||||
selectableStatuses: undefined
|
||||
selectableStatuses: undefined,
|
||||
customerId: undefined,
|
||||
multiple: false,
|
||||
selectedIds: () => []
|
||||
}
|
||||
)
|
||||
|
||||
@@ -223,6 +204,7 @@ const dictStore = useDictStore()
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
confirm: [contract: ContractVO]
|
||||
confirmMultiple: [contracts: ContractVO[]]
|
||||
}>()
|
||||
|
||||
const innerVisible = computed({
|
||||
@@ -230,7 +212,6 @@ const innerVisible = computed({
|
||||
set: value => emit('update:visible', value)
|
||||
})
|
||||
|
||||
const queryFormRef = ref<FormInstance>()
|
||||
const tableRef = ref<TableInstance>()
|
||||
const loading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
@@ -245,20 +226,30 @@ const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
|
||||
const queryParams = reactive<ContractQuery>({
|
||||
pageNum: 1,
|
||||
pageSize: 20,
|
||||
contractNo: '',
|
||||
contractName: '',
|
||||
customerName: '',
|
||||
keyword: '',
|
||||
customerId: undefined,
|
||||
excludeStatus: 'invalid',
|
||||
includeProducts: true
|
||||
})
|
||||
|
||||
const selectedIdSet = ref<Set<string>>(new Set())
|
||||
|
||||
const selectableStatusHint = computed(() => {
|
||||
if (!props.selectableStatuses?.length) return ''
|
||||
const labels = props.selectableStatuses
|
||||
.map(status => dictStore.getLabel('crm_contract_status', status))
|
||||
.filter(Boolean)
|
||||
if (!labels.length) return '仅部分状态的合同可被选择'
|
||||
return `仅「${labels.join('」「')}」状态的合同可被选择,其他状态仅展示不可选`
|
||||
const parts: string[] = []
|
||||
if (props.customerId) {
|
||||
parts.push('仅展示当前客户下的合同')
|
||||
}
|
||||
if (props.selectableStatuses?.length) {
|
||||
const labels = props.selectableStatuses
|
||||
.map(status => dictStore.getLabel('crm_contract_status', status))
|
||||
.filter(Boolean)
|
||||
if (labels.length) {
|
||||
parts.push(`仅「${labels.join('」「')}」状态可被选择,其他状态仅展示不可选`)
|
||||
} else {
|
||||
parts.push('仅部分状态的合同可被选择')
|
||||
}
|
||||
}
|
||||
return parts.join(';')
|
||||
})
|
||||
|
||||
function formatContractProductLine(product: ContractProductForm) {
|
||||
@@ -357,6 +348,15 @@ function selectContractRow(row: ContractVO | null | undefined, options?: { silen
|
||||
}
|
||||
|
||||
function handleRowClick(row: ContractVO) {
|
||||
if (props.multiple) {
|
||||
if (!isContractSelectable(row)) {
|
||||
warnContractNotSelectable(row)
|
||||
return
|
||||
}
|
||||
const id = String(row.id)
|
||||
handleCheckboxChange(row, !selectedIdSet.value.has(id))
|
||||
return
|
||||
}
|
||||
selectContractRow(row)
|
||||
}
|
||||
|
||||
@@ -364,6 +364,19 @@ function handleRadioChange(row: ContractVO) {
|
||||
selectContractRow(row)
|
||||
}
|
||||
|
||||
function handleCheckboxChange(row: ContractVO, checked: boolean) {
|
||||
if (!row?.id) return
|
||||
if (!isContractSelectable(row)) {
|
||||
warnContractNotSelectable(row)
|
||||
return
|
||||
}
|
||||
const id = String(row.id)
|
||||
const next = new Set(selectedIdSet.value)
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
selectedIdSet.value = next
|
||||
}
|
||||
|
||||
async function loadPlanCounts() {
|
||||
const ids = contractList.value.map(item => String(item.id)).filter(Boolean)
|
||||
if (!ids.length) {
|
||||
@@ -379,28 +392,13 @@ async function loadPlanCounts() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
queryParams.pageNum = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
queryFormRef.value?.resetFields()
|
||||
queryParams.pageNum = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => ({
|
||||
contractNo: queryParams.contractNo,
|
||||
contractName: queryParams.contractName,
|
||||
customerName: queryParams.customerName
|
||||
}),
|
||||
() => queryParams.keyword,
|
||||
() => {
|
||||
queryParams.pageNum = 1
|
||||
getList()
|
||||
},
|
||||
{ debounce: 300, deep: true }
|
||||
{ debounce: 300 }
|
||||
)
|
||||
|
||||
function handleCurrentChange(row: ContractVO | undefined) {
|
||||
@@ -422,8 +420,11 @@ function handleCurrentChange(row: ContractVO | undefined) {
|
||||
function handleOpen() {
|
||||
selectedId.value = ''
|
||||
currentRow.value = null
|
||||
selectedIdSet.value = new Set((props.selectedIds || []).map(String).filter(Boolean))
|
||||
planCountMap.value = {}
|
||||
queryParams.pageNum = 1
|
||||
queryParams.keyword = ''
|
||||
queryParams.customerId = props.customerId || undefined
|
||||
nextTick(() => {
|
||||
tableRef.value?.setCurrentRow(undefined)
|
||||
})
|
||||
@@ -431,6 +432,26 @@ function handleOpen() {
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (props.multiple) {
|
||||
const ids = Array.from(selectedIdSet.value)
|
||||
if (!ids.length) {
|
||||
ElMessage.warning('请至少选择一份合同')
|
||||
return
|
||||
}
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const details: ContractVO[] = []
|
||||
for (const id of ids) {
|
||||
const res = await getContract(id)
|
||||
details.push((res.data ?? res) as ContractVO)
|
||||
}
|
||||
emit('confirmMultiple', details)
|
||||
innerVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!currentRow.value?.id) return
|
||||
if (!isContractSelectable(currentRow.value)) {
|
||||
warnContractNotSelectable(currentRow.value)
|
||||
@@ -460,48 +481,18 @@ async function handleConfirm() {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.contract-selector-filter {
|
||||
padding: 12px 16px 10px;
|
||||
background: var(--mint-surface);
|
||||
border: 1px solid var(--mint-border);
|
||||
border-radius: var(--mint-radius);
|
||||
margin-bottom: 12px;
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-actions-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.filter-actions-end .el-button + .el-button {
|
||||
margin-left: 0 !important;
|
||||
.contract-selector-panel {
|
||||
> .list-body.list-wrap {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.contract-selector-filter .filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.contract-selector-quick-search {
|
||||
margin: 0 0 12px;
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.contract-selector-filter .filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
:deep(.el-input) {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
<el-tab-pane label="归档填写" name="archive" />
|
||||
<el-tab-pane label="计划概况" name="overview" />
|
||||
<el-tab-pane label="运输信息" name="transport" />
|
||||
<el-tab-pane label="合同信息" name="contract" />
|
||||
</el-tabs>
|
||||
|
||||
<div v-show="activeTab === 'archive'" class="pl-archive-tab-panel pl-arrange-form-scroll">
|
||||
@@ -189,7 +188,7 @@
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const activeTab = ref<'archive' | 'overview' | 'transport' | 'contract'>('archive')
|
||||
const activeTab = ref<'archive' | 'overview' | 'transport'>('archive')
|
||||
const tabsRef = ref<InstanceType<typeof PlPlanViewTabs> | null>(null)
|
||||
const plan = ref<Partial<Plan>>({})
|
||||
const feedbackMirrorEl = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="pl-plan-contract-panel" v-loading="loadingDetails">
|
||||
<slot
|
||||
name="toolbar"
|
||||
:open="openSelector"
|
||||
:disabled="triggerDisabled"
|
||||
:hint="hintText"
|
||||
:hint-warn="hintWarn"
|
||||
:count="selectedIds.length"
|
||||
>
|
||||
<div v-if="!readonly" class="pl-plan-contract-panel__toolbar">
|
||||
<el-button class="btn-ghost" :disabled="triggerDisabled" @click="openSelector">
|
||||
<el-icon><Search /></el-icon>选择合同
|
||||
</el-button>
|
||||
<span v-if="hintText" class="pl-plan-contract-panel__hint" :class="{ 'is-warn': hintWarn }">
|
||||
{{ hintText }}
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div v-if="detailRows.length" class="pl-plan-contract-cards">
|
||||
<article v-for="row in detailRows" :key="row.id" class="pl-plan-contract-card">
|
||||
<div class="pl-plan-contract-card__head">
|
||||
<div class="pl-plan-contract-card__meta">
|
||||
<span class="pl-plan-contract-card__no">{{ row.contractNo || row.id || '—' }}</span>
|
||||
<DictTag dict-type="crm_contract_type" :value="row.contractType" />
|
||||
<span class="pl-plan-contract-card__count">
|
||||
{{ row.products?.length || 0 }} 个产品
|
||||
</span>
|
||||
</div>
|
||||
<div class="pl-plan-contract-card__actions">
|
||||
<el-button link type="primary" @click="handleViewContract(String(row.id))">
|
||||
<el-icon><View /></el-icon>查看
|
||||
</el-button>
|
||||
<el-button v-if="!readonly" link type="danger" @click="removeContract(String(row.id))">
|
||||
<el-icon><Delete /></el-icon>移除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="row.products?.length" class="pl-plan-contract-card__body">
|
||||
<div class="pl-plan-contract-card__table-scroll">
|
||||
<el-table
|
||||
:data="row.products"
|
||||
class="list-table pl-plan-contract-card__table"
|
||||
size="small"
|
||||
stripe
|
||||
>
|
||||
<el-table-column label="产品名称" min-width="110" align="left" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.standardProductName || product.productName || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="别名" min-width="80" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.aliasName || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="品牌" min-width="72" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ formatBrandConstraintLabel(product.brandMode, product.brandName) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="型号/规格" min-width="90" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.model || product.productModel || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" min-width="64" align="right" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.quantity ?? '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" min-width="56" align="center">
|
||||
<template #default="{ row: product }">
|
||||
<DictTag dict-type="crm_product_unit" :value="product.unit" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="包装规格" min-width="90" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.packageSpec || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="送货方式" min-width="80" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.deliveryMethod || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="90" align="left" show-overflow-tooltip>
|
||||
<template #default="{ row: product }">
|
||||
{{ product.remark || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="pl-plan-contract-card__empty">暂无产品明细</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else-if="readonly || customerId" class="pl-plan-contract-panel__empty-hint">
|
||||
{{ readonly ? '暂无关联合同' : '暂未选择合同' }}
|
||||
</p>
|
||||
|
||||
<ContractSelector
|
||||
v-if="!readonly"
|
||||
v-model:visible="selectorVisible"
|
||||
:customer-id="customerId"
|
||||
:selectable-statuses="selectableStatuses"
|
||||
multiple
|
||||
:selected-ids="selectedIds"
|
||||
hide-amount
|
||||
@confirm-multiple="handleConfirmMultiple"
|
||||
/>
|
||||
|
||||
<ContractDetailDialog
|
||||
v-if="contractDetailDialogActive"
|
||||
ref="contractDetailRef"
|
||||
hide-product-price
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Delete, Search, View } from '@element-plus/icons-vue'
|
||||
import { getContract } from '@/api/crm/contract'
|
||||
import type { ContractProductForm, ContractVO } from '@/api/crm/contract/types'
|
||||
import { fetchProductDetail } from '@/api/crm/product'
|
||||
import ContractSelector from '@/components/business/contract-selector/index.vue'
|
||||
import DictTag from '@/components/DictTag/index.vue'
|
||||
import { applyAliasDisplayMeta, formatBrandConstraintLabel } from '@/utils/crm-product-brand'
|
||||
|
||||
const ContractDetailDialog = defineAsyncComponent(
|
||||
() => import('@/views/crm/contract/components/ContractDetailDialog.vue')
|
||||
)
|
||||
|
||||
export interface PlanContractLinkForm {
|
||||
contractId: string
|
||||
contractNo?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: PlanContractLinkForm[]
|
||||
customerId?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
/** 只读:展示合同卡片,标题栏提供「查看」 */
|
||||
readonly?: boolean
|
||||
selectableStatuses?: string[]
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
selectableStatuses: () => ['approved', 'executing']
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: PlanContractLinkForm[]]
|
||||
view: [contractId: string]
|
||||
}>()
|
||||
|
||||
const selectorVisible = ref(false)
|
||||
const detailRows = ref<ContractVO[]>([])
|
||||
const loadingDetails = ref(false)
|
||||
const contractDetailDialogActive = ref(false)
|
||||
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
|
||||
|
||||
const selectedIds = computed(() =>
|
||||
(props.modelValue || []).map(item => String(item.contractId)).filter(Boolean)
|
||||
)
|
||||
|
||||
const triggerDisabled = computed(() => props.disabled || !props.customerId)
|
||||
|
||||
const hintWarn = computed(
|
||||
() => !!props.customerId && props.required && !selectedIds.value.length
|
||||
)
|
||||
|
||||
const hintText = computed(() => {
|
||||
if (!props.customerId) return '请先选择客户'
|
||||
if (hintWarn.value) return '请至少选择一份合同'
|
||||
if (selectedIds.value.length) return `已选 ${selectedIds.value.length} 份`
|
||||
return ''
|
||||
})
|
||||
|
||||
function openSelector() {
|
||||
if (props.readonly || triggerDisabled.value) return
|
||||
selectorVisible.value = true
|
||||
}
|
||||
|
||||
async function enrichContractProducts(contract: ContractVO): Promise<ContractVO> {
|
||||
const products = (contract.products || []) as ContractProductForm[]
|
||||
if (!products.length) return contract
|
||||
await Promise.all(
|
||||
products.map(async line => {
|
||||
if (!line.productId) {
|
||||
line.standardProductName = line.standardProductName || line.productName
|
||||
return
|
||||
}
|
||||
if (line.standardProductName || line.aliasName) return
|
||||
try {
|
||||
const res: any = await fetchProductDetail({ id: String(line.productId) })
|
||||
const data = res?.data ?? res
|
||||
if (data) applyAliasDisplayMeta(line, data)
|
||||
} catch {
|
||||
line.standardProductName = line.productName
|
||||
line.aliasName = undefined
|
||||
}
|
||||
})
|
||||
)
|
||||
return { ...contract, products }
|
||||
}
|
||||
|
||||
function emitLinksFromDetails(rows: ContractVO[]) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
rows.map(row => ({
|
||||
contractId: String(row.id),
|
||||
contractNo: row.contractNo || undefined
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
async function loadDetails(ids: string[]) {
|
||||
if (!ids.length) {
|
||||
detailRows.value = []
|
||||
return
|
||||
}
|
||||
loadingDetails.value = true
|
||||
try {
|
||||
const rows: ContractVO[] = []
|
||||
for (const id of ids) {
|
||||
const existing = detailRows.value.find(item => String(item.id) === id)
|
||||
if (existing?.products?.length) {
|
||||
rows.push(await enrichContractProducts(existing))
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const res = await getContract(id)
|
||||
const detail = (res.data ?? res) as ContractVO
|
||||
rows.push(await enrichContractProducts(detail))
|
||||
} catch {
|
||||
const link = (props.modelValue || []).find(item => String(item.contractId) === id)
|
||||
rows.push({
|
||||
id,
|
||||
contractNo: link?.contractNo || id,
|
||||
products: []
|
||||
} as ContractVO)
|
||||
}
|
||||
}
|
||||
detailRows.value = rows
|
||||
} finally {
|
||||
loadingDetails.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmMultiple(contracts: ContractVO[]) {
|
||||
const enriched = await Promise.all((contracts || []).map(item => enrichContractProducts(item)))
|
||||
detailRows.value = enriched
|
||||
emitLinksFromDetails(detailRows.value)
|
||||
}
|
||||
|
||||
function removeContract(id: string) {
|
||||
const next = detailRows.value.filter(item => String(item.id) !== id)
|
||||
detailRows.value = next
|
||||
emitLinksFromDetails(next)
|
||||
}
|
||||
|
||||
async function handleViewContract(contractId: string) {
|
||||
const id = String(contractId || '').trim()
|
||||
if (!id) {
|
||||
ElMessage.warning('暂无关联合同')
|
||||
return
|
||||
}
|
||||
emit('view', id)
|
||||
contractDetailDialogActive.value = true
|
||||
await nextTick()
|
||||
contractDetailRef.value?.open(id)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openSelector
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.customerId,
|
||||
(id, prev) => {
|
||||
if (props.readonly) return
|
||||
if (prev && id !== prev) {
|
||||
detailRows.value = []
|
||||
emit('update:modelValue', [])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
selectedIds,
|
||||
ids => {
|
||||
const current = detailRows.value.map(item => String(item.id)).join(',')
|
||||
const next = ids.join(',')
|
||||
if (current !== next) {
|
||||
loadDetails(ids)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pl-plan-contract-panel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-contract-panel__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pl-plan-contract-panel__hint {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
|
||||
&.is-warn {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-plan-contract-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mint-border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--primary-bg, #ecfdf5);
|
||||
border-bottom: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__no {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark, #059669);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__count {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
|
||||
:deep(.el-table__header th) {
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-plan-contract-card__empty,
|
||||
.pl-plan-contract-panel__empty-hint {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -124,10 +124,56 @@
|
||||
{{ formatBrandQtySummary(row.brandQtyRows) || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="packageSpec" label="包装规格" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column label="送货方式" min-width="100" align="center">
|
||||
<el-table-column prop="packageSpec" label="包装规格" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ getDeliveryMethodLabel(row.deliveryMethod) }}</el-tag>
|
||||
<el-select
|
||||
v-if="editablePackageDelivery"
|
||||
v-model="row.packageSpec"
|
||||
class="pl-plan-product-cell-control"
|
||||
placeholder="请选择包装规格"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="val => handlePackageSpecChange(row, val)"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in dictStore.getOptions('crm_product_gg')"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
<template v-else>
|
||||
<DictTag v-if="row.packageSpec" dict-type="crm_product_gg" :value="row.packageSpec" />
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="送货方式" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-if="editablePackageDelivery"
|
||||
v-model="row.deliveryMethod"
|
||||
class="pl-plan-product-cell-control"
|
||||
placeholder="请选择送货方式"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in dictStore.getOptions('crm_shfs')"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
<template v-else>
|
||||
<el-tag v-if="row.deliveryMethod" size="small" type="info">
|
||||
{{ getDeliveryMethodDisplay(row.deliveryMethod) }}
|
||||
</el-tag>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="editableRemark" label="备注" min-width="140">
|
||||
@@ -190,6 +236,7 @@ import { formatBrandQtySummary } from '@/utils/pl-display'
|
||||
import PlProductNameHazardCell from '@/components/business/pl-product-name-hazard-cell/index.vue'
|
||||
import ProductAliasBrandDialog from '@/components/business/product-alias-brand-dialog/index.vue'
|
||||
import DictTag from '@/components/DictTag/index.vue'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
|
||||
const props = defineProps<{
|
||||
products: PlanFormProductRow[]
|
||||
@@ -201,6 +248,8 @@ const props = defineProps<{
|
||||
editableQty?: boolean
|
||||
/** 产品信息区:别名 / 品牌可改 */
|
||||
editableAliasBrand?: boolean
|
||||
/** 包装规格 / 送货方式可下拉编辑(对齐报价单) */
|
||||
editablePackageDelivery?: boolean
|
||||
/** 新增/编辑弹窗默认不展示实际数量 */
|
||||
showActualQty?: boolean
|
||||
/** 展示归档品牌数量摘要(如:君正 12 / 红四方 13) */
|
||||
@@ -214,10 +263,22 @@ const emit = defineEmits<{
|
||||
select: [row: PlanFormProductRow, index: number]
|
||||
}>()
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
const selectedProductIdSet = computed(
|
||||
() => new Set((props.selectedProductIds || []).map(id => String(id)))
|
||||
)
|
||||
|
||||
function handlePackageSpecChange(row: PlanFormProductRow, value: string | number | boolean | undefined) {
|
||||
row.packageSpec = value == null || value === '' ? '' : String(value)
|
||||
}
|
||||
|
||||
function getDeliveryMethodDisplay(method?: string) {
|
||||
const label = dictStore.getLabel('crm_shfs', method || '')
|
||||
if (label && label !== method) return label
|
||||
return getDeliveryMethodLabel(method)
|
||||
}
|
||||
|
||||
function isProductSelected(row: PlanFormProductRow) {
|
||||
return selectedProductIdSet.value.has(String(row.productId))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,68 +1,15 @@
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="pl-plan-transport-feedback-blocks">
|
||||
<section class="pl-feedback-shared-products">
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">产品信息</h3>
|
||||
<p v-if="!sharedProductTableRows.length && !loading" class="pl-plan-form-empty-hint"
|
||||
>暂无产品</p
|
||||
>
|
||||
<div v-else class="view-subtable-wrap">
|
||||
<ListTable
|
||||
table-key="business-pl-plan-transport-feedback-shared-products"
|
||||
:data="sharedProductTableRows"
|
||||
class="view-subtable list-table"
|
||||
stripe
|
||||
>
|
||||
<el-table-column type="index" label="序号" min-width="68" align="center" />
|
||||
<el-table-column
|
||||
prop="productNo"
|
||||
label="产品编码"
|
||||
min-width="110"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.productNo || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="productName"
|
||||
label="产品名称"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.productName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="aliasName" label="别名" min-width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.aliasName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="brandName" label="品牌" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.brandName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="specification"
|
||||
label="型号/规格"
|
||||
min-width="100"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.specification || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unitLabel" label="单位" min-width="72" align="center">
|
||||
<template #default="{ row }">{{ row.unitLabel || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="planQty" label="计划数量" min-width="100" align="right">
|
||||
<template #default="{ row }">{{ row.planQty ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="packageSpec"
|
||||
label="包装规格"
|
||||
min-width="110"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.packageSpec || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || '—' }}</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
</div>
|
||||
<p v-if="!planProductRows.length && !loading" class="pl-plan-form-empty-hint">暂无产品明细</p>
|
||||
<PlPlanFormProductTable
|
||||
v-else
|
||||
:products="planProductRows"
|
||||
show-actual-qty
|
||||
show-brand-qty
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="pl-feedback-cards-row">
|
||||
@@ -377,6 +324,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { CopyDocument, Edit } from '@element-plus/icons-vue'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
import PlPlanFormProductTable from '@/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue'
|
||||
import {
|
||||
businessTypeTagType,
|
||||
formatKgToTon
|
||||
@@ -422,19 +370,6 @@
|
||||
siteFeedbackImages: string
|
||||
}
|
||||
|
||||
type SharedProductTableRow = {
|
||||
key: string
|
||||
productNo?: string
|
||||
productName: string
|
||||
aliasName?: string
|
||||
brandName?: string
|
||||
specification?: string
|
||||
unitLabel?: string
|
||||
planQty?: number | null
|
||||
packageSpec?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
type TicketQtyRow = {
|
||||
key: string
|
||||
refLabel: string
|
||||
@@ -599,63 +534,19 @@
|
||||
return props.ioOrder.kind === 'inbound' ? '入库单回传' : '出库单回传'
|
||||
})
|
||||
|
||||
const sharedProductTableRows = computed<SharedProductTableRow[]>(() => {
|
||||
if (props.planProductRows?.length) {
|
||||
return props.planProductRows.map((row, index) => {
|
||||
const unitLabel =
|
||||
dictStore.getLabel('crm_product_unit', row.unit) || row.unit || ''
|
||||
const brandLabel = resolveDisplayBrandLabel({
|
||||
brandMode: row.brandMode,
|
||||
brandName: row.brandName
|
||||
})
|
||||
return {
|
||||
key: `plan-${row.id || row.productId || index}`,
|
||||
productNo: row.productNo,
|
||||
productName: row.standardProductName || row.productName || '',
|
||||
aliasName: row.aliasName,
|
||||
brandName: brandLabel,
|
||||
specification: row.model,
|
||||
unitLabel,
|
||||
planQty: row.quantity,
|
||||
packageSpec: row.packageSpec,
|
||||
remark: row.remark
|
||||
}
|
||||
})
|
||||
const feedbackQtyUnit = computed(() => {
|
||||
const planRow = props.planProductRows?.[0]
|
||||
if (planRow?.unit) {
|
||||
return dictStore.getLabel('crm_product_unit', planRow.unit) || planRow.unit || '吨'
|
||||
}
|
||||
|
||||
if (props.driverFeedbackTableRows?.length) {
|
||||
return props.driverFeedbackTableRows.map((row, index) => ({
|
||||
key: `driver-${index}-${row.productNo || row.productName}`,
|
||||
productNo: row.productNo,
|
||||
productName: row.productName || '',
|
||||
aliasName: row.aliasName,
|
||||
brandName: row.brandName,
|
||||
specification: row.specification || row.model,
|
||||
unitLabel: row.unitLabel || row.unit,
|
||||
planQty: row.planQty,
|
||||
packageSpec: row.packageSpec,
|
||||
remark: row.remark
|
||||
}))
|
||||
const driverRow = props.driverFeedbackTableRows?.[0]
|
||||
if (driverRow?.unitLabel || driverRow?.unit) {
|
||||
return driverRow.unitLabel || driverRow.unit || '吨'
|
||||
}
|
||||
|
||||
return resolvedIoOrderItems.value.map((row, index) => ({
|
||||
key: `io-${index}-${row.productId || row.productName}`,
|
||||
productNo: row.productNo,
|
||||
productName: row.productName || '',
|
||||
aliasName: row.aliasName,
|
||||
brandName: row.brandName,
|
||||
specification: row.specification,
|
||||
unitLabel: row.unitLabel || row.unit,
|
||||
planQty: row.planQty,
|
||||
packageSpec: row.packageSpec,
|
||||
remark: row.remark
|
||||
}))
|
||||
const ioRow = resolvedIoOrderItems.value[0]
|
||||
return ioRow?.unitLabel || ioRow?.unit || '吨'
|
||||
})
|
||||
|
||||
const feedbackQtyUnit = computed(
|
||||
() => sharedProductTableRows.value[0]?.unitLabel || '吨'
|
||||
)
|
||||
|
||||
function formatTicketQty(value: unknown) {
|
||||
if (value == null || value === '') return '—'
|
||||
return String(value)
|
||||
@@ -780,42 +671,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pl-feedback-shared-products {
|
||||
padding: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.view-subtable-wrap {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.view-subtable-wrap :deep(.el-table.view-subtable) {
|
||||
--el-table-header-bg-color: #f8fafc;
|
||||
--el-table-header-text-color: #1e293b;
|
||||
--el-table-border-color: #e2e8f0;
|
||||
width: 100%;
|
||||
|
||||
.el-table__header th.el-table__cell,
|
||||
.el-table__body td.el-table__cell {
|
||||
font-size: 14px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.el-table__header th.el-table__cell .cell {
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.el-table__body td.el-table__cell .cell {
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-feedback-cards-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<el-tab-pane label="计划概况" name="overview">
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">基础信息</h3>
|
||||
<ViewKvTable :fields="overviewBasicViewFields" mode="quint" />
|
||||
<ViewKvTable :fields="overviewBasicViewFields" mode="sept" />
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
@@ -18,6 +18,82 @@
|
||||
<PlPlanFormProductTable v-else :products="productRows" show-actual-qty show-brand-qty />
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">供需双方</h3>
|
||||
<div class="pl-plan-party-row">
|
||||
<div class="pl-plan-party-block">
|
||||
<h4 class="pl-plan-subtitle">供方信息</h4>
|
||||
<PlPlanContractMultiSelect
|
||||
:model-value="plan?.supplierContracts || []"
|
||||
:customer-id="plan?.supplierId"
|
||||
readonly
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="pl-plan-party-select-row">
|
||||
<div class="pl-plan-view-party-field pl-plan-party-select-row__customer">
|
||||
<span class="pl-plan-view-party-field__value">{{
|
||||
plan?.supplierName || '—'
|
||||
}}</span>
|
||||
<span
|
||||
v-if="supplierContactText"
|
||||
class="pl-plan-view-party-field__contact"
|
||||
>
|
||||
{{ supplierContactText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PlPlanContractMultiSelect>
|
||||
</div>
|
||||
|
||||
<div class="pl-plan-flow-bridge" aria-hidden="true">
|
||||
<span class="pl-plan-flow-bridge__label">供货</span>
|
||||
<span class="pl-plan-flow-bridge__arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="M5 12h11.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.5 8.5 18.5 12l-5 3.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pl-plan-party-block">
|
||||
<h4 class="pl-plan-subtitle">需方信息</h4>
|
||||
<PlPlanContractMultiSelect
|
||||
:model-value="plan?.demanderContracts || []"
|
||||
:customer-id="plan?.customerId"
|
||||
readonly
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="pl-plan-party-select-row">
|
||||
<div class="pl-plan-view-party-field pl-plan-party-select-row__customer">
|
||||
<span class="pl-plan-view-party-field__value">{{
|
||||
plan?.customerName || '—'
|
||||
}}</span>
|
||||
<span
|
||||
v-if="demanderContactText"
|
||||
class="pl-plan-view-party-field__contact"
|
||||
>
|
||||
{{ demanderContactText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PlPlanContractMultiSelect>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<h3 class="pl-plan-form-section-title">危化品证书信息</h3>
|
||||
<PlPlanHazardCertViewTable :products="hazardProducts" />
|
||||
@@ -127,59 +203,6 @@
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="合同信息" name="contract">
|
||||
<section class="pl-plan-form-section pl-plan-form-section--plain">
|
||||
<p v-if="!contractSnapshot" class="pl-plan-form-empty-hint">暂无关联合同信息</p>
|
||||
<template v-else>
|
||||
<div class="pl-plan-form-section-head">
|
||||
<h3 class="pl-plan-form-section-title">基本信息</h3>
|
||||
<div class="pl-plan-form-section-actions">
|
||||
<el-button class="btn-ghost" @click="handleViewContract">
|
||||
<el-icon><View /></el-icon>查看合同
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<ViewKvTable :fields="contractBasicViewFields" mode="triple" />
|
||||
|
||||
<h4 class="pl-plan-subtitle">供需双方</h4>
|
||||
<div class="parties-row">
|
||||
<div class="party-panel">
|
||||
<div class="party-panel__title">供方信息</div>
|
||||
<ViewKvTable :fields="contractSupplierViewFields" mode="dual" />
|
||||
</div>
|
||||
|
||||
<div class="party-flow-bridge" aria-hidden="true">
|
||||
<span class="party-flow-bridge__label">供货</span>
|
||||
<span class="party-flow-bridge__arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="M5 12h11.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.75"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.5 8.5 18.5 12l-5 3.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="party-panel">
|
||||
<div class="party-panel__title">需方信息</div>
|
||||
<ViewKvTable :fields="contractBuyerViewFields" mode="dual" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="pl-plan-subtitle">合同产品预览</h4>
|
||||
<PlPlanFormProductTable :products="contractProductRows" />
|
||||
</template>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- defer:目标在同次渲染的兄弟节点(归档左侧)时,等 flush 后再解析,避免 emitsOptions 空引用 -->
|
||||
@@ -187,7 +210,6 @@
|
||||
<PlPlanTransportFeedbackBlocks v-bind="feedbackBlocksBind" v-on="feedbackBlocksOn" />
|
||||
</Teleport>
|
||||
|
||||
<ContractDetailDialog v-if="contractDetailDialogActive" ref="contractDetailRef" hide-product-price />
|
||||
<PlPlanDriverFeedbackDialog
|
||||
v-model:visible="driverFeedbackDialogVisible"
|
||||
:plan-id="planId"
|
||||
@@ -335,8 +357,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close, CopyDocument, View } from '@element-plus/icons-vue'
|
||||
import { Check, Close, CopyDocument } from '@element-plus/icons-vue'
|
||||
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
|
||||
import PlPlanContractMultiSelect from '@/components/business/pl-plan-contract-multi-select/index.vue'
|
||||
import BrandQtyChipsField, {
|
||||
type BrandQtyRow
|
||||
} from '@/components/business/brand-qty-chips-field/index.vue'
|
||||
@@ -352,6 +375,8 @@
|
||||
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
|
||||
import type { ViewField } from '@/components/business/system-view-dialog/types'
|
||||
import type { ContractVO } from '@/api/crm/contract/types'
|
||||
import { getContactByCustomerId } from '@/api/crm/customer'
|
||||
import type { ContactVO } from '@/api/crm/customerTypes'
|
||||
import { inventoryApi } from '@/api/db/inventory'
|
||||
import {
|
||||
inboundOrderApi,
|
||||
@@ -362,7 +387,6 @@
|
||||
import {
|
||||
enrichPlanProductLinesAlias,
|
||||
getDeliveryMethodLabel,
|
||||
mapContractProducts,
|
||||
type PlanFormProductRow
|
||||
} from '@/composables/usePlContractToPlan'
|
||||
import { mapPlanProductToFormRow, usePlPlanViewDetail, type PlPlanTransportSiteRow } from '@/composables/usePlPlanViewDetail'
|
||||
@@ -387,10 +411,6 @@
|
||||
import { formatMoneyDisplay } from '@/utils/money-format'
|
||||
import type { Transport, TransportDriverProductFeedback } from '@/api/pl'
|
||||
|
||||
const ContractDetailDialog = defineAsyncComponent(
|
||||
() => import('@/views/crm/contract/components/ContractDetailDialog.vue')
|
||||
)
|
||||
|
||||
const dictStore = useDictStore()
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -405,7 +425,7 @@
|
||||
feedbackMirrorTo?: string | HTMLElement | null
|
||||
/** 隐藏内部 Tab 导航(由外层统一控制 Tab) */
|
||||
hideNav?: boolean
|
||||
/** 受控当前 Tab:overview / transport / contract */
|
||||
/** 受控当前 Tab:overview / transport */
|
||||
tab?: string
|
||||
}>(),
|
||||
{
|
||||
@@ -441,8 +461,6 @@
|
||||
}
|
||||
}
|
||||
)
|
||||
const contractDetailDialogActive = ref(false)
|
||||
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
|
||||
const vehicleViewVisible = ref(false)
|
||||
const driverFeedbackDialogVisible = ref(false)
|
||||
const siteFeedbackDialogVisible = ref(false)
|
||||
@@ -469,6 +487,80 @@
|
||||
resetDetail
|
||||
} = usePlPlanViewDetail()
|
||||
|
||||
type PartyContactDisplay = { contactName?: string; phone?: string }
|
||||
const supplierContact = ref<PartyContactDisplay>({})
|
||||
const demanderContact = ref<PartyContactDisplay>({})
|
||||
let partyContactLoadSeq = 0
|
||||
|
||||
function pickPrimaryContact(list: ContactVO[]): PartyContactDisplay {
|
||||
if (!list.length) return {}
|
||||
const primary =
|
||||
list.find(item => Number(item.isPrimary) === 1) ||
|
||||
list.find(item => Number(item.isKey) === 1) ||
|
||||
list[0]
|
||||
const contactName = String(primary?.contactName || '').trim()
|
||||
const phone = String(primary?.mobile || primary?.telephone || '').trim()
|
||||
return {
|
||||
contactName: contactName || undefined,
|
||||
phone: phone || undefined
|
||||
}
|
||||
}
|
||||
|
||||
function formatPartyContactText(contact: PartyContactDisplay): string {
|
||||
const parts: string[] = []
|
||||
if (contact.contactName) parts.push(contact.contactName)
|
||||
if (contact.phone) parts.push(contact.phone)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const supplierContactText = computed(() => formatPartyContactText(supplierContact.value))
|
||||
const demanderContactText = computed(() => formatPartyContactText(demanderContact.value))
|
||||
|
||||
async function loadPartyContact(customerId?: string | null): Promise<PartyContactDisplay> {
|
||||
const id = String(customerId || '').trim()
|
||||
if (!id) return {}
|
||||
try {
|
||||
const res = await getContactByCustomerId(id)
|
||||
const list = (res?.data || []) as ContactVO[]
|
||||
return pickPrimaryContact(Array.isArray(list) ? list : [])
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPartyContacts() {
|
||||
const seq = ++partyContactLoadSeq
|
||||
const supplierId = plan.value?.supplierId
|
||||
const customerId = plan.value?.customerId
|
||||
const [supplier, demanderFromCrm] = await Promise.all([
|
||||
loadPartyContact(supplierId),
|
||||
loadPartyContact(customerId)
|
||||
])
|
||||
if (seq !== partyContactLoadSeq) return
|
||||
|
||||
supplierContact.value = supplier
|
||||
|
||||
const planContactName = String(plan.value?.contactName || '').trim()
|
||||
const planContactPhone = String(plan.value?.contactPhone || '').trim()
|
||||
demanderContact.value = {
|
||||
contactName: demanderFromCrm.contactName || planContactName || undefined,
|
||||
phone: demanderFromCrm.phone || planContactPhone || undefined
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [plan.value?.id, plan.value?.supplierId, plan.value?.customerId] as const,
|
||||
() => {
|
||||
if (!plan.value?.id) {
|
||||
supplierContact.value = {}
|
||||
demanderContact.value = {}
|
||||
return
|
||||
}
|
||||
void refreshPartyContacts()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const ioOrderEditVisible = ref(false)
|
||||
const ioOrderSaving = ref(false)
|
||||
const ioOrderEditForm = ref<PlIoOrder | null>(null)
|
||||
@@ -1123,7 +1215,9 @@
|
||||
{ label: '计划日期', value: plan.value?.planDate },
|
||||
{ label: '归属公司', value: getPlPlanCompanyLabel(plan.value?.belongsCompany) },
|
||||
{ label: '单据类型', value: getPlPlanTypeLabel(plan.value?.planType || '') },
|
||||
{ label: '跟进人', value: plan.value?.followUserName }
|
||||
{ label: '跟进人', value: plan.value?.followUserName },
|
||||
{ label: '供方', value: plan.value?.supplierName },
|
||||
{ label: '需方', value: plan.value?.customerName }
|
||||
])
|
||||
|
||||
const summaryCarrierName = computed(() => displayCarrierName.value)
|
||||
@@ -1181,57 +1275,6 @@
|
||||
]
|
||||
})
|
||||
|
||||
const contractBasicViewFields = computed<ViewField[]>(() => {
|
||||
const c = contractSnapshot.value
|
||||
if (!c) return []
|
||||
const settlementLabel = c.settlementMethod
|
||||
? dictStore.getLabel('crm_settlement_method', c.settlementMethod)
|
||||
: '—'
|
||||
return [
|
||||
{ label: '合同编号', value: c.contractNo, long: false },
|
||||
{ label: '合同名称', value: c.contractName, long: false },
|
||||
{ label: '签订日期', value: c.signDate },
|
||||
{
|
||||
label: '合同有效期',
|
||||
value: c.startDate && c.endDate ? `${c.startDate} ~ ${c.endDate}` : '—',
|
||||
long: false
|
||||
},
|
||||
{ label: '结算方式', value: settlementLabel }
|
||||
]
|
||||
})
|
||||
|
||||
const contractSupplierViewFields = computed<ViewField[]>(() => {
|
||||
const c = contractSnapshot.value
|
||||
if (!c) return []
|
||||
return [
|
||||
{ label: '单位名称(章)', value: c.partyA },
|
||||
{ label: '单位地址', value: c.partyAAddress },
|
||||
{ label: '法定代表人', value: c.partyALegalPerson },
|
||||
{ label: '代理人', value: c.partyAAgent },
|
||||
{ label: '电话/传真', value: c.partyAPhoneFax },
|
||||
{ label: '开户银行', value: c.partyABank },
|
||||
{ label: '账号', value: c.partyAAccount }
|
||||
]
|
||||
})
|
||||
|
||||
const contractBuyerViewFields = computed<ViewField[]>(() => {
|
||||
const c = contractSnapshot.value
|
||||
if (!c) return []
|
||||
return [
|
||||
{ label: '单位名称(章)', value: c.partyB },
|
||||
{ label: '单位地址', value: c.partyBAddress },
|
||||
{ label: '法定代表人', value: c.partyBLegalPerson },
|
||||
{ label: '代理人', value: c.partyBAgent },
|
||||
{ label: '电话/传真', value: c.partyBPhoneFax },
|
||||
{ label: '开户银行', value: c.partyBBank },
|
||||
{ label: '账号', value: c.partyBAccount }
|
||||
]
|
||||
})
|
||||
|
||||
const contractProductRows = computed<PlanFormProductRow[]>(() =>
|
||||
mapContractProducts(contractSnapshot.value?.products)
|
||||
)
|
||||
|
||||
/** 危化证区域展示 CRM 标准名(productRows 已回填 standardProductName;库内 productName 可能是别名) */
|
||||
const hazardProducts = computed(() =>
|
||||
products.value
|
||||
@@ -1244,18 +1287,6 @@
|
||||
})
|
||||
)
|
||||
|
||||
async function handleViewContract() {
|
||||
const contractId = contractSnapshot.value?.id || plan.value?.contractId
|
||||
if (!contractId) {
|
||||
ElMessage.warning('暂无关联合同')
|
||||
return
|
||||
}
|
||||
|
||||
contractDetailDialogActive.value = true
|
||||
await nextTick()
|
||||
contractDetailRef.value?.open(contractId)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.planId,
|
||||
async (planId) => {
|
||||
@@ -1443,7 +1474,8 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.parties-row {
|
||||
.parties-row,
|
||||
.pl-plan-party-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
@@ -1451,6 +1483,100 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pl-plan-party-block {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pl-plan-subtitle {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.pl-plan-party-select-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pl-plan-party-select-row__customer {
|
||||
flex: 1.4 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-view-party-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-plan-view-party-field__value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pl-plan-view-party-field__contact {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pl-plan-flow-bridge {
|
||||
flex: 0 0 88px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.pl-plan-flow-bridge__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-dark, #168f45);
|
||||
letter-spacing: 0.06em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #e6f8ed;
|
||||
border: 1px solid #a8e0b8;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pl-plan-flow-bridge__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
color: var(--primary-dark, #168f45);
|
||||
background: linear-gradient(145deg, #e6f8ed 0%, #d4f0de 100%);
|
||||
border: 1px solid #a8e0b8;
|
||||
box-shadow: 0 2px 8px rgba(28, 168, 81, 0.14);
|
||||
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.party-panel {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
@@ -1536,19 +1662,22 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.parties-row {
|
||||
.parties-row,
|
||||
.pl-plan-party-row {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.party-flow-bridge {
|
||||
.party-flow-bridge,
|
||||
.pl-plan-flow-bridge {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.party-flow-bridge__arrow {
|
||||
.party-flow-bridge__arrow,
|
||||
.pl-plan-flow-bridge__arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user