Files
opc-manager/static/app.js

1479 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
active: "home",
data: null,
tenant: "科普·无界",
opFilter: "all",
finFilter: "已签约",
selectedProject: null,
taskQuery: "",
chart: null,
chart2: null,
productPlatform: "all",
uploadTasks: [],
};
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")}`;
const text = (value) => value === undefined || value === null || value === "" ? "—" : value;
const escapeHtml = (str) => String(str || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
function monthOptions(selected = '') {
const now = new Date();
const startYear = now.getFullYear() - 1;
const endYear = now.getFullYear() + 1;
let options = selected ? '' : '<option value="">选择月份</option>';
for (let y = startYear; y <= endYear; y++) {
for (const m of ["01","02","03","04","05","06","07","08","09","10","11","12"]) {
const val = y + "-" + m;
const sel = val === selected ? " selected" : "";
options += `<option value="${val}"${sel}>${val}</option>`;
}
}
return options;
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: options.body instanceof FormData ? undefined : { "Content-Type": "application/json" },
...options,
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "请求失败");
return data;
}
async function logActivity(targetType, targetId, content) {
try {
await api(`/api/followups/${targetType}/${targetId}`, {
method: "POST",
body: JSON.stringify({ data: { content, tenant: state.tenant } }),
});
} catch (e) { /* non-critical */ }
}
function badge(value) {
const val = String(value || "—");
let cls = "badge-slate";
if (["P0", "有风险", "已丢单", "已延期"].includes(val)) cls = "badge-red";
if (["P1", "方案中", "方案已提交", "商务谈判", "待客户确认"].includes(val)) cls = "badge-amber";
if (["已签约", "已上线", "已完成", "已归档"].includes(val)) cls = "badge-green";
if (["execution", "已签约执行项目"].includes(val)) cls = "badge-blue";
return `<span class="badge ${cls}">${val === "execution" ? "已签约执行项目" : val === "opportunity" ? "业务机会项目" : val}</span>`;
}
function card(content, cls = "") {
return `<section class="card ${cls}">${content}</section>`;
}
function renderTable(headers, rows, rowClicks) {
const trAttrs = (rowClicks || []).map((rc) => rc ? `onclick="openDrawer('${rc.resource}', ${rc.id})" class="clickable-row"` : "");
return card(`
<div class="overflow-x-auto">
<table>
<thead><tr>${headers.map((h) => `<th>${h}</th>`).join("")}</tr></thead>
<tbody>${rows.map((row, i) => `<tr ${trAttrs[i] || ""}>${row.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")}</tbody>
</table>
</div>
`);
}
async function load() {
state.data = await api(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
render();
}
function switchTab(tab) {
state.active = tab;
localStorage.setItem("opc-active-tab", tab);
document.querySelectorAll("#tabs button").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === tab));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
render();
}
function render() {
if (!state.data) return;
renderHome();
renderProjects();
renderProposals();
renderProducts();
renderFinance();
if (window.lucide) window.lucide.createIcons();
}
function renderHome() {
const { summary, financeMonthly } = state.data;
const m = summary.metrics;
const rows1 = [
["年度累计签约", money(m.signed_annual || m.signed_amount)],
["Q2 累计签约", money(m.signed_q2 || 0)],
["本月新增签约", money(m.signed_month || 0)],
["合同流程中", money(m.pipeline_amount)],
];
const rows2 = [
["年度累计确收", money(m.revenue_annual)],
["Q2 累计确收", money(m.revenue_q2)],
["本月新增确收", money(m.monthly_revenue)],
["已签约未执行", money(m.signed_not_executed)],
];
const rows3 = [
["年度累计毛利", money(m.gross_annual)],
["Q2 累计毛利", money(m.gross_q2)],
["本月新增毛利", money(m.monthly_net_profit)],
["合同毛利率", m.revenue_annual ? Math.round(m.gross_annual / m.revenue_annual * 100) + "%" : "—"],
];
const tblCard = (title, rows) => card(`<h3 class="text-sm font-bold text-slate-700 mb-3">${title}</h3><table class="w-full text-sm"><tbody>${rows.map(([label, value]) => `<tr class="border-b border-slate-100 last:border-0"><td class="py-2 pr-4 text-slate-500">${label}</td><td class="py-2 text-right font-semibold text-slate-800">${value}</td></tr>`).join("")}</tbody></table>`, "p-4");
document.querySelector("#home").innerHTML = `
<div class="grid gap-5">
<div class="grid grid-cols-4 gap-3">
${[
["经营管理", m.total_projects, "finance"],
["重点工作与台账", m.total_proposals, "projects"],
["业务方案", m.total_products, "proposals"],
["产品迭代", m.upcoming_products, "products"],
].map(([label, value, tab]) => `<button class="metric-card" onclick="switchTab('${tab}')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="gauge"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></button>`).join("")}
</div>
<div class="grid grid-cols-3 gap-5">${tblCard("合同金额", rows1)}${tblCard("确收金额", rows2)}${tblCard("确收毛利", rows3)}</div>
<div class="grid grid-cols-2 gap-5">
${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">财务趋势</h2>${badge("YYYY-MM")}</div><div style="position:relative;height:140px"><canvas id="financeChart"></canvas></div>`, "p-4")}
${card(`<h2 class="text-lg font-bold">风险提醒</h2><div class="mt-3 grid gap-2">${(summary.risks.length ? summary.risks : [{ title: "暂无高风险", content: "当前无明确阻塞,按周更新即可。" }]).map((r) => `<div class="rounded-md border border-amber-200 bg-amber-50 p-3"><p class="font-bold text-amber-900">${r.title}</p><p class="mt-1 text-sm text-amber-800 break-words">${r.content}</p></div>`).join("")}</div>`, "p-5")}
</div>
${card(`<h2 class="text-lg font-bold">近期动态</h2><div class="mt-4 grid gap-2">${summary.recent.map((r) => `<div class="flex items-start justify-between rounded-md bg-slate-50 px-3 py-2 text-sm group"><span class="break-words">${r.content}</span><div class="flex items-center gap-2 flex-shrink-0 ml-2"><span class="text-xs text-slate-400">${r.followed_at}</span><button class="btn btn-ghost btn-sm text-red-400 opacity-0 group-hover:opacity-100 p-0 w-5 h-5" onclick="event.preventDefault();deleteActivity(${r.id})" title="删除动态"><i data-lucide="x" style="width:14px;height:14px"></i></button></div></div>`).join("")}</div>`, "p-5")}
</div>
`;
renderChart(financeMonthly);
}
function renderChart(data) {
const canvas = document.querySelector("#financeChart");
if (!canvas || !window.Chart) return;
if (state.chart) state.chart.destroy();
state.chart = new Chart(canvas, {
type: "line",
data: {
labels: data.map((x) => x.month),
datasets: [
{ label: "收入", data: data.map((x) => x.revenue), borderColor: "#2563eb", tension: 0.3 },
{ label: "毛利", data: data.map((x) => x.gross_profit), borderColor: "#059669", tension: 0.3 },
{ label: "成本/费用", data: data.map((x) => x.cost_expense), borderColor: "#d97706", tension: 0.3 },
{ label: "净利", data: data.map((x) => x.net_profit), borderColor: "#7c3aed", tension: 0.3 },
],
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } }, scales: { x: { ticks: { font: { size: 11 } }, grid: { display: false } }, y: { ticks: { font: { size: 11 } } } } },
});
}
function formHtml(fields, button) {
return `<form class="inline-form flex flex-wrap items-end gap-3" onsubmit="${button.handler}(event)">
${fields.map((f) => `<label class="grid gap-1 text-sm"><span class="font-bold text-slate-600">${f.label}</span>${f.input}</label>`).join("")}
<button class="btn btn-primary" type="submit">${button.text}</button>
</form>`;
}
async function createResource(event, resource) {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
data.tenant = state.tenant;
try {
const result = await api(`/api/${resource}`, { method: "POST", body: JSON.stringify({ data }) });
// 活动记录
const targetMap = { sales: "sales", proposals: "proposal", operations: "operation", products: "product" };
const resType = targetMap[resource] || resource;
const name = data.project_name || data.target_customer || data.customer_or_project_name || data.product_name || "";
if (result.id && name) logActivity(resType, result.id, "创建了" + name);
form.reset();
await load();
} catch (error) {
alert("创建失败:" + error.message);
}
}
window.createSales = (event) => createResource(event, "sales");
window.createProposal = (event) => createResource(event, "proposals");
window.createOperation = async (event) => {
await createResource(event, "operations");
if (typeof closeNewProjectModal === "function") closeNewProjectModal();
};
window.openProductDrawer = () => {
const drawer = document.querySelector("#productDrawer");
drawer.innerHTML = `<div class="task-drawer-hd">
<span class="task-drawer-title">新增产品版本</span>
<button class="task-close" onclick="closeProductDrawer()"><i data-lucide="x"></i></button>
</div>
<form class="task-drawer-form" onsubmit="submitProductDrawer(event)">
<label class="task-field"><span>产品名称</span><input name="product_name" required></label>
<label class="task-field"><span>版本号</span><input name="version" required></label>
<label class="task-field"><span>版本目标</span><input name="version_goal"></label>
<label class="task-field"><span>核心功能</span><textarea name="feature_list" rows="3"></textarea></label>
<label class="task-field"><span>上线日期</span><input name="launch_date" type="date"></label>
<label class="task-field"><span>状态</span><select name="status"><option>规划中</option><option>设计中</option><option>开发中</option><option>测试中</option><option>已上线</option><option>已延期</option><option>已取消</option></select></label>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeProductDrawer()">取消</button>
<button type="submit" class="btn btn-primary btn-sm">确认新增</button>
</div>
</form>`;
drawer.classList.add("open");
if (window.lucide) window.lucide.createIcons();
};
window.closeProductDrawer = () => {
document.querySelector("#productDrawer").classList.remove("open");
};
window.cycleProductStatus = async (id) => {
const products = state.data.products || [];
const product = products.find(x => x.id === id);
if (!product) return;
const statuses = ["规划中", "设计中", "开发中", "测试中", "已上线", "已延期", "已取消"];
const current = statuses.indexOf(product.status) >= 0 ? product.status : "规划中";
const newStatus = statuses[(statuses.indexOf(current) + 1) % statuses.length];
try {
await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { status: newStatus } }) });
product.status = newStatus;
renderProducts();
} catch (error) {
alert("更新失败:" + error.message);
}
};
window.editProductDate = (event, id) => {
event.stopPropagation();
const products = state.data.products || [];
const product = products.find(x => x.id === id);
if (!product) return;
const span = event.currentTarget;
const td = span.parentElement;
const currentValue = product.launch_date || "";
const input = document.createElement("input");
input.type = "date";
input.className = "w-full rounded border border-slate-200 px-1 py-1 text-sm";
input.value = currentValue;
input.addEventListener("change", async () => {
const newValue = input.value;
try {
await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { launch_date: newValue } }) });
product.launch_date = newValue;
td.innerHTML = `<span class="cursor-pointer hover:text-blue-600" onclick="editProductDate(event, ${id})">${newValue || '—'}</span>`;
} catch (e) { alert("修改失败:" + e.message); }
});
input.addEventListener("blur", () => {
td.innerHTML = `<span class="cursor-pointer hover:text-blue-600" onclick="editProductDate(event, ${id})">${currentValue || '—'}</span>`;
});
td.innerHTML = "";
td.appendChild(input);
input.focus();
};
window.submitProductDrawer = async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
data.platform = "";
data.tenant = state.tenant;
try {
const result = await api("/api/products", { method: "POST", body: JSON.stringify({ data }) });
form.reset();
closeProductDrawer();
if (result.id) logActivity("product", result.id, "创建了产品版本「" + data.product_name + " " + data.version + "」");
await load();
} catch (error) {
alert("创建失败:" + error.message);
}
};
window.openTaskForm = (projectId, taskId) => {
if (!projectId) return;
const drawer = document.querySelector(`#task-drawer-${projectId}`);
const titleEl = drawer.querySelector(".task-drawer-title");
if (taskId === null) {
document.querySelector(`#task-id-${projectId}`).value = "";
document.querySelector(`#task-name-${projectId}`).value = "";
document.querySelector(`#task-phase-${projectId}`).value = "商务洽谈";
document.querySelector(`#task-owner-${projectId}`).value = "";
document.querySelector(`#task-due-${projectId}`).value = "";
document.querySelector(`#task-notes-${projectId}`).value = "";
document.querySelector(`#task-blockers-${projectId}`).value = "";
document.querySelector(`#task-priority-${projectId}`).value = "P2";
document.querySelector(`#task-status-${projectId}`).value = "未开始";
document.querySelector(`#task-submit-btn-${projectId}`).textContent = "确认新增";
if (titleEl) titleEl.textContent = "新增任务";
} else {
const task = (state.data.tasks || []).find((t) => t.id === taskId);
if (!task) return;
document.querySelector(`#task-id-${projectId}`).value = task.id;
document.querySelector(`#task-name-${projectId}`).value = task.task || "";
document.querySelector(`#task-phase-${projectId}`).value = task.phase || "商务洽谈";
document.querySelector(`#task-owner-${projectId}`).value = task.owner || "";
document.querySelector(`#task-due-${projectId}`).value = task.due_date || "";
document.querySelector(`#task-notes-${projectId}`).value = task.notes || "";
document.querySelector(`#task-blockers-${projectId}`).value = task.blockers || "";
document.querySelector(`#task-priority-${projectId}`).value = task.priority || "P2";
document.querySelector(`#task-status-${projectId}`).value = task.status || "未开始";
document.querySelector(`#task-submit-btn-${projectId}`).textContent = "保存修改";
if (titleEl) titleEl.textContent = "编辑任务";
}
drawer.classList.add("open");
};
window.closeTaskDrawer = (projectId) => {
document.querySelector(`#task-drawer-${projectId}`).classList.remove("open");
refreshTaskList(projectId);
};
window.refreshTaskList = (projectId) => {
const container = document.querySelector(`.task-feed`);
if (!container) return;
container.innerHTML = renderTaskListHTML(projectId);
if (window.lucide) window.lucide.createIcons();
};
window.submitTaskForm = async (event, projectId) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
data.project_id = Number(projectId);
data.tenant = state.tenant;
const taskId = data.task_id;
delete data.task_id;
try {
if (taskId) {
await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data }) });
const task = (state.data.tasks || []).find(t => t.id === parseInt(taskId));
if (task) Object.assign(task, data);
if (data.task) logActivity("task", taskId, "更新了任务「" + data.task + "」");
// 局部更新该行,保持滚动位置
const row = document.querySelector(`.task-item[data-id="${taskId}"]`);
if (row) {
const titleEl = row.querySelector(".task-title");
const descEl = row.querySelector(".task-desc");
const ownerEl = row.querySelector(".task-meta");
const dateEl = row.querySelectorAll(".task-meta")[1];
if (titleEl) titleEl.textContent = data.task || "";
if (descEl) { if (data.notes) { descEl.textContent = data.notes; } else { descEl.remove(); } }
if (ownerEl) ownerEl.textContent = data.owner || "";
if (dateEl) dateEl.textContent = data.due_date || "";
let blockEl = row.querySelector(".task-blocker");
if (data.blockers) {
if (!blockEl) {
blockEl = document.createElement("span");
blockEl.className = "task-blocker";
row.querySelector(".task-content").appendChild(blockEl);
}
blockEl.textContent = "\u26a0 " + data.blockers;
} else if (blockEl) {
blockEl.remove();
}
}
closeTaskDrawer(projectId);
} else {
const result = await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) });
if (result.id && data.task) logActivity("task", result.id, "创建了任务「" + data.task + "」");
await load();
}
} catch (error) {
alert("保存失败:" + error.message);
}
};
window.createFinance = async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
data.tenant = state.tenant;
data.sign_amount = parseFloat(data.sign_amount) || 0;
data.total_rev = parseFloat(data.total_rev) || 0;
data.total_gross = parseFloat(data.total_gross) || 0;
// 收集动态预算行
const months = form.querySelectorAll('[name="budget_month[]"]');
const revs = form.querySelectorAll('[name="budget_rev[]"]');
const grosses = form.querySelectorAll('[name="budget_gross[]"]');
const payments = form.querySelectorAll('[name="budget_payment[]"]');
const costs = form.querySelectorAll('[name="budget_cost[]"]');
const budgetRows = [];
for (let i = 0; i < months.length; i++) {
const m = months[i].value.trim();
if (!m) continue;
budgetRows.push({ month: m, rev: parseFloat(revs[i].value) || 0, gross: parseFloat(grosses[i].value) || 0, payment: parseFloat(payments[i].value) || 0, cost: parseFloat(costs[i].value) || 0 });
}
data.budget_data = JSON.stringify(budgetRows);
// 同时填充到旧版12月字段以保持兼容
for (const budgetMonth of budgetRows) {
const k = budgetMonth.month.replace("-", "_");
data["rev_" + k] = budgetMonth.rev;
data["gross_" + k] = budgetMonth.gross;
}
const pfId = data.pf_id;
delete data.pf_id;
try {
if (pfId) {
await api("/api/projectFinances/" + pfId, { method: "PUT", body: JSON.stringify({ data }) });
if (data.customer_name) logActivity("finance", pfId, "更新了「" + data.customer_name + "」的财务信息");
} else {
const result = await api("/api/projectFinances", { method: "POST", body: JSON.stringify({ data }) });
if (result.id && data.customer_name) logActivity("finance", result.id, "创建了「" + data.customer_name + "」的财务项目");
}
form.reset();
document.querySelector("#pf-id-input").value = "";
document.querySelector("#financeModalTitle").textContent = "新增项目财务";
closeFinanceModal();
await load();
} catch (error) {
alert("保存失败:" + error.message);
}
};
window.switchTab = switchTab;
window.switchTenant = (tenant) => {
state.tenant = tenant;
state.selectedProject = null;
localStorage.setItem("opc-active-tenant", tenant);
const label = tenant.replace("·无界", "");
document.querySelector("#workspaceTitle").textContent = label + " OPC 工作台";
document.querySelectorAll(".workspace-nav-item").forEach(el => el.classList.toggle("active", el.dataset.tenant === tenant));
load();
};
window.doLogout = async () => {
await api("/api/auth/logout", { method: "POST" });
window.location.href = "/login";
};
// 根据用户权限调整可见工作台
function applyUserTenants() {
fetch("/api/auth/me").then(r => r.json()).then(data => {
if (!data.logged_in) { window.location.href = "/login"; return; }
const user = data.user;
document.querySelector("#userAvatar").textContent = user.display_name.charAt(0);
document.querySelector("#userAvatar").title = user.display_name;
// 只显示有权限的工作台
const allowedTenants = data.tenants || [];
document.querySelectorAll(".workspace-nav-item").forEach(el => {
el.style.display = allowedTenants.includes(el.dataset.tenant) ? "" : "none";
});
// 如果当前工作台不在允许列表中,自动切换到第一个
if (!allowedTenants.includes(state.tenant)) {
switchTenant(allowedTenants[0]);
}
});
}
// 页面加载时验证登录状态
applyUserTenants();
window.selectProject = (id) => {
state.selectedProject = id;
renderProjects();
};
window.togglePhase = (phaseId) => {
const wrap = document.getElementById(phaseId);
const toggle = document.getElementById(phaseId + "-toggle");
if (!wrap || !toggle) return;
const isCollapsed = wrap.classList.toggle("collapsed");
toggle.innerHTML = isCollapsed ? '<i data-lucide="chevron-right"></i>' : '<i data-lucide="chevron-down"></i>';
if (window.lucide) window.lucide.createIcons({ icons: { ChevronDown: "chevron-down", ChevronRight: "chevron-right" } });
};
window.filterTasks = (query) => {
state.taskQuery = query;
const body = document.querySelector(".task-feed-body");
if (body && state.selectedProject) {
body.innerHTML = renderTaskListHTML(state.selectedProject);
if (window.lucide) window.lucide.createIcons();
}
};
window.showProjectContext = (event, id) => {
event.preventDefault();
event.stopPropagation();
const menu = document.querySelector("#projectContextMenu");
if (!menu) return;
menu.dataset.projectId = id;
menu.style.left = event.clientX + "px";
menu.style.top = event.clientY + "px";
menu.classList.remove("hidden");
};
window.openProjectDrawer = () => {
const id = parseInt(document.querySelector("#projectContextMenu").dataset.projectId || "0");
hideProjectContext();
if (id) openDrawer("operations", id);
};
window.hideProjectContext = () => {
const menu = document.querySelector("#projectContextMenu");
if (menu) menu.classList.add("hidden");
};
window.openNewProjectModal = () => {
document.querySelector("#newProjectModal").classList.remove("hidden");
};
window.closeNewProjectModal = () => {
document.querySelector("#newProjectModal").classList.add("hidden");
};
function renderProjects() {
const items = state.data.operations;
// 默认选中第一个项目
if (!state.selectedProject && items.length > 0) {
state.selectedProject = items[0].id;
}
document.querySelector("#projects").innerHTML = /*html*/`
<div class="project-board">
<div class="project-board-body">
<div class="project-tree">
<div class="project-tree-hd">
<span>项目</span>
<button class="btn btn-ghost btn-sm rounded-full w-7 h-7 p-0" onclick="openNewProjectModal()" title="新增项目">
<i data-lucide="plus" style="width:16px;height:16px"></i>
</button>
</div>
<div class="project-tree-list">
${items.map((x) => `
<div class="project-tree-node ${state.selectedProject === x.id ? 'active' : ''}"
onclick="selectProject(${x.id})"
oncontextmenu="showProjectContext(event, ${x.id})">
<span class="project-tree-icon"><i data-lucide="folder"></i></span>
<span class="project-tree-name">${x.project_name}</span>
</div>
`).join("")}
${items.length === 0 ? '<div class="project-tree-empty">暂无项目</div>' : ''}
</div>
</div>
<div class="task-feed">
<div class="task-feed-hd">
<div class="project-search">
<i data-lucide="search" style="width:14px;height:14px"></i>
<input placeholder="搜索任务..." value="${escapeHtml(state.taskQuery)}" oninput="filterTasks(this.value)">
</div>
<button class="btn btn-primary btn-sm" onclick="openTaskForm(${state.selectedProject || 0}, null)">
<i data-lucide="plus"></i>新增任务
</button>
</div>
${state.selectedProject ? '<div class="task-feed-body">' + renderTaskListHTML(state.selectedProject) + '</div>' : `
<div class="project-empty">
<div class="text-center">
<i data-lucide="arrow-left" style="width:32px;height:32px;margin:0 auto 12px;color:#cbd5e1"></i>
<p>请从左侧选择项目查看台账</p>
</div>
</div>`}
</div>
</div>
</div>`;
// 右键菜单
document.querySelector("#projectContextMenu")?.remove();
const menu = document.createElement("div");
menu.id = "projectContextMenu";
menu.className = "project-context-menu hidden";
menu.innerHTML = `<div class="project-context-item" onclick="openProjectDrawer()"><i data-lucide="info"></i>查看项目详情</div>`;
document.body.appendChild(menu);
document.removeEventListener("click", hideProjectContext);
document.addEventListener("click", hideProjectContext);
// 创建任务抽屉 DOM
if (state.selectedProject) renderProjectTasks(state.selectedProject);
if (window.lucide) window.lucide.createIcons();
}
function filterPhaseTasks(tasks, phase) {
return tasks.filter(t => t.phase === phase);
}
function renderTaskListHTML(projectId) {
const project = state.data.operations.find((x) => x.id === projectId);
if (!project) return "";
const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId);
const q = (state.taskQuery || "").toLowerCase();
const filtered = q ? tasks.filter(t => (t.task||"").toLowerCase().includes(q) || (t.notes||"").toLowerCase().includes(q)) : tasks;
const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"];
const customPhases = [...new Set(filtered.map(t => t.phase).filter(Boolean))];
// 按默认顺序排列,自定义阶段追加末尾
const phaseOrder = [...defaultPhases];
customPhases.forEach(p => { if (!phaseOrder.includes(p)) phaseOrder.push(p); });
const phases = phaseOrder.filter(p => filterPhaseTasks(filtered, p).length > 0);
const phaseTasks = phases.map(p => ({ phase: p, tasks: filterPhaseTasks(filtered, p) }));
return `
${phaseTasks.map(({ phase, tasks: pt }) => {
if (!pt.length) return "";
const phaseId = "phase-" + projectId + "-" + phase.replace(/\s/g, "");
return `<div class="task-section">
<div class="task-section-hd" onclick="togglePhase('${phaseId}')">
<span class="task-section-toggle" id="${phaseId}-toggle"><i data-lucide="chevron-down"></i></span>
<span class="task-section-icon"><i data-lucide="layers"></i></span>
<span class="task-section-label">${phase}</span>
<span class="task-section-n">${pt.length}</span>
</div>
<div class="task-section-list-wrap" id="${phaseId}">
<div class="task-section-list" data-phase="${phase}" ondrop="handleTaskDrop(event, ${projectId}, '${phase}')" ondragover="event.preventDefault(); event.currentTarget.classList.add('drag-over')" ondragleave="event.currentTarget.classList.remove('drag-over')">
${pt.map((t) => `<div class="task-item ${t.status === '已结束' ? 'task-done' : ''} ${t.priority === 'P0' ? 'task-p0' : t.priority === 'P1' ? 'task-p1' : ''}" data-id="${t.id}" draggable="true" ondragstart="handleTaskDragStart(event, ${t.id})" ondragend="event.currentTarget.classList.remove('dragging')">
<span class="task-grip"><i data-lucide="grip-vertical"></i></span>
<span class="task-status-badge status-${t.status || '未开始'}" onclick="event.stopPropagation(); cycleTaskStatus(${t.id}, ${projectId})" title="点击切换状态">${t.status || '未开始'}</span>
<span class="task-priority-badge priority-${(t.priority || 'P2').toLowerCase()}">${t.priority || 'P2'}</span>
<div class="task-content" onclick="openTaskForm(${projectId}, ${t.id})">
<span class="task-title">${t.task}</span>
${t.notes ? '<span class="task-desc">' + t.notes + '</span>' : ""}
${t.blockers ? '<span class="task-blocker">\u26a0 ' + t.blockers + '</span>' : ""}
</div>
<span class="task-meta">${t.owner || ''}</span>
<span class="task-meta text-slate-400">${t.due_date || ''}</span>
</div>`).join("")}
</div>
</div>
</div>`;
}).join("")}
${filtered.length === 0 ? (q ? '<div class="task-empty">无匹配任务</div>' : '<div class="task-empty">暂无任务,点击上方按钮创建</div>') : ''}
`;
}
function renderProjectTasks(projectId) {
const project = state.data.operations.find((x) => x.id === projectId);
if (!project) { state.selectedProject = null; renderProjects(); return; }
const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId);
const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"];
const customPhases = [...new Set(tasks.map(t => t.phase).filter(Boolean))];
const phases = [...new Set([...defaultPhases, ...customPhases])];
// 只更新任务列表体,不覆盖头部(搜索框 + 新增按钮)
const body = document.querySelector(".task-feed-body");
if (body) body.innerHTML = renderTaskListHTML(projectId);
// 任务抽屉放在 #projects 面板下
let drawer = document.querySelector(`#task-drawer-${projectId}`);
if (!drawer) {
drawer = document.createElement("div");
drawer.id = `task-drawer-${projectId}`;
drawer.className = "task-drawer";
document.querySelector("#projects").appendChild(drawer);
}
drawer.innerHTML = `<div class="task-drawer-hd"><span class="task-drawer-title">编辑任务</span><div class="flex items-center gap-2"><button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteTask(${projectId})"><i data-lucide="trash-2"></i>删除</button><button class="task-close" onclick="closeTaskDrawer(${projectId})"><i data-lucide="x"></i></button></div></div>
<form class="task-drawer-form" onsubmit="submitTaskForm(event, ${projectId})">
<input type="hidden" name="task_id" id="task-id-${projectId}" value="">
<label class="task-field"><span>任务名称</span><input name="task" required id="task-name-${projectId}"></label>
<label class="task-field"><span>任务阶段</span><select name="phase" id="task-phase-${projectId}">${phases.map((p) => `<option>${p}</option>`).join("")}</select></label>
<label class="task-field"><span>优先级</span><select name="priority" id="task-priority-${projectId}"><option>P0</option><option>P1</option><option selected>P2</option><option>P3</option></select></label>
<label class="task-field"><span>状态</span><select name="status" id="task-status-${projectId}"><option></option><option></option><option></option><option></option></select></label>
<label class="task-field"><span>负责人</span><input name="owner" id="task-owner-${projectId}"></label>
<label class="task-field"><span>截止时间</span><input name="due_date" type="date" id="task-due-${projectId}"></label>
<label class="task-field"><span>任务说明</span><textarea name="notes" rows="3" id="task-notes-${projectId}"></textarea></label>
<label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" id="task-blockers-${projectId}" placeholder=""></textarea></label>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeTaskDrawer(${projectId})">取消</button>
<button type="submit" class="btn btn-primary btn-sm" id="task-submit-btn-${projectId}">确认新增</button>
</div>
</form>`;
if (window.lucide) window.lucide.createIcons();
}
function showTaskModal(projectId) {
const project = state.data.operations.find((x) => x.id === projectId);
const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId);
const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"];
const customPhases = [...new Set(tasks.map(t => t.phase).filter(Boolean))];
const phases = customPhases.length ? customPhases : defaultPhases;
document.querySelector("#taskModal").innerHTML = `<div class="task-overlay" onclick="closeTaskModal()"><div class="task-panel" onclick="event.stopPropagation()"><div class="task-header"><h2 class="task-title">${project.project_name} · 任务清单</h2><div class="flex items-center gap-3"><button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); openTaskForm(${projectId}, null)"><i data-lucide="plus"></i>新增任务</button><button class="task-close" onclick="closeTaskModal()"><i data-lucide="x"></i></button></div></div><div class="task-body-wrap">
<div class="task-body">
${phases.map((phase) => {
const pt = tasks.filter((t) => t.phase === phase);
if (!pt.length) return "";
return `<div class="task-group"><div class="task-group-hd"><span class="task-group-icon"><i data-lucide="layers"></i></span><span class="task-group-label">${phase}</span><span class="task-group-n">${pt.length}</span></div><div class="task-group-list">${pt.map((t) => `<div class="task-row" data-id="${t.id}" onclick="event.stopPropagation(); openTaskForm(${projectId}, ${t.id})"><span class="task-dot"><i data-lucide="${t.status === 'done' ? 'check-circle' : 'circle'}"></i></span><div class="task-main"><span class="task-name">${t.task}</span>${t.notes ? `<span class="task-desc">${t.notes}</span>` : ""}${t.blockers ? `<span class="task-blocker">⚠ ${t.blockers}</span>` : ""}</div><span class="task-col">${t.owner || ""}</span><span class="task-col-badge">${t.due_date || ""}</span></div>`).join("")}</div></div>`;
}).join("")}
</div>
<div id="task-drawer-${projectId}" class="task-drawer">
<div class="task-drawer-hd"><span class="task-drawer-title">编辑任务</span><button class="task-close" onclick="closeTaskDrawer(${projectId})"><i data-lucide="x"></i></button></div>
<form class="task-drawer-form" onsubmit="submitTaskForm(event, ${projectId})">
<input type="hidden" name="task_id" id="task-id-${projectId}" value="">
<label class="task-field"><span>任务名称</span><input name="task" required id="task-name-${projectId}"></label>
<label class="task-field"><span>任务阶段</span><select name="phase" id="task-phase-${projectId}">${phases.map((p) => `<option>${p}</option>`).join("")}</select></label>
<label class="task-field"><span>负责人</span><input name="owner" id="task-owner-${projectId}"></label>
<label class="task-field"><span>截止时间</span><input name="due_date" type="date" id="task-due-${projectId}"></label>
<label class="task-field"><span>任务说明</span><textarea name="notes" rows="3" id="task-notes-${projectId}"></textarea></label>
<label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" id="task-blockers-${projectId}" placeholder="风险卡点、依赖项等"></textarea></label>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeTaskDrawer(${projectId})">取消</button>
<button type="submit" class="btn btn-primary btn-sm" id="task-submit-btn-${projectId}">确认新增</button>
</div>
</form>
</div>
</div></div></div>`;
document.querySelector("#taskModal").classList.add("active");
if (window.lucide) window.lucide.createIcons();
}
window.closeTaskModal = () => {
document.querySelector("#taskModal").classList.remove("active");
document.querySelector("#taskModal").innerHTML = "";
};
function renderProposals() {
const items = state.data.proposals || [];
const rows = items.map((p) => [
`<strong>${p.customer_or_project_name}</strong>`,
p.proposal_type || "业务方案",
text(p.notes || ""),
(p.created_at || "").slice(0, 10) || "\u2014",
]);
document.querySelector("#proposals").innerHTML = `<div class="grid gap-4">
<div class="flex justify-end">
<button class="btn btn-primary btn-sm" onclick="openProposalModal()"><i data-lucide="plus"></i>新增方案</button>
</div>
${renderTable(["方案名称", "方案类型", "方案说明", "日期"], rows, items.map((p) => ({ resource: "proposals", id: p.id })))}
</div>
<div id="proposalModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeProposalModal()">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onclick="event.stopPropagation()">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h3 class="text-lg font-semibold text-slate-800">新增方案</h3>
<button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeProposalModal()"><i data-lucide="x"></i></button>
</div>
<form onsubmit="submitProposal(event)" class="p-6 grid gap-4">
<label class="block"><span class="text-xs font-medium text-slate-500">方案名称</span><input name="customer_or_project_name" required class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm"></label>
<label class="block"><span class="text-xs font-medium text-slate-500">方案类型</span><select name="proposal_type" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm">${["业务方案","报价与成本","SOP","PRD","设计稿","其他"].map(t => `<option>${t}</option>`).join("")}</select></label>
<label class="block"><span class="text-xs font-medium text-slate-500">方案说明</span><textarea name="notes" rows="3" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm"></textarea></label>
<div class="flex justify-end gap-3 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeProposalModal()">取消</button>
<button type="submit" class="btn btn-primary btn-sm">创建</button>
</div>
</form>
</div>
</div>`;
if (window.lucide) window.lucide.createIcons();
}
window.openProposalModal = () => {
document.querySelector("#proposalModal").classList.remove("hidden");
};
window.closeProposalModal = () => {
document.querySelector("#proposalModal").classList.add("hidden");
};
window.submitProposal = async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
data.tenant = state.tenant;
if (!data.version) data.version = "v1.0";
if (!data.description) data.description = "";
if (!data.status) data.status = "草稿";
if (!data.created_date) data.created_date = new Date().toISOString().slice(0, 10);
try {
const result = await api("/api/proposals", { method: "POST", body: JSON.stringify({ data }) });
if (result.id && data.customer_or_project_name) logActivity("proposal", result.id, "创建了方案「" + data.customer_or_project_name + "」");
form.reset();
closeProposalModal();
await load();
} catch (error) {
alert("保存失败:" + error.message);
}
};
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" });
// 从本地 state 中移除该文件,重新打开抽屉
for (const listKey of ["proposals", "operations", "sales", "products"]) {
if (!state.data[listKey]) continue;
for (const item of state.data[listKey]) {
if (!item.files) continue;
const idx = item.files.findIndex(f => f.id === fileId);
if (idx !== -1) {
item.files.splice(idx, 1);
openDrawer(listKey, item.id);
return;
}
}
}
};
window.uploadFile = (event, module, ownerId, version, category) => {
const file = event.target.files[0];
if (!file) return;
const taskId = Date.now();
const task = { id: taskId, name: file.name, progress: 0, xhr: null };
state.uploadTasks.push(task);
renderUploadTasks();
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);
const xhr = new XMLHttpRequest();
task.xhr = xhr;
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
task.progress = Math.round((e.loaded / e.total) * 100);
renderUploadTasks();
}
});
xhr.addEventListener("load", () => {
if (xhr.status === 200) {
task.progress = 100;
renderUploadTasks();
const result = JSON.parse(xhr.responseText);
const resourceMap = { proposal: "proposals", operation: "operations", sales: "sales", product: "products" };
const listKey = resourceMap[module];
if (listKey && state.data[listKey]) {
const item = state.data[listKey].find(x => x.id === ownerId);
if (item) {
if (!item.files) item.files = [];
item.files.push({ id: result.id, file_name: file.name, file_category: category });
}
}
setTimeout(() => {
state.uploadTasks = state.uploadTasks.filter(t => t.id !== taskId);
renderUploadTasks();
if (listKey) openDrawer(listKey, ownerId);
}, 600);
}
});
xhr.addEventListener("error", () => {
state.uploadTasks = state.uploadTasks.filter(t => t.id !== taskId);
renderUploadTasks();
});
xhr.open("POST", "/api/files/upload");
xhr.send(form);
};
window.cancelUpload = (taskId) => {
const task = state.uploadTasks.find(t => t.id === taskId);
if (task && task.xhr) task.xhr.abort();
state.uploadTasks = state.uploadTasks.filter(t => t.id !== taskId);
renderUploadTasks();
};
window.renderUploadTasks = () => {
const el = document.querySelector("#uploadTaskList");
if (!el) return;
el.innerHTML = state.uploadTasks.map(t => `
<div class="upload-task">
<span class="upload-task-name">${t.name}</span>
<div class="upload-task-bar"><div class="upload-task-fill" style="width:${t.progress}%"></div></div>
<span class="upload-task-pct">${t.progress}%</span>
<button class="upload-task-cancel" onclick="cancelUpload(${t.id})"><i data-lucide="x"></i></button>
</div>
`).join("");
el.style.display = state.uploadTasks.length ? "block" : "none";
if (window.lucide) window.lucide.createIcons();
};
function renderProducts() {
const items = state.data.products;
const productRows = items.map((p) => `
<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openDrawer('products', ${p.id})">
<td class="p-2" onclick="event.stopPropagation()"><span class="task-status-badge status-${p.status} text-sm font-normal" onclick="cycleProductStatus(${p.id})" title="点击切换状态">${p.status || '规划中'}</span></td>
<td class="p-2 text-sm font-medium">${p.product_name}</td>
<td class="p-2 text-sm">${p.version}</td>
<td class="p-2 text-sm">${text(p.version_goal)}</td>
<td class="p-2 text-sm">${text(p.feature_list)}</td>
<td class="p-2 text-sm" onclick="event.stopPropagation()"><span class="cursor-pointer hover:text-blue-600" onclick="editProductDate(event, ${p.id})">${p.launch_date || '—'}</span></td>
</tr>`).join("");
document.querySelector("#products").innerHTML = `<div class="grid gap-4">
<div class="flex justify-between items-center">
<h3 class="font-bold text-slate-700">产品版本</h3>
<button class="btn btn-primary btn-sm" onclick="openProductDrawer()"><i data-lucide="plus"></i>新增版本</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="bg-slate-50 border-b border-slate-200">
<th class="p-2 text-left font-semibold w-20">状态</th>
<th class="p-2 text-left font-semibold">产品名称</th>
<th class="p-2 text-left font-semibold">版本号</th>
<th class="p-2 text-left font-semibold">版本目标</th>
<th class="p-2 text-left font-semibold">核心功能</th>
<th class="p-2 text-left font-semibold">上线日期</th>
</tr></thead>
<tbody>${productRows}</tbody>
</table>
</div>
</div>
<aside id="productDrawer" class="task-drawer" style="width:480px" aria-hidden="true"></aside>`;
}
function renderFinance() {
const pfs = state.data.projectFinances || [];
const ops = state.data.operations || [];
const fmTypesByTenant = {
"科普·无界": ["科普音频","科普视频","科普文章","全品类科普"],
"科研·无界": ["真实世界研究","调研问卷","病例征集","患者招募"],
"医患·无界": ["医患运营","患者管理","患教会","创新支付","电商","其他"],
};
const fmTypes = fmTypesByTenant[state.tenant] || fmTypesByTenant["科普·无界"];
const tenantOps = (state.data.operations || []).filter(o => (o.project_name || "").includes(state.tenant.replace("·无界","")) || o.tenant === state.tenant);
const now = new Date();
const thisMonth = now.getMonth() + 1; // 1-12
const displayMonths = [];
for (let i = 0; i < 4; i++) {
const m = thisMonth + i;
const mm = m > 12 ? m - 12 : m;
displayMonths.push({ key: "2026_" + String(mm).padStart(2, "0"), label: mm + "月" });
}
const months = displayMonths.map(d => d.key);
const monthLabels = displayMonths.map(d => d.label);
// Aggregates
const signed = pfs.filter(x => x.status === "已签约");
const inContract = pfs.filter(x => x.status === "流程中");
const pending = pfs.filter(x => x.status === "待签约");
const sumSign = Math.round(signed.reduce((s,x) => s + (x.sign_amount||0), 0));
const sumPending = Math.round(pending.reduce((s,x) => s + (x.sign_amount||0), 0));
const sumContract = Math.round(inContract.reduce((s,x) => s + (x.sign_amount||0), 0));
const monthRev = months.map(m => signed.reduce((s,x) => s + (x["rev_"+m]||0), 0));
const monthGross = months.map(m => signed.reduce((s,x) => s + (x["gross_"+m]||0), 0));
// 本月财务指标(从 budget_data 汇总)
const thisMonthKey = displayMonths[0].key; // "2026_06"
const thisMonthRev = monthRev[0];
const thisMonthGross = monthGross[0];
let monthPayment = 0, monthCost = 0;
for (const pf of pfs) {
let budget = [];
try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
for (const b of budget) {
const bKey = (b.month || "").replace("-", "_");
if (bKey === thisMonthKey) {
monthPayment += parseFloat(b.payment || 0);
monthCost += parseFloat(b.cost || 0);
break;
}
}
}
monthPayment = Math.round(monthPayment);
monthCost = Math.round(monthCost);
const monthCashflow = monthPayment - monthCost;
const renderPfRow = (pf) => {
const mCols = months.map(m => {
const rev = pf["rev_"+m] || 0;
const gross = pf["gross_"+m] || 0;
return `<td class="p-2 text-right whitespace-nowrap"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
}).join("");
const totalRev = pf.total_rev || 0;
const totalGross = pf.total_gross || 0;
const totalCol = `<td class="p-2 text-right whitespace-nowrap font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
const sm = pf.sign_month || "";
const signMonthCell = `<td class="p-2 text-center text-sm" onclick="event.stopPropagation()"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium">${pf.customer_name}</td><td class="p-2 text-sm">${pf.business_type}</td><td class="p-2 text-sm">${pf.status === "已签约" ? badge("已签约") : pf.status === "流程中" ? badge("流程中","blue") : badge("待签约","amber")}</td>${signMonthCell}<td class="p-2 text-right text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}<td class="p-2 text-sm text-slate-500">${pf.sales_person || ""}</td></tr>`;
};
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
<div class="grid grid-cols-6 gap-3">
${[["已签项目","" + signed.length],["签约金额",money(sumSign)],["流程项目","" + inContract.length],["流程金额",money(sumContract)],["待签项目","" + pending.length],["待签金额",money(sumPending)]].map(([l,v]) => `<div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs text-slate-500">${l}</p><p class="text-xl font-bold text-slate-800">${v}</p></div>`).join("")}
</div>
<div class="grid grid-cols-5 gap-3">
${[["本月确收",money(thisMonthRev)],["本月毛利",money(thisMonthGross)],["本月回款",money(monthPayment)],["本月费用",money(monthCost)],["本月现金流",money(monthCashflow)]].map(([l,v]) => `<div class="bg-white rounded-lg border border-slate-200 p-3 text-center"><p class="text-xs text-slate-500">${l}</p><p class="text-xl font-bold text-slate-800">${v}</p></div>`).join("")}
</div>
<div class="flex justify-end"><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></div>
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-5xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><button class="btn btn-ghost btn-sm text-blue-600 hidden" id="financeTransferBtn" onclick="event.stopPropagation();transferFinance()"><i data-lucide="move-right"></i>转移</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div>
<div class="finance-tabs">
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')">基本信息</button>
<button class="finance-tab" data-tab="budget" onclick="switchFinanceTab('budget')">月度预算</button>
</div>
<form onsubmit="createFinance(event)" class="p-8 grid gap-6"><input type="hidden" name="pf_id" id="pf-id-input" value="">
<div id="financeTabInfo"><div class="grid grid-cols-2 gap-6"><div class="bg-slate-50 rounded-xl p-5"><h4 class="text-sm font-semibold text-slate-500 mb-4 flex items-center gap-2"><i data-lucide="briefcase-business"></i>基本信息</h4><div class="grid gap-4"><label class="block"><span class="text-xs font-medium text-slate-500">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm bg-slate-100 cursor-not-allowed" value="${state.tenant}" disabled></label><label class="block"><span class="text-xs font-medium text-slate-500">业务类型</span><select name="business_type" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm bg-white">${fmTypes.map(t => `<option>${t}</option>`).join("")}</select></label><label class="block"><span class="text-xs font-medium text-slate-500">客户名称</span><input name="customer_name" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm"></label></div></div><div class="bg-slate-50 rounded-xl p-5"><h4 class="text-sm font-semibold text-slate-500 mb-4 flex items-center gap-2"><i data-lucide="banknote"></i>签约信息</h4><div class="grid gap-4"><label class="block"><span class="text-xs font-medium text-slate-500">签约金额(元)</span><input name="sign_amount" type="number" step="0.01" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm" placeholder="0.00"></label><label class="block"><span class="text-xs font-medium text-slate-500">签约月份</span><select name="sign_month" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm bg-white"><option value="">选择月份</option>${monthOptions('')}</select></label><label class="block"><span class="text-xs font-medium text-slate-500">项目状态</span><select name="status" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm bg-white"><option>已签约</option><option>流程中</option><option>待签约</option></select></label><label class="block"><span class="text-xs font-medium text-slate-500">历史总确收</span><input name="total_rev" type="number" step="0.01" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm" placeholder="0.00"></label><label class="block"><span class="text-xs font-medium text-slate-500">历史总毛利</span><input name="total_gross" type="number" step="0.01" class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2.5 text-sm" placeholder="0.00"></label></div></div></div></div>
<div id="financeTabBudget" class="hidden"><div class="bg-slate-50 rounded-xl p-5">
<table class="w-full text-sm" id="budgetTable">
<thead><tr class="border-b border-slate-200"><th class="p-2 text-left font-medium text-slate-500" style="min-width:100px">月份</th><th class="p-2 text-right font-medium text-slate-500">毛利</th><th class="p-2 text-right font-medium text-slate-500">确收</th><th class="p-2 text-right font-medium text-slate-500">回款</th><th class="p-2 text-right font-medium text-slate-500">费用</th><th class="p-2 w-8"></th></tr></thead>
<tbody id="budgetTbody"></tbody>
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addBudgetRow()"><i data-lucide="plus"></i>添加月份</button>
</div></div>
<div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>
${card(`<h3 class="font-bold text-slate-700 mb-3">项目明细 <span class="text-slate-400 font-normal">(${pfs.length})</span></h3><div class="flex gap-2 mb-3">${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => `<button class="btn btn-sm ${state.finFilter === k ? 'btn-primary' : 'btn-ghost'}" onclick="state.finFilter='${k}';renderFinance()">${v} (${pfs.filter(x=>x.status===k).length})</button>`).join("")}</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-left font-semibold">客户</th><th class="p-2 text-left font-semibold">类型</th><th class="p-2 text-left font-semibold">状态</th><th class="p-2 text-center font-semibold">签约月份</th><th class="p-2 text-right font-semibold">签约金额</th>${monthLabels.map(l => `<th class="p-2 text-center font-semibold">${l}<br><span class="text-xs text-slate-400">确收/毛利</span></th>`).join("")}<th class="p-2 text-center font-semibold">总计<br><span class="text-xs text-slate-400">确收/毛利</span></th><th class="p-2 text-left font-semibold">销售</th></tr></thead><tbody>${pfs.filter(x => x.status === state.finFilter).map(renderPfRow).join("")}</tbody></table></div>`, "p-4")}
</div>`;
}
window.openFinanceModal = () => {
const modal = document.querySelector("#financeModal");
const form = modal.querySelector("form");
form.querySelector('[name="project_id"]').value = state.tenant;
const dept = form.querySelector('input[disabled]');
if (dept) dept.value = state.tenant;
// 新增时初始化默认12行编辑时不重置
const pfIdInput = form.querySelector('[name="pf_id"]');
if (!pfIdInput || !pfIdInput.value) {
initBudgetTable(null);
document.querySelector("#financeTransferBtn").classList.add("hidden");
}
modal.classList.remove("hidden");
};
window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = '') => {
const tbody = document.querySelector("#budgetTbody");
if (!tbody) return;
const row = document.createElement("tr");
row.innerHTML = `<td><select name="budget_month[]" class="w-full rounded border border-slate-200 px-2 py-1.5 text-sm">${monthOptions(month)}</select></td>
<td><input name="budget_rev[]" type="number" step="0.01" class="w-full rounded border border-slate-200 px-2 py-1.5 text-sm text-right" placeholder="0" value="${rev}"></td>
<td><input name="budget_gross[]" type="number" step="0.01" class="w-full rounded border border-slate-200 px-2 py-1.5 text-sm text-right" placeholder="0" value="${gross}"></td>
<td><input name="budget_payment[]" type="number" step="0.01" class="w-full rounded border border-slate-200 px-2 py-1.5 text-sm text-right" placeholder="0" value="${payment}"></td>
<td><input name="budget_cost[]" type="number" step="0.01" class="w-full rounded border border-slate-200 px-2 py-1.5 text-sm text-right" placeholder="0" value="${cost}"></td>
<td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
tbody.appendChild(row);
if (window.lucide) window.lucide.createIcons();
};
window.initBudgetTable = (budgetData) => {
const tbody = document.querySelector("#budgetTbody");
if (!tbody) return;
tbody.innerHTML = "";
const rows = budgetData || [];
if (rows.length === 0) {
// 默认当前年份1-12月
const year = new Date().getFullYear();
for (const m of ["01","02","03","04","05","06","07","08","09","10","11","12"]) {
addBudgetRow(year + "-" + m);
}
} else {
rows.forEach(r => addBudgetRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.cost || ''));
}
};
window.closeFinanceModal = () => {
const modal = document.querySelector("#financeModal");
modal.classList.add("hidden");
};
window.editPfSignMonth = (event, pfId) => {
event.stopPropagation();
const pf = (state.data.projectFinances || []).find(x => x.id === pfId);
if (!pf) return;
const span = event.currentTarget;
const td = span.parentElement;
const currentValue = pf.sign_month || "";
const select = document.createElement("select");
select.innerHTML = monthOptions(currentValue);
select.className = "w-full rounded border border-slate-200 px-1 py-1 text-sm";
select.value = currentValue;
select.addEventListener("change", async () => {
const newValue = select.value;
try {
await api(`/api/projectFinances/${pfId}`, { method: "PUT", body: JSON.stringify({ data: { sign_month: newValue } }) });
pf.sign_month = newValue;
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-blue-600" onclick="editPfSignMonth(event, ${pfId})">${newValue || '—'}</span>`;
} catch (e) { alert("修改失败:" + e.message); }
});
select.addEventListener("blur", () => {
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-blue-600" onclick="editPfSignMonth(event, ${pfId})">${currentValue || '—'}</span>`;
});
td.innerHTML = "";
td.appendChild(select);
select.focus();
};
window.switchFinanceTab = (tab) => {
document.querySelectorAll(".finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
document.querySelector("#financeTabInfo").classList.toggle("hidden", tab !== "info");
document.querySelector("#financeTabBudget").classList.toggle("hidden", tab !== "budget");
};
window.openPfEditModal = (pfId) => {
const pf = (state.data.projectFinances || []).find(x => x.id === pfId);
if (!pf) return;
document.querySelector("#pf-id-input").value = pf.id;
document.querySelector("#financeModalTitle").textContent = "编辑项目财务";
document.querySelector("#financeTransferBtn").classList.remove("hidden");
const form = document.querySelector("#financeModal form");
form.querySelector('[name="project_id"]').value = pf.project_id || "";
const deptDisplay = form.querySelector('.bg-slate-50 [disabled]');
if (deptDisplay) deptDisplay.value = pf.project_id || "";
form.querySelector('[name="business_type"]').value = pf.business_type || "";
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
form.querySelector('[name="sign_amount"]').value = pf.sign_amount || "";
const signMonthValue = pf.sign_month || "";
const signMonthEl = form.querySelector('[name="sign_month"]');
if (signMonthEl && signMonthValue) {
signMonthEl.innerHTML = monthOptions(signMonthValue);
signMonthEl.value = signMonthValue;
}
form.querySelector('[name="status"]').value = pf.status || "待签约";
form.querySelector('[name="total_rev"]').value = pf.total_rev || "";
form.querySelector('[name="total_gross"]').value = pf.total_gross || "";
// 回填预算表
let budgetData = [];
try { budgetData = JSON.parse(pf.budget_data || "[]"); } catch (e) { budgetData = []; }
if (budgetData.length === 0) {
// 如果没有新数据从旧12月字段构建
for (const m of ["01","02","03","04","05","06","07","08","09","10","11","12"]) {
const rev = pf["rev_2026_" + m] || 0;
const gross = pf["gross_2026_" + m] || 0;
if (rev || gross) budgetData.push({ month: "2026-" + m, rev: rev, gross: gross });
}
}
initBudgetTable(budgetData.length ? budgetData : null);
openFinanceModal();
};
function renderChartOn(id, data) {
const canvas = document.querySelector(`#${id}`);
if (!canvas || !window.Chart) return;
if (state.chart2) state.chart2.destroy();
state.chart2 = new Chart(canvas, {
type: "line",
data: {
labels: data.map((x) => x.month),
datasets: [
{ label: "收入", data: data.map((x) => x.revenue), borderColor: "#2563eb", tension: 0.3 },
{ label: "毛利", data: data.map((x) => x.gross_profit), borderColor: "#059669", tension: 0.3 },
{ label: "成本/费用", data: data.map((x) => x.cost_expense), borderColor: "#d97706", tension: 0.3 },
{ label: "净利", data: data.map((x) => x.net_profit), borderColor: "#7c3aed", tension: 0.3 },
],
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } }, scales: { x: { ticks: { font: { size: 11 } }, grid: { display: false } }, y: { ticks: { font: { size: 11 } } } } },
});
}
function drawerField(icon, label, name, value, multiline = false, customControl = null) {
const initialValue = text(value);
const control = customControl
? customControl
: multiline
? `<textarea name="${name}" rows="2" class="drawer-value drawer-textarea" data-original="${initialValue}">${initialValue}</textarea>`
: `<input name="${name}" value="${initialValue}" class="drawer-value" data-original="${initialValue}">`;
return `<div class="drawer-field">
<div class="drawer-field-label"><i data-lucide="${icon}"></i><span>${label}</span></div>
<div class="drawer-field-control">${control}</div>
</div>`;
}
function openDrawer(resource, id) {
const list = resource === "sales" ? state.data.sales : resource === "operations" ? state.data.operations : resource === "proposals" ? state.data.proposals : state.data.products;
const item = list.find((x) => x.id === id);
const drawer = document.querySelector("#drawer");
const fields = resource === "sales"
? [["target_customer","业务机会"],["priority","优先级"],["status","状态"]]
: resource === "operations"
? [["project_name","项目名称"],["owner","负责人"],["expected_sign_date","截止时间"],["expected_contract_amount","金额"],["notes","项目说明"]]
: resource === "proposals"
? [["customer_or_project_name","客户/项目"],["proposal_type","方案类型"],["notes","备注"]]
: [["product_name","产品名称"],["version","版本号"],["version_goal","版本目标"],["feature_list","核心功能"],["launch_date","上线日期"],["status","状态"],["notes","备注"]];
const fieldIcons = {
target_customer: "user", priority: "flag", status: "circle-dot",
project_name: "briefcase-business", project_version: "git-branch", project_status: "circle-dot", current_stage: "map-pin",
owner: "user", customer_need: "file-text", expected_contract_amount: "banknote", expected_sign_date: "calendar",
sign_probability: "percent", sop_stage: "list-checks", execution_progress: "activity",
current_deliverable: "package", risks: "alert-triangle", next_action: "arrow-right",
product_name: "box", version: "tag", version_goal: "target", feature_list: "list", platform: "layers",
launch_date: "calendar", notes: "sticky-note", proposal_type: "tag", customer_or_project_name: "building"
};
const multilineFields = ["customer_need", "current_deliverable", "risks", "next_action", "version_goal", "feature_list", "notes"];
const followupTarget = resource === "sales" ? "sales" : resource === "proposals" ? "proposal" : resource === "operations" ? "operation" : resource === "products" ? "product" : "";
const title = item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name;
drawer.innerHTML = `<div class="drawer-panel"><div class="sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white/95 px-5 py-3 backdrop-blur"><div><p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Detail Drawer</p><div class="flex items-center gap-2"><h2 class="drawer-title text-[17px] font-semibold leading-6 text-slate-900">${title}</h2><span id="drawerSaveStatus" class="save-status"></span></div></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-blue-600 hover:bg-blue-50" onclick="openTransferModal('${resource}', ${id}, '${title.replace(/'/g, "\\'")}')" ${resource === 'operations' ? '' : 'style="display:none"'}><i data-lucide="move-right"></i>转移</button><button class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteDrawerItem('${resource}', ${id})"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm" onclick="closeDrawer()">关闭</button></div></div><div class="grid gap-5 p-5">
<section>
<h3 class="drawer-section-title">属性</h3>
<form id="drawerForm" class="drawer-fields">
${drawerField("map-pin", "当前阶段", "current_stage", "", false, `<select name="current_stage" class="drawer-value" onchange="saveDrawerField(this,'${resource}',${id})">${["商务洽谈","系统上线","团队分工","项目交付","上线推广","结项验收"].map((s) => `<option ${s === item.current_stage ? "selected" : ""}>${s}</option>`).join("")}</select>`)}
${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>${fileGroup("proposal", item.id, "", "附件", item.files || [])}</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>
<form class="comment-box mt-3" onsubmit="submitComment(event,'${followupTarget}',${item.id},'${resource}')">
<div class="squire-toolbar">
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('bold')" title="加粗"><i data-lucide="bold"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('italic')" title="斜体"><i data-lucide="italic"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('underline')" title="下划线"><i data-lucide="underline"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('strikethrough')" title="删除线"><i data-lucide="strikethrough"></i></button>
<span class="squire-sep"></span>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('makeUnorderedList')" title="无序列表"><i data-lucide="list"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('makeOrderedList')" title="有序列表"><i data-lucide="list-ordered"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('blockquote')" title="引用"><i data-lucide="quote"></i></button>
<span class="squire-sep"></span>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('undo')" title="撤销"><i data-lucide="undo"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('redo')" title="重做"><i data-lucide="redo"></i></button>
</div>
<div class="squire-editor" id="squire_${resource}_${item.id}" placeholder="添加评论"></div>
<div class="comment-toolbar">
<span class="comment-hint">支持富文本编辑</span>
<button class="btn btn-primary btn-sm comment-submit" type="submit">评论</button>
</div>
</form>
</section>` : ""}
<div id="uploadTaskList"></div>
</div></div>`;
drawer.classList.add("open");
bindDrawerAutosave(resource, item.id, item);
if (window.lucide) window.lucide.createIcons();
renderUploadTasks();
// Decode and render rich HTML content in followup records
drawer.querySelectorAll(".rich-content").forEach((el) => {
const html = el.dataset.html;
if (html) el.innerHTML = decodeURIComponent(html);
});
// Initialize Squire editor
const squireDiv = drawer.querySelector(".squire-editor");
if (squireDiv && window.Squire) {
const id = squireDiv.id;
if (window.squireInstances[id]) window.squireInstances[id].destroy();
const sq = new Squire(squireDiv, { blockTag: "P" });
sq.addEventListener("input", () => {
const form = squireDiv.closest("form");
const btn = form.querySelector(".comment-submit");
});
window.squireInstances[id] = sq;
// Handle placeholder
squireDiv.addEventListener("focus", () => squireDiv.classList.add("focused"));
squireDiv.addEventListener("blur", () => {
if (!squireDiv.textContent.trim()) squireDiv.classList.remove("focused");
});
}
}
function setDrawerSaveStatus(message, tone = "muted") {
const el = document.querySelector("#drawerSaveStatus");
if (!el) return;
el.textContent = message;
el.dataset.tone = tone;
}
function bindDrawerAutosave(resource, id, item) {
document.querySelectorAll("#drawerForm .drawer-value").forEach((field) => {
field.addEventListener("keydown", (event) => {
if (event.key === "Enter" && field.tagName !== "TEXTAREA") field.blur();
});
const doSave = async () => {
const value = field.value;
if (value === field.dataset.original) return;
const previous = field.dataset.original;
field.dataset.original = value;
setDrawerSaveStatus("保存中…");
try {
await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { [field.name]: value } }) });
item[field.name] = value;
const titleValue = item.target_customer || item.project_name || item.customer_or_project_name || item.product_name;
const titleEl = document.querySelector(".drawer-title");
if (titleEl) titleEl.textContent = titleValue;
render();
setDrawerSaveStatus("已保存", "success");
setTimeout(() => setDrawerSaveStatus(""), 1200);
} catch (error) {
field.dataset.original = previous;
setDrawerSaveStatus("保存失败", "danger");
alert(`自动保存失败:${error.message}`);
}
};
field.addEventListener("blur", doSave);
if (field.tagName === "SELECT") field.addEventListener("change", doSave);
});
}
window.openDrawer = openDrawer;
window.deleteDrawerItem = async (resource, id) => {
if (!confirm("确认删除?此操作不可撤销。")) return;
try {
// 记录删除前的名称用于活动日志
const listKey = { sales: "sales", proposals: "proposals", operations: "operations", products: "products" }[resource];
let name = "";
if (listKey && state.data[listKey]) {
const item = state.data[listKey].find(x => x.id === id);
name = item ? (item.target_customer || item.project_name || item.customer_or_project_name || item.product_name || "") : "";
}
await api(`/api/${resource}/${id}`, { method: "DELETE" });
if (name) {
const resType = { sales: "sales", proposals: "proposal", operations: "operation", products: "product" }[resource] || resource;
logActivity(resType, id, "删除了「" + name + "」");
}
closeDrawer();
await load();
} catch (error) {
alert("删除失败:" + error.message);
}
};
window.openTransferModal = (resource, id, title) => {
document.querySelector("#transfer-resource").value = resource;
document.querySelector("#transfer-id").value = id;
document.querySelector("#transfer-title-text").textContent = "将「" + title + "」转移到:";
document.querySelector("#transferModal").classList.remove("hidden");
};
window.closeTransferModal = () => {
document.querySelector("#transferModal").classList.add("hidden");
};
window.submitTransfer = async (event) => {
event.preventDefault();
const form = event.currentTarget;
const resource = form.querySelector('[name="transfer_resource"]').value;
const id = form.querySelector('[name="transfer_id"]').value;
const newTenant = form.querySelector('[name="transfer_tenant"]').value;
try {
await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { tenant: newTenant } }) });
closeTransferModal();
closeDrawer();
await load();
} catch (error) {
alert("转移失败:" + error.message);
}
};
window.transferFinance = async () => {
const pfId = document.querySelector("#pf-id-input").value;
if (!pfId) return;
const pf = (state.data.projectFinances || []).find(x => x.id === parseInt(pfId));
if (!pf) return;
const title = pf.customer_name || "财务项目";
document.querySelector("#transfer-resource").value = "projectFinances";
document.querySelector("#transfer-id").value = pfId;
document.querySelector("#transfer-title-text").textContent = "将「" + title + "」的财务数据转移到:";
document.querySelector("#transferModal").classList.remove("hidden");
};
window.cycleTaskStatus = async (taskId, projectId) => {
const tasks = state.data.tasks || [];
const task = tasks.find((t) => t.id === taskId);
if (!task) return;
const statuses = ["未开始", "进行中", "验收中", "已结束"];
const current = statuses.indexOf(task.status) >= 0 ? task.status : "未开始";
const newStatus = statuses[(statuses.indexOf(current) + 1) % statuses.length];
try {
await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data: { status: newStatus } }) });
task.status = newStatus;
const row = document.querySelector(`.task-item[data-id="${taskId}"]`);
if (row) {
row.classList.toggle("task-done", newStatus === "已结束");
const badge = row.querySelector(".task-status-badge");
if (badge) {
badge.textContent = newStatus;
badge.className = "task-status-badge status-" + newStatus;
}
}
} catch (error) {
alert("更新失败:" + error.message);
}
};
window.deleteTask = async (projectId) => {
const taskId = document.querySelector(`#task-id-${projectId}`).value;
if (!taskId) return;
if (!confirm("确认删除该任务?此操作不可撤销。")) return;
try {
const task = (state.data.tasks || []).find(t => t.id === parseInt(taskId));
const taskName = task ? task.task : "";
await api(`/api/tasks/${taskId}`, { method: "DELETE" });
if (taskName) logActivity("task", taskId, "删除了任务「" + taskName + "」");
closeTaskDrawer(projectId);
state.data.tasks = (state.data.tasks || []).filter(t => t.id !== parseInt(taskId));
// 只移除该 DOM 行,不重渲染
const row = document.querySelector(`.task-item[data-id="${taskId}"]`);
if (row) row.remove();
} catch (error) {
alert("删除失败:" + error.message);
}
};
let dragTaskId = null;
window.handleTaskDragStart = (event, taskId) => {
dragTaskId = taskId;
event.currentTarget.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
};
window.handleTaskDrop = async (event, projectId, phase) => {
event.preventDefault();
event.currentTarget.classList.remove("drag-over");
const target = event.currentTarget;
if (!dragTaskId) return;
// Find the dragged element and insert after the nearest task
const dragged = document.querySelector(`.task-item[data-id="${dragTaskId}"]`);
if (!dragged) return;
const afterElement = getDragAfterElement(target, event.clientY);
if (afterElement) {
target.insertBefore(dragged, afterElement);
} else {
target.appendChild(dragged);
}
dragged.classList.remove("dragging");
// Update sort_order in DB
const rows = [...target.querySelectorAll(".task-item")];
const updates = rows.map((row, i) => ({ id: parseInt(row.dataset.id), sort_order: i }));
try {
await api(`/api/tasks/batch-sort`, { method: "POST", body: JSON.stringify({ items: updates }) });
} catch (e) { /* non-critical */ }
dragTaskId = null;
};
function getDragAfterElement(container, y) {
const elements = [...container.querySelectorAll(".task-item:not(.dragging)")];
return elements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
}
return closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
window.closeDrawer = () => document.querySelector("#drawer").classList.remove("open");
window.squireInstances = {};
window.squireCmd = (cmd) => {
const currentEditor = document.querySelector(".squire-editor");
if (!currentEditor) return;
const id = currentEditor.id;
const sq = window.squireInstances[id];
if (!sq) return;
sq.focus();
setTimeout(() => {
if (cmd === "bold") {
sq.hasFormat("b") || sq.hasFormat("strong") ? sq.removeBold() : sq.bold();
} else if (cmd === "italic") {
sq.hasFormat("i") || sq.hasFormat("em") ? sq.removeItalic() : sq.italic();
} else if (cmd === "underline") {
sq.hasFormat("u") ? sq.changeFormat(null, { tag: "u" }, null) : sq.changeFormat({ tag: "u" }, null, null);
} else if (cmd === "strikethrough") {
sq.hasFormat("s") || sq.hasFormat("del") || sq.hasFormat("strike") ? sq.changeFormat(null, { tag: "s" }, null) : sq.changeFormat({ tag: "s" }, null, null);
} else {
sq[cmd]();
}
}, 10);
};
window.submitComment = async (event, targetType, targetId, resource) => {
event.preventDefault();
const form = event.currentTarget;
const editorDiv = form.querySelector(".squire-editor");
const sq = window.squireInstances[editorDiv.id];
const content = sq ? sq.getHTML().trim() : "";
if (!content || content === "<div><br></div>" || content === "<p><br></p>") return;
const button = form.querySelector(".comment-submit");
button.disabled = true;
button.textContent = "发送中…";
await api(`/api/followups/${targetType}/${targetId}`, { method: "POST", body: JSON.stringify({ data: { content } }) });
await load();
openDrawer(resource, targetId);
};
window.deleteActivity = async (id) => {
if (!confirm("确认删除这条动态?")) return;
await api(`/api/followups/${id}`, { method: "DELETE" });
await load();
};
window.deleteFollowup = async (event, followupId, resource, targetId) => {
event.stopPropagation();
if (!confirm("确认删除这条评论?")) return;
await api(`/api/followups/${followupId}`, { method: "DELETE" });
await load();
openDrawer(resource, targetId);
};
document.querySelector("#tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-tab]");
if (button) switchTab(button.dataset.tab);
});
// 恢复上次的工作台和标签页
const savedTenant = localStorage.getItem("opc-active-tenant");
if (savedTenant) {
state.tenant = savedTenant;
document.querySelectorAll(".workspace-nav-item").forEach(el => el.classList.toggle("active", el.dataset.tenant === savedTenant));
const label = savedTenant.replace("·无界", "");
document.querySelector("#workspaceTitle").textContent = label + " OPC 工作台";
}
const savedTab = localStorage.getItem("opc-active-tab");
load().then(() => {
if (savedTab && savedTab !== "home") switchTab(savedTab);
}).catch((error) => {
document.querySelector("main").innerHTML = `<section class="card p-6 text-red-700">加载失败:${error.message}</section>`;
});