|
|
|
|
@@ -93,6 +93,7 @@ function render() {
|
|
|
|
|
|
|
|
|
|
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)],
|
|
|
|
|
@@ -114,16 +115,14 @@ function renderHome() {
|
|
|
|
|
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">
|
|
|
|
|
<div class="grid grid-cols-6 gap-3">
|
|
|
|
|
${[
|
|
|
|
|
["P0 客户数", m.p0_customers, "projects"],
|
|
|
|
|
["跟进中销售机会", m.active_sales, "projects"],
|
|
|
|
|
["已签约执行项目", m.execution_projects, "projects"],
|
|
|
|
|
["有风险项目", m.risk_projects, "projects"],
|
|
|
|
|
["本月收入", money(m.monthly_revenue), "finance"],
|
|
|
|
|
["重点项目", m.total_projects, "projects"],
|
|
|
|
|
["业务方案", m.total_proposals, "proposals"],
|
|
|
|
|
["产品版本", m.total_products, "products"],
|
|
|
|
|
["本月确收", money(m.monthly_revenue), "finance"],
|
|
|
|
|
["本月毛利", money(m.monthly_gross || m.monthly_net_profit), "finance"],
|
|
|
|
|
["本月净利", money(m.monthly_net_profit), "finance"],
|
|
|
|
|
["即将上线版本", m.upcoming_products, "products"],
|
|
|
|
|
["已签约未执行", money(m.signed_not_executed), "finance"],
|
|
|
|
|
].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>
|
|
|
|
|
@@ -284,11 +283,11 @@ function renderProjectTasks(projectId) {
|
|
|
|
|
${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="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>`;
|
|
|
|
|
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" 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-row ${t.status === 'done' ? 'task-done' : ''}" data-id="${t.id}" draggable="true" ondragstart="handleTaskDragStart(event, ${t.id})" ondragend="event.currentTarget.classList.remove('dragging')"><span class="task-dot" onclick="event.stopPropagation(); toggleTaskDone(${t.id}, ${projectId})"><i data-lucide="${t.status === 'done' ? 'check-circle' : 'circle'}"></i></span><div class="task-main" onclick="openTaskForm(${projectId}, ${t.id})"><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>
|
|
|
|
|
<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>
|
|
|
|
|
@@ -587,6 +586,69 @@ window.deleteOperation = async (id) => {
|
|
|
|
|
alert("删除失败:" + error.message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
window.toggleTaskDone = async (taskId, projectId) => {
|
|
|
|
|
const task = (state.data.tasks || []).find((t) => t.id === taskId);
|
|
|
|
|
if (!task) return;
|
|
|
|
|
const newStatus = task.status === "done" ? "" : "done";
|
|
|
|
|
try {
|
|
|
|
|
await api(`/api/tasks/${taskId}`, { method: "PUT", body: JSON.stringify({ data: { status: newStatus } }) });
|
|
|
|
|
await load();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
alert("更新失败:" + error.message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
window.deleteTask = async (projectId) => {
|
|
|
|
|
const taskId = document.querySelector(`#task-id-${projectId}`).value;
|
|
|
|
|
if (!taskId) return;
|
|
|
|
|
if (!confirm("确认删除该任务?此操作不可撤销。")) return;
|
|
|
|
|
try {
|
|
|
|
|
await api(`/api/tasks/${taskId}`, { method: "DELETE" });
|
|
|
|
|
closeTaskDrawer(projectId);
|
|
|
|
|
await load();
|
|
|
|
|
} 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-row[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-row")];
|
|
|
|
|
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-row: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) => {
|
|
|
|
|
|