优化项目管理
This commit is contained in:
@@ -42,6 +42,9 @@ service.interceptors.response.use(
|
||||
},
|
||||
error => {
|
||||
if (error.response) {
|
||||
// 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作),
|
||||
// 避免页面只显示 "Request failed with status code xxx"
|
||||
const bizMsg = error.response.data?.msg || error.response.data?.message;
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
console.error('未授权,请重新登录');
|
||||
@@ -57,6 +60,9 @@ service.interceptors.response.use(
|
||||
default:
|
||||
console.error('请求失败,请稍后再试');
|
||||
}
|
||||
if (bizMsg) {
|
||||
return Promise.reject(new Error(bizMsg));
|
||||
}
|
||||
} else if (error.request) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
console.error('请求超时,请稍后重试');
|
||||
|
||||
@@ -56,6 +56,13 @@
|
||||
<el-input v-model="form.industry" placeholder="如:互联网、制造业" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="预计金额" prop="amount">
|
||||
<el-input v-model="form.amount" placeholder="请输入预计金额">
|
||||
<template #append>元</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="公司地址" prop="address">
|
||||
<el-input v-model="form.address" placeholder="请输入公司地址" maxlength="255" />
|
||||
@@ -142,6 +149,7 @@ const defaultForm = () => ({
|
||||
owner_user_id: "",
|
||||
owner_user_name: "",
|
||||
industry: "",
|
||||
amount: "",
|
||||
next_contact_time: "",
|
||||
clue_level: "2",
|
||||
contact_person: "",
|
||||
@@ -220,7 +228,7 @@ async function handleSubmit() {
|
||||
if (owner) form.owner_user_name = owner.name;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = { ...form };
|
||||
const payload = { ...form, amount: Number(form.amount) || 0 };
|
||||
if (internalId.value) {
|
||||
await updateClue(internalId.value, payload);
|
||||
ElMessage.success("更新成功");
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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>
|
||||
<span v-if="locked" class="party-company">{{ party?.ref_name || tenantName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 关联主体:当前租户方锁定,其余模糊搜索客户库 / 供应商库 -->
|
||||
@@ -62,13 +62,30 @@
|
||||
<!-- <div v-if="party.ref_name" class="picked-name">已关联:{{ party.ref_name }}</div> -->
|
||||
</template>
|
||||
|
||||
<!-- 签约人 -->
|
||||
<!-- 签约人:本公司方从组织架构人员中选择,其余方从关联企业联系人中选择,均自动带出联系电话;也支持手动输入 -->
|
||||
<div class="party-row signer-row">
|
||||
<el-input
|
||||
<el-select
|
||||
v-model="party.signer_name"
|
||||
placeholder="签约人姓名"
|
||||
maxlength="30"
|
||||
/>
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
:loading="signerLoading"
|
||||
:placeholder="contactPlaceholder"
|
||||
@change="handleSignerPick"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in contacts"
|
||||
:key="c.id"
|
||||
:label="c.contact_name"
|
||||
:value="c.contact_name"
|
||||
>
|
||||
<span class="option-name">{{ c.contact_name }}</span>
|
||||
<span class="option-sub">
|
||||
{{ [c.position || c.department, signerPhoneOf(c)].filter(Boolean).join(" · ") }}
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-input v-model="party.signer_phone" placeholder="联系电话" maxlength="20" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,6 +95,8 @@
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm";
|
||||
import { listContacts } from "@/api/contact";
|
||||
import { erpOrganizationApi } from "@/api/organization";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -102,6 +121,83 @@ const party = computed(() => props.modelValue);
|
||||
const searching = ref(false);
|
||||
const options = ref([]);
|
||||
|
||||
/* ------------------------------ 签约人:人员联动 ------------------------------ */
|
||||
|
||||
/** 是否为本公司方:锁定方,或 ref_type=0(本公司) */
|
||||
const isOwnCompany = computed(() => props.locked || Number(party.value?.ref_type) === 0);
|
||||
|
||||
const contacts = ref([]);
|
||||
const contactLoading = ref(false);
|
||||
/** 本公司人员:来自租户组织架构(与组织架构模块同源) */
|
||||
const ownEmployees = ref([]);
|
||||
const ownLoading = ref(false);
|
||||
|
||||
/** 签约人下拉加载态:本公司方取员工加载态,其余取联系人加载态 */
|
||||
const signerLoading = computed(() => (isOwnCompany.value ? ownLoading.value : contactLoading.value));
|
||||
|
||||
/** 联系人电话:手机优先,其次座机 */
|
||||
const signerPhoneOf = (c) => c?.mobile || c?.phone || "";
|
||||
|
||||
const contactPlaceholder = computed(() => {
|
||||
if (isOwnCompany.value) return "选择本公司人员(也可输入)";
|
||||
return party.value?.ref_id ? "选择签约人(也可输入)" : "请先选择客户 / 供应商";
|
||||
});
|
||||
|
||||
/** 拉取本公司人员(在职员工),映射为与联系人一致的字段结构,便于复用下拉渲染 */
|
||||
async function fetchOwnEmployees() {
|
||||
ownLoading.value = true;
|
||||
try {
|
||||
const res = await erpOrganizationApi.getEmployeeList({ status: 1 });
|
||||
const list = Array.isArray(res) ? res : res?.data || [];
|
||||
ownEmployees.value = (Array.isArray(list) ? list : []).map((e) => ({
|
||||
id: `emp_${e.id}`,
|
||||
contact_name: e.name || e.account || "",
|
||||
position: e.position || "",
|
||||
department: e.department_name || "",
|
||||
mobile: e.phone || "",
|
||||
phone: "",
|
||||
}));
|
||||
} catch {
|
||||
ownEmployees.value = [];
|
||||
} finally {
|
||||
ownLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取签约人候选:本公司方取组织架构人员,其余取关联企业联系人(客户 / 供应商) */
|
||||
async function fetchContacts() {
|
||||
if (isOwnCompany.value) {
|
||||
if (!ownEmployees.value.length) await fetchOwnEmployees();
|
||||
contacts.value = ownEmployees.value;
|
||||
return;
|
||||
}
|
||||
const refID = party.value?.ref_id;
|
||||
if (!refID) {
|
||||
contacts.value = [];
|
||||
return;
|
||||
}
|
||||
contactLoading.value = true;
|
||||
try {
|
||||
const res = await listContacts({
|
||||
related_type: Number(party.value.ref_type) === 2 ? 2 : 1,
|
||||
related_id: refID,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
contacts.value = res?.data?.list || [];
|
||||
} catch (e) {
|
||||
contacts.value = [];
|
||||
} finally {
|
||||
contactLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择签约人:自动带出该联系人的联系电话 */
|
||||
function handleSignerPick(name) {
|
||||
const hit = contacts.value.find((c) => c.contact_name === name);
|
||||
if (hit) party.value.signer_phone = signerPhoneOf(hit);
|
||||
}
|
||||
|
||||
const refTypeText = computed(() =>
|
||||
Number(party.value.ref_type) === 2 ? "供应商" : "客户"
|
||||
);
|
||||
@@ -121,7 +217,11 @@ const ensureOption = () => {
|
||||
|
||||
watch(
|
||||
() => party.value?.ref_id,
|
||||
() => ensureOption(),
|
||||
() => {
|
||||
ensureOption();
|
||||
// 关联企业变化时同步刷新其联系人,供签约人下拉选择
|
||||
fetchContacts();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
@@ -160,14 +260,20 @@ const handlePick = (id) => {
|
||||
const handleClear = () => {
|
||||
party.value.ref_id = null;
|
||||
party.value.ref_name = "";
|
||||
party.value.signer_name = "";
|
||||
party.value.signer_phone = "";
|
||||
options.value = [];
|
||||
contacts.value = [];
|
||||
};
|
||||
|
||||
/** 丙/丁方切换客户 / 供应商来源时清空已选 */
|
||||
const handleRefTypeChange = () => {
|
||||
party.value.ref_id = null;
|
||||
party.value.ref_name = "";
|
||||
party.value.signer_name = "";
|
||||
party.value.signer_phone = "";
|
||||
options.value = [];
|
||||
contacts.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -170,9 +170,9 @@
|
||||
|
||||
<div class="add-row">
|
||||
<el-button :icon="Plus" @click="addRow">添加产品</el-button>
|
||||
<span class="add-tip">
|
||||
<!-- <span class="add-tip">
|
||||
每行可选「成品」或「软件开发」:成品按数量×单价计价;软件开发点「模块报价」编辑分层模块树(叶子填人天/人天单价,父级自动汇总),统一计入合同总价
|
||||
</span>
|
||||
</span> -->
|
||||
</div>
|
||||
|
||||
<!-- 金额汇总 -->
|
||||
@@ -388,8 +388,9 @@ 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) {
|
||||
// 仅当产品行类型为「软件开发」时才带入模块树并切换为开发行。
|
||||
// 不能再用 dev_tree 兜底判断:成品可能残留 "[]" 之类的空值(字符串非空即 truthy),会被误判为开发行
|
||||
if (hit.line_type === "dev") {
|
||||
row.line_type = "dev";
|
||||
row.dev_unit_price = Number(hit.dev_unit_price) || 0;
|
||||
row.dev_tree = Array.isArray(hit.dev_tree)
|
||||
@@ -409,6 +410,14 @@ function onProductPick(row, val) {
|
||||
if (!row.unit) row.unit = "项";
|
||||
return;
|
||||
}
|
||||
// 命中成品:行类型切回成品并清空开发字段
|
||||
// (修正历史版本中被误判为「软件开发」的行:重选产品即可恢复)
|
||||
row.line_type = "product";
|
||||
row.dev_unit_price = 0;
|
||||
row.dev_tree = [];
|
||||
row.dev_total_price = 0;
|
||||
row.dev_auto_total = 0;
|
||||
if (hit.category) row.category = hit.category;
|
||||
row.spec = hit.spec || "";
|
||||
row.unit = hit.unit || "";
|
||||
row.price = hit.price || 0;
|
||||
|
||||
@@ -179,18 +179,6 @@
|
||||
</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">
|
||||
@@ -240,6 +228,7 @@ import { ElMessage } from "element-plus";
|
||||
import { InfoFilled } from "@element-plus/icons-vue";
|
||||
import { createContract, updateContract } from "@/api/crmContract";
|
||||
import { getProjectList } from "@/api/crmPipeline";
|
||||
import { erpOrganizationApi } from "@/api/organization";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
CONTRACT_CATEGORY_OPTIONS,
|
||||
@@ -294,7 +283,6 @@ const emptyForm = () => ({
|
||||
sign_date: "",
|
||||
effective_date: "",
|
||||
expire_date: "",
|
||||
tech_date: "",
|
||||
remark: "",
|
||||
status: 1,
|
||||
step: 1,
|
||||
@@ -346,6 +334,24 @@ const ownPartyRoleLabel = computed(() => {
|
||||
|
||||
const tenantName = computed(() => authStore.user?.tenant_name || "本公司");
|
||||
|
||||
/**
|
||||
* 本公司主体:取自租户「组织架构」中的企业单位(is_company=1,顶级组织优先),
|
||||
* 与组织架构模块同源;未配置或接口不可用时回退登录租户名,保证不出现空值。
|
||||
*/
|
||||
const ownCompany = ref(null);
|
||||
const ownCompanyName = computed(() => ownCompany.value?.org_name || tenantName.value);
|
||||
|
||||
/** 拉取组织架构中的企业单位;异常时清空,交由 tenantName 兜底 */
|
||||
const loadOwnCompany = async () => {
|
||||
try {
|
||||
const res = await erpOrganizationApi.getCompanys();
|
||||
const list = Array.isArray(res) ? res : res?.data || [];
|
||||
ownCompany.value = list.find((o) => Number(o.parent_id) === 0) || list[0] || null;
|
||||
} catch {
|
||||
ownCompany.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const isOwnParty = (role) => role === ownPartyRole.value;
|
||||
|
||||
const activeRoles = computed(() => PARTY_ROLES.slice(0, Number(form.party_count) || 2));
|
||||
@@ -454,19 +460,19 @@ const setPartyCount = (v) => {
|
||||
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;
|
||||
own.ref_name = ownCompanyName.value;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
async (val) => {
|
||||
if (!val) return;
|
||||
activeStep.value = Math.max(0, Math.min(MAX_STEP, (props.initStep || 1) - 1));
|
||||
internalId.value = null;
|
||||
@@ -487,7 +493,6 @@ 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,
|
||||
@@ -516,6 +521,8 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
// 先取组织架构中的企业单位,再写入本公司方(避免先用登录租户名填充、后被覆盖)
|
||||
await loadOwnCompany();
|
||||
// 兜底补齐参与方并写入本公司方
|
||||
const count = Number(form.party_count) || 2;
|
||||
parties.value = PARTY_ROLES.slice(0, count).map(
|
||||
@@ -576,7 +583,6 @@ 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 })),
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ref, reactive, watch, nextTick } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Edit, Delete, ArrowDown } from "@element-plus/icons-vue";
|
||||
import {
|
||||
@@ -127,9 +127,12 @@ const logRef = ref();
|
||||
const econtractRef = ref();
|
||||
const invoiceRef = ref();
|
||||
|
||||
// 同时监听展开状态与目标合同 id:
|
||||
// 页面被 keep-alive 缓存时,从其他入口再打开另一份合同时 visible 可能一直是 true(未变化),
|
||||
// 只监听 visible 会导致不重新加载、内容串成上一份合同。
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (val) => {
|
||||
[() => props.visible, () => props.contract?.id],
|
||||
async ([val]) => {
|
||||
if (!val) return;
|
||||
activeTab.value = "basic";
|
||||
counts.payback = 0;
|
||||
@@ -146,6 +149,9 @@ watch(
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// 抽屉已打开时不会再触发 @opened,这里主动刷新各 Tab 数据(回款 / 附件 / 日志)
|
||||
await nextTick();
|
||||
loadAll();
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
<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>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<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">
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
@@ -92,7 +92,23 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" size="small" @click="handleDeleteRecord(row)">删除</el-button>
|
||||
<el-tooltip
|
||||
:disabled="Number((planOf(row.payback_id) || {}).status) !== 2"
|
||||
content="所属回款计划已完成(回款已收齐),不允许删除回款记录"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="Number((planOf(row.payback_id) || {}).status) === 2"
|
||||
@click="handleDeleteRecord(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
@@ -420,8 +436,12 @@ async function submitReceive() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除一条回款记录(会同步回写计划的已回款金额) */
|
||||
/** 删除一条回款记录(会同步回写计划的已回款金额;所属计划已完成时禁止) */
|
||||
async function handleDeleteRecord(row) {
|
||||
if (Number((planOf(row.payback_id) || {}).status) === 2) {
|
||||
ElMessage.warning("所属回款计划已完成(回款已收齐),不允许删除回款记录");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除 ${row.received_date || "该"} 的这笔回款(¥${formatMoney(row.amount)})吗?`,
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:clearable="false"
|
||||
@change="loadStats"
|
||||
@change="onRangeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -184,12 +184,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ref, reactive, onMounted, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getContractList,
|
||||
getContractStats,
|
||||
getContractDetail,
|
||||
} from "@/api/crmContract";
|
||||
import ContractCreate from "./components/create.vue";
|
||||
import ContractDetail from "./components/detail.vue";
|
||||
@@ -207,6 +209,9 @@ import {
|
||||
formatMoney,
|
||||
} from "../dict";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const tableData = ref([]);
|
||||
const createVisible = ref(false);
|
||||
@@ -223,28 +228,33 @@ const filters = reactive({
|
||||
});
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
/** 统计周期:month/quarter/year/custom(自定义选区间) */
|
||||
const statsPeriod = ref("month");
|
||||
/** 统计周期:month/quarter/year/custom(自定义选区间),默认本年 */
|
||||
const statsPeriod = ref("year");
|
||||
const statsRange = ref([]);
|
||||
|
||||
/** 列表页统计:后端按周期汇总(排除已作废),周期参数透传给 Stats */
|
||||
const stats = ref({ total: 0, total_amount: 0, total_cost: 0, total_profit: 0 });
|
||||
|
||||
/** 周期参数:统计与列表共用,保证两者口径一致 */
|
||||
function periodParams() {
|
||||
const params = { period: statsPeriod.value };
|
||||
if (statsPeriod.value === "custom" && statsRange.value && statsRange.value.length === 2) {
|
||||
params.start_date = statsRange.value[0];
|
||||
params.end_date = statsRange.value[1];
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const params = { period: statsPeriod.value };
|
||||
if (statsPeriod.value === "custom" && statsRange.value && statsRange.value.length === 2) {
|
||||
params.start_date = statsRange.value[0];
|
||||
params.end_date = statsRange.value[1];
|
||||
}
|
||||
const res = await getContractStats(params);
|
||||
const res = await getContractStats(periodParams());
|
||||
stats.value = res?.data || stats.value;
|
||||
} catch {
|
||||
/* 统计失败不影响列表 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 切到「自定义」首次未选区间时,默认当前月,避免卡片空值 */
|
||||
/** 周期变化:列表与统计一并刷新(回到第一页) */
|
||||
function onPeriodChange(val) {
|
||||
if (val === "custom" && (!statsRange.value || statsRange.value.length !== 2)) {
|
||||
const now = new Date();
|
||||
@@ -253,6 +263,15 @@ function onPeriodChange(val) {
|
||||
const last = new Date(y, now.getMonth() + 1, 0).getDate();
|
||||
statsRange.value = [`${y}-${m}-01`, `${y}-${m}-${last}`];
|
||||
}
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
loadStats();
|
||||
}
|
||||
|
||||
/** 自定义区间变化:同样刷新列表与统计 */
|
||||
function onRangeChange() {
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
loadStats();
|
||||
}
|
||||
|
||||
@@ -261,6 +280,16 @@ onMounted(() => {
|
||||
loadStats();
|
||||
});
|
||||
|
||||
// 页面被 keep-alive 缓存后 onMounted 只执行一次,改为监听 ?id 变化,
|
||||
// 保证每次从项目详情「合同管理」等入口带 id 跳转进来都能打开目标合同。
|
||||
watch(
|
||||
() => route.query.id,
|
||||
(id) => {
|
||||
if (id) openByQueryId();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -268,6 +297,8 @@ async function fetchList() {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters,
|
||||
// 与顶部统计同口径:按所选周期筛选列表
|
||||
...periodParams(),
|
||||
});
|
||||
tableData.value = res?.data?.list || [];
|
||||
pagination.total = res?.data?.total || 0;
|
||||
@@ -281,6 +312,30 @@ async function fetchList() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 带 ?id= 时自动打开对应合同详情(供项目详情「合同管理」点击合同名称跳转)。
|
||||
* 打开后清除 query,避免刷新或返回本页时反复弹出。
|
||||
*/
|
||||
async function openByQueryId() {
|
||||
const id = route.query.id;
|
||||
if (!id) return;
|
||||
// 抽屉可能因 keep-alive 缓存仍处于打开状态,先收起再打开,确保按新合同重新加载
|
||||
detailVisible.value = false;
|
||||
await nextTick();
|
||||
try {
|
||||
const res = await getContractDetail(id);
|
||||
const row = res?.data;
|
||||
if (!row?.id) throw new Error("合同不存在或已被删除");
|
||||
openDetail(row);
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "打开合同详情失败");
|
||||
} finally {
|
||||
const query = { ...route.query };
|
||||
delete query.id;
|
||||
router.replace({ path: route.path, query });
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
|
||||
@@ -241,15 +241,16 @@ const rangeText = computed(() => {
|
||||
|
||||
/* ------------------------------ 统计卡片 ------------------------------ */
|
||||
const statMeta: StatMeta[] = [
|
||||
{ key: "newCustomer", label: "新增客户", type: "count", icon: markRaw(UserFilled) },
|
||||
{ key: "newContact", label: "新增联系人", type: "count", icon: markRaw(Postcard) },
|
||||
{ key: "newProject", label: "新增项目", type: "count", icon: markRaw(FolderOpened) },
|
||||
{ key: "newContract", label: "新增合同", type: "count", icon: markRaw(Tickets) },
|
||||
// 展示顺序:线索 → 商机 → 项目 → 合同 → 客户/联系人 → 回款 → 回访
|
||||
{ key: "newClue", label: "新增线索", type: "count", icon: markRaw(Aim) },
|
||||
{ key: "newChance", label: "新增商机", type: "count", icon: markRaw(TrendCharts) },
|
||||
{ key: "projectAmount", label: "项目金额", type: "amount", icon: markRaw(Files) },
|
||||
{ key: "contractAmount", label: "合同金额", type: "amount", icon: markRaw(Document) },
|
||||
{ key: "newProject", label: "新增项目", type: "count", icon: markRaw(FolderOpened) },
|
||||
{ key: "newContract", label: "新增合同", type: "count", icon: markRaw(Tickets) },
|
||||
{ key: "newCustomer", label: "新增客户", type: "count", icon: markRaw(UserFilled) },
|
||||
{ key: "newContact", label: "新增联系人", type: "count", icon: markRaw(Postcard) },
|
||||
{ key: "clueAmount", label: "线索金额", type: "amount", icon: markRaw(Money) },
|
||||
{ key: "chanceAmount", label: "商机金额", type: "amount", icon: markRaw(Money) },
|
||||
{ key: "projectAmount", label: "项目金额", type: "amount", icon: markRaw(Files) },
|
||||
{ key: "paymentAmount", label: "回款金额", type: "amount", icon: markRaw(Wallet) },
|
||||
{ key: "newVisit", label: "新增回访", type: "count", icon: markRaw(Phone) },
|
||||
];
|
||||
@@ -337,6 +338,7 @@ const renderTrendChart = () => {
|
||||
legend: {
|
||||
data: ["新增客户", "新增商机"],
|
||||
right: 0,
|
||||
top: 0,
|
||||
icon: "roundRect",
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
@@ -460,6 +462,7 @@ const renderAmountChart = () => {
|
||||
legend: {
|
||||
data: ["合同金额", "回款金额"],
|
||||
right: 0,
|
||||
top: 0,
|
||||
icon: "roundRect",
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
@@ -682,10 +685,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
/* 统计卡片:固定一行 6 个 */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(212px, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@@ -693,8 +696,8 @@ onBeforeUnmount(() => {
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
gap: 10px;
|
||||
padding: 14px 12px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -290,7 +290,8 @@ export function overdueText(days) {
|
||||
|
||||
/** 项目逾期信息:{ overdue, days } */
|
||||
export function projectOverdue(row) {
|
||||
const days = overdueDays(row?.end_date);
|
||||
// 项目起止时间以关联合同汇总为准(contract_end_date),兼容历史的手工日期 end_date
|
||||
const days = overdueDays(row?.contract_end_date || row?.end_date);
|
||||
const overdue = days > 0 && Number(row?.status) !== 3;
|
||||
return { overdue, days: overdue ? days : 0 };
|
||||
}
|
||||
|
||||
@@ -16,7 +16,24 @@
|
||||
|
||||
|
||||
<el-button type="primary" link :icon="Edit" @click="emit('edit', detail)">编辑</el-button>
|
||||
<el-button type="danger" link :icon="Delete" @click="handleDelete" style="margin-left: 0px">删除</el-button>
|
||||
<el-tooltip
|
||||
:disabled="Number(detail.status) !== 2"
|
||||
content="该回款计划已完成(回款已收齐),不允许删除"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
:icon="Delete"
|
||||
:disabled="Number(detail.status) === 2"
|
||||
@click="handleDelete"
|
||||
style="margin-left: 0px"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border class="desc-block">
|
||||
@@ -78,7 +95,23 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" size="small" @click="handleDeleteRecord(row)">删除</el-button>
|
||||
<el-tooltip
|
||||
:disabled="Number(detail.status) !== 2"
|
||||
content="该回款计划已完成(回款已收齐),不允许删除回款记录"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="Number(detail.status) === 2"
|
||||
@click="handleDeleteRecord(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
@@ -320,6 +353,10 @@ async function submitRecord() {
|
||||
}
|
||||
|
||||
async function handleDeleteRecord(row) {
|
||||
if (Number(detail.value.status) === 2) {
|
||||
ElMessage.warning("该回款计划已完成(回款已收齐),不允许删除回款记录");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除 ${row.received_date || "该"} 的这笔回款(¥${formatMoney(row.amount)})吗?`,
|
||||
@@ -337,6 +374,10 @@ async function handleDeleteRecord(row) {
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (Number(detail.value.status) === 2) {
|
||||
ElMessage.warning("该回款计划已完成(回款已收齐),不允许删除");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除合同「${detail.value.contract_name}」的回款计划吗?删除后不可恢复。`,
|
||||
|
||||
@@ -60,61 +60,127 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container" v-loading="loading">
|
||||
<el-table :data="tableData" stripe border row-key="id">
|
||||
<el-table-column label="状态" width="90" align="center" fixed>
|
||||
<el-table
|
||||
:data="tableData"
|
||||
stripe
|
||||
border
|
||||
row-key="__rowKey"
|
||||
:tree-props="{ children: 'children' }"
|
||||
>
|
||||
<!-- 展开 / 折叠 独立成列(el-table 树形图标固定渲染在第一列),避免与「状态」挤在同一格 -->
|
||||
<el-table-column width="46" align="center" />
|
||||
<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="120" align="center" fixed>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="paybackPlanOverdue(row).overdue" type="danger" size="small" effect="dark">
|
||||
{{ overdueText(paybackPlanOverdue(row).days) }}
|
||||
<el-tag v-if="row.__isPlan" :type="paybackStatusTag(row.status)" size="small">
|
||||
{{ paybackStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
<el-tag v-else size="small" effect="plain" type="success">回款</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="合同名称" min-width="180" show-overflow-tooltip fixed>
|
||||
<el-table-column label="逾期" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="name-link" @click="openDetail(row)">{{ row.contract_name || "-" }}</span>
|
||||
<template v-if="row.__isPlan">
|
||||
<el-tag v-if="paybackPlanOverdue(row).overdue" type="danger" size="small" effect="dark">
|
||||
{{ overdueText(paybackPlanOverdue(row).days) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户名称" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.customer_name || "-" }}</template>
|
||||
<el-table-column label="合同名称 / 回款记录" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.__isPlan" class="name-link" @click="openDetail(row)">{{ row.contract_name || "-" }}</span>
|
||||
<span v-else class="record-name">
|
||||
{{ row.received_date || "-" }} 回款
|
||||
<span v-if="row.remark" class="record-remark">({{ row.remark }})</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回款进度" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-progress v-if="row.__isPlan" :percentage="paybackPercent(row)" :stroke-width="10" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="合同总金额" width="130" align="right">
|
||||
<template #default="{ row }">{{ row.__isPlan ? formatMoney(row.contract_amount) : "" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已回款" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.__isPlan">{{ formatMoney(row.received_amount) }}</span>
|
||||
<span v-else class="record-amount">¥{{ formatMoney(row.amount) }}</span>
|
||||
</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>
|
||||
<el-tag v-if="row.__isPlan" size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回款方式" width="110" align="center">
|
||||
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="合同总金额" width="130" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.contract_amount) }}</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="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
|
||||
</template>
|
||||
<el-table-column label="客户名称" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.__isPlan ? row.customer_name || "-" : "" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" width="90">
|
||||
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
|
||||
<template #default="{ row }">{{ row.__isPlan ? row.owner_user_name || "-" : "" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提前提醒" width="90" align="center">
|
||||
<template #default="{ row }">{{ row.remind_days ? `${row.remind_days}天` : "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center" class-name="op-col" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleRowDelete(row)">删除</el-button>
|
||||
{{ row.__isPlan && row.remind_days ? `${row.remind_days}天` : "" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" align="center" class-name="op-col" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.__isPlan">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-tooltip
|
||||
:disabled="Number(row.status) !== 2"
|
||||
content="该回款计划已完成(回款已收齐),不允许删除"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="Number(row.status) === 2"
|
||||
@click="handleRowDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-link
|
||||
v-if="row.receipt_url"
|
||||
type="primary"
|
||||
:href="row.receipt_url"
|
||||
target="_blank"
|
||||
:underline="false"
|
||||
style="margin-right: 8px"
|
||||
>
|
||||
回执
|
||||
</el-link>
|
||||
<el-tooltip
|
||||
:disabled="Number(row.__planStatus) !== 2"
|
||||
content="所属回款计划已完成(回款已收齐),不允许删除回款记录"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="Number(row.__planStatus) === 2"
|
||||
@click="handleDeleteRecord(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无回款计划" :image-size="80" /></template>
|
||||
@@ -151,7 +217,7 @@
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||
import { getPaybackList, deletePayback } from "@/api/crmPayback";
|
||||
import { getPaybackList, deletePayback, deletePaybackRecord } from "@/api/crmPayback";
|
||||
import PaybackCreate from "./components/create.vue";
|
||||
import PaybackDetail from "./components/detail.vue";
|
||||
import {
|
||||
@@ -184,7 +250,19 @@ async function fetchList() {
|
||||
pageSize: pagination.pageSize,
|
||||
...filters,
|
||||
});
|
||||
tableData.value = res?.data?.list || [];
|
||||
// 树形结构:回款计划为父行,其回款记录作为子行。
|
||||
// 计划与记录来自不同表,id 会重复,故用 __rowKey 保证树形 row-key 唯一。
|
||||
tableData.value = (res?.data?.list || []).map((p) => ({
|
||||
...p,
|
||||
__isPlan: true,
|
||||
__rowKey: `plan_${p.id}`,
|
||||
children: (p.children || []).map((r) => ({
|
||||
...r,
|
||||
__isPlan: false,
|
||||
__planStatus: p.status, // 带上所属计划状态,用于禁止删除已完成计划的回款记录
|
||||
__rowKey: `rec_${r.id}`,
|
||||
})),
|
||||
}));
|
||||
pagination.total = res?.data?.total || 0;
|
||||
summary.value = res?.data?.summary || { total_amount: 0, received_amount: 0, pending_amount: 0 };
|
||||
} catch (e) {
|
||||
@@ -244,8 +322,12 @@ function handleDetailDelete() {
|
||||
fetchList();
|
||||
}
|
||||
|
||||
/** 列表行删除:确认后删除并刷新 */
|
||||
/** 列表行删除:确认后删除并刷新(已完成计划不允许删除) */
|
||||
async function handleRowDelete(row) {
|
||||
if (Number(row.status) === 2) {
|
||||
ElMessage.warning("该回款计划已完成(回款已收齐),不允许删除");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除合同「${row.contract_name}」的回款计划吗?删除后不可恢复。`,
|
||||
@@ -260,6 +342,26 @@ async function handleRowDelete(row) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除子行的回款记录(同步回写计划已回款金额;所属计划已完成时禁止) */
|
||||
async function handleDeleteRecord(row) {
|
||||
if (Number(row.__planStatus) === 2) {
|
||||
ElMessage.warning("所属回款计划已完成(回款已收齐),不允许删除回款记录");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除 ${row.received_date || "该"} 的这笔回款(¥${formatMoney(row.amount)})吗?`,
|
||||
"删除确认",
|
||||
{ type: "warning" }
|
||||
);
|
||||
await deletePaybackRecord(row.id);
|
||||
ElMessage.success("删除成功");
|
||||
fetchList();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchList);
|
||||
</script>
|
||||
|
||||
@@ -306,6 +408,22 @@ onMounted(fetchList);
|
||||
}
|
||||
}
|
||||
|
||||
/* 树形子行(回款记录) */
|
||||
.record-name {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
.record-remark {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.record-amount {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
::deep(.el-table .op-col .cell) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -161,11 +161,14 @@ const rules = {
|
||||
product_name: [{ required: true, message: "请输入产品名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
/** 切换行类型:软件开发确保有模块树容器 */
|
||||
/** 切换行类型:开发行确保有模块树容器;切回成品时清空开发字段,避免残留脏数据 */
|
||||
function onLineTypeChange() {
|
||||
if (form.line_type === "dev") {
|
||||
if (!Array.isArray(form.dev_tree)) form.dev_tree = [];
|
||||
if (form.dev_unit_price == null) form.dev_unit_price = 0;
|
||||
} else {
|
||||
form.dev_tree = [];
|
||||
form.dev_unit_price = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,71 @@
|
||||
<template>
|
||||
<el-descriptions v-if="project" :column="2" border size="small">
|
||||
<el-descriptions-item label="项目名称" :span="2">{{ project.project_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目编号">{{ project.project_no || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户名称">{{ project.customer_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目金额">{{ formatMoney(project.amount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目状态">
|
||||
<el-tag :type="projectStatusTag(project.status)" size="small">
|
||||
{{ projectStatusText(project.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">{{ project.owner_user_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户行业">{{ project.industry || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开始日期">{{ formatDateOnly(project.start_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束日期">{{ formatDateOnly(project.end_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="来源商机">
|
||||
{{ project.business_name || (project.business_id ? `#${project.business_id}` : "-") }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人">{{ project.contact_person || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人职位">{{ project.contact_position || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="对接人手机">{{ project.contact_phone || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目地址" :span="2">{{ project.address || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(project.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(project.update_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ project.remark || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div v-if="project" class="project-basic">
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="项目名称" :span="2">{{ project.project_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目编号">{{ project.project_no || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目类型">
|
||||
<el-tag :type="isBackToBack ? 'warning' : 'info'" size="small" effect="plain">
|
||||
{{ isBackToBack ? "上下游项目" : "单一项目" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="项目状态">
|
||||
<el-tag :type="projectStatusTag(project.status)" size="small">
|
||||
{{ projectStatusText(project.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">{{ project.owner_user_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="isBackToBack" label="项目毛利">
|
||||
<span class="profit">¥{{ formatMoney(profitAmount) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="项目起止时间" :span="2">
|
||||
{{ formatDateOnly(contractStart) }} ~ {{ formatDateOnly(contractEnd) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="来源商机">
|
||||
{{ project.business_name || (project.business_id ? `#${project.business_id}` : "-") }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(project.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(project.update_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目地址" :span="2">{{ project.address || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ project.remark || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 单一项目不存在上下游概念,只展示客户信息;上下游项目则区分上游(客户侧) -->
|
||||
<el-divider content-position="left">{{ isBackToBack ? "上游信息" : "客户信息" }}</el-divider>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="客户名称">{{ project.customer_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="isBackToBack ? '上游金额' : '项目金额'">
|
||||
{{ formatMoney(upstreamAmount) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="isBackToBack ? '上游对接人' : '对接人'">
|
||||
{{ project.upstream_contact || project.contact_person || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="isBackToBack ? '上游联系电话' : '联系电话'">
|
||||
{{ project.upstream_phone || project.contact_phone || "-" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 下游(供应商侧):仅上下游项目展示 -->
|
||||
<template v-if="isBackToBack">
|
||||
<el-divider content-position="left">下游信息</el-divider>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="供应商/客户">
|
||||
{{ project.supplier_name || "-" }}
|
||||
<el-tag v-if="Number(project.supplier_ref_type) === 1" size="small" effect="plain" type="success">客户</el-tag>
|
||||
<el-tag v-else-if="Number(project.supplier_ref_type) === 2" size="small" effect="plain" type="warning">
|
||||
供应商
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="下游金额">{{ formatMoney(project.downstream_amount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下游对接人">{{ project.downstream_contact || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下游联系电话">{{ project.downstream_phone || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
projectStatusText,
|
||||
projectStatusTag,
|
||||
@@ -35,8 +74,46 @@ import {
|
||||
formatDateOnly,
|
||||
} from "../../dict";
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
// 项目详情对象
|
||||
project: { type: Object, default: null },
|
||||
});
|
||||
|
||||
/** 是否背靠背项目:需要展示下游(供应商)信息 */
|
||||
const isBackToBack = computed(() => Number(props.project?.project_type) === 2);
|
||||
|
||||
/** 上游金额:新字段优先,兼容存量项目仅有旧「项目金额」的情况 */
|
||||
const upstreamAmount = computed(
|
||||
() => Number(props.project?.upstream_amount) || Number(props.project?.amount) || 0
|
||||
);
|
||||
|
||||
/** 项目毛利:上游金额 − 下游金额 */
|
||||
const profitAmount = computed(
|
||||
() => upstreamAmount.value - (Number(props.project?.downstream_amount) || 0)
|
||||
);
|
||||
|
||||
/** 项目起止时间:由关联合同汇总(最早开始 / 最晚结束),兼容历史手工日期 */
|
||||
const contractStart = computed(() => props.project?.contract_start_date || props.project?.start_date || "");
|
||||
const contractEnd = computed(() => props.project?.contract_end_date || props.project?.end_date || "");
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.project-basic {
|
||||
padding-top:20px;
|
||||
/* 首段标题紧贴容器顶部,避免多余留白 */
|
||||
:deep(.el-divider:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.profit {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
<span class="tab-name">{{ c.contract_name }}</span>
|
||||
<el-tag :type="contractStatusTag(c.status)" size="small" effect="plain">
|
||||
{{ contractStatusText(c.status) }}
|
||||
<el-tag :type="contractSideOf(c) === 'down' ? 'warning' : 'success'" size="small" effect="plain">
|
||||
{{ contractSideOf(c) === "down" ? "下游" : "上游" }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
@@ -25,12 +25,15 @@
|
||||
<div class="contract-pane">
|
||||
<div class="pane-head">
|
||||
<div class="pane-title">
|
||||
<span class="name">{{ c.contract_name }}</span>
|
||||
<!-- <el-tag :type="contractStatusTag(c.status)" size="small">
|
||||
{{ contractStatusText(c.status) }}
|
||||
</el-tag> -->
|
||||
<el-tag type="primary" size="small" effect="plain">
|
||||
{{ ourRoleText(c.our_role) }}
|
||||
<span
|
||||
class="name link"
|
||||
title="前往合同管理查看该合同完整详情(回款计划、电子合同、发票等)"
|
||||
@click="goContract(c)"
|
||||
>
|
||||
{{ c.contract_name }}
|
||||
</span>
|
||||
<el-tag :type="contractSideOf(c) === 'down' ? 'warning' : 'success'" size="small" effect="plain">
|
||||
{{ contractSideOf(c) === "down" ? "下游" : "上游" }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<!-- <div class="pane-actions">
|
||||
@@ -38,11 +41,10 @@
|
||||
<el-button link type="danger" size="small" @click="handleDelete(c)">删除</el-button>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="pane-sub">
|
||||
{{ c.contract_no || "-" }} · {{ contractCategoryText(c.contract_category) }}
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left">基础信息</el-divider>
|
||||
<el-descriptions :column="2" border style="margin-top: 12px">
|
||||
<el-descriptions-item label="合同编号">{{ c.contract_no || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="合同类型">{{ contractCategoryText(c.contract_category) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="签订日期">{{ formatDateOnly(c.sign_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">{{ c.owner_user_name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生效日期">{{ formatDateOnly(c.effective_date) }}</el-descriptions-item>
|
||||
@@ -154,15 +156,14 @@ import { ref, computed } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { getContractList, deleteContract } from "@/api/crmContract";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useMenuStore } from "@/stores/menu";
|
||||
import ContractCreate from "../../contract/components/create.vue";
|
||||
import { PARTY_ROLES, buildSummary } from "../../contract/components/utils";
|
||||
import {
|
||||
formatMoney,
|
||||
formatDateOnly,
|
||||
ourRoleText,
|
||||
contractCategoryText,
|
||||
contractStatusText,
|
||||
contractStatusTag,
|
||||
productCategoryText,
|
||||
productCategoryTag,
|
||||
} from "../../dict";
|
||||
@@ -175,6 +176,9 @@ const props = defineProps({
|
||||
});
|
||||
const emit = defineEmits(["count"]);
|
||||
|
||||
const router = useRouter();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref([]);
|
||||
const activeId = ref("");
|
||||
@@ -190,6 +194,18 @@ const presetProject = computed(() =>
|
||||
|
||||
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
|
||||
|
||||
/**
|
||||
* 合同在项目中的上下游归属:
|
||||
* 判定以「我方是收款方还是付款方」为准 —— 参与方中出现供应商(我方下游)即下游合同;
|
||||
* 其余按我方角色判断:我方为甲方(采购 / 付款方)算下游,为乙方(供货 / 收款方)算上游。
|
||||
* 注意:不能只看对方是否在客户库建档,客户也可能是我方的上游供应商。
|
||||
*/
|
||||
function contractSideOf(c) {
|
||||
const parties = Array.isArray(c?.parties) ? c.parties : [];
|
||||
if (parties.some((p) => Number(p?.ref_type) === 2)) return "down";
|
||||
return Number(c?.our_role) === 1 ? "down" : "up";
|
||||
}
|
||||
|
||||
/** 金额汇总:优先后端 summary,缺失时按产品清单前端重算 */
|
||||
function summaryOf(c) {
|
||||
const s = c?.summary;
|
||||
@@ -220,6 +236,34 @@ async function reload() {
|
||||
emit("count", list.value.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找「合同管理」菜单路径:按菜单中配置的组件路径定位,
|
||||
* 避免硬编码(菜单路径可由管理员调整,且需当前用户有该菜单权限)。
|
||||
*/
|
||||
function findContractManagePath() {
|
||||
const walk = (nodes) => {
|
||||
for (const m of nodes || []) {
|
||||
if (String(m.component_path || "").includes("crm/contract/index")) return m.path;
|
||||
if (m.children?.length) {
|
||||
const hit = walk(m.children);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
return walk(menuStore.menus) || "";
|
||||
}
|
||||
|
||||
/** 点击合同名称:跳转「合同管理」并自动打开该合同详情 */
|
||||
function goContract(c) {
|
||||
const path = findContractManagePath();
|
||||
if (!path) {
|
||||
ElMessage.warning("未找到「合同管理」菜单入口,请联系管理员配置");
|
||||
return;
|
||||
}
|
||||
router.push({ path, query: { id: c.id } });
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
editRow.value = null;
|
||||
initStep.value = 1;
|
||||
@@ -312,18 +356,22 @@ defineExpose({ reload });
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
/* 可点击:跳转合同管理查看完整详情 */
|
||||
&.link {
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pane-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pane-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
@opened="handleOpened"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" label-position="right">
|
||||
<!-- 项目类型:决定是否需要维护下游(供应商)信息 -->
|
||||
<el-form-item label="项目类型" prop="project_type">
|
||||
<el-radio-group v-model="form.project_type">
|
||||
<el-radio-button :value="1">单一项目</el-radio-button>
|
||||
<el-radio-button :value="2">上下游项目</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">项目概况</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目名称" prop="project_name">
|
||||
@@ -19,6 +28,40 @@
|
||||
<el-input v-model="form.project_no" placeholder="请输入项目编号" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人" prop="owner_user_id">
|
||||
<el-select v-model="form.owner_user_id" filterable placeholder="请选择负责人" style="width: 100%">
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.name" :value="String(u.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目状态" prop="status">
|
||||
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in PROJECT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="isBackToBack" :span="12">
|
||||
<el-form-item label="项目毛利">
|
||||
<span class="profit-value">¥{{ formatMoney(profitAmount) }}</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="项目地址" prop="address">
|
||||
<el-input v-model="form.address" placeholder="请输入项目地址" maxlength="255" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注(选填)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 单一项目不存在上下游概念,只展示客户信息;上下游项目才区分上游(客户侧) -->
|
||||
<el-divider content-position="left">{{ isBackToBack ? "上游信息" : "客户信息" }}</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户" prop="customer_id">
|
||||
<el-select
|
||||
@@ -36,67 +79,103 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户行业" prop="industry">
|
||||
<el-input v-model="form.industry" placeholder="如:互联网、制造业" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目金额" prop="amount">
|
||||
<el-input v-model="form.amount" placeholder="请输入项目金额">
|
||||
<el-form-item :label="isBackToBack ? '上游金额' : '项目金额'" prop="upstream_amount">
|
||||
<el-input v-model="form.upstream_amount" placeholder="对客户的收入金额">
|
||||
<template #append>元</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="项目状态" prop="status">
|
||||
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in PROJECT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" />
|
||||
<el-form-item :label="isBackToBack ? '上游对接人' : '对接人'" prop="upstream_contact">
|
||||
<!-- 对接人从该客户已有联系人中带出(自动带出电话),不支持手工输入 -->
|
||||
<el-select
|
||||
v-model="form.upstream_contact"
|
||||
filterable
|
||||
clearable
|
||||
:loading="upstreamContactLoading"
|
||||
:disabled="!form.customer_id"
|
||||
:placeholder="form.customer_id ? '选择客户联系人' : '请先选择客户'"
|
||||
style="width: 100%"
|
||||
@change="onUpstreamContactPick"
|
||||
>
|
||||
<el-option v-for="c in upstreamContacts" :key="c.id" :label="c.contact_name" :value="c.contact_name">
|
||||
<span>{{ c.contact_name }}</span>
|
||||
<span class="opt-meta">
|
||||
{{ [c.position || c.department, c.mobile || c.phone].filter(Boolean).join(" · ") }}
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人" prop="owner_user_id">
|
||||
<el-select v-model="form.owner_user_id" filterable placeholder="请选择负责人" style="width: 100%">
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.name" :value="String(u.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开始日期" prop="start_date">
|
||||
<el-date-picker v-model="form.start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="结束日期" prop="end_date">
|
||||
<el-date-picker v-model="form.end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人" prop="contact_person">
|
||||
<el-input v-model="form.contact_person" placeholder="请输入对接人" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人职位" prop="contact_position">
|
||||
<el-input v-model="form.contact_position" placeholder="如:采购经理、技术总监" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="对接人手机" prop="contact_phone">
|
||||
<el-input v-model="form.contact_phone" placeholder="请输入手机号" maxlength="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="项目地址" prop="address">
|
||||
<el-input v-model="form.address" placeholder="请输入项目地址" maxlength="255" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注(选填)" />
|
||||
<el-form-item :label="isBackToBack ? '上游联系电话' : '联系电话'" prop="upstream_phone">
|
||||
<el-input v-model="form.upstream_phone" placeholder="客户方对接人电话" maxlength="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 下游信息:仅上下游项目需要维护 -->
|
||||
<template v-if="isBackToBack">
|
||||
<el-divider content-position="left">下游信息</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="供应商/客户" prop="supplier_name">
|
||||
<el-autocomplete
|
||||
v-model="form.supplier_name"
|
||||
clearable
|
||||
value-key="supplier_name"
|
||||
:fetch-suggestions="querySuppliers"
|
||||
placeholder="输入名称搜索供应商库 / 客户库,没有则直接填写"
|
||||
style="width: 100%"
|
||||
@focus="searchSuppliers(form.supplier_name || '')"
|
||||
@select="applySupplier"
|
||||
@blur="() => applySupplier(null)"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<span>{{ item.supplier_name }}</span>
|
||||
<el-tag size="small" effect="plain" :type="Number(item.ref_type) === 1 ? 'success' : 'warning'">
|
||||
{{ Number(item.ref_type) === 1 ? "客户" : "供应商" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下游金额" prop="downstream_amount">
|
||||
<el-input v-model="form.downstream_amount" placeholder="支付给供应商的成本">
|
||||
<template #append>元</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下游对接人" prop="downstream_contact">
|
||||
<!-- 对接人从下游主体(供应商 / 客户)已有联系人中带出,不支持手工输入 -->
|
||||
<el-select
|
||||
v-model="form.downstream_contact"
|
||||
filterable
|
||||
clearable
|
||||
:loading="downstreamContactLoading"
|
||||
:disabled="!form.supplier_id"
|
||||
:placeholder="form.supplier_id ? '选择对方联系人' : '请先选择供应商/客户'"
|
||||
style="width: 100%"
|
||||
@change="onDownstreamContactPick"
|
||||
>
|
||||
<el-option v-for="c in downstreamContacts" :key="c.id" :label="c.contact_name" :value="c.contact_name">
|
||||
<span>{{ c.contact_name }}</span>
|
||||
<span class="opt-meta">
|
||||
{{ [c.position || c.department, c.mobile || c.phone].filter(Boolean).join(" · ") }}
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下游联系电话" prop="downstream_phone">
|
||||
<el-input v-model="form.downstream_phone" placeholder="供应商方对接人电话" maxlength="20" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<!-- <el-divider content-position="left">
|
||||
@@ -113,14 +192,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { computed, ref, reactive, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { createProject, updateProject } from "@/api/crmPipeline";
|
||||
import { getProductList } from "@/api/crmProduct";
|
||||
import { getCrmCustomerList } from "@/api/crm";
|
||||
import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm";
|
||||
import { listContacts } from "@/api/contact";
|
||||
import { getAllUsers } from "@/api/user";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { PROJECT_STATUS_OPTIONS } from "../../dict";
|
||||
import { PROJECT_STATUS_OPTIONS, formatMoney } from "../../dict";
|
||||
import ProductList from "../../contract/components/ProductList.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -141,18 +221,26 @@ const customerLoading = ref(false);
|
||||
const defaultForm = () => ({
|
||||
project_name: "",
|
||||
project_no: "",
|
||||
// 项目类型:1=单一项目(仅上游) 2=上下游项目(上游客户 + 下游供应商)
|
||||
project_type: 1,
|
||||
customer_id: "",
|
||||
customer_name: "",
|
||||
supplier_id: 0,
|
||||
supplier_ref_type: 0,
|
||||
supplier_name: "",
|
||||
supplier_industry: "",
|
||||
owner_user_id: "",
|
||||
owner_user_name: "",
|
||||
industry: "",
|
||||
amount: "",
|
||||
upstream_amount: "",
|
||||
downstream_amount: "",
|
||||
upstream_contact: "",
|
||||
upstream_phone: "",
|
||||
downstream_contact: "",
|
||||
downstream_phone: "",
|
||||
status: 1,
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
contact_person: "",
|
||||
contact_position: "",
|
||||
contact_phone: "",
|
||||
address: "",
|
||||
remark: "",
|
||||
products: [],
|
||||
@@ -160,6 +248,29 @@ const defaultForm = () => ({
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
/** 是否上下游项目:需要维护下游(供应商)信息 */
|
||||
const isBackToBack = computed(() => Number(form.project_type) === 2);
|
||||
|
||||
/** 项目毛利:上游金额 − 下游金额(仅上下游项目有意义) */
|
||||
const profitAmount = computed(
|
||||
() => (Number(form.upstream_amount) || 0) - (Number(form.downstream_amount) || 0)
|
||||
);
|
||||
|
||||
/** 切换为单一项目时清空下游信息,避免残留数据造成误解 */
|
||||
watch(
|
||||
() => form.project_type,
|
||||
(val) => {
|
||||
if (Number(val) === 2) return;
|
||||
form.supplier_id = 0;
|
||||
form.supplier_ref_type = 0;
|
||||
form.supplier_name = "";
|
||||
form.supplier_industry = "";
|
||||
form.downstream_amount = "";
|
||||
form.downstream_contact = "";
|
||||
form.downstream_phone = "";
|
||||
}
|
||||
);
|
||||
|
||||
/** 解析项目产品清单:兼容 JSON 字符串 / 数组 / 空,并补全行内 __key(el-table 的 row-key) */
|
||||
function parseProducts(val) {
|
||||
let arr = [];
|
||||
@@ -220,6 +331,13 @@ watch(
|
||||
internalId.value = props.editData.id;
|
||||
Object.assign(form, defaultForm(), props.editData);
|
||||
form.status = Number(props.editData.status) || 1;
|
||||
form.project_type = Number(props.editData.project_type) || 1;
|
||||
// 存量项目兼容:只有旧「项目金额 / 对接人」时回填到上游侧
|
||||
if (!Number(form.upstream_amount) && Number(props.editData.amount)) {
|
||||
form.upstream_amount = props.editData.amount;
|
||||
}
|
||||
if (!form.upstream_contact) form.upstream_contact = props.editData.contact_person || "";
|
||||
if (!form.upstream_phone) form.upstream_phone = props.editData.contact_phone || "";
|
||||
// 解析项目已关联的产品清单(后端返回 JSON 字符串)
|
||||
const rawProducts = props.editData.products;
|
||||
form.products = parseProducts(rawProducts);
|
||||
@@ -238,6 +356,9 @@ watch(
|
||||
}
|
||||
loadUsers();
|
||||
searchCustomers(form.customer_name || "");
|
||||
// 对接人候选随所选企业加载(编辑回显时也需带上,赋值未变化时不触发 watch)
|
||||
loadUpstreamContacts();
|
||||
loadDownstreamContacts();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -267,6 +388,137 @@ async function searchCustomers(keyword) {
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------- 下游主体(供应商 / 客户) --------------------------- */
|
||||
|
||||
const supplierOptions = ref([]);
|
||||
|
||||
/**
|
||||
* 下游主体候选:同时检索供应商库与客户库。
|
||||
* 同一家单位在系统里可能以客户身份建档,因此下游既支持选供应商,也支持选客户。
|
||||
*/
|
||||
async function searchSuppliers(keyword) {
|
||||
const kw = String(keyword || "").trim();
|
||||
try {
|
||||
const [supRes, cusRes] = await Promise.all([
|
||||
getCrmSupplierList({ page: 1, pageSize: 50, keyword: kw }),
|
||||
getCrmCustomerList({ page: 1, pageSize: 50, keyword: kw }),
|
||||
]);
|
||||
const suppliers = (supRes?.data?.list || []).map((s) => ({
|
||||
id: s.id,
|
||||
ref_type: 2,
|
||||
supplier_name: s.supplier_name || "",
|
||||
}));
|
||||
const customers = (cusRes?.data?.list || []).map((c) => ({
|
||||
id: c.id,
|
||||
ref_type: 1,
|
||||
supplier_name: c.customer_name || "",
|
||||
}));
|
||||
supplierOptions.value = [...suppliers, ...customers];
|
||||
} catch {
|
||||
supplierOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 供应商名称输入建议:本地模糊过滤已加载候选(自由输入同样生效) */
|
||||
function querySuppliers(query, cb) {
|
||||
const kw = String(query || "").trim().toLowerCase();
|
||||
const list = (supplierOptions.value || []).filter(
|
||||
(s) => !kw || String(s.supplier_name || "").toLowerCase().includes(kw)
|
||||
);
|
||||
// 附加 value 字段:兼容 autocomplete 默认以 value 作为选中值
|
||||
cb(list.slice(0, 50).map((s) => ({ ...s, value: s.supplier_name })));
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中的库中主体:记录来源类型(1客户 / 2供应商)与 ID,并写入标准名称;
|
||||
* 手工输入且未命中库时清空关联,仅按名称保存。
|
||||
*/
|
||||
function applySupplier(item) {
|
||||
if (item && item.id) {
|
||||
form.supplier_id = item.id;
|
||||
form.supplier_ref_type = Number(item.ref_type) || 0;
|
||||
form.supplier_name = item.supplier_name;
|
||||
return;
|
||||
}
|
||||
const name = String(form.supplier_name || "").trim();
|
||||
const hit = supplierOptions.value.find((s) => s.supplier_name === name);
|
||||
if (hit) {
|
||||
form.supplier_id = hit.id;
|
||||
form.supplier_ref_type = Number(hit.ref_type) || 0;
|
||||
} else {
|
||||
form.supplier_id = 0;
|
||||
form.supplier_ref_type = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------- 对接人(取自企业联系人) --------------------------- */
|
||||
|
||||
const upstreamContacts = ref([]);
|
||||
const upstreamContactLoading = ref(false);
|
||||
const downstreamContacts = ref([]);
|
||||
const downstreamContactLoading = ref(false);
|
||||
|
||||
/** 查询某企业下的联系人:relatedType 1=客户 2=供应商 */
|
||||
async function fetchContactsOf(relatedType, relatedID) {
|
||||
try {
|
||||
const res = await listContacts({
|
||||
related_type: relatedType,
|
||||
related_id: relatedID,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
return res?.data?.list || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游对接人候选:所选客户(上游)的联系人 */
|
||||
async function loadUpstreamContacts() {
|
||||
const id = Number(form.customer_id) || 0;
|
||||
if (!id) {
|
||||
upstreamContacts.value = [];
|
||||
return;
|
||||
}
|
||||
upstreamContactLoading.value = true;
|
||||
try {
|
||||
upstreamContacts.value = await fetchContactsOf(1, id);
|
||||
} finally {
|
||||
upstreamContactLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 下游对接人候选:所选下游主体(供应商 / 客户)的联系人 */
|
||||
async function loadDownstreamContacts() {
|
||||
const id = Number(form.supplier_id) || 0;
|
||||
if (!id) {
|
||||
downstreamContacts.value = [];
|
||||
return;
|
||||
}
|
||||
downstreamContactLoading.value = true;
|
||||
try {
|
||||
downstreamContacts.value = await fetchContactsOf(Number(form.supplier_ref_type) === 1 ? 1 : 2, id);
|
||||
} finally {
|
||||
downstreamContactLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择上游对接人:自动带出联系电话 */
|
||||
function onUpstreamContactPick(name) {
|
||||
const hit = upstreamContacts.value.find((c) => c.contact_name === name);
|
||||
if (hit) form.upstream_phone = hit.mobile || hit.phone || "";
|
||||
}
|
||||
|
||||
/** 选择下游对接人:自动带出联系电话 */
|
||||
function onDownstreamContactPick(name) {
|
||||
const hit = downstreamContacts.value.find((c) => c.contact_name === name);
|
||||
if (hit) form.downstream_phone = hit.mobile || hit.phone || "";
|
||||
}
|
||||
|
||||
// 客户 / 下游主体变化时刷新对接人候选(不清空已选值,兼容历史手工数据)
|
||||
watch(() => form.customer_id, loadUpstreamContacts);
|
||||
watch(() => [form.supplier_id, form.supplier_ref_type], loadDownstreamContacts);
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
@@ -286,7 +538,35 @@ async function handleSubmit() {
|
||||
if (owner) form.owner_user_name = owner.name;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = { ...form, amount: Number(form.amount) || 0, products: JSON.stringify(form.products || []) };
|
||||
const upstream = Number(form.upstream_amount) || 0;
|
||||
// 仅提交新字段(旧 amount / contact_* 不再使用,由后端按上游侧同步)
|
||||
const payload = {
|
||||
project_name: form.project_name,
|
||||
project_no: form.project_no,
|
||||
project_type: Number(form.project_type) || 1,
|
||||
customer_id: Number(form.customer_id) || 0,
|
||||
customer_name: form.customer_name,
|
||||
supplier_id: Number(form.supplier_id) || 0,
|
||||
supplier_ref_type: Number(form.supplier_ref_type) || 0,
|
||||
supplier_name: form.supplier_name,
|
||||
supplier_industry: form.supplier_industry,
|
||||
owner_user_id: form.owner_user_id,
|
||||
owner_user_name: form.owner_user_name,
|
||||
industry: form.industry,
|
||||
upstream_amount: upstream,
|
||||
downstream_amount: isBackToBack.value ? Number(form.downstream_amount) || 0 : 0,
|
||||
amount: upstream, // 兼容旧字段
|
||||
status: Number(form.status) || 1,
|
||||
start_date: form.start_date,
|
||||
end_date: form.end_date,
|
||||
upstream_contact: form.upstream_contact,
|
||||
upstream_phone: form.upstream_phone,
|
||||
downstream_contact: isBackToBack.value ? form.downstream_contact : "",
|
||||
downstream_phone: isBackToBack.value ? form.downstream_phone : "",
|
||||
address: form.address,
|
||||
remark: form.remark,
|
||||
products: JSON.stringify(form.products || []),
|
||||
};
|
||||
if (internalId.value) {
|
||||
await updateProject(internalId.value, payload);
|
||||
ElMessage.success("更新成功");
|
||||
@@ -310,4 +590,21 @@ async function handleSubmit() {
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.type-tip {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.profit-value {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.opt-meta {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,10 +48,30 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="project_no" label="项目编号" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="customer_name" label="客户名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="项目金额" width="130" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
|
||||
<el-table-column label="类型" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain" :type="Number(row.project_type) === 2 ? 'warning' : 'info'">
|
||||
{{ Number(row.project_type) === 2 ? "上下游" : "单一" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<!-- 上下游项目:客户 / 供应商;单一项目:仅客户 -->
|
||||
<span v-if="Number(row.project_type) === 2">
|
||||
{{ row.customer_name || "-" }} / {{ row.supplier_name || "-" }}
|
||||
</span>
|
||||
<span v-else>{{ row.customer_name || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column label="上游金额" width="120" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.upstream_amount || row.amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下游金额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ Number(row.project_type) === 2 ? formatMoney(row.downstream_amount) : "-" }}
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column label="逾期" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="projectOverdue(row).overdue" type="danger" size="small" effect="dark">
|
||||
@@ -61,17 +81,17 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开始日期" width="120" align="center">
|
||||
<template #default="{ row }">{{ formatDateOnly(row.start_date) }}</template>
|
||||
<template #default="{ row }">{{ formatDateOnly(row.contract_start_date || row.start_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束日期" width="120" align="center">
|
||||
<template #default="{ row }">{{ formatDateOnly(row.end_date) }}</template>
|
||||
<template #default="{ row }">{{ formatDateOnly(row.contract_end_date || row.end_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" width="100">
|
||||
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对接人" width="100">
|
||||
<template #default="{ row }">{{ row.contact_person || "-" }}</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column label="上游对接人" width="110">
|
||||
<template #default="{ row }">{{ row.upstream_contact || row.contact_person || "-" }}</template>
|
||||
</el-table-column> -->
|
||||
<template #empty><el-empty description="暂无项目" :image-size="80" /></template>
|
||||
</el-table>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user