Compare commits

...

7 Commits

3 changed files with 138 additions and 56 deletions

View File

@@ -170,56 +170,86 @@ window.createSales = (event) => createResource(event, "sales");
window.createProposal = (event) => createResource(event, "proposals");
window.createOperation = (event) => createResource(event, "operations");
window.createProduct = (event) => createResource(event, "products");
async function createTask(event, projectId) {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
data.project_id = projectId;
try {
await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) });
form.reset();
form.classList.add("hidden");
await load();
showTaskModal(projectId);
} catch (error) {
alert("创建失败:" + error.message);
}
}
window.createFinance = (event) => createResource(event, "finance");
window.switchTab = switchTab;
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 }));
const items = state.data.operations;
const rows = items.map((x) => [
`<strong>${x.project_name}</strong>`,
text(x.customer_need || x.notes),
badge(x.current_stage || x.project_status),
x.expected_contract_amount ? money(x.expected_contract_amount) : "—",
text(x.owner || "—"),
`<button class="btn btn-ghost btn-sm text-blue-600" onclick="event.stopPropagation(); showTaskModal(${x.id})"><i data-lucide="eye"></i>查看</button>`
]);
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")}
${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}'; renderProjects()">${v}</button>`).join("")}</div>
${renderTable(["项目/客户", "类型", "状态", "金额", "当前阶段", "最新跟进"], rows, clicks)}
<div class="flex items-center justify-between">
<div class="flex gap-2">
${[["all","全部"],["项目准备","准备"],["项目执行","执行"],["项目验收","验收"],["验收完毕","完毕"]].map(([k,v]) => `<button class="btn ${state.opFilter === k ? "btn-primary" : "btn-ghost"} btn-sm" onclick="state.opFilter='${k}'; renderProjects()">${v}</button>`).join("")}
</div>
<button class="btn btn-primary" onclick="document.querySelector('#project-form').classList.toggle('hidden')">
<i data-lucide="plus"></i>新增项目
</button>
</div>
<div id="project-form" class="hidden">
${card(formHtml([
{ label: "项目名称", input: `<input name="project_name" required>` },
{ label: "当前阶段", input: `<select name="current_stage"><option>项目准备</option><option></option><option></option><option></option></select>` },
{ label: "项目金额", input: `<input name="expected_contract_amount" type="number" step="0.01" placeholder="万元">` },
{ label: "负责人", input: `<input name="owner">` },
], { handler: "createOperation", text: "确认新增" }), "p-4")}
</div>
${renderTable(["项目", "项目说明", "当前阶段", "项目金额", "负责人", "进展"], rows, items.map((x) => ({ resource: "operations", id: x.id })))}
</div>`;
}
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 phases = ["项目准备", "项目执行", "项目验收", "验收完毕"];
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(); document.querySelector('#task-form-${projectId}').classList.toggle('hidden')"><i data-lucide="plus"></i>新增任务</button><button class="task-close" onclick="closeTaskModal()"><i data-lucide="x"></i></button></div></div><div class="task-body">
<form id="task-form-${projectId}" class="hidden task-form" onsubmit="createTask(event, ${projectId})">
<div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>任务名称</span><input name="task" required></label>
<label class="task-field"><span>任务阶段</span><select name="phase">${phases.map((p) => `<option>${p}</option>`).join("")}</select></label>
<label class="task-field"><span>负责人</span><input name="owner"></label>
<label class="task-field"><span>截止时间</span><input name="due_date" type="date"></label>
<label class="task-field col-span-2"><span>任务说明</span><textarea name="notes" rows="2"></textarea></label>
</div>
<div class="flex justify-end gap-2 mt-3">
<button type="button" class="btn btn-ghost btn-sm" onclick="document.querySelector('#task-form-${projectId}').classList.add('hidden')">取消</button>
<button type="submit" class="btn btn-primary btn-sm">确认新增</button>
</div>
</form>
${phases.map((phase) => {
const pt = tasks.filter((t) => t.phase === phase);
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.length ? pt.map((t) => `<div class="task-row" data-id="${t.id}"><span class="task-dot"><i data-lucide="${t.status === 'done' ? 'check-circle' : 'circle'}"></i></span><span class="task-name">${t.task}</span>${(t.owner || t.due_date) ? `<span class="task-meta">${t.owner || ""}${t.owner && t.due_date ? " · " : ""}${t.due_date || ""}</span>` : ""}</div>`).join("") : `<div class="task-none">暂无任务</div>`}</div></div>`;
}).join("")}</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 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 }));
@@ -354,20 +384,6 @@ 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>

View File

@@ -489,3 +489,68 @@ td {
color: #64748b;
margin: 4px 0;
}
/* Task Modal — Plane style */
.task-modal { display: none; }
.task-modal.active { display: block; }
.task-overlay {
position: fixed; inset: 0; background: rgba(10,12,16,0.5); z-index: 200;
display: flex; align-items: flex-start; justify-content: center;
padding-top: 48px; overflow-y: auto;
}
.task-panel {
background: #18191c; border-radius: 12px; width: 600px; max-width: 92vw;
box-shadow: 0 24px 80px rgba(0,0,0,0.4); margin-bottom: 48px;
}
.task-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid #2a2d34;
}
.task-title { color: #e4e5e7; font-size: 15px; font-weight: 600; }
.task-close {
color: #6b6d75; background: none; border: none; cursor: pointer;
padding: 4px; border-radius: 6px; display: flex;
}
.task-close:hover { color: #e4e5e7; background: #2a2d34; }
.task-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.task-group {
background: #1e2025; border-radius: 8px;
border: 1px solid #2a2d34; overflow: hidden;
}
.task-group-hd {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
}
.task-group-icon { color: #6b6d75; display: flex; }
.task-group-label { color: #aeafb4; font-size: 13px; font-weight: 600; }
.task-group-n {
background: #2a2d34; color: #6b6d75; font-size: 11px;
padding: 1px 7px; border-radius: 10px;
}
.task-group-list { display: flex; flex-direction: column; }
.task-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px; border-top: 1px solid #24272d;
}
.task-dot { display: flex; color: #4b4d54; }
.task-name { color: #c5c6ca; font-size: 13px; }
.task-meta { color: #5a5c63; font-size: 11px; margin-left: auto; white-space: nowrap; }
.task-none { color: #4b4d54; font-size: 13px; padding: 12px 14px; text-align: center; border-top: 1px solid #24272d; }
.task-form {
background: #141518; border: 1px solid #2a2d34; border-radius: 8px;
padding: 14px; margin-bottom: 16px;
}
.task-field { display: flex; flex-direction: column; gap: 4px; }
.task-field span { color: #6b6d75; font-size: 12px; }
.task-field input, .task-field select, .task-field textarea {
background: #1e2025; border: 1px solid #2a2d34; border-radius: 6px;
color: #e4e5e7; font-size: 13px; padding: 6px 10px; outline: none;
}
.task-field input:focus, .task-field select:focus, .task-field textarea:focus { border-color: #4a6cf7; }
.col-span-2 { grid-column: span 2; }
.task-group-add {
display: block; width: 100%; padding: 10px; text-align: center;
color: #6b6d75; font-size: 13px; background: none; border: none;
border-top: 1px solid #24272d; cursor: pointer;
}
.task-group-add:hover { color: #e4e5e7; background: #24272d; }

View File

@@ -51,6 +51,7 @@
</main>
<aside id="drawer" class="drawer" aria-hidden="true"></aside>
<div id="taskModal" class="task-modal"></div>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>