v1.2.0 — 合并业务机会+运营为重点项目 Tab,新增项目任务时间线
This commit is contained in:
122
static/app.js
122
static/app.js
@@ -61,9 +61,8 @@ function switchTab(tab) {
|
||||
function render() {
|
||||
if (!state.data) return;
|
||||
renderHome();
|
||||
renderSales();
|
||||
renderProjects();
|
||||
renderProposals();
|
||||
renderOperations();
|
||||
renderProducts();
|
||||
renderFinance();
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
@@ -98,10 +97,10 @@ function renderHome() {
|
||||
<div class="grid gap-5">
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
${[
|
||||
["P0 客户数", m.p0_customers, "sales"],
|
||||
["跟进中销售机会", m.active_sales, "sales"],
|
||||
["已签约执行项目", m.execution_projects, "operations"],
|
||||
["有风险项目", m.risk_projects, "operations"],
|
||||
["P0 客户数", m.p0_customers, "projects"],
|
||||
["跟进中销售机会", m.active_sales, "projects"],
|
||||
["已签约执行项目", m.execution_projects, "projects"],
|
||||
["有风险项目", m.risk_projects, "projects"],
|
||||
["本月收入", money(m.monthly_revenue), "finance"],
|
||||
["本月净利", money(m.monthly_net_profit), "finance"],
|
||||
["即将上线版本", m.upcoming_products, "products"],
|
||||
@@ -110,8 +109,8 @@ function renderHome() {
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
${[
|
||||
["已签约合同总额", money(m.signed_amount), "operations"],
|
||||
["合同流程中", money(m.pipeline_amount), "operations"],
|
||||
["已签约合同总额", money(m.signed_amount), "projects"],
|
||||
["合同流程中", money(m.pipeline_amount), "projects"],
|
||||
["年度累计确收", money(m.revenue_annual), "finance"],
|
||||
["Q2 累计确收", money(m.revenue_q2), "finance"],
|
||||
["年度累计毛利", money(m.gross_annual), "finance"],
|
||||
@@ -174,77 +173,50 @@ window.createProduct = (event) => createResource(event, "products");
|
||||
window.createFinance = (event) => createResource(event, "finance");
|
||||
window.switchTab = switchTab;
|
||||
|
||||
function renderSales() {
|
||||
const rows = state.data.sales.map((x) => [x.target_customer, badge(x.priority), badge(x.status), text(x.latest_follow_up_record)]);
|
||||
const salesClicks = state.data.sales.map((x) => ({ resource: "sales", id: x.id }));
|
||||
document.querySelector("#sales").innerHTML = `<div class="grid gap-4">
|
||||
function renderProjects() {
|
||||
// Merge sales_leads and operation_projects into one table
|
||||
const salesItems = state.data.sales.map((x) => ({
|
||||
name: x.target_customer,
|
||||
version: "",
|
||||
type: "opportunity",
|
||||
status: x.status,
|
||||
amount: 0,
|
||||
stage: "",
|
||||
files: 0,
|
||||
followup: x.latest_follow_up_record,
|
||||
resource: "sales",
|
||||
id: x.id,
|
||||
}));
|
||||
const opItems = state.data.operations.map((x) => ({
|
||||
name: x.project_name,
|
||||
version: x.project_version,
|
||||
type: x.project_type,
|
||||
status: x.project_status,
|
||||
amount: x.expected_contract_amount || 0,
|
||||
stage: x.current_stage || x.sop_stage,
|
||||
files: x.files.length,
|
||||
followup: x.latest_follow_up_record,
|
||||
resource: "operations",
|
||||
id: x.id,
|
||||
}));
|
||||
const allItems = [...salesItems, ...opItems];
|
||||
const items = state.opFilter === "all" ? allItems : allItems.filter((x) => x.type === state.opFilter || (state.opFilter === "opportunity" && x.type === "opportunity"));
|
||||
const rows = items.map((x) => [`<strong>${x.name}</strong>${x.version ? `<p class="text-xs text-slate-500">${x.version}</p>` : ""}`, badge(x.type), badge(x.status), x.amount ? money(x.amount) : "—", text(x.stage), text(x.followup)]);
|
||||
const clicks = items.map((x) => ({ resource: x.resource, id: x.id }));
|
||||
document.querySelector("#projects").innerHTML = `<div class="grid gap-4">
|
||||
${card(formHtml([
|
||||
{ label: "业务机会", input: `<input name="target_customer" required placeholder="客户名称">` },
|
||||
{ label: "优先级", input: `<select name="priority"><option>P0</option><option selected>P1</option><option>P2</option><option>P3</option></select>` },
|
||||
{ label: "状态", input: `<select name="status"><option>待跟进</option><option>跟进中</option><option>方案中</option><option>商务谈判</option><option>已签约</option><option>暂缓</option><option>已丢单</option></select>` },
|
||||
], { handler: "createSales", text: `<i data-lucide="plus"></i>新增业务机会` }), "p-4")}
|
||||
${renderTable(["业务机会", "优先级", "状态", "最新跟进记录"], rows, salesClicks)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderProposals() {
|
||||
const categories = ["方案", "成本", "SOP", "财务流程"];
|
||||
const proposalRows = state.data.proposals.map((p) => [p.customer_or_project_name, p.version, badge(p.status), p.files.length + " 个"]);
|
||||
const proposalClicks = state.data.proposals.map((p) => ({ resource: "proposals", id: p.id }));
|
||||
document.querySelector("#proposals").innerHTML = `<div class="grid gap-4">
|
||||
${card(formHtml([
|
||||
{ label: "客户/项目", input: `<input name="customer_or_project_name" required placeholder="如:信达生物">` },
|
||||
{ label: "版本号", input: `<input name="version" required placeholder="v1.0">` },
|
||||
{ label: "状态", input: `<select name="status"><option>草稿</option><option>内部评审</option><option selected>已提交客户</option><option>客户反馈中</option><option>已确认</option><option>已归档</option></select>` },
|
||||
], { handler: "createProposal", text: "新增版本" }), "p-4")}
|
||||
${renderTable(["客户/项目", "版本号", "状态", "文件数"], proposalRows, proposalClicks)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function fileGroup(module, ownerId, version, category, files) {
|
||||
return `<div class="rounded-md border border-slate-200 px-3 py-2">
|
||||
<div class="flex items-center justify-between gap-3"><p class="text-[13px] font-semibold text-slate-800">${category}</p><label class="inline-flex cursor-pointer items-center gap-1 rounded-md border border-slate-200 px-2 py-1 text-[12px] font-medium text-slate-600 hover:bg-slate-50"><i data-lucide="upload"></i>上传<input class="hidden" type="file" onchange="uploadFile(event,'${module}',${ownerId},'${version}','${category}')"></label></div>
|
||||
<div class="mt-2 grid gap-1.5">${files.length ? files.map(fileItem).join("") : `<p class="text-[12px] text-slate-400">暂无文件</p>`}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function fileItem(file) {
|
||||
return `<div class="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-1.5 text-[13px]"><div class="min-w-0 flex-1"><p class="truncate font-medium text-slate-800">${file.file_name}</p><div class="mt-0.5 flex gap-3"><a class="file-link inline-flex items-center gap-1" target="_blank" href="/api/files/${file.id}/content?inline=true"><i data-lucide="eye"></i>预览</a><a class="file-link inline-flex items-center gap-1 text-slate-600" href="/api/files/${file.id}/content?inline=false"><i data-lucide="download"></i>下载</a></div></div><button class="btn btn-ghost btn-sm text-red-600" onclick="deleteFile(${file.id})" title="删除"><i data-lucide="trash-2"></i></button></div>`;
|
||||
}
|
||||
|
||||
window.deleteFile = async (fileId) => {
|
||||
if (!confirm("确认删除此文件?")) return;
|
||||
await api(`/api/files/${fileId}`, { method: "DELETE" });
|
||||
await load();
|
||||
closeDrawer();
|
||||
};
|
||||
|
||||
window.uploadFile = async (event, module, ownerId, version, category) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
const form = new FormData();
|
||||
form.append("module", module);
|
||||
form.append("owner_id", ownerId);
|
||||
form.append("owner_version", version);
|
||||
form.append("file_category", category);
|
||||
form.append("file", file);
|
||||
await api("/api/files/upload", { method: "POST", body: form });
|
||||
await load();
|
||||
};
|
||||
|
||||
function renderOperations() {
|
||||
const items = state.opFilter === "all" ? state.data.operations : state.data.operations.filter((x) => x.project_type === state.opFilter);
|
||||
const opRows = items.map((x) => [`<strong>${x.project_name}</strong><p class="text-xs text-slate-500">${x.project_version}</p>`, badge(x.project_type), badge(x.project_status), x.expected_contract_amount ? money(x.expected_contract_amount) : "—", text(x.current_stage || x.sop_stage), `${x.files.length} 个`, text(x.latest_follow_up_record)]);
|
||||
const opClicks = items.map((x) => ({ resource: "operations", id: x.id }));
|
||||
document.querySelector("#operations").innerHTML = `<div class="grid gap-4">
|
||||
${card(formHtml([
|
||||
{ label: "项目名称", input: `<input name="project_name" required>` },
|
||||
{ label: "项目版本", input: `<input name="project_version" value="v1.0">` },
|
||||
{ label: "项目类型", input: `<select name="project_type"><option value="opportunity">业务机会项目</option><option value="execution">已签约执行项目</option></select>` },
|
||||
{ label: "状态", input: `<input name="project_status" value="线索发现">` },
|
||||
], { handler: "createOperation", text: "新增项目" }), "p-4")}
|
||||
<div class="flex gap-2">${[["all","全部项目"],["opportunity","业务机会项目"],["execution","已签约执行项目"]].map(([k,v]) => `<button class="btn ${state.opFilter === k ? "btn-primary" : "btn-ghost"}" onclick="state.opFilter='${k}'; renderOperations()">${v}</button>`).join("")}</div>
|
||||
${renderTable(["项目名称", "类型", "状态", "金额", "当前阶段", "交付文件", "最新跟进"], opRows, opClicks)}
|
||||
<div class="flex gap-2">${[["all","全部"],["opportunity","业务机会"],["execution","已签约执行"]].map(([k,v]) => `<button class="btn ${state.opFilter === k ? "btn-primary" : "btn-ghost"}" onclick="state.opFilter='${k}'; renderProjects()">${v}</button>`).join("")}</div>
|
||||
${renderTable(["项目/客户", "类型", "状态", "金额", "当前阶段", "最新跟进"], rows, clicks)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -338,6 +310,20 @@ function openDrawer(resource, id) {
|
||||
<form id="drawerForm" class="drawer-fields">${fields.map(([key,label]) => drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key))).join("")}</form>
|
||||
</section>
|
||||
${resource === "proposals" ? `<section><h3 class="drawer-section-title">方案文件</h3><div class="grid gap-2">${["方案","成本","SOP","财务流程"].map((cat) => fileGroup("proposal", item.id, item.version, cat, item.files.filter((f) => f.file_category === cat))).join("")}</div></section>` : ""}
|
||||
${resource === "operations" ? (() => {
|
||||
const tasks = (state.data.tasks || []).filter((t) => t.project_id === id);
|
||||
if (!tasks.length) return "";
|
||||
const phases = [...new Set(tasks.map((t) => t.phase))];
|
||||
return `<section><h3 class="drawer-section-title">项目任务</h3><div class="grid gap-3">${phases.map((phase) => {
|
||||
const pt = tasks.filter((t) => t.phase === phase);
|
||||
return `<div class="rounded-md border border-slate-200 p-3"><p class="text-[13px] font-semibold text-slate-700 mb-2"><i data-lucide="layers" style="width:14px;height:14px;display:inline;vertical-align:-2px;margin-right:4px"></i>${phase}</p><div class="grid gap-1.5">${pt.map((t) => {
|
||||
const due = t.due_date ? `<span class="text-[11px] text-slate-400 ml-1">📅 ${t.due_date}</span>` : "";
|
||||
const owner = t.owner ? `<span class="text-[11px] text-slate-500 ml-1">👤 ${t.owner}</span>` : "";
|
||||
const blocker = t.blockers ? `<p class="text-[11px] text-red-600 mt-0.5">⚠ ${t.blockers}</p>` : "";
|
||||
return `<div class="rounded bg-slate-50 px-2.5 py-1.5"><p class="text-[12px] text-slate-800"><strong>${t.milestone ? t.milestone + ":": ""}</strong>${t.task}${due}${owner}</p>${blocker}</div>`;
|
||||
}).join("")}</div></div>`;
|
||||
}).join("")}</div></section>`;
|
||||
})() : ""}
|
||||
${followupTarget ? `<section>
|
||||
<h3 class="drawer-section-title">活动 / 跟进</h3>
|
||||
<div class="grid gap-2">${(item.followups || []).map((f) => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${f.follower} · ${f.follow_up_method}</span><span>${f.followed_at}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" onclick="deleteFollowup(event, ${f.id}, '${resource}', ${item.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")}</div>
|
||||
|
||||
Reference in New Issue
Block a user