新增合同管理相关功能
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="party-select" :class="{ own: locked }">
|
||||
<div class="party-head">
|
||||
<span class="party-role">{{ roleLabel }}</span>
|
||||
<el-tag v-if="locked" size="small" type="primary" effect="light">本公司</el-tag>
|
||||
<el-tag v-else-if="party?.ref_name" size="small" type="info" effect="plain">
|
||||
{{ party.ref_type === 2 ? "供应商" : "客户" }}
|
||||
</el-tag>
|
||||
<span v-if="locked" class="party-company">{{ tenantName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 关联主体:当前租户方锁定,其余模糊搜索客户库 / 供应商库 -->
|
||||
<template v-if="!locked">
|
||||
<div class="party-row">
|
||||
<el-select
|
||||
v-if="allowChooseRefType"
|
||||
v-model="party.ref_type"
|
||||
class="ref-type-select"
|
||||
size="default"
|
||||
@change="handleRefTypeChange"
|
||||
>
|
||||
<el-option label="客户" :value="1" />
|
||||
<el-option label="供应商" :value="2" />
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="party.ref_id"
|
||||
class="ref-select"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
reserve-keyword
|
||||
:loading="searching"
|
||||
:remote-method="handleSearch"
|
||||
:placeholder="`搜索${refTypeText}名称`"
|
||||
@change="handlePick"
|
||||
@clear="handleClear"
|
||||
@visible-change="handleDropdownOpen"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in options"
|
||||
:key="item.id"
|
||||
:label="item.label"
|
||||
:value="item.id"
|
||||
>
|
||||
<span class="option-name">{{ item.label }}</span>
|
||||
<span class="option-sub">{{ item.sub }}</span>
|
||||
</el-option>
|
||||
<template #footer>
|
||||
<div class="select-footer">
|
||||
<el-button link type="primary" :icon="Plus" @click="emit('create')">
|
||||
新建{{ refTypeText }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
|
||||
<el-tooltip :content="`没有找到?点击新建${refTypeText}`" placement="top">
|
||||
<el-button :icon="Plus" @click="emit('create')" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-if="party.ref_name" class="picked-name">已关联:{{ party.ref_name }}</div>
|
||||
</template>
|
||||
|
||||
<!-- 签约人 -->
|
||||
<div class="party-row signer-row">
|
||||
<el-input
|
||||
v-model="party.signer_name"
|
||||
placeholder="签约人姓名"
|
||||
maxlength="30"
|
||||
/>
|
||||
<el-input v-model="party.signer_phone" placeholder="联系电话" maxlength="20" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const props = defineProps({
|
||||
/** 参与方对象:{ role, ref_type, ref_id, ref_name, signer_name, signer_phone } */
|
||||
modelValue: { type: Object, required: true },
|
||||
/** 甲方 / 乙方 / 丙方 / 丁方 */
|
||||
roleLabel: { type: String, default: "" },
|
||||
/** 是否为当前租户方(锁定为本公司) */
|
||||
locked: { type: Boolean, default: false },
|
||||
/** 丙/丁方允许在客户 / 供应商库之间切换来源 */
|
||||
allowChooseRefType: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["create"]);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const tenantName = computed(() => authStore.user?.tenant_name || "本公司");
|
||||
|
||||
/** 参与方对象由父组件持有,这里直接读写其字段 */
|
||||
const party = computed(() => props.modelValue);
|
||||
|
||||
const searching = ref(false);
|
||||
const options = ref([]);
|
||||
|
||||
const refTypeText = computed(() =>
|
||||
Number(party.value.ref_type) === 2 ? "供应商" : "客户"
|
||||
);
|
||||
|
||||
/** 初次打开按已有名称兜底展示,避免回显成 id */
|
||||
const ensureOption = () => {
|
||||
if (party.value?.ref_id && party.value?.ref_name) {
|
||||
const exists = options.value.some((i) => String(i.id) === String(party.value.ref_id));
|
||||
if (!exists) {
|
||||
options.value = [
|
||||
{ id: party.value.ref_id, label: party.value.ref_name, sub: "" },
|
||||
...options.value,
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => party.value?.ref_id,
|
||||
() => ensureOption(),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const remoteSearch = async (keyword) => {
|
||||
searching.value = true;
|
||||
try {
|
||||
const api =
|
||||
Number(party.value.ref_type) === 2 ? getCrmSupplierList : getCrmCustomerList;
|
||||
const res = await api({ keyword, page: 1, pageSize: 50 });
|
||||
const list = res?.data?.list || [];
|
||||
options.value = list.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.customer_name || item.supplier_name || item.name || `#${item.id}`,
|
||||
sub: item.contact_person || item.industry || "",
|
||||
}));
|
||||
ensureOption();
|
||||
} catch {
|
||||
options.value = [];
|
||||
} finally {
|
||||
searching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (keyword) => remoteSearch(keyword || "");
|
||||
|
||||
/** 下拉首次展开时加载全量候选 */
|
||||
const handleDropdownOpen = (visible) => {
|
||||
if (visible && options.value.length === 0) remoteSearch("");
|
||||
};
|
||||
|
||||
const handlePick = (id) => {
|
||||
const hit = options.value.find((i) => String(i.id) === String(id));
|
||||
party.value.ref_name = hit?.label || "";
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
party.value.ref_id = null;
|
||||
party.value.ref_name = "";
|
||||
options.value = [];
|
||||
};
|
||||
|
||||
/** 丙/丁方切换客户 / 供应商来源时清空已选 */
|
||||
const handleRefTypeChange = () => {
|
||||
party.value.ref_id = null;
|
||||
party.value.ref_name = "";
|
||||
options.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.party-select {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
|
||||
/* 本公司方:主色浅底突出显示 */
|
||||
&.own {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.party-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.party-role {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.party-company {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.party-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
&.signer-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ref-type-select {
|
||||
width: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ref-select {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.picked-name {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.select-footer {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<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="产品名称" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.name" placeholder="请输入产品/服务名称" maxlength="100" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产品类别" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.category" placeholder="类别">
|
||||
<el-option
|
||||
v-for="i in CONTRACT_PRODUCT_CATEGORY_OPTIONS"
|
||||
:key="i.value"
|
||||
:label="i.label"
|
||||
:value="i.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="规格型号" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.spec" :placeholder="specPlaceholder(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.unit" placeholder="套" maxlength="10" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.quantity"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
placeholder="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价(元)" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
placeholder="0.00"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成本单价(元)" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.cost_price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
placeholder="0.00"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计(元)" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="line-amount">{{ formatMoney(rowAmount(row)) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.remark" placeholder="备注" maxlength="100" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70" align="center" fixed="right">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removeRow($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="add-row">
|
||||
<el-button :icon="Plus" @click="addRow">添加产品</el-button>
|
||||
<span class="add-tip">按类别自动归集:硬件 / 软件单列金额,服务、开发等计入其他</span>
|
||||
</div>
|
||||
|
||||
<!-- 金额汇总 -->
|
||||
<div class="summary-panel">
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item hardware">
|
||||
<span class="summary-label">硬件部分金额</span>
|
||||
<span class="summary-value">¥{{ formatMoney(summary.hardware_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-item software">
|
||||
<span class="summary-label">软件部分金额</span>
|
||||
<span class="summary-value">¥{{ formatMoney(summary.software_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-item other">
|
||||
<span class="summary-label">其他部分金额</span>
|
||||
<span class="summary-value">¥{{ formatMoney(summary.other_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-item total">
|
||||
<span class="summary-label">合同总金额</span>
|
||||
<span class="summary-value">¥{{ formatMoney(summary.total_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-item cost">
|
||||
<span class="summary-label">产品总成本</span>
|
||||
<span class="summary-value">¥{{ formatMoney(summary.total_cost) }}</span>
|
||||
</div>
|
||||
<div class="summary-item profit">
|
||||
<span class="summary-label">合同总利润</span>
|
||||
<span class="summary-value" :class="{ loss: summary.total_profit < 0 }">
|
||||
¥{{ formatMoney(summary.total_profit) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { formatMoney } from "../../dict";
|
||||
import { buildSummary } from "./utils";
|
||||
|
||||
const props = defineProps({
|
||||
/** 产品行数组(父组件持有) */
|
||||
products: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const summary = computed(() => buildSummary(props.products));
|
||||
|
||||
let rowSeed = 0;
|
||||
const addRow = () => {
|
||||
props.products.push({
|
||||
__key: `row_${Date.now()}_${rowSeed++}`,
|
||||
name: "",
|
||||
category: "",
|
||||
spec: "",
|
||||
unit: "",
|
||||
quantity: 1,
|
||||
price: 0,
|
||||
cost_price: 0,
|
||||
remark: "",
|
||||
});
|
||||
};
|
||||
|
||||
const removeRow = (index) => {
|
||||
props.products.splice(index, 1);
|
||||
};
|
||||
|
||||
const rowAmount = (row) =>
|
||||
Math.round(((Number(row.quantity) || 0) * (Number(row.price) || 0) || 0) * 100) / 100;
|
||||
|
||||
const specPlaceholder = (row) => {
|
||||
if (String(row.category) === "1") return "如:型号 / 配置";
|
||||
if (String(row.category) === "4") return "如:功能模块 / 里程碑";
|
||||
return "规格 / 说明";
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.product-list {
|
||||
.line-amount {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
|
||||
.add-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&.loss {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
&.hardware .summary-value {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
&.software .summary-value {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
&.total .summary-value {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 20px;
|
||||
}
|
||||
&.profit .summary-value {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="`快速新建${refTypeText}`"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleClose"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<el-alert
|
||||
:title="`创建成功后将自动关联到合同参与方`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item :label="`${refTypeText}名称`" prop="name">
|
||||
<el-input v-model="form.name" :placeholder="`请输入${refTypeText}名称`" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="`${refTypeText}类型`" prop="type">
|
||||
<el-select v-model="form.type" style="width: 100%">
|
||||
<el-option
|
||||
v-for="i in typeOptions"
|
||||
:key="i.value"
|
||||
:label="i.label"
|
||||
:value="i.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人" prop="contact_person">
|
||||
<el-input v-model="form.contact_person" placeholder="请输入联系人(选填)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话" prop="contact_phone">
|
||||
<el-input v-model="form.contact_phone" placeholder="请输入联系电话(选填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
创建并关联
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { createCrmCustomer, createCrmSupplier } from "@/api/crm";
|
||||
import { CUSTOMER_TYPE_OPTIONS, SUPPLIER_TYPE_OPTIONS } from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
/** 1=客户 2=供应商 */
|
||||
refType: { type: [Number, String], default: 1 },
|
||||
/** 从搜索词带入名称 */
|
||||
presetName: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible", "created"]);
|
||||
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
|
||||
const refTypeText = computed(() => (Number(props.refType) === 2 ? "供应商" : "客户"));
|
||||
const typeOptions = computed(() =>
|
||||
Number(props.refType) === 2 ? SUPPLIER_TYPE_OPTIONS : CUSTOMER_TYPE_OPTIONS
|
||||
);
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: "",
|
||||
type: "1",
|
||||
contact_person: "",
|
||||
contact_phone: "",
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "名称不能为空", trigger: "blur" }],
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
Object.assign(form, defaultForm(), { name: props.presetName || "" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function handleClosed() {
|
||||
formRef.value?.resetFields();
|
||||
Object.assign(form, defaultForm());
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
status: "1",
|
||||
remark: "",
|
||||
contact_person: form.contact_person,
|
||||
contact_phone: form.contact_phone,
|
||||
};
|
||||
let row;
|
||||
if (Number(props.refType) === 2) {
|
||||
payload.supplier_name = form.name;
|
||||
payload.supplier_type = form.type;
|
||||
const res = await createCrmSupplier(payload);
|
||||
row = res?.data || { id: null, supplier_name: form.name };
|
||||
} else {
|
||||
payload.customer_name = form.name;
|
||||
payload.customer_type = form.type;
|
||||
const res = await createCrmCustomer(payload);
|
||||
row = res?.data || { id: null, customer_name: form.name };
|
||||
}
|
||||
ElMessage.success(`${refTypeText.value}创建成功`);
|
||||
emit("created", {
|
||||
...row,
|
||||
id: row?.id ?? row?.customer_id ?? row?.supplier_id ?? null,
|
||||
label: form.name,
|
||||
});
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "创建失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,723 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="1080px"
|
||||
top="4vh"
|
||||
:close-on-click-modal="false"
|
||||
@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>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="96px" style="margin-top: 20px">
|
||||
<!-- 步骤 1:合同信息 -->
|
||||
<div v-show="activeStep === 0">
|
||||
<!-- 合同参与方:先定形式与我方角色,再逐方选择签约对象 -->
|
||||
<el-divider content-position="left">合同参与方</el-divider>
|
||||
<div class="party-config">
|
||||
<div class="config-block">
|
||||
<span class="config-label">合同形式</span>
|
||||
<div class="form-cards">
|
||||
<div
|
||||
v-for="i in CONTRACT_PARTY_COUNT_OPTIONS"
|
||||
:key="i.value"
|
||||
class="form-card"
|
||||
:class="{ active: Number(form.party_count) === Number(i.value) }"
|
||||
@click="setPartyCount(i.value)"
|
||||
>
|
||||
<span class="form-card-name">{{ partyCountName(i.value) }}</span>
|
||||
<!-- <s pan class="form-card-roles">{{ partyCountRoles(i.value) }}</span> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-block">
|
||||
<span class="config-label">我方角色</span>
|
||||
<el-radio-group v-model="form.our_role">
|
||||
<el-radio-button v-for="i in roleOptionsForCount" :key="i.value" :value="i.value">
|
||||
{{ i.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
<div class="parties-grid">
|
||||
<div v-for="role in activeRoles" :key="role.key">
|
||||
<PartySelect
|
||||
:model-value="getParty(role.key)"
|
||||
:role-label="role.label"
|
||||
:locked="isOwnParty(role.key)"
|
||||
:allow-choose-ref-type="!isOwnParty(role.key)"
|
||||
@create="openQuickCreate(role.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nature-tip">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>
|
||||
我方({{ tenantName }})已自动填入{{ ownPartyRoleLabel }};其余各方从客户库或供应商库中搜索关联,也可点击 + 快速新建。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 项目关联 -->
|
||||
<el-divider content-position="left">
|
||||
项目关联
|
||||
<el-tag v-if="form.project_id" size="small" type="primary" effect="light" style="margin-left: 8px">
|
||||
项目合同
|
||||
</el-tag>
|
||||
<el-tag v-else size="small" type="warning" effect="light" style="margin-left: 8px">
|
||||
无头合同
|
||||
</el-tag>
|
||||
</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="关联项目" prop="project_id">
|
||||
<el-select
|
||||
v-model="form.project_id"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
reserve-keyword
|
||||
:loading="projectSearching"
|
||||
:remote-method="searchProjects"
|
||||
placeholder="搜索项目名称,留空则为无头合同"
|
||||
style="width: 100%"
|
||||
@change="handleProjectChange"
|
||||
@visible-change="handleProjectDropdown"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in projectOptions"
|
||||
:key="item.id"
|
||||
:label="item.project_name"
|
||||
:value="item.id"
|
||||
>
|
||||
<span class="option-name">{{ item.project_name }}</span>
|
||||
<span class="option-sub">{{ item.project_no }}{{ item.customer_name ? ` / ${item.customer_name}` : "" }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</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>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="合同名称" prop="contract_name">
|
||||
<el-input v-model="form.contract_name" placeholder="请输入合同名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="合同编号" prop="contract_no">
|
||||
<el-input v-model="form.contract_no" placeholder="保存时自动生成" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="合同分类" prop="contract_category">
|
||||
<el-select v-model="form.contract_category" placeholder="请选择" style="width: 100%">
|
||||
<el-option
|
||||
v-for="i in CONTRACT_CATEGORY_OPTIONS"
|
||||
:key="i.value"
|
||||
:label="i.label"
|
||||
:value="i.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="签订日期" required>
|
||||
<el-row :gutter="12" style="width: 100%">
|
||||
<el-col :span="8">
|
||||
<el-form-item prop="sign_date" style="margin-bottom: 0">
|
||||
<el-date-picker
|
||||
v-model="form.sign_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="签订日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item prop="effective_date" style="margin-bottom: 0">
|
||||
<el-date-picker
|
||||
v-model="form.effective_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="生效日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item prop="expire_date" style="margin-bottom: 0">
|
||||
<el-date-picker
|
||||
v-model="form.expire_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="结束日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="备注" prop="remark" style="margin-top: 16px">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="合同补充说明(选填)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 2:产品清单 -->
|
||||
<div v-show="activeStep === 1">
|
||||
<ProductList :products="products" />
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="footer-left">
|
||||
<el-button v-if="activeStep > 0" @click="prevStep">上一步</el-button>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button :loading="saving" @click="handleSaveProgress">
|
||||
保存{{ isLastStep ? "" : ",稍后继续" }}
|
||||
</el-button>
|
||||
<el-button v-if="!isLastStep" type="primary" :loading="saving" @click="nextStep">
|
||||
下一步
|
||||
</el-button>
|
||||
<el-button v-else type="primary" :loading="saving" @click="handleFinish">
|
||||
保存并关闭
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 快速新建客户 / 供应商 -->
|
||||
<QuickCreateParty
|
||||
v-model:visible="quickVisible"
|
||||
:ref-type="quickRefType"
|
||||
:preset-name="quickPresetName"
|
||||
@created="handleQuickCreated"
|
||||
/>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { InfoFilled } from "@element-plus/icons-vue";
|
||||
import { createContract, updateContract } from "@/api/crmContract";
|
||||
import { getProjectList } from "@/api/crmPipeline";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
CONTRACT_CATEGORY_OPTIONS,
|
||||
OUR_ROLE_OPTIONS,
|
||||
CONTRACT_PARTY_COUNT_OPTIONS,
|
||||
} from "../../dict";
|
||||
import PartySelect from "./PartySelect.vue";
|
||||
import QuickCreateParty from "./QuickCreateParty.vue";
|
||||
import ProductList from "./ProductList.vue";
|
||||
import { buildSummary, genContractNo, PARTY_ROLES } from "./utils";
|
||||
|
||||
const MAX_STEP = 1;
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
/** 编辑草稿 / 查看合同 */
|
||||
editData: { type: Object, default: null },
|
||||
/** 打开时定位的步骤(1=合同信息 2=产品清单) */
|
||||
initStep: { type: Number, default: 1 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible", "success"]);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const formRef = ref();
|
||||
const activeStep = ref(0);
|
||||
const saving = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const internalId = ref(null);
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (!isEdit.value) return "新建合同";
|
||||
return Number(form.status) === 1 ? "编辑合同(草稿)" : "编辑合同";
|
||||
});
|
||||
|
||||
const isLastStep = computed(() => activeStep.value >= MAX_STEP);
|
||||
|
||||
/* ------------------------------ 表单数据 ------------------------------ */
|
||||
|
||||
const emptyForm = () => ({
|
||||
contract_no: "",
|
||||
contract_name: "",
|
||||
contract_category: "",
|
||||
our_role: 2, // 我方角色:1甲方 2乙方 3丙方 4丁方,默认乙方
|
||||
party_count: 2, // 合同形式:2双方 3三方 4四方,默认双方
|
||||
project_id: null,
|
||||
project_name: "",
|
||||
owner_name: authStore.user?.name || "",
|
||||
sign_date: "",
|
||||
effective_date: "",
|
||||
expire_date: "",
|
||||
remark: "",
|
||||
status: 1,
|
||||
step: 1,
|
||||
});
|
||||
|
||||
const form = reactive(emptyForm());
|
||||
|
||||
/** 参与方(甲乙丙丁):ref_type 0=本公司 1=客户 2=供应商,非我方默认客户可切换 */
|
||||
const emptyParty = (role) => ({
|
||||
role,
|
||||
ref_type: 1,
|
||||
ref_id: null,
|
||||
ref_name: "",
|
||||
signer_name: "",
|
||||
signer_phone: "",
|
||||
});
|
||||
|
||||
const parties = ref([]);
|
||||
const products = ref([]);
|
||||
|
||||
/** 初始化各方:当前租户锁定占据我方角色一方,其余为待选参与方 */
|
||||
const initParties = () => {
|
||||
const count = Number(form.party_count) || 2;
|
||||
parties.value = PARTY_ROLES.slice(0, count).map((r) => emptyParty(r.key));
|
||||
};
|
||||
|
||||
/** 当前租户在合同中的角色:由「我方角色」决定(1甲方/2乙方/3丙方/4丁方) */
|
||||
const ownPartyRole = computed(() => {
|
||||
const idx = Math.min(Math.max(Number(form.our_role) || 2, 1), 4);
|
||||
return PARTY_ROLES[idx - 1].key;
|
||||
});
|
||||
|
||||
const ownPartyRoleLabel = computed(() => {
|
||||
const hit = PARTY_ROLES.find((r) => r.key === ownPartyRole.value);
|
||||
return hit?.label || "乙方";
|
||||
});
|
||||
|
||||
const tenantName = computed(() => authStore.user?.tenant_name || "本公司");
|
||||
|
||||
const isOwnParty = (role) => role === ownPartyRole.value;
|
||||
|
||||
const activeRoles = computed(() => PARTY_ROLES.slice(0, Number(form.party_count) || 2));
|
||||
|
||||
const getParty = (role) => {
|
||||
let hit = parties.value.find((p) => p.role === role);
|
||||
if (!hit) {
|
||||
hit = emptyParty(role);
|
||||
parties.value.push(hit);
|
||||
}
|
||||
return hit;
|
||||
};
|
||||
|
||||
/* ------------------------------ 校验规则 ------------------------------ */
|
||||
|
||||
const rules = {
|
||||
contract_name: [{ required: true, message: "请输入合同名称", trigger: "blur" }],
|
||||
contract_category: [{ required: true, message: "请选择合同分类", trigger: "change" }],
|
||||
sign_date: [{ required: true, message: "请选择签订日期", trigger: "change" }],
|
||||
};
|
||||
|
||||
/* ------------------------------ 项目搜索 ------------------------------ */
|
||||
|
||||
const projectSearching = ref(false);
|
||||
const projectOptions = ref([]);
|
||||
|
||||
const searchProjects = async (keyword) => {
|
||||
projectSearching.value = true;
|
||||
try {
|
||||
const res = await getProjectList({ keyword: keyword || "", page: 1, pageSize: 50 });
|
||||
projectOptions.value = res?.data?.list || [];
|
||||
} catch {
|
||||
projectOptions.value = [];
|
||||
} finally {
|
||||
projectSearching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectDropdown = (visible) => {
|
||||
if (visible && projectOptions.value.length === 0) searchProjects("");
|
||||
};
|
||||
|
||||
const handleProjectChange = (id) => {
|
||||
const hit = projectOptions.value.find((i) => String(i.id) === String(id));
|
||||
form.project_name = hit?.project_name || "";
|
||||
};
|
||||
|
||||
/* ------------------------------ 联动 ------------------------------ */
|
||||
|
||||
/** 我方角色切换:原我方还原为待选方(保留签约人),新我方写入当前租户,其余各方不受影响 */
|
||||
watch(
|
||||
() => form.our_role,
|
||||
(nv, ov) => {
|
||||
if (!props.visible) return;
|
||||
const roleKeyOf = (v) => PARTY_ROLES[Math.min(Math.max(Number(v) || 2, 1), 4) - 1].key;
|
||||
const prevKey = roleKeyOf(ov);
|
||||
const nextKey = roleKeyOf(nv);
|
||||
if (prevKey === nextKey) return;
|
||||
const prev = parties.value.find((p) => p.role === prevKey);
|
||||
if (prev) {
|
||||
const signerName = prev.signer_name;
|
||||
const signerPhone = prev.signer_phone;
|
||||
Object.assign(prev, emptyParty(prev.role), { signer_name: signerName, signer_phone: signerPhone });
|
||||
}
|
||||
applyOwnParty();
|
||||
}
|
||||
);
|
||||
|
||||
/** 合同形式文案:2双方 3三方 4四方 */
|
||||
const partyCountName = (v) => ({ 2: "双方", 3: "三方", 4: "四方" }[Number(v)] || "双方");
|
||||
|
||||
/** 形式对应的参与方示意:甲方 / 乙方(/ 丙方 / 丁方) */
|
||||
const partyCountRoles = (v) =>
|
||||
PARTY_ROLES.slice(0, Number(v) || 2)
|
||||
.map((r) => r.label)
|
||||
.join(" / ");
|
||||
|
||||
/** 我方角色候选随合同形式收敛:双方只能选甲乙,三方甲乙丙,四方全部 */
|
||||
const roleOptionsForCount = computed(() =>
|
||||
OUR_ROLE_OPTIONS.slice(0, Number(form.party_count) || 2)
|
||||
);
|
||||
|
||||
/** 切换合同形式:增删丙丁方保留已有内容;我方角色超出范围时收敛到乙方 */
|
||||
const setPartyCount = (v) => {
|
||||
form.party_count = Number(v) || 2;
|
||||
const max = Number(form.party_count);
|
||||
if (Number(form.our_role) > max) {
|
||||
form.our_role = 2;
|
||||
}
|
||||
parties.value = PARTY_ROLES.slice(0, max).map(
|
||||
(r) => parties.value.find((p) => p.role === r.key) || emptyParty(r.key)
|
||||
);
|
||||
applyOwnParty();
|
||||
};
|
||||
|
||||
/** 把当前租户写入本公司方 */
|
||||
const applyOwnParty = () => {
|
||||
const own = parties.value.find((p) => p.role === ownPartyRole.value);
|
||||
if (own) {
|
||||
own.ref_type = 0;
|
||||
own.ref_id = 0;
|
||||
own.ref_name = tenantName.value;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (!val) return;
|
||||
activeStep.value = Math.max(0, Math.min(MAX_STEP, (props.initStep || 1) - 1));
|
||||
internalId.value = null;
|
||||
products.value = [];
|
||||
|
||||
if (props.editData?.id) {
|
||||
isEdit.value = true;
|
||||
internalId.value = props.editData.id;
|
||||
Object.assign(form, emptyForm(), props.editData);
|
||||
// 类型归一:后端返回数字,统一转为对应类型确保回显匹配
|
||||
form.our_role = Number(props.editData.our_role) || 2;
|
||||
form.contract_category =
|
||||
props.editData.contract_category == null ? "" : String(props.editData.contract_category);
|
||||
form.party_count = Number(props.editData.party_count) || 2;
|
||||
form.status = Number(props.editData.status) || 1;
|
||||
form.step = Number(props.editData.step) || 1;
|
||||
form.project_id = props.editData.project_id || null;
|
||||
parties.value = (props.editData.parties || []).map((p) => ({ ...emptyParty(p.role), ...p }));
|
||||
products.value = (props.editData.products || []).map((row, i) => ({
|
||||
...row,
|
||||
__key: `row_edit_${i}_${Date.now()}`,
|
||||
}));
|
||||
if (form.project_id && !projectOptions.value.some((i) => String(i.id) === String(form.project_id))) {
|
||||
projectOptions.value = [
|
||||
{ id: form.project_id, project_name: form.project_name || `项目 #${form.project_id}` },
|
||||
];
|
||||
}
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
Object.assign(form, emptyForm());
|
||||
form.contract_no = genContractNo();
|
||||
projectOptions.value = [];
|
||||
}
|
||||
|
||||
// 兜底补齐参与方并写入本公司方
|
||||
const count = Number(form.party_count) || 2;
|
||||
parties.value = PARTY_ROLES.slice(0, count).map(
|
||||
(r) => parties.value.find((p) => p.role === r.key) || emptyParty(r.key)
|
||||
);
|
||||
applyOwnParty();
|
||||
}
|
||||
);
|
||||
|
||||
/* ------------------------------ 快速创建 ------------------------------ */
|
||||
|
||||
const quickVisible = ref(false);
|
||||
const quickRefType = ref(1);
|
||||
const quickPresetName = ref("");
|
||||
const quickTargetRole = ref("");
|
||||
|
||||
const openQuickCreate = (role) => {
|
||||
const party = getParty(role);
|
||||
quickTargetRole.value = role;
|
||||
quickRefType.value = Number(party.ref_type) === 2 ? 2 : 1;
|
||||
quickPresetName.value = "";
|
||||
quickVisible.value = true;
|
||||
};
|
||||
|
||||
const handleQuickCreated = (row) => {
|
||||
const party = getParty(quickTargetRole.value);
|
||||
if (!row?.id) return;
|
||||
party.ref_type = quickRefType.value;
|
||||
party.ref_id = row.id;
|
||||
party.ref_name = row.label || party.ref_name || "";
|
||||
// 补充签约人默认信息
|
||||
if (!party.signer_name && row.contact_person) party.signer_name = row.contact_person;
|
||||
if (!party.signer_phone && row.contact_phone) party.signer_phone = row.contact_phone;
|
||||
};
|
||||
|
||||
/* ------------------------------ 步骤与保存 ------------------------------ */
|
||||
|
||||
const prevStep = () => {
|
||||
if (activeStep.value > 0) activeStep.value--;
|
||||
};
|
||||
|
||||
/** 组装保存 payload;数值字段统一转数字(后端 int8 解析),summary 由产品清单实时计算 */
|
||||
const buildPayload = (step, status) => ({
|
||||
...form,
|
||||
our_role: Number(form.our_role) || 2,
|
||||
party_count: Number(form.party_count) || 2,
|
||||
parties: parties.value.map((p) => ({ ...p })),
|
||||
products: products.value.map(({ __key, ...rest }) => ({
|
||||
...rest,
|
||||
quantity: Number(rest.quantity) || 0,
|
||||
price: Number(rest.price) || 0,
|
||||
cost_price: Number(rest.cost_price) || 0,
|
||||
})),
|
||||
summary: buildSummary(products.value),
|
||||
step,
|
||||
status,
|
||||
});
|
||||
|
||||
/** 进度式保存核心:草稿只校验合同名称;完成时全量校验 */
|
||||
const saveContract = async ({ finish = false }) => {
|
||||
if (!form.contract_name || !String(form.contract_name).trim()) {
|
||||
ElMessage.warning("请先填写合同名称");
|
||||
activeStep.value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (finish) {
|
||||
if (!formRef.value) return false;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
activeStep.value = 0;
|
||||
ElMessage.warning("请先补全合同信息必填项");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const step = finish ? 2 : Math.max(1, activeStep.value + 1);
|
||||
// 向导保存一律为草稿,签订 / 履约等状态在列表「状态」中流转
|
||||
const status = 1;
|
||||
const payload = buildPayload(step, status);
|
||||
|
||||
if (internalId.value) {
|
||||
await updateContract(internalId.value, payload);
|
||||
} else {
|
||||
const res = await createContract(payload);
|
||||
const newId = res?.data?.id ?? res?.data?.contract_id;
|
||||
if (newId) {
|
||||
internalId.value = newId;
|
||||
isEdit.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (finish) {
|
||||
ElMessage.success("合同已保存,可在列表中流转状态");
|
||||
emit("success");
|
||||
handleClose();
|
||||
} else {
|
||||
form.step = step;
|
||||
form.status = status;
|
||||
ElMessage.success(isEdit.value && internalId.value ? "进度已保存" : "草稿已保存,可继续填写");
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "保存失败");
|
||||
return false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 下一步:先自动保存当前进度,成功后前进 */
|
||||
const nextStep = async () => {
|
||||
const ok = await saveContract({ finish: false });
|
||||
if (ok && activeStep.value < MAX_STEP) activeStep.value++;
|
||||
};
|
||||
|
||||
/** 手动保存当前进度(不关窗,随时可存) */
|
||||
const handleSaveProgress = async () => {
|
||||
await saveContract({ finish: false });
|
||||
};
|
||||
|
||||
/** 保存并完成 */
|
||||
const handleFinish = async () => {
|
||||
await saveContract({ finish: true });
|
||||
};
|
||||
|
||||
const handleClose = () => emit("update:visible", false);
|
||||
|
||||
const handleClosed = () => {
|
||||
formRef.value?.resetFields();
|
||||
activeStep.value = 0;
|
||||
internalId.value = null;
|
||||
parties.value = [];
|
||||
products.value = [];
|
||||
Object.assign(form, emptyForm());
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.steps-wrapper {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.footer-left,
|
||||
.footer-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.parties-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
/* 合同参与方配置面板:合同形式 + 我方角色 */
|
||||
.party-config {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 56px;
|
||||
margin: 0 16px 16px;
|
||||
padding: 14px 20px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
|
||||
.config-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-cards {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
width: 78px;
|
||||
padding: 7px 0 6px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--el-bg-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: 0 1px 4px rgba(64, 158, 255, 0.25);
|
||||
|
||||
.form-card-name {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.form-card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.form-card-roles {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.config-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.nature-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 12px 16px 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-color-info-light-9);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.option-name {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.option-sub {
|
||||
float: right;
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="visible"
|
||||
title="合同详情"
|
||||
size="680px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
<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 {
|
||||
formatMoney,
|
||||
formatDateOnly,
|
||||
contractCategoryText,
|
||||
ourRoleText,
|
||||
contractStatusText,
|
||||
contractStatusTag,
|
||||
productCategoryText,
|
||||
productCategoryTag,
|
||||
} from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
/** 行数据(含 id),详情从接口拉取完整数据 */
|
||||
contract: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible"]);
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = ref({});
|
||||
|
||||
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 || "-";
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (val) => {
|
||||
if (!val) return;
|
||||
detail.value = {};
|
||||
if (!props.contract?.id) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getContractDetail(props.contract.id);
|
||||
detail.value = res?.data || props.contract;
|
||||
} catch {
|
||||
detail.value = props.contract;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.contract-detail {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
.detail-title {
|
||||
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;
|
||||
|
||||
.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,62 @@
|
||||
/**
|
||||
* 合同组件工具:金额汇总计算(产品清单 → 各部分金额 / 总金额 / 总成本 / 总利润)
|
||||
*
|
||||
* 金额归属:
|
||||
* - 硬件部分 = Σ 产品类别为「硬件(货物)」的小计
|
||||
* - 软件部分 = Σ 产品类别为「软件(许可)」的小计
|
||||
* - 其他部分 = Σ 服务 / 开发 / 其他类小计
|
||||
* - 合同总金额 = 硬件 + 软件 + 其他
|
||||
* - 产品总成本 = Σ (数量 × 成本单价)
|
||||
* - 合同总利润 = 合同总金额 - 产品总成本
|
||||
*/
|
||||
|
||||
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
|
||||
|
||||
export function buildSummary(products) {
|
||||
const rows = Array.isArray(products) ? products : [];
|
||||
let hardware = 0;
|
||||
let software = 0;
|
||||
let other = 0;
|
||||
let totalCost = 0;
|
||||
rows.forEach((row) => {
|
||||
const qty = Number(row?.quantity) || 0;
|
||||
const price = Number(row?.price) || 0;
|
||||
const costPrice = Number(row?.cost_price) || 0;
|
||||
const amount = round2(qty * price);
|
||||
totalCost += round2(qty * costPrice);
|
||||
const cat = String(row?.category || "");
|
||||
if (cat === "1") hardware += amount;
|
||||
else if (cat === "2") software += amount;
|
||||
else other += amount;
|
||||
});
|
||||
hardware = round2(hardware);
|
||||
software = round2(software);
|
||||
other = round2(other);
|
||||
const totalAmount = round2(hardware + software + other);
|
||||
return {
|
||||
hardware_amount: hardware,
|
||||
software_amount: software,
|
||||
other_amount: other,
|
||||
total_amount: totalAmount,
|
||||
total_cost: round2(totalCost),
|
||||
total_profit: round2(totalAmount - round2(totalCost)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成合同编号建议值:HT-YYYYMMDD-4位随机 */
|
||||
export function genContractNo() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const rand = String(Math.floor(Math.random() * 10000)).padStart(4, "0");
|
||||
return `HT-${y}${m}${day}-${rand}`;
|
||||
}
|
||||
|
||||
/** 甲乙丙丁角色 key,按 party_count 取前 n 个 */
|
||||
export const PARTY_ROLES = [
|
||||
{ key: "party_a", label: "甲方" },
|
||||
{ key: "party_b", label: "乙方" },
|
||||
{ key: "party_c", label: "丙方" },
|
||||
{ key: "party_d", label: "丁方" },
|
||||
];
|
||||
Reference in New Issue
Block a user