优化合同个管理和产品管理相关功能
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="title || '软件开发模块报价'"
|
||||
width="860px"
|
||||
top="5vh"
|
||||
append-to-body
|
||||
@update:model-value="(v) => emit('update:visible', v)"
|
||||
@open="syncLocal"
|
||||
>
|
||||
<div class="dev-tree">
|
||||
<div class="tree-toolbar">
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="addRoot">添加顶级模块</el-button>
|
||||
<span class="tree-tip">叶子模块只填「人天」,人天单价统一填写;父级自动汇总;可任意层级向下拆分</span>
|
||||
</div>
|
||||
|
||||
<div class="unit-price-bar">
|
||||
<span class="up-label">统一人天单价(元 / 人天)</span>
|
||||
<el-input-number v-model="unitPrice" :min="0" :precision="2" :controls="false" style="width: 180px" />
|
||||
<span class="up-total">开发总报价:<b>¥{{ formatMoney(treeSellTotal(tree, unitPrice)) }}</b></span>
|
||||
</div>
|
||||
|
||||
<div v-if="tree.length" class="tree-head">
|
||||
<span class="th-name">模块名称</span>
|
||||
<span class="th-md">人天</span>
|
||||
<span class="th-amount">小计(元)</span>
|
||||
<span class="th-ops">操作</span>
|
||||
</div>
|
||||
|
||||
<ModuleNode
|
||||
v-for="n in tree"
|
||||
:key="n.__id"
|
||||
:node="n"
|
||||
:depth="0"
|
||||
:unit-price="unitPrice"
|
||||
@remove="removeNode"
|
||||
/>
|
||||
|
||||
<el-empty v-if="!tree.length" description="暂无模块,点击「添加顶级模块」开始" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="handleConfirm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { formatMoney } from "../../dict";
|
||||
import { treeSellTotal } from "./utils";
|
||||
import ModuleNode from "./ModuleNode.vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
unitPrice: { type: Number, default: 0 },
|
||||
title: { type: String, default: "" },
|
||||
});
|
||||
const emit = defineEmits(["update:visible", "update:modelValue", "update:unitPrice"]);
|
||||
|
||||
const tree = ref([]);
|
||||
const unitPrice = ref(0);
|
||||
let uid = 0;
|
||||
const newNode = () => ({
|
||||
__id: `m_${Date.now()}_${uid++}`,
|
||||
name: "",
|
||||
children: [],
|
||||
man_days: 0,
|
||||
});
|
||||
|
||||
const syncLocal = () => {
|
||||
tree.value = Array.isArray(props.modelValue) ? JSON.parse(JSON.stringify(props.modelValue)) : [];
|
||||
unitPrice.value = Number(props.unitPrice) || 0;
|
||||
};
|
||||
|
||||
watch(tree, (val) => emit("update:modelValue", val), { deep: true });
|
||||
watch(unitPrice, (val) => emit("update:unitPrice", val));
|
||||
|
||||
const addRoot = () => tree.value.push(newNode());
|
||||
|
||||
/** 递归从树中移除指定 __id 节点 */
|
||||
const removeNode = (target) => {
|
||||
const cut = (list) => {
|
||||
const idx = list.findIndex((n) => n.__id === target.__id);
|
||||
if (idx >= 0) {
|
||||
list.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
return list.some((n) => Array.isArray(n.children) && cut(n.children));
|
||||
};
|
||||
cut(tree.value);
|
||||
};
|
||||
|
||||
const handleConfirm = () => emit("update:visible", false);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.dev-tree {
|
||||
.tree-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.tree-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.unit-price-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
|
||||
.up-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.up-total {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
b {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tree-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px 4px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-weight: 600;
|
||||
|
||||
.th-name {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
.th-md {
|
||||
width: 120px;
|
||||
}
|
||||
.th-amount {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
.th-ops {
|
||||
width: 96px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="module-node">
|
||||
<div class="node-row" :style="{ paddingLeft: depth * 22 + 'px' }">
|
||||
<span class="node-bar" :style="{ background: depthColor }" />
|
||||
<el-input v-model="node.name" class="node-name" placeholder="模块名称(如:Web端 / 用户中心)" maxlength="60" />
|
||||
<template v-if="hasChildren">
|
||||
<span class="node-agg">汇总</span>
|
||||
<span class="node-amount">{{ formatMoney(treeSellTotal([node], unitPrice)) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-input-number
|
||||
v-model="node.man_days"
|
||||
:min="0"
|
||||
:precision="1"
|
||||
:controls="false"
|
||||
class="node-md"
|
||||
placeholder="人天"
|
||||
/>
|
||||
<span class="node-amount">{{ formatMoney((Number(node.man_days) || 0) * (Number(unitPrice) || 0)) }}</span>
|
||||
</template>
|
||||
<div class="node-ops">
|
||||
<el-button link type="primary" size="small" @click="addChild">+ 子级</el-button>
|
||||
<el-button link type="danger" size="small" @click="emit('remove', node)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<ModuleNode
|
||||
v-for="c in node.children"
|
||||
:key="c.__id"
|
||||
:node="c"
|
||||
:depth="depth + 1"
|
||||
:unit-price="unitPrice"
|
||||
@remove="(n) => emit('remove', n)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { formatMoney } from "../../dict";
|
||||
import { treeSellTotal } from "./utils";
|
||||
|
||||
defineOptions({ name: "ModuleNode" });
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, required: true },
|
||||
depth: { type: Number, default: 0 },
|
||||
unitPrice: { type: Number, default: 0 },
|
||||
});
|
||||
const emit = defineEmits(["remove"]);
|
||||
|
||||
let uid = 0;
|
||||
const newNode = () => ({
|
||||
__id: `m_${Date.now()}_${uid++}`,
|
||||
name: "",
|
||||
children: [],
|
||||
man_days: 0,
|
||||
});
|
||||
|
||||
const hasChildren = computed(() => Array.isArray(props.node.children) && props.node.children.length > 0);
|
||||
|
||||
const depthColor = ["#409EFF", "#67C23A", "#E6A23C", "#909399", "#9254DE"][props.depth % 5];
|
||||
|
||||
const addChild = () => {
|
||||
if (!Array.isArray(props.node.children)) props.node.children = [];
|
||||
props.node.children.push(newNode());
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.module-node {
|
||||
.node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-bg-color);
|
||||
margin: 6px 0;
|
||||
|
||||
.node-bar {
|
||||
width: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.node-agg {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-md {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-amount {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-ops {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,9 +2,18 @@
|
||||
<div class="product-list">
|
||||
<el-table :data="products" border row-key="__key" :empty-text="'暂无产品,点击下方按钮添加'">
|
||||
<el-table-column label="序号" type="index" width="50" align="center" />
|
||||
<el-table-column label="行类型" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.line_type" style="width: 100%" @change="() => onLineTypeChange(row)">
|
||||
<el-option label="成品" value="product" />
|
||||
<el-option label="软件开发" value="dev" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产品类别" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-if="row.line_type === 'product'"
|
||||
v-model="row.category"
|
||||
filterable
|
||||
clearable
|
||||
@@ -19,51 +28,62 @@
|
||||
:value="c.name"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-else class="muted">软件开发</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产品名称" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.name"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
:disabled="!row.category"
|
||||
:placeholder="row.category ? '搜索或输入产品名称' : '请先选择产品类别'"
|
||||
style="width: 100%"
|
||||
@focus="onProductFocus(row)"
|
||||
@visible-change="(v) => { if (v) onProductFocus(row) }"
|
||||
@change="(val) => onProductPick(row, val)"
|
||||
>
|
||||
<el-option
|
||||
v-for="p in productOptions"
|
||||
:key="p.id"
|
||||
:label="p.product_name"
|
||||
:value="p.product_name"
|
||||
<template v-if="row.line_type === 'dev'">
|
||||
<el-button link type="primary" @click="openDev(row)">
|
||||
📁 模块报价({{ (row.dev_tree || []).length }} 项 · ¥{{ formatMoney(lineAmount(row)) }})
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-select
|
||||
v-model="row.name"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
:disabled="!row.category"
|
||||
:placeholder="row.category ? '搜索或输入产品名称' : '请先选择产品类别'"
|
||||
style="width: 100%"
|
||||
@focus="onProductFocus(row)"
|
||||
@visible-change="(v) => { if (v) onProductFocus(row) }"
|
||||
@change="(val) => onProductPick(row, val)"
|
||||
>
|
||||
<span class="opt-name">{{ p.product_name }}</span>
|
||||
<span class="opt-meta">{{ p.spec || "—" }} · ¥{{ p.price }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<!-- <div v-if="row.product_id" class="linked-tip">
|
||||
已关联产品管理 #{{ row.product_id }}(成本取自产品管理)
|
||||
</div> -->
|
||||
<el-option
|
||||
v-for="p in productOptions"
|
||||
:key="p.id"
|
||||
:label="p.product_name"
|
||||
:value="p.product_name"
|
||||
>
|
||||
<span class="opt-name">{{ p.product_name }}</span>
|
||||
<span class="opt-meta">{{ p.spec || "—" }} · ¥{{ p.price }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<!-- <div v-if="row.product_id" class="linked-tip">
|
||||
已关联产品管理 #{{ row.product_id }}(成本取自产品管理)
|
||||
</div> -->
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="规格型号" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.spec" :placeholder="specPlaceholder(row)" />
|
||||
<el-input v-if="row.line_type === 'product'" v-model="row.spec" :placeholder="specPlaceholder(row)" />
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.unit" placeholder="套" maxlength="10" />
|
||||
<el-input v-if="row.line_type === 'product'" v-model="row.unit" placeholder="套" maxlength="10" />
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-if="row.line_type === 'product'"
|
||||
v-model="row.quantity"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
@@ -71,11 +91,13 @@
|
||||
placeholder="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价(元)" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-if="row.line_type === 'product'"
|
||||
v-model="row.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
@@ -83,26 +105,31 @@
|
||||
placeholder="0.00"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成本单价(元)" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip content="成本单价取自产品管理,关联产品后不可修改" :disabled="!row.product_id">
|
||||
<el-input-number
|
||||
v-model="row.cost_price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
:disabled="!!row.product_id"
|
||||
placeholder="0.00"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<template v-if="row.line_type === 'product'">
|
||||
<el-tooltip content="成本单价取自产品管理,关联产品后不可修改" :disabled="!row.product_id">
|
||||
<el-input-number
|
||||
v-model="row.cost_price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
:disabled="!!row.product_id"
|
||||
placeholder="0.00"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="税率(%)" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-if="row.line_type === 'product'"
|
||||
v-model="row.tax_rate"
|
||||
:min="0"
|
||||
:max="100"
|
||||
@@ -110,11 +137,12 @@
|
||||
:controls="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计(元)" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="line-amount">{{ formatMoney(rowAmount(row)) }}</span>
|
||||
<span class="line-amount">{{ formatMoney(lineAmount(row)) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="120">
|
||||
@@ -132,7 +160,7 @@
|
||||
<div class="add-row">
|
||||
<el-button :icon="Plus" @click="addRow">添加产品</el-button>
|
||||
<span class="add-tip">
|
||||
先选产品类别,再在该分类下搜索/选择产品(自动带出规格、单位、成本、税率);分类内无对应名称可输入新增,提交时自动建档到产品管理
|
||||
每行可选「成品」或「软件开发」:成品按数量×单价计价;软件开发点「模块报价」编辑分层模块树(叶子填人天/人天单价,父级自动汇总),统一计入合同总价
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -170,6 +198,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DevModuleTree
|
||||
v-if="editingRow"
|
||||
v-model="editingRow.dev_tree"
|
||||
:unit-price="editingRow.dev_unit_price"
|
||||
v-model:visible="devVisible"
|
||||
@update:unit-price="(v) => (editingRow.dev_unit_price = v)"
|
||||
:title="editingRow.name ? editingRow.name + ' - 模块报价' : '软件开发模块报价'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -177,9 +214,10 @@
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { formatMoney } from "../../dict";
|
||||
import { buildSummary } from "./utils";
|
||||
import { buildSummary, lineAmount } from "./utils";
|
||||
import { getProductList } from "@/api/crmProduct";
|
||||
import { getProductCategoryList } from "@/api/crmProductCategory";
|
||||
import DevModuleTree from "./DevModuleTree.vue";
|
||||
|
||||
const props = defineProps({
|
||||
/** 产品行数组(父组件持有) */
|
||||
@@ -196,6 +234,7 @@ let rowSeed = 0;
|
||||
const addRow = () => {
|
||||
props.products.push({
|
||||
__key: `row_${Date.now()}_${rowSeed++}`,
|
||||
line_type: "product",
|
||||
name: "",
|
||||
product_id: null,
|
||||
category: "",
|
||||
@@ -205,6 +244,8 @@ const addRow = () => {
|
||||
price: 0,
|
||||
cost_price: 0,
|
||||
tax_rate: 0,
|
||||
dev_tree: [],
|
||||
dev_unit_price: 0,
|
||||
remark: "",
|
||||
});
|
||||
};
|
||||
@@ -213,8 +254,23 @@ const removeRow = (index) => {
|
||||
props.products.splice(index, 1);
|
||||
};
|
||||
|
||||
const rowAmount = (row) =>
|
||||
Math.round(((Number(row.quantity) || 0) * (Number(row.price) || 0) || 0) * 100) / 100;
|
||||
/** 切换行类型:软件开发行确保有模块树容器 */
|
||||
function onLineTypeChange(row) {
|
||||
if (row.line_type === "dev") {
|
||||
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
|
||||
if (row.dev_unit_price == null) row.dev_unit_price = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ 软件开发模块树编辑 ------------------------------ */
|
||||
const devVisible = ref(false);
|
||||
const editingRow = ref(null);
|
||||
|
||||
function openDev(row) {
|
||||
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
|
||||
editingRow.value = row;
|
||||
devVisible.value = true;
|
||||
}
|
||||
|
||||
const specPlaceholder = (row) => {
|
||||
const cat = String(row.category || "");
|
||||
@@ -273,6 +329,15 @@ function onProductPick(row, val) {
|
||||
const hit = productOptions.value.find((p) => p.product_name === val);
|
||||
if (hit) {
|
||||
row.product_id = hit.id;
|
||||
// 软件开发产品:带入模块树与人天单价,行类型自动切为 dev
|
||||
if (hit.line_type === "dev" || hit.dev_tree) {
|
||||
row.line_type = "dev";
|
||||
row.dev_unit_price = Number(hit.dev_unit_price) || 0;
|
||||
row.dev_tree = Array.isArray(hit.dev_tree)
|
||||
? JSON.parse(JSON.stringify(hit.dev_tree))
|
||||
: safeParseTree(hit.dev_tree);
|
||||
return;
|
||||
}
|
||||
row.spec = hit.spec || "";
|
||||
row.unit = hit.unit || "";
|
||||
row.price = hit.price || 0;
|
||||
@@ -284,6 +349,17 @@ function onProductPick(row, val) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 安全解析产品管理返回的 dev_tree(可能为字符串或数组) */
|
||||
function safeParseTree(v) {
|
||||
if (Array.isArray(v)) return v;
|
||||
try {
|
||||
const parsed = typeof v === "string" ? JSON.parse(v) : v;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
// 为已加载的合同产品预置下拉选项,保证名称回显
|
||||
@@ -311,6 +387,10 @@ onMounted(() => {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.opt-name {
|
||||
float: left;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
@update:model-value="handleClose"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<!-- 进度条 -->
|
||||
<!-- 进度条(支持点击步骤直接跳转) -->
|
||||
<div class="steps-wrapper">
|
||||
<el-steps :active="activeStep" finish-status="success" align-center>
|
||||
<el-step title="合同信息" description="关键点信息与各方签约主体" />
|
||||
<el-step title="产品清单" description="产品明细与金额汇总" />
|
||||
<el-steps :active="activeStep" finish-status="success" align-center class="clickable-steps">
|
||||
<el-step
|
||||
title="合同信息"
|
||||
description="关键点信息与各方签约主体"
|
||||
class="step-clickable"
|
||||
@click="goStep(0)"
|
||||
/>
|
||||
<el-step title="产品清单" description="产品明细与金额汇总" class="step-clickable" @click="goStep(1)" />
|
||||
</el-steps>
|
||||
</div>
|
||||
|
||||
@@ -103,8 +108,8 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目负责人" prop="owner_name">
|
||||
<el-input v-model="form.owner_name" placeholder="默认为创建人" />
|
||||
<el-form-item label="项目负责人" prop="owner_user_name">
|
||||
<el-input v-model="form.owner_user_name" placeholder="默认为创建人" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -145,6 +150,7 @@
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="签订日期"
|
||||
style="width: 100%"
|
||||
@change="onSignDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -173,6 +179,18 @@
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="技术日期" prop="tech_date">
|
||||
<el-date-picker
|
||||
v-model="form.tech_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="开发/交付完成日期"
|
||||
style="width: 240px"
|
||||
/>
|
||||
<span class="form-tip">技术日期到期且回款未回完,合同状态将自动转为「执行异常」</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="备注" prop="remark" style="margin-top: 16px">
|
||||
@@ -272,10 +290,11 @@ const emptyForm = () => ({
|
||||
party_count: 2, // 合同形式:2双方 3三方 4四方,默认双方
|
||||
project_id: null,
|
||||
project_name: "",
|
||||
owner_name: authStore.user?.name || "",
|
||||
owner_user_name: authStore.user?.name || "",
|
||||
sign_date: "",
|
||||
effective_date: "",
|
||||
expire_date: "",
|
||||
tech_date: "",
|
||||
remark: "",
|
||||
status: 1,
|
||||
step: 1,
|
||||
@@ -395,6 +414,19 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
/** 选择签订日期:生效日期为空时默认带入(生效日期仍可手动修改,改过则不再被覆盖) */
|
||||
watch(
|
||||
() => form.sign_date,
|
||||
(nv) => {
|
||||
if (nv && !form.effective_date) form.effective_date = nv;
|
||||
}
|
||||
);
|
||||
|
||||
/** 签订日期变更(用户操作):自动带入生效日期 */
|
||||
function onSignDateChange(val) {
|
||||
if (val && !form.effective_date) form.effective_date = val;
|
||||
}
|
||||
|
||||
/** 合同形式文案:2双方 3三方 4四方 */
|
||||
const partyCountName = (v) => ({ 2: "双方", 3: "三方", 4: "四方" }[Number(v)] || "双方");
|
||||
|
||||
@@ -455,6 +487,7 @@ watch(
|
||||
form.sign_date = normalizeDate(props.editData.sign_date);
|
||||
form.effective_date = normalizeDate(props.editData.effective_date);
|
||||
form.expire_date = normalizeDate(props.editData.expire_date);
|
||||
form.tech_date = normalizeDate(props.editData.tech_date);
|
||||
parties.value = (props.editData.parties || []).map((p) => ({ ...emptyParty(p.role), ...p }));
|
||||
products.value = (props.editData.products || []).map((row, i) => ({
|
||||
...row,
|
||||
@@ -524,6 +557,13 @@ const prevStep = () => {
|
||||
if (activeStep.value > 0) activeStep.value--;
|
||||
};
|
||||
|
||||
/** 点击上方步骤条直接跳转(无需按上一步 / 下一步) */
|
||||
const goStep = (index) => {
|
||||
const target = Math.max(0, Math.min(MAX_STEP, Number(index) || 0));
|
||||
if (target === activeStep.value) return;
|
||||
activeStep.value = target;
|
||||
};
|
||||
|
||||
/** 组装保存 payload;数值字段统一转数字(后端 int8 解析),summary 由产品清单实时计算 */
|
||||
const buildPayload = (step, status) => ({
|
||||
...form,
|
||||
@@ -531,6 +571,7 @@ const buildPayload = (step, status) => ({
|
||||
sign_date: normalizeDate(form.sign_date),
|
||||
effective_date: normalizeDate(form.effective_date),
|
||||
expire_date: normalizeDate(form.expire_date),
|
||||
tech_date: normalizeDate(form.tech_date),
|
||||
our_role: Number(form.our_role) || 2,
|
||||
party_count: Number(form.party_count) || 2,
|
||||
parties: parties.value.map((p) => ({ ...p })),
|
||||
@@ -633,6 +674,18 @@ const handleClosed = () => {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* 步骤条可点击跳转 */
|
||||
.clickable-steps {
|
||||
:deep(.step-clickable) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.el-step__head),
|
||||
:deep(.el-step__main) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,143 +1,88 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="visible"
|
||||
title="合同详情"
|
||||
size="680px"
|
||||
:title="detail?.contract_name || '合同详情'"
|
||||
direction="rtl"
|
||||
size="900px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
@update:model-value="handleClose"
|
||||
@opened="loadAll"
|
||||
>
|
||||
<div v-loading="loading" class="contract-detail">
|
||||
<template v-if="detail.id">
|
||||
<div class="detail-head">
|
||||
<div class="detail-title">
|
||||
<span class="name">{{ detail.contract_name }}</span>
|
||||
<el-tag :type="contractStatusTag(detail.status)" size="small">
|
||||
{{ contractStatusText(detail.status) }}
|
||||
</el-tag>
|
||||
<el-tag type="primary" size="small" effect="plain">
|
||||
我方·{{ ourRoleText(detail.our_role) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="detail-sub">
|
||||
{{ detail.contract_no }} · {{ contractCategoryText(detail.contract_category) }}
|
||||
</div>
|
||||
<div v-if="detail.id" class="detail-toolbar">
|
||||
<div class="toolbar-status">
|
||||
<!-- <el-tag :type="contractStatusTag(detail.status)" size="small">{{ contractStatusText(detail.status) }}</el-tag> -->
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border style="margin-top: 16px">
|
||||
<el-descriptions-item label="所属项目" :span="2">
|
||||
<template v-if="detail.project_id">
|
||||
<el-tag size="small" type="primary" effect="light">项目合同</el-tag>
|
||||
<span style="margin-left: 8px">{{ detail.project_name }}</span>
|
||||
<div class="toolbar-actions">
|
||||
<el-dropdown trigger="click" @command="(cmd) => handleChangeStatus(cmd)">
|
||||
<el-button link type="warning" size="small">
|
||||
状态流转<el-icon style="margin-left: 2px"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="i in statusOptionsExcept(detail.status)"
|
||||
:key="i.value"
|
||||
:command="i.value"
|
||||
>
|
||||
{{ i.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tag size="small" type="warning" effect="light">无头合同</el-tag>
|
||||
</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="签订日期">{{ formatDateOnly(detail.sign_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目负责人">{{ detail.owner_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生效日期">{{ formatDateOnly(detail.effective_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束日期">{{ formatDateOnly(detail.expire_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider content-position="left">各方签约主体</el-divider>
|
||||
<el-table :data="detail.parties || []" border size="small">
|
||||
<el-table-column label="角色" width="80" align="center">
|
||||
<template #default="{ row }">{{ partyLabel(row.role) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="签约主体" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.ref_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="Number(row.ref_type) === 1" size="small" type="success" effect="plain">客户</el-tag>
|
||||
<el-tag v-else-if="Number(row.ref_type) === 2" size="small" type="warning" effect="plain">供应商</el-tag>
|
||||
<span v-else>本公司</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="signer_name" label="签约人" width="100">
|
||||
<template #default="{ row }">{{ row.signer_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="signer_phone" label="联系电话" width="120">
|
||||
<template #default="{ row }">{{ row.signer_phone || "-" }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-divider content-position="left">产品清单</el-divider>
|
||||
<el-table :data="detail.products || []" border size="small">
|
||||
<el-table-column type="index" label="#" width="45" align="center" />
|
||||
<el-table-column prop="name" label="产品名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="类别" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="productCategoryTag(row.category)" size="small" effect="plain">
|
||||
{{ productCategoryText(row.category) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="60" align="center" />
|
||||
<el-table-column label="数量" width="80" align="right">
|
||||
<template #default="{ row }">{{ row.quantity ?? "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney((Number(row.quantity) || 0) * (Number(row.price) || 0)) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || "-" }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-divider content-position="left">金额汇总</el-divider>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<span class="label">合同总金额</span>
|
||||
<span class="value primary">¥{{ formatMoney(summary.total_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">产品总成本</span>
|
||||
<span class="value">¥{{ formatMoney(summary.total_cost) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">合同总利润</span>
|
||||
<span class="value success">¥{{ formatMoney(summary.total_profit) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">硬件部分金额</span>
|
||||
<span class="value warning">¥{{ formatMoney(summary.hardware_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">软件部分金额</span>
|
||||
<span class="value primary">¥{{ formatMoney(summary.software_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">其他部分金额</span>
|
||||
<span class="value">¥{{ formatMoney(summary.other_amount) }}</span>
|
||||
</div>
|
||||
</el-dropdown>
|
||||
<el-button type="primary" size="small" :icon="Edit" @click="handleEdit">编辑</el-button>
|
||||
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<el-tabs v-if="detail.id" v-model="activeTab" tab-position="left" class="detail-tabs">
|
||||
<!-- 基本信息(拆分自原详情内容) -->
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<DetailBasic :detail="detail" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 回款计划(针对本合同创建 / 查看回款计划) -->
|
||||
<el-tab-pane :label="`回款计划 (${counts.payback})`" name="payback">
|
||||
<DetailPayback ref="paybackRef" :contract="detail" @count="counts.payback = $event" @saved="emit('refresh')" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 电子合同(上传 / 查看合同扫描件、电子合同正本) -->
|
||||
<el-tab-pane :label="`电子合同 (${counts.econtract})`" name="econtract">
|
||||
<DetailAttach
|
||||
ref="econtractRef"
|
||||
:related-id="detail.id"
|
||||
:related-type="E_CONTRACT_TYPE"
|
||||
tip="上传合同扫描件 / 电子合同正本,支持图片与 PDF"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 操作日志 -->
|
||||
<el-tab-pane label="操作日志" name="log">
|
||||
<DetailLog ref="logRef" :related-id="detail.id" :related-type="RELATED_TYPE" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-empty v-else-if="!loading" description="暂无数据" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { getContractDetail } from "@/api/crmContract";
|
||||
import { PARTY_ROLES, buildSummary } from "./utils";
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Edit, Delete, ArrowDown } from "@element-plus/icons-vue";
|
||||
import {
|
||||
formatMoney,
|
||||
formatDateOnly,
|
||||
contractCategoryText,
|
||||
ourRoleText,
|
||||
getContractDetail,
|
||||
changeContractStatus,
|
||||
deleteContract,
|
||||
} from "@/api/crmContract";
|
||||
import {
|
||||
CONTRACT_STATUS_OPTIONS,
|
||||
contractStatusText,
|
||||
contractStatusTag,
|
||||
productCategoryText,
|
||||
productCategoryTag,
|
||||
} from "../../dict";
|
||||
import DetailBasic from "./detail_basic.vue";
|
||||
import DetailPayback from "./detail_payback.vue";
|
||||
import DetailLog from "../../project/components/detail_log.vue";
|
||||
import DetailAttach from "../../project/components/detail_attach.vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
@@ -145,23 +90,29 @@ const props = defineProps({
|
||||
contract: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible"]);
|
||||
const emit = defineEmits(["update:visible", "refresh", "edit"]);
|
||||
|
||||
// 合同操作日志与项目共用关联类型 3
|
||||
const RELATED_TYPE = 3;
|
||||
// 电子合同附件专属关联类型(与线索 1 / 商机 2 / 项目 3 区分)
|
||||
const E_CONTRACT_TYPE = 4;
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = ref({});
|
||||
const activeTab = ref("basic");
|
||||
const counts = reactive({ payback: 0, econtract: 0 });
|
||||
|
||||
const summary = computed(() => {
|
||||
const s = detail.value.summary;
|
||||
if (s && Object.keys(s).length) return s;
|
||||
return buildSummary(detail.value.products || []);
|
||||
});
|
||||
|
||||
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
|
||||
const paybackRef = ref();
|
||||
const logRef = ref();
|
||||
const econtractRef = ref();
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (val) => {
|
||||
if (!val) return;
|
||||
activeTab.value = "basic";
|
||||
counts.payback = 0;
|
||||
counts.econtract = 0;
|
||||
detail.value = {};
|
||||
if (!props.contract?.id) return;
|
||||
loading.value = true;
|
||||
@@ -175,6 +126,56 @@ watch(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
/** 抽屉打开后刷新各 Tab 数据 */
|
||||
function loadAll() {
|
||||
if (!detail.value?.id) return;
|
||||
paybackRef.value?.reload();
|
||||
logRef.value?.reload();
|
||||
econtractRef.value?.reload();
|
||||
}
|
||||
|
||||
/** 状态流转候选:排除当前状态 */
|
||||
function statusOptionsExcept(cur) {
|
||||
return CONTRACT_STATUS_OPTIONS.filter((i) => String(i.value) !== String(cur));
|
||||
}
|
||||
|
||||
async function handleChangeStatus(status) {
|
||||
try {
|
||||
await changeContractStatus(detail.value.id, Number(status));
|
||||
ElMessage.success(`状态已更新为「${contractStatusText(status)}」`);
|
||||
detail.value.status = Number(status);
|
||||
emit("refresh");
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "状态更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
emit("edit", detail.value);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除合同「${detail.value.contract_name}」吗?删除后不可恢复。`,
|
||||
"删除确认",
|
||||
{ type: "warning" }
|
||||
);
|
||||
await deleteContract(detail.value.id);
|
||||
ElMessage.success("删除成功");
|
||||
emit("update:visible", false);
|
||||
emit("refresh");
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ loadAll });
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@@ -182,58 +183,72 @@ watch(
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
.detail-title {
|
||||
.detail-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 4px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
.toolbar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.name {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
:deep(.el-drawer__body) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
.detail-tabs {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
:deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
padding: 0 12px;
|
||||
min-width: 96px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-tab-pane) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
width: 100%;
|
||||
|
||||
.el-button.is-link {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
|
||||
&.primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
&.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
&.warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
.el-button + .el-button {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="contract-basic">
|
||||
<div class="detail-head">
|
||||
<div class="detail-title">
|
||||
<span class="name">{{ detail.contract_name || "-" }}</span>
|
||||
<!-- <el-tag :type="contractStatusTag(detail.status)" size="small">
|
||||
{{ contractStatusText(detail.status) }}
|
||||
</el-tag> -->
|
||||
<el-tag v-if="contractOverdue(detail).overdue" type="danger" size="small" effect="dark">
|
||||
{{ overdueText(contractOverdue(detail).days) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<!-- <div class="detail-sub">
|
||||
{{ detail.contract_no || "-" }}
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border style="margin-top: 16px">
|
||||
<el-descriptions-item label="合同类型">
|
||||
<el-tag v-if="detail.project_id" size="small" type="primary" effect="light">项目合同</el-tag>
|
||||
<el-tag v-else size="small" type="warning" effect="light">无头合同</el-tag>
|
||||
<el-tag size="small" type="warning" effect="light" style="margin-left: 8px">{{ contractCategoryText(detail.contract_category) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="合同编号">{{ detail.contract_no || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="履约状态">
|
||||
<el-tag :type="contractStatusTag(detail.status)" size="small">{{ contractStatusText(detail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="我方角色">{{ ourRoleText(detail.our_role) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属项目" :span="2">
|
||||
<span v-if="detail.project_id">{{ detail.project_name }}</span>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="项目负责人">{{ detail.owner_user_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="签订日期">{{ formatDateOnly(detail.sign_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生效日期">{{ formatDateOnly(detail.effective_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束日期">{{ formatDateOnly(detail.expire_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="技术日期">{{ formatDateOnly(detail.tech_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider content-position="left">各方签约主体</el-divider>
|
||||
<el-table :data="detail.parties || []" border size="small">
|
||||
<el-table-column label="角色" width="80" align="center">
|
||||
<template #default="{ row }">{{ partyLabel(row.role) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="签约主体" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.ref_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="Number(row.ref_type) === 1" size="small" type="success" effect="plain">客户</el-tag>
|
||||
<el-tag v-else-if="Number(row.ref_type) === 2" size="small" type="warning" effect="plain">供应商</el-tag>
|
||||
<span v-else>本公司</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="signer_name" label="签约人" width="100">
|
||||
<template #default="{ row }">{{ row.signer_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="signer_phone" label="联系电话" width="120">
|
||||
<template #default="{ row }">{{ row.signer_phone || "-" }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-divider content-position="left">产品清单</el-divider>
|
||||
<el-table :data="detail.products || []" border size="small">
|
||||
<el-table-column type="index" label="#" width="45" align="center" />
|
||||
<el-table-column prop="name" label="产品名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="类别" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="productCategoryTag(row.category)" size="small" effect="plain">
|
||||
{{ productCategoryText(row.category) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="60" align="center" />
|
||||
<el-table-column label="数量" width="80" align="right">
|
||||
<template #default="{ row }">{{ row.quantity ?? "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney((Number(row.quantity) || 0) * (Number(row.price) || 0)) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || "-" }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-divider content-position="left">金额汇总</el-divider>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<span class="label">合同总金额</span>
|
||||
<span class="value primary">¥{{ formatMoney(summary.total_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">产品总成本</span>
|
||||
<span class="value">¥{{ formatMoney(summary.total_cost) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">合同总利润</span>
|
||||
<span class="value success">¥{{ formatMoney(summary.total_profit) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">硬件部分金额</span>
|
||||
<span class="value warning">¥{{ formatMoney(summary.hardware_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">软件部分金额</span>
|
||||
<span class="value primary">¥{{ formatMoney(summary.software_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">其他部分金额</span>
|
||||
<span class="value">¥{{ formatMoney(summary.other_amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { PARTY_ROLES, buildSummary } from "./utils";
|
||||
import {
|
||||
formatMoney,
|
||||
formatDateOnly,
|
||||
contractCategoryText,
|
||||
ourRoleText,
|
||||
contractStatusText,
|
||||
contractStatusTag,
|
||||
contractOverdue,
|
||||
overdueText,
|
||||
productCategoryText,
|
||||
productCategoryTag,
|
||||
} from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
/** 合同详情完整数据 */
|
||||
detail: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const summary = computed(() => {
|
||||
const s = props.detail.summary;
|
||||
if (s && Object.keys(s).length) return s;
|
||||
return buildSummary(props.detail.products || []);
|
||||
});
|
||||
|
||||
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.detail-head {
|
||||
.detail-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.name {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
.summary-card {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
|
||||
&.primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
&.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
&.warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="contract-payback">
|
||||
<div class="section-head">
|
||||
<span class="section-title">回款计划</span>
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="openCreate">新建回款计划</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border size="small">
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回款周期" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回款进度" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计划回款总额" width="130" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已回款" width="120" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回款方式" width="100" align="center">
|
||||
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" width="90">
|
||||
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="该合同暂无回款计划,点击右上角新建" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 新建 / 编辑回款计划(预设当前合同) -->
|
||||
<PaybackCreate
|
||||
v-model:visible="createVisible"
|
||||
:preset-contract="contract"
|
||||
:edit-data="editRow"
|
||||
@success="handleSaved"
|
||||
/>
|
||||
|
||||
<!-- 回款计划详情 -->
|
||||
<PaybackDetail v-model:visible="detailVisible" :payback="currentRow" @edit="handleEdit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { getPaybackList } from "@/api/crmPayback";
|
||||
import PaybackCreate from "../../payback/components/create.vue";
|
||||
import PaybackDetail from "../../payback/components/detail.vue";
|
||||
import {
|
||||
paybackCycleText,
|
||||
paybackMethodText,
|
||||
paybackStatusText,
|
||||
paybackStatusTag,
|
||||
formatMoney,
|
||||
} from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
/** 当前合同详情对象(含 id / contract_name / contract_no / parties / summary) */
|
||||
contract: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["count", "saved"]);
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref([]);
|
||||
const createVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const currentRow = ref(null);
|
||||
const editRow = ref(null);
|
||||
|
||||
/** 回款进度百分比 */
|
||||
function paybackPercent(row) {
|
||||
const total = Number(row.total_amount) || 0;
|
||||
if (!total) return 0;
|
||||
return Math.min(100, Math.round((Number(row.received_amount) / total) * 100));
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editRow.value = null;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
currentRow.value = { ...row };
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
/** 详情内点击编辑:关闭详情并打开编辑弹窗 */
|
||||
function handleEdit(row) {
|
||||
detailVisible.value = false;
|
||||
editRow.value = { ...row };
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
function handleSaved() {
|
||||
reload();
|
||||
emit("saved");
|
||||
}
|
||||
|
||||
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||
async function reload() {
|
||||
const contractId = props.contract?.id;
|
||||
if (!contractId) {
|
||||
list.value = [];
|
||||
emit("count", 0);
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getPaybackList({ contract_id: contractId, page: 1, pageSize: 100 });
|
||||
list.value = res?.data?.list || [];
|
||||
} catch (e) {
|
||||
list.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
emit("count", list.value.length);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reload });
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,36 @@
|
||||
|
||||
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
|
||||
|
||||
/**
|
||||
* 软件开发模块树节点售价合计:
|
||||
* - 含子节点 → 汇总子节点;
|
||||
* - 叶子节点 → 人天 × 统一人天单价。
|
||||
*/
|
||||
export function treeSellTotal(nodes, unitPrice = 0) {
|
||||
const list = Array.isArray(nodes) ? nodes : [];
|
||||
const up = Number(unitPrice) || 0;
|
||||
return round2(
|
||||
list.reduce((sum, n) => {
|
||||
if (Array.isArray(n.children) && n.children.length) {
|
||||
return sum + treeSellTotal(n.children, up);
|
||||
}
|
||||
return sum + round2((Number(n.man_days) || 0) * up);
|
||||
}, 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** 单行金额:成品 = 数量×单价;软件开发 = 模块树售价合计(人天×统一人天单价) */
|
||||
export function lineAmount(row) {
|
||||
if (row?.line_type === "dev") return treeSellTotal(row.dev_tree, row.dev_unit_price);
|
||||
return round2((Number(row?.quantity) || 0) * (Number(row?.price) || 0));
|
||||
}
|
||||
|
||||
/** 单行成本:成品 = 数量×成本单价;软件开发不计成本 */
|
||||
export function lineCost(row) {
|
||||
if (row?.line_type === "dev") return 0;
|
||||
return round2((Number(row?.quantity) || 0) * (Number(row?.cost_price) || 0));
|
||||
}
|
||||
|
||||
export function buildSummary(products) {
|
||||
const rows = Array.isArray(products) ? products : [];
|
||||
let hardware = 0;
|
||||
@@ -20,6 +50,11 @@ export function buildSummary(products) {
|
||||
let other = 0;
|
||||
let totalCost = 0;
|
||||
rows.forEach((row) => {
|
||||
if (row?.line_type === "dev") {
|
||||
const amount = treeSellTotal(row.dev_tree, row.dev_unit_price);
|
||||
software += amount; // 软件开发计入软件部分,不计成本
|
||||
return;
|
||||
}
|
||||
const qty = Number(row?.quantity) || 0;
|
||||
const price = Number(row?.price) || 0;
|
||||
const costPrice = Number(row?.cost_price) || 0;
|
||||
|
||||
Reference in New Issue
Block a user