Przeglądaj źródła

feat(pl): add plan contract completion page with inline expand

List only plans missing contracts and allow per-side multi-select save in an expand row, reusing PlPlanContractMultiSelect without contract CRUD.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 miesięcy temu
rodzic
commit
368cb5567a
1 zmienionych plików z 703 dodań i 0 usunięć
  1. 703 0
      web-admin/src/views/pl/plan-contract/index.vue

+ 703 - 0
web-admin/src/views/pl/plan-contract/index.vue

@@ -0,0 +1,703 @@
+<!-- 运输计划合同完善:仅缺合同计划,行内展开分侧保存 -->
+<template>
+  <div class="page-container">
+    <section class="panel">
+      <div class="panel-top pl-plan-contract-panel-top">
+        <h2 class="title-bar-heading">运输计划合同完善</h2>
+        <span class="pl-plan-contract-stat">待完善 {{ pagination.total }} 条</span>
+      </div>
+
+      <div class="filter-block">
+        <el-form :model="searchForm" class="filter-form" @submit.prevent>
+          <div class="filter-grid">
+            <el-form-item label="计划编号">
+              <el-input
+                v-model="searchForm.planNo"
+                placeholder="搜索计划编号"
+                clearable
+                @keyup.enter="handleSearch"
+              />
+            </el-form-item>
+            <el-form-item label="缺口类型">
+              <el-select v-model="searchForm.gapType" placeholder="全部缺口" clearable>
+                <el-option label="全部缺口" value="" />
+                <el-option label="仅缺供方合同" value="supplier" />
+                <el-option label="仅缺需方合同" value="demander" />
+                <el-option label="两侧均缺" value="both" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="供方 / 需方">
+              <el-input
+                v-model="searchForm.customerKeyword"
+                placeholder="客户名称"
+                clearable
+                @keyup.enter="handleSearch"
+              />
+            </el-form-item>
+            <el-form-item label="计划日期">
+              <el-date-picker
+                v-model="dateRange"
+                type="daterange"
+                range-separator="-"
+                start-placeholder="开始日期"
+                end-placeholder="结束日期"
+                value-format="YYYY-MM-DD"
+                style="width: 100%"
+              />
+            </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="handleReset">
+                <el-icon><RefreshLeft /></el-icon>重置
+              </el-button>
+              <el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
+                <el-icon><Search /></el-icon>搜索
+              </el-button>
+            </div>
+          </div>
+        </el-form>
+      </div>
+
+      <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 class="btn-ghost" :loading="loading" @click="fetchData">
+            <el-icon><Refresh /></el-icon>刷新
+          </el-button>
+        </div>
+      </div>
+
+      <div class="list-body list-wrap">
+        <el-table
+          ref="tableRef"
+          class="list-table"
+          :data="tableData"
+          v-loading="loading"
+          stripe
+          row-key="id"
+          :expand-row-keys="expandedRowKeys"
+          @expand-change="handleExpandChange"
+          @row-dblclick="handleView"
+        >
+          <el-table-column type="expand" min-width="48">
+            <template #default="{ row }">
+              <div class="pl-contract-expand" v-if="expandDraft[row.id]">
+                <div class="pl-contract-expand__grid">
+                  <div
+                    class="pl-contract-side"
+                    :class="sideBoxClass(row, 'supplier')"
+                  >
+                    <div class="pl-contract-side__head">
+                      <h4>供方合同</h4>
+                      <el-tag
+                        :type="sideTagType(row, 'supplier')"
+                        size="small"
+                        :effect="row.supplierContractExempt ? 'plain' : 'dark'"
+                      >
+                        {{ sideTagLabel(row, 'supplier') }}
+                      </el-tag>
+                    </div>
+                    <p class="pl-contract-side__customer">
+                      {{ row.supplierName || '—' }}
+                    </p>
+                    <template v-if="row.supplierContractExempt">
+                      <p class="pl-contract-side__exempt">内置公司,合同可免</p>
+                    </template>
+                    <template v-else>
+                      <PlPlanContractMultiSelect
+                        v-model="expandDraft[row.id].supplierContracts"
+                        :customer-id="row.supplierId"
+                        :disabled="!row.supplierId"
+                        :required="false"
+                      />
+                      <div class="pl-contract-side__actions">
+                        <el-button
+                          class="btn-main"
+                          type="primary"
+                          size="small"
+                          :loading="savingKey === `${row.id}:supplier`"
+                          @click="handleSaveSide(row, 'supplier')"
+                        >
+                          <el-icon><Check /></el-icon>保存供方合同
+                        </el-button>
+                      </div>
+                    </template>
+                  </div>
+
+                  <div
+                    class="pl-contract-side"
+                    :class="sideBoxClass(row, 'demander')"
+                  >
+                    <div class="pl-contract-side__head">
+                      <h4>需方合同</h4>
+                      <el-tag
+                        :type="sideTagType(row, 'demander')"
+                        size="small"
+                        :effect="row.demanderContractExempt ? 'plain' : 'dark'"
+                      >
+                        {{ sideTagLabel(row, 'demander') }}
+                      </el-tag>
+                    </div>
+                    <p class="pl-contract-side__customer">
+                      {{ row.customerName || '—' }}
+                    </p>
+                    <template v-if="row.demanderContractExempt">
+                      <p class="pl-contract-side__exempt">内置公司,合同可免</p>
+                    </template>
+                    <template v-else>
+                      <PlPlanContractMultiSelect
+                        v-model="expandDraft[row.id].demanderContracts"
+                        :customer-id="row.customerId"
+                        :disabled="!row.customerId"
+                        :required="false"
+                      />
+                      <div class="pl-contract-side__actions">
+                        <el-button
+                          class="btn-main"
+                          type="primary"
+                          size="small"
+                          :loading="savingKey === `${row.id}:demander`"
+                          @click="handleSaveSide(row, 'demander')"
+                        >
+                          <el-icon><Check /></el-icon>保存需方合同
+                        </el-button>
+                      </div>
+                    </template>
+                  </div>
+                </div>
+              </div>
+            </template>
+          </el-table-column>
+
+          <el-table-column
+            type="index"
+            label="序号"
+            min-width="68"
+            align="center"
+            :index="index => (pagination.page - 1) * pagination.pageSize + index + 1"
+          />
+          <el-table-column prop="planNo" label="计划编号" min-width="150" show-overflow-tooltip>
+            <template #default="{ row }">
+              <el-link type="primary" @click="handleView(row)">{{ row.planNo }}</el-link>
+            </template>
+          </el-table-column>
+          <el-table-column prop="planDate" label="计划日期" min-width="120" align="center" />
+          <el-table-column
+            prop="supplierName"
+            label="供方"
+            min-width="200"
+            align="left"
+            show-overflow-tooltip
+          >
+            <template #default="{ row }">
+              <div class="party-cell">
+                <span>{{ row.supplierName || '—' }}</span>
+                <span class="party-cell__sub">{{ partySubText(row, 'supplier') }}</span>
+              </div>
+            </template>
+          </el-table-column>
+          <el-table-column
+            prop="customerName"
+            label="需方"
+            min-width="200"
+            align="left"
+            show-overflow-tooltip
+          >
+            <template #default="{ row }">
+              <div class="party-cell">
+                <span>{{ row.customerName || '—' }}</span>
+                <span class="party-cell__sub">{{ partySubText(row, 'demander') }}</span>
+              </div>
+            </template>
+          </el-table-column>
+          <el-table-column label="合同缺口" min-width="130" align="center">
+            <template #default="{ row }">
+              <el-tag :type="gapTag(row).type" size="small" effect="dark">
+                {{ gapTag(row).text }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="followStatus" label="跟进状态" min-width="110" align="center">
+            <template #default="{ row }">
+              <el-tag
+                :type="getPlPlanFollowStatusTagType(row.followStatus)"
+                size="small"
+                effect="dark"
+              >
+                {{ getPlPlanFollowStatusLabel(row.followStatus) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" fixed="right" min-width="200" align="center">
+            <template #default="{ row }">
+              <el-button
+                link
+                type="warning"
+                @click="toggleExpand(row)"
+              >
+                <el-icon><EditPen /></el-icon>
+                {{ isExpanded(row.id) ? '收起' : '展开补充' }}
+              </el-button>
+              <el-button link type="primary" @click="handleView(row)">
+                <el-icon><View /></el-icon>查看计划
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <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, 50, 100]"
+            layout="total, sizes, prev, pager, next, jumper"
+            background
+            @current-change="handlePageChange"
+            @size-change="handleSizeChange"
+          />
+        </div>
+      </div>
+    </section>
+
+    <PlPlanViewDialog
+      ref="viewDialogRef"
+      v-model:visible="viewVisible"
+      :plan-id="viewPlanId"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Check, EditPen, Refresh, RefreshLeft, Search, View } from '@element-plus/icons-vue'
+import {
+  planApi,
+  type PlanContractGapItem,
+  type PlanContractLink
+} from '@/api/pl'
+import PlPlanContractMultiSelect from '@/components/business/pl-plan-contract-multi-select/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 ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
+import { useListDateShortcut } from '@/composables/useListDateShortcut'
+import {
+  getPlPlanFollowStatusLabel,
+  getPlPlanFollowStatusTagType
+} from '@/utils/pl-display'
+import { refreshMenuBadges } from '@/utils/menu-badge-registry'
+import { useMenuStore } from '@/store/modules/menu'
+
+const menuStore = useMenuStore()
+const loading = ref(false)
+const tableRef = ref()
+const tableData = ref<PlanContractGapItem[]>([])
+const dateRange = ref<string[]>([])
+const quickSearch = ref('')
+const expandedRowKeys = ref<string[]>([])
+const expandDraft = reactive<
+  Record<
+    string,
+    {
+      supplierContracts: PlanContractLink[]
+      demanderContracts: PlanContractLink[]
+      baselineSupplier: string
+      baselineDemander: string
+    }
+  >
+>({})
+const savingKey = ref('')
+const viewDialogRef = ref<InstanceType<typeof PlPlanViewDialog>>()
+const viewVisible = ref(false)
+const viewPlanId = ref('')
+
+const searchForm = reactive({
+  planNo: '',
+  gapType: '' as '' | 'supplier' | 'demander' | 'both',
+  customerKeyword: ''
+})
+
+const pagination = reactive({
+  page: 1,
+  pageSize: 20,
+  total: 0
+})
+
+function cloneLinks(links?: PlanContractLink[]): PlanContractLink[] {
+  return (links || []).map(item => ({
+    contractId: item.contractId,
+    contractNo: item.contractNo
+  }))
+}
+
+function linksFingerprint(links: PlanContractLink[]) {
+  return cloneLinks(links)
+    .map(item => item.contractId)
+    .sort()
+    .join(',')
+}
+
+function gapTag(row: PlanContractGapItem) {
+  const sides = row.gapSides || []
+  if (sides.includes('supplier') && sides.includes('demander')) {
+    return { text: '两侧均缺', type: 'danger' as const }
+  }
+  if (sides.includes('supplier')) {
+    return { text: '缺供方合同', type: 'warning' as const }
+  }
+  if (sides.includes('demander')) {
+    return { text: '缺需方合同', type: 'warning' as const }
+  }
+  return { text: '—', type: 'info' as const }
+}
+
+function partySubText(row: PlanContractGapItem, side: 'supplier' | 'demander') {
+  if (side === 'supplier') {
+    if (row.supplierContractExempt) return '内置 · 合同可免'
+    const n = row.supplierContracts?.length || 0
+    return n ? `已关联 ${n} 份` : '未关联'
+  }
+  if (row.demanderContractExempt) return '内置 · 合同可免'
+  const n = row.demanderContracts?.length || 0
+  return n ? `已关联 ${n} 份` : '未关联'
+}
+
+function sideBoxClass(row: PlanContractGapItem, side: 'supplier' | 'demander') {
+  const exempt = side === 'supplier' ? row.supplierContractExempt : row.demanderContractExempt
+  if (exempt) return 'is-exempt'
+  const missing = (row.gapSides || []).includes(side)
+  return missing ? 'is-need' : 'is-ok'
+}
+
+function sideTagLabel(row: PlanContractGapItem, side: 'supplier' | 'demander') {
+  const exempt = side === 'supplier' ? row.supplierContractExempt : row.demanderContractExempt
+  if (exempt) return '可免'
+  return (row.gapSides || []).includes(side) ? '待补充' : '已齐'
+}
+
+function sideTagType(row: PlanContractGapItem, side: 'supplier' | 'demander') {
+  const exempt = side === 'supplier' ? row.supplierContractExempt : row.demanderContractExempt
+  if (exempt) return 'info'
+  return (row.gapSides || []).includes(side) ? 'warning' : 'success'
+}
+
+function isExpanded(id: string) {
+  return expandedRowKeys.value.includes(id)
+}
+
+function ensureDraft(row: PlanContractGapItem) {
+  if (expandDraft[row.id]) return
+  const supplierContracts = cloneLinks(row.supplierContracts)
+  const demanderContracts = cloneLinks(row.demanderContracts)
+  expandDraft[row.id] = {
+    supplierContracts,
+    demanderContracts,
+    baselineSupplier: linksFingerprint(supplierContracts),
+    baselineDemander: linksFingerprint(demanderContracts)
+  }
+}
+
+function isDraftDirty(id: string) {
+  const draft = expandDraft[id]
+  if (!draft) return false
+  return (
+    linksFingerprint(draft.supplierContracts) !== draft.baselineSupplier ||
+    linksFingerprint(draft.demanderContracts) !== draft.baselineDemander
+  )
+}
+
+async function collapseRow(id: string) {
+  if (isDraftDirty(id)) {
+    try {
+      await ElMessageBox.confirm('当前有未保存的合同变更,确认收起吗?', '提示', {
+        type: 'warning',
+        confirmButtonText: '收起',
+        cancelButtonText: '继续编辑'
+      })
+    } catch {
+      return false
+    }
+  }
+  delete expandDraft[id]
+  expandedRowKeys.value = expandedRowKeys.value.filter(key => key !== id)
+  return true
+}
+
+async function expandRow(row: PlanContractGapItem) {
+  const current = expandedRowKeys.value[0]
+  if (current && current !== row.id) {
+    const ok = await collapseRow(current)
+    if (!ok) return
+  }
+  ensureDraft(row)
+  expandedRowKeys.value = [row.id]
+}
+
+async function toggleExpand(row: PlanContractGapItem) {
+  if (isExpanded(row.id)) {
+    await collapseRow(row.id)
+  } else {
+    await expandRow(row)
+  }
+}
+
+async function handleExpandChange(row: PlanContractGapItem, expandedRows: PlanContractGapItem[]) {
+  const willExpand = expandedRows.some(item => item.id === row.id)
+  if (willExpand) {
+    const current = expandedRowKeys.value[0]
+    if (current && current !== row.id) {
+      const ok = await collapseRow(current)
+      if (!ok) {
+        nextTick(() => {
+          tableRef.value?.toggleRowExpansion?.(row, false)
+        })
+        return
+      }
+    }
+    ensureDraft(row)
+    expandedRowKeys.value = [row.id]
+  } else if (expandedRowKeys.value.includes(row.id)) {
+    const ok = await collapseRow(row.id)
+    if (!ok) {
+      nextTick(() => {
+        tableRef.value?.toggleRowExpansion?.(row, true)
+      })
+    }
+  }
+}
+
+async function fetchData() {
+  loading.value = true
+  try {
+    const keyword = quickSearch.value.trim()
+    const res = await planApi.listContractGaps({
+      q: keyword || undefined,
+      planNo: keyword ? undefined : searchForm.planNo || undefined,
+      gapType: searchForm.gapType || undefined,
+      customerKeyword: keyword ? undefined : searchForm.customerKeyword || undefined,
+      planDateFrom: dateRange.value?.[0] || undefined,
+      planDateTo: dateRange.value?.[1] || undefined,
+      page: pagination.page,
+      pageSize: pagination.pageSize
+    })
+    const data = (res as any).data ?? res
+    tableData.value = data.list || []
+    pagination.total = data.total || 0
+    // 收起已不在列表中的展开行
+    const ids = new Set(tableData.value.map(item => item.id))
+    expandedRowKeys.value = expandedRowKeys.value.filter(id => ids.has(id))
+    Object.keys(expandDraft).forEach(id => {
+      if (!ids.has(id)) delete expandDraft[id]
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  pagination.page = 1
+  fetchData()
+}
+
+function handleReset() {
+  searchForm.planNo = ''
+  searchForm.gapType = ''
+  searchForm.customerKeyword = ''
+  quickSearch.value = ''
+  resetDateShortcut()
+  pagination.page = 1
+  fetchData()
+}
+
+function handleQuickSearch() {
+  searchForm.planNo = ''
+  searchForm.customerKeyword = ''
+  pagination.page = 1
+  fetchData()
+}
+
+function handleQuickSearchClear() {
+  quickSearch.value = ''
+  handleQuickSearch()
+}
+
+const { activeDateShortcut, handleDateShortcut, resetDateShortcut } = useListDateShortcut(
+  dateRange,
+  handleSearch
+)
+
+function handlePageChange() {
+  fetchData()
+}
+
+function handleSizeChange() {
+  pagination.page = 1
+  fetchData()
+}
+
+function handleView(row: PlanContractGapItem) {
+  viewPlanId.value = row.id
+  viewVisible.value = true
+}
+
+async function handleSaveSide(row: PlanContractGapItem, side: 'supplier' | 'demander') {
+  const draft = expandDraft[row.id]
+  if (!draft) return
+  const contracts =
+    side === 'supplier' ? draft.supplierContracts : draft.demanderContracts
+  savingKey.value = `${row.id}:${side}`
+  try {
+    const res = await planApi.updateSideContracts(row.id, side, contracts)
+    const updated = ((res as any).data ?? res) as PlanContractGapItem
+    ElMessage.success(side === 'supplier' ? '供方合同已保存' : '需方合同已保存')
+
+    if (!(updated.gapSides || []).length) {
+      tableData.value = tableData.value.filter(item => item.id !== row.id)
+      pagination.total = Math.max(0, pagination.total - 1)
+      delete expandDraft[row.id]
+      expandedRowKeys.value = []
+      await refreshMenuBadges(menuStore.menuList)
+      return
+    }
+
+    const idx = tableData.value.findIndex(item => item.id === row.id)
+    if (idx >= 0) {
+      tableData.value[idx] = updated
+    }
+    draft.supplierContracts = cloneLinks(updated.supplierContracts)
+    draft.demanderContracts = cloneLinks(updated.demanderContracts)
+    draft.baselineSupplier = linksFingerprint(draft.supplierContracts)
+    draft.baselineDemander = linksFingerprint(draft.demanderContracts)
+    await refreshMenuBadges(menuStore.menuList)
+  } finally {
+    savingKey.value = ''
+  }
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<style scoped lang="scss">
+.pl-plan-contract-panel-top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 16px 20px 12px;
+  border-bottom: 1px solid #eef2f6;
+}
+
+.pl-plan-contract-stat {
+  display: inline-flex;
+  align-items: center;
+  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);
+}
+
+.party-cell {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  line-height: 1.4;
+}
+
+.party-cell__sub {
+  font-size: 12px;
+  color: var(--el-text-color-secondary, #64748b);
+}
+
+.pl-contract-expand {
+  padding: 12px 16px 16px;
+  background: #f8fffb;
+  border-top: 1px dashed #a7f3d0;
+}
+
+.pl-contract-expand__grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 14px;
+}
+
+@media (max-width: 960px) {
+  .pl-contract-expand__grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+.pl-contract-side {
+  background: #fff;
+  border: 1px solid var(--el-border-color, #d9e8e2);
+  border-radius: 8px;
+  padding: 12px;
+}
+
+.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.is-exempt {
+  border-color: #e2e8f0;
+  background: #f8fafc;
+}
+
+.pl-contract-side__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 8px;
+
+  h4 {
+    margin: 0;
+    font-size: 13px;
+    font-weight: 600;
+  }
+}
+
+.pl-contract-side__customer {
+  margin: 0 0 10px;
+  font-size: 13px;
+}
+
+.pl-contract-side__exempt {
+  margin: 0;
+  padding: 14px;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  text-align: center;
+  color: var(--el-text-color-secondary, #64748b);
+  font-size: 12px;
+  background: #f8fafc;
+}
+
+.pl-contract-side__actions {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px;
+}
+</style>