Filter and display deduped delivery dates beside planDate. Co-authored-by: Cursor <cursoragent@cursor.com>
1123 lines
33 KiB
Vue
1123 lines
33 KiB
Vue
<!-- eslint-disable vue/no-v-model-argument -->
|
||
<!-- 运输计划合同完善:仅缺合同计划,按段次客户弹窗补充合同 -->
|
||
<template>
|
||
<div class="page-container">
|
||
<section class="panel">
|
||
|
||
<div class="list-toolbar">
|
||
<div class="list-toolbar-row pl-plan-list-toolbar-row">
|
||
<ListToolbarDateShortcuts
|
||
variant="pills"
|
||
:active="activeDateShortcut"
|
||
aria-label="计划日期快捷筛选"
|
||
@change="handleDateShortcut"
|
||
/>
|
||
<ListQuickSearchInput
|
||
v-model="quickSearch"
|
||
placeholder="计划编号/客户名称"
|
||
@search="handleQuickSearch"
|
||
@clear="handleQuickSearchClear"
|
||
/>
|
||
<el-button
|
||
v-if="hasActiveFilters"
|
||
class="btn-ghost"
|
||
@click="clearAllFilters"
|
||
>
|
||
<el-icon><Filter /></el-icon>清空列过滤
|
||
</el-button>
|
||
<ListExportDropdown
|
||
:export-fn="planApi.exportContractGaps"
|
||
:get-filter-params="getContractGapExportFilterParams"
|
||
:has-selection="false"
|
||
:selected-ids="[]"
|
||
:visible-columns="contractGapExportColumns"
|
||
filename="计划合同完善.xlsx"
|
||
/>
|
||
<el-button class="btn-ghost" :loading="loading" @click="fetchData">
|
||
<el-icon><Refresh /></el-icon>刷新
|
||
</el-button>
|
||
<span class="pl-plan-contract-stat">待完善 {{ pagination.total }} 条</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="list-body list-wrap">
|
||
<div ref="planContractTableWrapRef" class="table-scroll bx-list-table-wrap">
|
||
<el-table
|
||
ref="planContractTableRef"
|
||
class="list-table pl-plan-merge-list-table"
|
||
:height="tableHeight"
|
||
:data="displayRows"
|
||
v-loading="loading"
|
||
row-key="rowKey"
|
||
:span-method="planListSpanMethod"
|
||
:row-class-name="getPlPlanListRowClassName"
|
||
@row-dblclick="handleDisplayRowDblClick"
|
||
>
|
||
<el-table-column
|
||
type="index"
|
||
label="序号"
|
||
width="68"
|
||
align="center"
|
||
:index="getDisplayRowIndex"
|
||
/>
|
||
<el-table-column prop="planNo" label="计划编号" min-width="150" show-overflow-tooltip>
|
||
<template #header>
|
||
<div class="col-header-with-filter">
|
||
<span class="col-header-with-filter__label">计划编号</span>
|
||
<ColumnHeaderFilter
|
||
prop="planNo"
|
||
label="计划编号"
|
||
type="text"
|
||
:is-active="isFilterActive('planNo')"
|
||
:model-value="filters.planNo || null"
|
||
@update:model-value="(v) => setFilterState('planNo', v)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template #default="{ row }">
|
||
<el-link type="primary" @click="handleView(asGapItem(row))">{{
|
||
row.plan.planNo
|
||
}}</el-link>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="planDate" label="计划日期" min-width="120" align="center">
|
||
<template #header>
|
||
<div class="col-header-with-filter">
|
||
<span class="col-header-with-filter__label">计划日期</span>
|
||
<ColumnHeaderFilter
|
||
prop="planDate"
|
||
label="计划日期"
|
||
type="datetime"
|
||
:is-active="isFilterActive('planDate')"
|
||
:model-value="filters.planDate || null"
|
||
@update:model-value="(v) => setFilterState('planDate', v)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template #default="{ row }">{{ asGapItem(row).planDate || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="deliveryTimes" label="送货时间" min-width="130" align="center">
|
||
<template #header>
|
||
<div class="col-header-with-filter">
|
||
<span class="col-header-with-filter__label">送货时间</span>
|
||
<ColumnHeaderFilter
|
||
prop="deliveryTimes"
|
||
label="送货时间"
|
||
type="datetime"
|
||
:is-active="isFilterActive('deliveryTimes')"
|
||
:model-value="filters.deliveryTimes || null"
|
||
@update:model-value="(v) => setFilterState('deliveryTimes', v)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
<template #default="{ row }">
|
||
<div
|
||
v-if="(asGapItem(row).deliveryTimes || []).length"
|
||
class="pl-delivery-times-cell"
|
||
>
|
||
<div
|
||
v-for="(t, i) in asGapItem(row).deliveryTimes"
|
||
:key="`${asGapItem(row).id}-dt-${i}`"
|
||
>
|
||
{{ t }}
|
||
</div>
|
||
</div>
|
||
<span v-else>—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="planType" label="单据类型" min-width="100" align="center">
|
||
<template #header>
|
||
<div class="col-header-with-filter">
|
||
<span class="col-header-with-filter__label">单据类型</span>
|
||
<ColumnHeaderFilter
|
||
prop="planType"
|
||
label="单据类型"
|
||
type="enum"
|
||
:is-active="isFilterActive('planType')"
|
||
:model-value="filters.planType || null"
|
||
:options="enumOptionsFor('planType')"
|
||
@update:model-value="(v) => setFilterState('planType', v)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template #default="{ row }">
|
||
{{ getPlanTypeLabel(asGapItem(row).planType) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="followStatus" label="跟进状态" min-width="110" align="center">
|
||
<template #header>
|
||
<div class="col-header-with-filter">
|
||
<span class="col-header-with-filter__label">跟进状态</span>
|
||
<ColumnHeaderFilter
|
||
prop="followStatus"
|
||
label="跟进状态"
|
||
type="enum"
|
||
:is-active="isFilterActive('followStatus')"
|
||
:model-value="filters.followStatus || null"
|
||
:options="enumOptionsFor('followStatus')"
|
||
@update:model-value="(v) => setFilterState('followStatus', v)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template #default="{ row }">
|
||
<el-tag
|
||
:type="getPlPlanFollowStatusTagType(asGapItem(row).followStatus)"
|
||
size="small"
|
||
effect="dark"
|
||
>
|
||
{{ getPlPlanFollowStatusLabel(asGapItem(row).followStatus) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="stopKind"
|
||
label="段次"
|
||
min-width="88"
|
||
align="center"
|
||
class-name="pl-plan-stop-col"
|
||
>
|
||
<template #default="{ row }">
|
||
<span class="pl-plan-stop-kind-text">{{ getStopKindLabel(row) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="stopCustomer"
|
||
label="客户"
|
||
min-width="200"
|
||
align="left"
|
||
class-name="col-name-wrap pl-plan-stop-col"
|
||
label-class-name="col-name-wrap"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #header>
|
||
<div class="col-header-with-filter">
|
||
<span class="col-header-with-filter__label is-left">客户</span>
|
||
<ColumnHeaderFilter
|
||
prop="stopCustomer"
|
||
label="客户"
|
||
type="text"
|
||
:is-active="isFilterActive('stopCustomer')"
|
||
:model-value="filters.stopCustomer || null"
|
||
@update:model-value="(v) => setFilterState('stopCustomer', v)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template #default="{ row }">
|
||
<span>{{ getStopCustomerName(row) }}</span>
|
||
<el-tag
|
||
v-if="isPlInternalCustomerName(getStopCustomerName(row))"
|
||
type="success"
|
||
size="small"
|
||
class="pl-plan-internal-customer-tag"
|
||
>
|
||
内部
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="stopAddressTime"
|
||
label="地址 / 时间"
|
||
min-width="220"
|
||
align="left"
|
||
class-name="col-name-wrap pl-plan-stop-col"
|
||
label-class-name="col-name-wrap"
|
||
>
|
||
<template #default="{ row }">
|
||
<div class="pl-plan-stop-address-time">
|
||
<div class="pl-plan-stop-address-time__addr">{{ getStopAddress(row) }}</div>
|
||
<div class="pl-plan-stop-address-time__time">{{ getStopTime(row) }}</div>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="gapType"
|
||
label="合同缺口"
|
||
min-width="260"
|
||
align="left"
|
||
header-align="left"
|
||
class-name="col-name-wrap pl-plan-stop-col"
|
||
label-class-name="col-name-wrap"
|
||
>
|
||
<template #header>
|
||
<div class="col-header-with-filter">
|
||
<span class="col-header-with-filter__label is-left">合同缺口</span>
|
||
<ColumnHeaderFilter
|
||
prop="gapType"
|
||
label="合同缺口"
|
||
type="enum"
|
||
:is-active="isFilterActive('gapType')"
|
||
:model-value="filters.gapType || null"
|
||
:options="enumOptionsFor('gapType')"
|
||
@update:model-value="(v) => setFilterState('gapType', v)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template #default="{ row }">
|
||
<div class="pl-gap-contract-cell">
|
||
<PlPlanContractCell
|
||
:lines="getContractGapStopLines(row)"
|
||
@open="openContractDetail"
|
||
/>
|
||
<el-button
|
||
v-if="showGapSupplementAction(row)"
|
||
link
|
||
type="warning"
|
||
class="pl-gap-contract-cell__action"
|
||
@click.stop="openSupplementDialog(row)"
|
||
>
|
||
<el-icon><EditPen /></el-icon>
|
||
补充
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="产品名称"
|
||
min-width="160"
|
||
align="left"
|
||
class-name="col-name-wrap pl-plan-stop-col"
|
||
label-class-name="col-name-wrap"
|
||
>
|
||
<template #default="{ row }">
|
||
<PlPlanListProductCell
|
||
:stop-products="getStopProducts(row)"
|
||
:plan-products="asGapItem(row).products"
|
||
field="name"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="型号/规格"
|
||
min-width="120"
|
||
align="center"
|
||
class-name="pl-plan-stop-col"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="{ row }">
|
||
<PlPlanListProductCell
|
||
:stop-products="getStopProducts(row)"
|
||
:plan-products="asGapItem(row).products"
|
||
field="model"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="包装规格"
|
||
min-width="120"
|
||
align="center"
|
||
class-name="pl-plan-stop-col"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="{ row }">
|
||
<PlPlanListProductCell
|
||
:stop-products="getStopProducts(row)"
|
||
:plan-products="asGapItem(row).products"
|
||
field="packageSpec"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="单位" min-width="80" align="center" class-name="pl-plan-stop-col">
|
||
<template #default="{ row }">
|
||
<PlPlanListProductCell
|
||
:stop-products="getStopProducts(row)"
|
||
:plan-products="asGapItem(row).products"
|
||
field="unit"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="计划数量"
|
||
min-width="100"
|
||
align="right"
|
||
header-align="right"
|
||
class-name="pl-plan-stop-col"
|
||
>
|
||
<template #default="{ row }">
|
||
<PlPlanListProductCell
|
||
:stop-products="getStopProducts(row)"
|
||
:plan-products="asGapItem(row).products"
|
||
field="planQty"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="实际数量"
|
||
min-width="100"
|
||
align="right"
|
||
header-align="right"
|
||
class-name="pl-plan-stop-col"
|
||
>
|
||
<template #default="{ row }">
|
||
<PlPlanListProductCell
|
||
:stop-kind="row.kind"
|
||
:stop-products="getStopProducts(row)"
|
||
:plan-products="asGapItem(row).products"
|
||
field="actualQty"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="入库数量"
|
||
min-width="100"
|
||
align="right"
|
||
header-align="right"
|
||
class-name="pl-plan-stop-col"
|
||
>
|
||
<template #default="{ row }">
|
||
<PlPlanListProductCell
|
||
:stop-products="getStopProducts(row)"
|
||
:plan-products="asGapItem(row).products"
|
||
field="inboundQty"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="出库数量"
|
||
min-width="100"
|
||
align="right"
|
||
header-align="right"
|
||
class-name="pl-plan-stop-col"
|
||
>
|
||
<template #default="{ row }">
|
||
<PlPlanListProductCell
|
||
:stop-products="getStopProducts(row)"
|
||
:plan-products="asGapItem(row).products"
|
||
field="outboundQty"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<div ref="planContractBelowRef">
|
||
<div class="pagination list-pager-bar">
|
||
<el-pagination
|
||
v-model:current-page="pagination.page"
|
||
v-model:page-size="pagination.pageSize"
|
||
:total="pagination.total"
|
||
:page-sizes="[10, 20, 100, 500]"
|
||
layout="total, sizes, prev, pager, next, jumper"
|
||
background
|
||
@current-change="handlePageChange"
|
||
@size-change="handleSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<PlPlanViewDialog
|
||
ref="viewDialogRef"
|
||
v-model:visible="viewVisible"
|
||
:plan-id="viewPlanId"
|
||
show-inbound-outbound-qty
|
||
/>
|
||
|
||
<el-dialog
|
||
v-model="supplementVisible"
|
||
class="dialog-form-layout pl-contract-supplement-dialog"
|
||
modal-class="order-module-dialog-mask"
|
||
width="min(2000px, calc(100vw - 48px))"
|
||
align-center
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
:before-close="handleSupplementBeforeClose"
|
||
>
|
||
<template #header>
|
||
<span class="pl-contract-supplement-dialog__title">
|
||
补充合同 · {{ supplementStopKindLabel }} · {{ supplementPlanNo }}
|
||
</span>
|
||
</template>
|
||
|
||
<div v-if="supplementRow" class="pl-contract-supplement-body">
|
||
<div class="pl-contract-supplement-summary">
|
||
<div class="pl-contract-supplement-kv">
|
||
<div class="k">计划日期</div>
|
||
<div class="v">{{ asGapItem(supplementRow).planDate || '—' }}</div>
|
||
</div>
|
||
<div class="pl-contract-supplement-kv">
|
||
<div class="k">跟进状态</div>
|
||
<div class="v">
|
||
<el-tag
|
||
:type="getPlPlanFollowStatusTagType(asGapItem(supplementRow).followStatus)"
|
||
size="small"
|
||
effect="dark"
|
||
>
|
||
{{ getPlPlanFollowStatusLabel(asGapItem(supplementRow).followStatus) }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="pl-contract-supplement-kv">
|
||
<div class="k">本段客户</div>
|
||
<div class="v">{{ getStopCustomerName(supplementRow) }}</div>
|
||
</div>
|
||
<div class="pl-contract-supplement-kv">
|
||
<div class="k">地址 / 时间</div>
|
||
<div class="v">
|
||
{{ getStopAddress(supplementRow) }}
|
||
<template v-if="getStopTime(supplementRow)">
|
||
· {{ getStopTime(supplementRow) }}
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pl-contract-side" :class="supplementSideBoxClass">
|
||
<div class="pl-contract-side__head">
|
||
<h4>{{ supplementStopKindLabel }}合同</h4>
|
||
<el-tag :type="supplementSideTagType" size="small" effect="dark">
|
||
{{ supplementSideTagLabel }}
|
||
</el-tag>
|
||
</div>
|
||
<p class="pl-contract-side__customer">{{ getStopCustomerName(supplementRow) }}</p>
|
||
<PlPlanContractMultiSelect
|
||
v-model="supplementDraft.contracts"
|
||
:customer-id="supplementRow.stop.customerId"
|
||
:disabled="!supplementRow.stop.customerId"
|
||
:required="false"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<el-button class="btn-ghost" @click="requestCloseSupplement">
|
||
<el-icon><Close /></el-icon>取消
|
||
</el-button>
|
||
<el-button
|
||
class="btn-main"
|
||
type="primary"
|
||
:disabled="!supplementDraft.stopId || !supplementRow?.stop.customerId"
|
||
:loading="supplementSaving"
|
||
@click="handleSaveStopContracts"
|
||
>
|
||
<el-icon><Check /></el-icon>保存本段合同
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<ContractDetailDialog ref="contractDetailRef" hide-product-price />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {
|
||
Check,
|
||
Close,
|
||
EditPen,
|
||
Refresh,
|
||
Filter
|
||
} from '@element-plus/icons-vue'
|
||
import {
|
||
planApi,
|
||
type PlanContractGapItem,
|
||
type PlanContractLink
|
||
} from '@/api/pl'
|
||
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
|
||
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
|
||
import { useColumnSort } from '@/composables/useColumnSort'
|
||
import { plPlanContractColumnFilterDefs } from './columnFilterDefs'
|
||
import PlPlanContractCell from '@/components/business/pl-plan-contract-cell/index.vue'
|
||
import PlPlanContractMultiSelect from '@/components/business/pl-plan-contract-multi-select/index.vue'
|
||
import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue'
|
||
import PlPlanViewDialog from '@/components/business/pl-plan-view-dialog/index.vue'
|
||
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
|
||
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
|
||
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
|
||
import { useContractDetailDialog } from '@/composables/useContractDetailDialog'
|
||
import { useListDateShortcut } from '@/composables/useListDateShortcut'
|
||
import { usePlPlanMergeListTable } from '@/composables/usePlPlanMergeListTable'
|
||
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
|
||
import {
|
||
getPlPlanFollowStatusLabel,
|
||
getPlPlanFollowStatusTagType
|
||
} from '@/utils/pl-display'
|
||
import { planContractSideLines } from '@/utils/pl-plan-contract-display'
|
||
import {
|
||
getPlPlanListRowClassName,
|
||
getStopAddress,
|
||
getStopContractLines,
|
||
getStopCustomerName,
|
||
getStopKindLabel,
|
||
getStopProducts,
|
||
getStopTime,
|
||
type PlPlanListDisplayRow
|
||
} from '@/utils/pl-plan-list-display-rows'
|
||
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
|
||
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
|
||
import { useMenuStore } from '@/store/modules/menu'
|
||
|
||
const PLAN_MERGE_COLUMN_PROPS = ['planDate', 'deliveryTimes', 'planType', 'followStatus'] as const
|
||
|
||
const menuStore = useMenuStore()
|
||
const { ContractDetailDialog, contractDetailRef, openContractDetail } = useContractDetailDialog()
|
||
const loading = ref(false)
|
||
const tableData = ref<PlanContractGapItem[]>([])
|
||
const dateRange = ref<string[]>([])
|
||
const quickSearch = ref('')
|
||
const supplementVisible = ref(false)
|
||
const supplementSaving = ref(false)
|
||
const supplementRow = ref<PlPlanListDisplayRow | null>(null)
|
||
const supplementDraft = reactive<{
|
||
contracts: PlanContractLink[]
|
||
baseline: string
|
||
stopType: 'load' | 'delivery'
|
||
stopId: string
|
||
planId: string
|
||
rowKey: string
|
||
}>({
|
||
contracts: [],
|
||
baseline: '',
|
||
stopType: 'load',
|
||
stopId: '',
|
||
planId: '',
|
||
rowKey: ''
|
||
})
|
||
const viewDialogRef = ref<InstanceType<typeof PlPlanViewDialog>>()
|
||
const viewVisible = ref(false)
|
||
const viewPlanId = ref('')
|
||
|
||
const pagination = reactive({
|
||
page: 1,
|
||
pageSize: 20,
|
||
total: 0
|
||
})
|
||
|
||
const {
|
||
filters,
|
||
hasActiveFilters,
|
||
setFilterState,
|
||
clearAllFilters,
|
||
isFilterActive,
|
||
toQueryParams,
|
||
resetToInitial
|
||
} = useServerColumnFilters(plPlanContractColumnFilterDefs, {
|
||
initialFilters: {},
|
||
onChange: () => handleSearch()
|
||
})
|
||
|
||
const {
|
||
sortField,
|
||
sortOrder,
|
||
setSort,
|
||
clearSort,
|
||
isSortActive,
|
||
toQueryParams: toSortQueryParams
|
||
} = useColumnSort({
|
||
onChange: () => handleSearch()
|
||
})
|
||
|
||
provide('crmColumnSort', {
|
||
setSort,
|
||
clearSort,
|
||
isSortActive,
|
||
sortField,
|
||
sortOrder
|
||
})
|
||
|
||
function enumOptionsFor(prop: string) {
|
||
const def = plPlanContractColumnFilterDefs.find((d) => d.prop === prop)
|
||
return (def?.enumOptions || []).map((o) => ({ ...o, count: 0 }))
|
||
}
|
||
|
||
|
||
const planContractTableWrapRef = ref<HTMLElement | null>(null)
|
||
const planContractBelowRef = ref<HTMLElement | null>(null)
|
||
const planContractTableRef = ref<any>(null)
|
||
const { tableHeight, recalc: recalcTableHeight } = useListTableViewportHeight({
|
||
tableEl: planContractTableWrapRef,
|
||
pagerEl: planContractBelowRef,
|
||
tableRef: planContractTableRef
|
||
})
|
||
|
||
const planListForDisplay = computed(() => tableData.value as unknown as import('@/api/pl').Plan[])
|
||
|
||
const visiblePlanMergeProps = computed(() => [...PLAN_MERGE_COLUMN_PROPS])
|
||
|
||
const {
|
||
displayRows,
|
||
planListSpanMethod,
|
||
getDisplayRowIndex,
|
||
handleDisplayRowDblClick
|
||
} = usePlPlanMergeListTable({
|
||
planList: planListForDisplay,
|
||
visiblePlanMergeProps,
|
||
selectedRows: ref([]),
|
||
pagination,
|
||
onView: (plan) => handleView(plan as unknown as PlanContractGapItem)
|
||
})
|
||
|
||
const supplementStopKindLabel = computed(() =>
|
||
supplementRow.value ? getStopKindLabel(supplementRow.value) : ''
|
||
)
|
||
const supplementPlanNo = computed(() =>
|
||
supplementRow.value ? asGapItem(supplementRow.value).planNo || '—' : '—'
|
||
)
|
||
const supplementSideBoxClass = computed(() => {
|
||
if (!supplementDraft.contracts.length) return 'is-need'
|
||
return 'is-ok'
|
||
})
|
||
const supplementSideTagLabel = computed(() =>
|
||
supplementDraft.contracts.length ? '已选' : '待补充'
|
||
)
|
||
const supplementSideTagType = computed(() =>
|
||
supplementDraft.contracts.length ? 'success' : 'warning'
|
||
)
|
||
|
||
function asGapItem(row: PlPlanListDisplayRow): PlanContractGapItem {
|
||
return row.plan as unknown as PlanContractGapItem
|
||
}
|
||
|
||
/** 段内合同:优先段上 contracts;无则回退旧计划级供/需合同 */
|
||
function getContractGapStopLines(row: PlPlanListDisplayRow) {
|
||
if ((row.stop.contracts?.length ?? 0) > 0) {
|
||
return getStopContractLines(row)
|
||
}
|
||
const gap = asGapItem(row)
|
||
if (row.kind === 'load') {
|
||
return planContractSideLines(
|
||
'供',
|
||
gap.supplierContracts,
|
||
gap.supplierName || row.stop.customerName
|
||
)
|
||
}
|
||
return planContractSideLines(
|
||
'需',
|
||
gap.demanderContracts,
|
||
row.stop.customerName || gap.customerName
|
||
)
|
||
}
|
||
|
||
function isStopContractExempt(row: PlPlanListDisplayRow): boolean {
|
||
return isPlInternalCustomerName(getStopCustomerName(row))
|
||
}
|
||
|
||
function stopHasContracts(row: PlPlanListDisplayRow): boolean {
|
||
return getContractGapStopLines(row).some((line) => !!line.contractId && !line.pending)
|
||
}
|
||
|
||
/** 仅非内置客户、且本段缺合同时显示「补充」 */
|
||
function showGapSupplementAction(row: PlPlanListDisplayRow): boolean {
|
||
if (isStopContractExempt(row)) return false
|
||
if (!row.stop.id) return false
|
||
return !stopHasContracts(row)
|
||
}
|
||
|
||
function buildListQueryParams(): Record<string, any> {
|
||
const colParams = { ...toQueryParams(), ...toSortQueryParams() }
|
||
const keyword = quickSearch.value.trim()
|
||
const params: Record<string, any> = { ...colParams }
|
||
if (keyword) params.q = keyword
|
||
|
||
if (colParams.followStatusIn) {
|
||
params.followStatus = colParams.followStatusIn
|
||
delete params.followStatusIn
|
||
}
|
||
if (colParams.gapTypeIn) {
|
||
params.gapType = colParams.gapTypeIn
|
||
delete params.gapTypeIn
|
||
}
|
||
|
||
if (!params.planDateFrom && !params.planDateTo) {
|
||
if (dateRange.value?.[0]) params.planDateFrom = dateRange.value[0]
|
||
if (dateRange.value?.[1]) params.planDateTo = dateRange.value[1]
|
||
}
|
||
return params
|
||
}
|
||
|
||
function getContractGapExportFilterParams() {
|
||
return { ...buildListQueryParams() }
|
||
}
|
||
|
||
const contractGapExportColumns = [
|
||
'planNo',
|
||
'planDate',
|
||
'deliveryTimes',
|
||
'planType',
|
||
'followStatus',
|
||
'supplierName',
|
||
'customerName',
|
||
'gapType'
|
||
]
|
||
|
||
function cloneLinks(links?: PlanContractLink[]): PlanContractLink[] {
|
||
return (links || []).map((item) => ({
|
||
contractId: item.contractId,
|
||
contractNo: item.contractNo,
|
||
stopId: item.stopId
|
||
}))
|
||
}
|
||
|
||
function linksFingerprint(links: PlanContractLink[]) {
|
||
return cloneLinks(links)
|
||
.map((item) => item.contractId)
|
||
.sort()
|
||
.join(',')
|
||
}
|
||
|
||
function getPlanTypeLabel(type?: string | null) {
|
||
const map: Record<string, string> = {
|
||
direct: '直销单',
|
||
inbound: '入库单',
|
||
outbound: '出库单'
|
||
}
|
||
return type ? map[type] || type : '—'
|
||
}
|
||
|
||
function resolveStopDraftContracts(row: PlPlanListDisplayRow): PlanContractLink[] {
|
||
if ((row.stop.contracts?.length ?? 0) > 0) {
|
||
return cloneLinks(row.stop.contracts)
|
||
}
|
||
const gap = asGapItem(row)
|
||
if (row.kind === 'load') return cloneLinks(gap.supplierContracts)
|
||
return cloneLinks(gap.demanderContracts)
|
||
}
|
||
|
||
function isSupplementDirty() {
|
||
return linksFingerprint(supplementDraft.contracts) !== supplementDraft.baseline
|
||
}
|
||
|
||
function resetSupplementDraft() {
|
||
supplementRow.value = null
|
||
supplementDraft.contracts = []
|
||
supplementDraft.baseline = ''
|
||
supplementDraft.stopType = 'load'
|
||
supplementDraft.stopId = ''
|
||
supplementDraft.planId = ''
|
||
supplementDraft.rowKey = ''
|
||
}
|
||
|
||
function openSupplementDialog(row: PlPlanListDisplayRow) {
|
||
if (!row.stop.id) {
|
||
ElMessage.warning('该段次缺少段 ID,无法补充合同')
|
||
return
|
||
}
|
||
if (isStopContractExempt(row)) {
|
||
ElMessage.info('内置客户无需补充合同')
|
||
return
|
||
}
|
||
const contracts = resolveStopDraftContracts(row)
|
||
supplementRow.value = row
|
||
supplementDraft.contracts = contracts
|
||
supplementDraft.baseline = linksFingerprint(contracts)
|
||
supplementDraft.stopType = row.kind
|
||
supplementDraft.stopId = String(row.stop.id || '').trim()
|
||
supplementDraft.planId = row.plan.id
|
||
supplementDraft.rowKey = row.rowKey
|
||
supplementVisible.value = true
|
||
}
|
||
|
||
async function confirmDiscardIfDirty(): Promise<boolean> {
|
||
if (!isSupplementDirty()) return true
|
||
try {
|
||
await ElMessageBox.confirm('当前有未保存的合同变更,确认关闭吗?', '提示', {
|
||
type: 'warning',
|
||
confirmButtonText: '关闭',
|
||
cancelButtonText: '继续编辑'
|
||
})
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function requestCloseSupplement() {
|
||
const ok = await confirmDiscardIfDirty()
|
||
if (!ok) return
|
||
supplementVisible.value = false
|
||
resetSupplementDraft()
|
||
}
|
||
|
||
async function handleSupplementBeforeClose(done: () => void) {
|
||
const ok = await confirmDiscardIfDirty()
|
||
if (!ok) return
|
||
resetSupplementDraft()
|
||
done()
|
||
}
|
||
|
||
async function fetchData() {
|
||
loading.value = true
|
||
try {
|
||
const res = await planApi.listContractGaps({
|
||
...buildListQueryParams(),
|
||
page: pagination.page,
|
||
pageSize: pagination.pageSize
|
||
})
|
||
const data = res.data ?? res
|
||
tableData.value = data.list || []
|
||
pagination.total = data.total || 0
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleSearch() {
|
||
pagination.page = 1
|
||
fetchData()
|
||
}
|
||
|
||
function handleReset() {
|
||
quickSearch.value = ''
|
||
clearAllFilters()
|
||
resetDateShortcut()
|
||
pagination.page = 1
|
||
fetchData()
|
||
}
|
||
|
||
function handleQuickSearch() {
|
||
pagination.page = 1
|
||
fetchData()
|
||
}
|
||
|
||
function handleQuickSearchClear() {
|
||
quickSearch.value = ''
|
||
handleQuickSearch()
|
||
}
|
||
|
||
const {
|
||
activeDateShortcut,
|
||
handleDateShortcut: handleDateShortcutInner,
|
||
resetDateShortcut
|
||
} = useListDateShortcut(dateRange, handleSearch)
|
||
|
||
function handleDateShortcut(type: 'all' | 'today' | 'tomorrow' | 'yesterday' | 'week') {
|
||
if (filters.planDate) {
|
||
delete filters.planDate
|
||
}
|
||
handleDateShortcutInner(type)
|
||
}
|
||
|
||
function handlePageChange() {
|
||
fetchData()
|
||
}
|
||
|
||
function handleSizeChange() {
|
||
pagination.page = 1
|
||
fetchData()
|
||
}
|
||
|
||
function handleView(row: PlanContractGapItem) {
|
||
viewPlanId.value = row.id
|
||
viewVisible.value = true
|
||
}
|
||
|
||
async function handleSaveStopContracts() {
|
||
if (!supplementDraft.stopId) {
|
||
ElMessage.warning('该段次缺少段 ID,无法保存')
|
||
return
|
||
}
|
||
if (!supplementRow.value?.stop.customerId) {
|
||
ElMessage.warning('请先维护本段客户后再选择合同')
|
||
return
|
||
}
|
||
if (supplementDraft.contracts.length === 0) {
|
||
ElMessage.warning('请先选择合同')
|
||
return
|
||
}
|
||
if (supplementDraft.contracts.length > 1) {
|
||
ElMessage.warning('只能保留一份合同,请移除多余合同后再保存')
|
||
return
|
||
}
|
||
supplementSaving.value = true
|
||
try {
|
||
await planApi.updateStopContracts(supplementDraft.planId, {
|
||
stopType: supplementDraft.stopType,
|
||
stopId: supplementDraft.stopId,
|
||
contracts: supplementDraft.contracts
|
||
})
|
||
ElMessage.success('本段合同已保存')
|
||
|
||
// 先对齐 baseline,避免关闭时被 before-close 当成未保存变更
|
||
supplementDraft.baseline = linksFingerprint(supplementDraft.contracts)
|
||
supplementVisible.value = false
|
||
resetSupplementDraft()
|
||
|
||
await fetchData()
|
||
await refreshMenuBadges(menuStore.menuList)
|
||
} finally {
|
||
supplementSaving.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchData()
|
||
nextTick(() => recalcTableHeight())
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
@use '@styles/business/pl-plan-merge-list-table.scss';
|
||
|
||
.pl-plan-list-toolbar-row {
|
||
flex-wrap: nowrap;
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
|
||
> .list-toolbar-date-shortcuts,
|
||
> .quick-search-input,
|
||
> .quick-search-group,
|
||
> .el-button,
|
||
> .pl-plan-contract-stat {
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.pl-delivery-times-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 2px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.pl-plan-contract-stat {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
margin-left: auto;
|
||
padding: 4px 10px;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #d97706;
|
||
background: #fffbeb;
|
||
border: 1px solid rgba(217, 119, 6, 0.2);
|
||
}
|
||
|
||
.pl-contract-supplement-dialog__title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: var(--text, #1e293b);
|
||
}
|
||
|
||
.pl-contract-supplement-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.pl-contract-supplement-summary {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 10px;
|
||
padding: 12px 14px;
|
||
background: #f8fafc;
|
||
border: 1px solid #eef2f6;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
@media (max-width: 700px) {
|
||
.pl-contract-supplement-summary {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
|
||
.pl-contract-supplement-kv {
|
||
min-width: 0;
|
||
|
||
.k {
|
||
font-size: 11px;
|
||
color: var(--mint-text-muted, #64748b);
|
||
}
|
||
|
||
.v {
|
||
margin-top: 2px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text, #1e293b);
|
||
word-break: break-word;
|
||
}
|
||
}
|
||
|
||
.pl-contract-side {
|
||
background: #fff;
|
||
border: 1px solid var(--el-border-color, #d9e8e2);
|
||
border-radius: 8px;
|
||
padding: 14px;
|
||
}
|
||
|
||
.pl-contract-side.is-need {
|
||
border-color: rgba(217, 119, 6, 0.35);
|
||
background: #fffdf8;
|
||
}
|
||
|
||
.pl-contract-side.is-ok {
|
||
border-color: #a7f3d0;
|
||
background: #f6fffb;
|
||
}
|
||
|
||
.pl-contract-side__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
|
||
h4 {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
.pl-contract-side__customer {
|
||
margin: 0 0 12px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.pl-gap-contract-cell {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
width: 100%;
|
||
}
|
||
|
||
.pl-gap-contract-cell__action {
|
||
flex-shrink: 0;
|
||
margin-top: 0;
|
||
}
|
||
|
||
.pl-plan-contract-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
flex: 1;
|
||
text-align: left;
|
||
}
|
||
|
||
.pl-plan-contract-cell__row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 6px;
|
||
width: 100%;
|
||
min-width: 0;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
color: var(--text, #1e293b);
|
||
}
|
||
|
||
.pl-plan-contract-cell__side {
|
||
flex-shrink: 0;
|
||
font-weight: 600;
|
||
color: var(--mint-text-muted, #64748b);
|
||
}
|
||
|
||
.pl-plan-contract-cell__no {
|
||
min-width: 0;
|
||
white-space: normal;
|
||
word-break: break-word;
|
||
overflow-wrap: anywhere;
|
||
|
||
&.is-pending {
|
||
font-weight: 600;
|
||
color: #d97706;
|
||
}
|
||
}
|
||
</style>
|