feat: 新增采购模块 + 财务表格重建

采购模块:
- 新增 purchase_records 表 + CRUD 路由 + bootstrap 数据加载
- 前端采购管理页面: 表格 + 弹窗新增/编辑 + 侧边栏入口

财务模块:
- 表格新增签约金额、执行率、回款率、付款率、毛利、毛利率列
- 状态/日期筛选统一为无边框下拉框,移至页面顶部工具栏
- 删除 metric 卡片,新增 11 张汇总卡片(签约金额~现金流)
- 表格合计行移至表头下方,后改为汇总卡片
- 所有单元格统一水平/垂直居中
- 修复季度视图 gross 变量缺失、合计行遗漏
This commit is contained in:
mac
2026-07-06 13:04:56 +08:00
parent ff7eb19d5d
commit 8430c93b97
6 changed files with 148 additions and 25 deletions

View File

@@ -146,6 +146,20 @@ def migrate_create_tables():
budget_data TEXT,
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
"""CREATE TABLE IF NOT EXISTS purchase_records (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界',
project_name VARCHAR(200) NOT NULL DEFAULT '',
purchase_item VARCHAR(300) NOT NULL DEFAULT '',
supplier VARCHAR(200) NOT NULL DEFAULT '',
amount DOUBLE NOT NULL DEFAULT 0,
purchase_date VARCHAR(30) NOT NULL DEFAULT '',
status VARCHAR(50) NOT NULL DEFAULT '待审批',
category VARCHAR(100) NOT NULL DEFAULT '',
notes VARCHAR(1000) NOT NULL DEFAULT '',
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
]

View File

@@ -24,6 +24,7 @@ TABLES = {
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]),
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]),
"purchase": ("purchase_records", ["project_name", "purchase_item", "supplier", "amount", "purchase_date", "status", "category", "notes", "tenant"]),
}
# ---------- 鉴权装饰器 ----------
@@ -321,7 +322,7 @@ def bootstrap():
"recent": sorted(all_recent, key=lambda x: x.get("id", 0), reverse=True)[:8],
"risks": [],
}
return jsonify({"summary": summary, "sales": [], "proposals": [], "operations": [], "products": [], "finance": [], "projectFinances": [], "financeMonthly": merged_monthly, "tasks": [], "tenant": tenant, "tenants": allowed})
return jsonify({"summary": summary, "sales": [], "proposals": [], "operations": [], "products": [], "finance": [], "projectFinances": [], "financeMonthly": merged_monthly, "tasks": [], "purchase": [], "tenant": tenant, "tenants": allowed})
def q(sql, *args):
return rows(conn, sql, args)
@@ -332,6 +333,7 @@ def bootstrap():
finance = q("SELECT * FROM finance_records WHERE tenant=? ORDER BY month DESC, id DESC", tenant)
tasks = q("SELECT * FROM project_tasks WHERE tenant=? ORDER BY phase, sort_order, id", tenant)
pfs = q("SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", tenant)
purchase = q("SELECT * FROM purchase_records WHERE tenant=? ORDER BY id DESC", tenant)
signed_pfs = [x for x in pfs if x["status"] == "已签约"]
def parse_budget(pf):
@@ -434,7 +436,7 @@ def bootstrap():
"recent": q("SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", tenant),
"risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5],
}
return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "tenant": tenant, "tenants": allowed})
return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "purchase": purchase, "tenant": tenant, "tenants": allowed})
finally:
conn.close()

View File

@@ -99,20 +99,15 @@ function renderFinance() {
}
})();
const sm = pf.sign_month || "";
const signMonthCell = `<td class="p-2 text-center text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); 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 text-center">${esc(pf.customer_name)}</td>${signMonthCell}<td class="p-2 text-center text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
const signMonthCell = `<td class="p-2 text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); 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 text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="p-2 text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
};
const finHeaderBase = `<button class="btn btn-sm ${state.finView === 'overview' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="overview" onclick="setFinView('overview')">总视图</button><button class="btn btn-sm ${state.finView === 'monthly' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="monthly" onclick="setFinView('monthly')">月度</button><button class="btn btn-sm ${state.finView === 'quarterly' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="quarterly" onclick="setFinView('quarterly')">季度</button><span class="text-slate-300 mx-0.5">|</span><span class="text-xs text-slate-400">筛选:</span><span class="text-xs text-slate-500">状态:</span><select onchange="state.finFilter=this.value;renderFinance()" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="已签约" ${state.finFilter==='已签约'?'selected':''}>已签约 (${pfs.filter(x=>x.status==='已签约').length})</option><option value="流程中" ${state.finFilter==='流程中'?'selected':''}>流程中 (${pfs.filter(x=>x.status==='流程中').length})</option><option value="待签约" ${state.finFilter==='待签约'?'selected':''}>待签约 (${pfs.filter(x=>x.status==='待签约').length})</option></select>`;
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
<div class="grid grid-cols-4 gap-3">
${[["已签项目","" + signed.length,"file-check-2"],["签约金额",moneyWan(sumSign),"coins"],["待签项目","" + pending.length,"file-question"],["待签金额",moneyWan(sumPending),"hourglass"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
</div>
<div class="grid grid-cols-5 gap-3">
${[["本月确收",moneyInt(thisMonthRev),"trending-up"],["本月毛利",moneyInt(thisMonthGross),"percent"],["本月回款",moneyInt(monthPayment),"wallet"],["本月应付",moneyInt(monthCost),"receipt"],["本月现金流",moneyInt(monthCashflow),"repeat"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
</div>
<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</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-6xl 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><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></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>
<div class="finance-tabs">
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
@@ -232,7 +227,7 @@ function renderFinance() {
const fmt = (v) => v ? `<span class="font-medium">${money(v)}</span>` : '<span class="text-slate-300">—</span>';
const fmtDiff = (v) => { if (!v) return '<span class="text-slate-300">—</span>'; return `<span class="${v > 0 ? 'text-amber-600 font-medium' : 'text-green-600 font-medium'}">${money(Math.abs(v))}</span>`; };
const rows = [];
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0;
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0, sumGross=0;
allPfs.forEach(pf => {
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
const b = bd.find(x => (x.month||"") === selMonth) || {};
@@ -240,14 +235,15 @@ function renderFinance() {
const payment = Math.round(parseFloat(b.payment||0)||0);
const cost = Math.round(parseFloat(b.cost||0)||0);
const paid = Math.round(parseFloat(b.paid||0)||0);
if (!rev && !payment && !cost && !paid) return;
const gross = Math.round(parseFloat(b.gross||0)||0);
if (!rev && !payment && !cost && !paid && !gross) return;
const payDiff = rev - payment;
const costDiff = cost - paid;
const cashflow = payment - paid;
sumRev+=rev; sumPay+=payment; sumCost+=cost; sumPaid+=paid;
rows.push(`<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 text-center">${esc(pf.customer_name)}</td><td class="p-2 text-center text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(rev)}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(payment)}</td><td class="p-2 text-center align-middle">${fmtDiff(payDiff)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(paid)}</td><td class="p-2 text-center align-middle">${fmtDiff(costDiff)}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`);
sumRev+=rev; sumPay+=payment; sumCost+=cost; sumPaid+=paid; sumGross+=gross;
rows.push(`<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 text-center align-middle">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(rev)}</td><td class="p-2 text-center align-middle text-xs">${rev&&pf.sign_amount?Math.round(rev/pf.sign_amount*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(payment)}</td><td class="p-2 text-center align-middle text-xs">${rev&&payment?Math.round(payment/rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(paid)}</td><td class="p-2 text-center align-middle text-xs">${cost&&paid?Math.round(paid/cost*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmt(gross)}</td><td class="p-2 text-center align-middle text-xs">${rev&&gross?Math.round(gross/rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`);
});
return card(`<div class="flex justify-between items-center mb-3"><div class="flex items-center gap-2">${finHeaderBase}<span class="text-xs text-slate-500">月份:</span><select onchange="state.finMonth=this.value;renderFinance()" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">${monthOpts}</select></div>${finAddBtn}</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-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="9" class="p-6 text-center text-slate-400">该月份暂无数据</td></tr>'}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="2">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td><td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
return card(`<div class="flex items-center gap-2 mb-3"><span class="text-xs text-slate-500">月份:</span><select onchange="state.finMonth=this.value;renderFinance()" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">${monthOpts}</select></div><div class="grid grid-cols-6 gap-3 mb-3">${(()=>{const s=allPfs.reduce((a,p)=>a+(p.sign_amount||0),0);return[["签约金额",money(s),"text-slate-700"],["已确收",money(sumRev),"text-blue-700"],["执行率",sumRev&&s?Math.round(sumRev/s*100)+'%':'—',"text-slate-500"],["已回款",money(sumPay),"text-amber-700"],["回款率",sumRev&&sumPay?Math.round(sumPay/sumRev*100)+'%':'—',"text-slate-500"],["应付",money(sumCost),"text-rose-700"],["已付",money(sumPaid),"text-purple-700"],["付款率",sumCost&&sumPaid?Math.round(sumPaid/sumCost*100)+'%':'—',"text-slate-500"],["毛利",money(sumGross),"text-green-700"],["毛利率",sumRev&&sumGross?Math.round(sumGross/sumRev*100)+'%':'—',"text-slate-500"],["现金流",money(sumPay-sumPaid),sumPay-sumPaid>=0?"text-green-600":"text-red-600"]].map(([l,v,c])=>'<div class="metric-card p-3"><span class="flex items-center gap-2 text-xs text-slate-500">'+l+'</span><strong class="mt-2 block text-xl '+c+'">'+v+'</strong></div>').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-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle">签约金额</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle">执行率</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款率</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">付款率</th><th class="p-2 text-center font-semibold align-middle">毛利</th><th class="p-2 text-center font-semibold align-middle">毛利率</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="13" class="p-6 text-center text-slate-400">该月份暂无数据</td></tr>'}</tbody></table></div>`, "p-4");
})() : state.finView === 'quarterly' ? (() => {
const allPfs = pfs.filter(x => x.status === state.finFilter);
const qRanges = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
@@ -270,34 +266,35 @@ function renderFinance() {
};
const fmt = (v) => v ? `<span class="font-medium">${money(v)}</span>` : '<span class="text-slate-300">—</span>';
const fmtDiff = (v) => { if (!v) return '<span class="text-slate-300">—</span>'; return `<span class="${v > 0 ? 'text-amber-600 font-medium' : 'text-green-600 font-medium'}">${money(Math.abs(v))}</span>`; };
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0;
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
allPfs.forEach(pf => {
const rev = sumBudget(pf, "rev");
const payment = sumBudget(pf, "payment");
const cost = sumBudget(pf, "cost");
const paid = sumBudget(pf, "paid");
const gross = sumBudget(pf, "gross");
const payDiff = rev - payment;
const costDiff = cost - paid;
const cashflow = payment - paid;
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid;
rows.push(`<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 text-center">${esc(pf.customer_name)}</td><td class="p-2 text-center text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(rev)}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(payment)}</td><td class="p-2 text-center align-middle">${fmtDiff(payDiff)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(paid)}</td><td class="p-2 text-center align-middle">${fmtDiff(costDiff)}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`);
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
rows.push(`<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 text-center align-middle">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(rev)}</td><td class="p-2 text-center align-middle text-xs">${rev&&pf.sign_amount?Math.round(rev/pf.sign_amount*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(payment)}</td><td class="p-2 text-center align-middle text-xs">${rev&&payment?Math.round(payment/rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(paid)}</td><td class="p-2 text-center align-middle text-xs">${cost&&paid?Math.round(paid/cost*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmt(gross)}</td><td class="p-2 text-center align-middle text-xs">${rev&&gross?Math.round(gross/rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`);
});
return card(`<div class="flex justify-between items-center mb-3"><div class="flex items-center gap-2">${finHeaderBase}<span class="text-xs text-slate-500">季度:</span><select onchange="state.finQuarter=parseInt(this.value);localStorage.setItem('opc-fin-quarter',this.value);renderFinance()" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">${quarterOpts}</select></div>${finAddBtn}</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-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="9" class="p-6 text-center text-slate-400">该季度暂无数据</td></tr>'}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="2">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td> <td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
return card(`<div class="flex items-center gap-2 mb-3"><span class="text-xs text-slate-500">季度:</span><select onchange="state.finQuarter=parseInt(this.value);localStorage.setItem('opc-fin-quarter',this.value);renderFinance()" class="text-xs font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">${quarterOpts}</select></div><div class="grid grid-cols-6 gap-3 mb-3">${(()=>{const s=allPfs.reduce((a,p)=>a+(p.sign_amount||0),0);return[["签约金额",money(s),"text-slate-700"],["已确收",money(sumRev),"text-blue-700"],["执行率",sumRev&&s?Math.round(sumRev/s*100)+'%':'—',"text-slate-500"],["已回款",money(sumPay),"text-amber-700"],["回款率",sumRev&&sumPay?Math.round(sumPay/sumRev*100)+'%':'—',"text-slate-500"],["应付",money(sumCost),"text-rose-700"],["已付",money(sumPaid),"text-purple-700"],["付款率",sumCost&&sumPaid?Math.round(sumPaid/sumCost*100)+'%':'—',"text-slate-500"],["毛利",money(sumGross),"text-green-700"],["毛利率",sumRev&&sumGross?Math.round(sumGross/sumRev*100)+'%':'—',"text-slate-500"],["现金流",money(sumPay-sumPaid),sumPay-sumPaid>=0?"text-green-600":"text-red-600"]].map(([l,v,c])=>'<div class="metric-card p-3"><span class="flex items-center gap-2 text-xs text-slate-500">'+l+'</span><strong class="mt-2 block text-xl '+c+'">'+v+'</strong></div>').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-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle">签约金额</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle">执行率</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款率</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">付款率</th><th class="p-2 text-center font-semibold align-middle">毛利</th><th class="p-2 text-center font-semibold align-middle">毛利率</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="13" class="p-6 text-center text-slate-400">该季度暂无数据</td></tr>'}</tbody></table></div>`, "p-4");
})() : (() => {
const calcTotals = (pf) => {
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let rev = 0, payment = 0, cost = 0;
budget.forEach(b => { rev += parseFloat(b.rev||0)||0; payment += parseFloat(b.payment||0)||0; cost += parseFloat(b.cost||0)||0; });
let rev = 0, payment = 0, cost = 0, gross = 0;
budget.forEach(b => { rev += parseFloat(b.rev||0)||0; gross += parseFloat(b.gross||0)||0; payment += parseFloat(b.payment||0)||0; cost += parseFloat(b.cost||0)||0; });
const paid = parseFloat(pf.total_paid) || 0;
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid) };
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross) };
};
const allPfs = pfs.filter(x => x.status === state.finFilter);
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0;
allPfs.forEach(pf => { const t = calcTotals(pf); sumRev+=t.rev; sumPay+=t.payment; sumCost+=t.cost; sumPaid+=t.paid; });
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0, sumGross=0;
allPfs.forEach(pf => { const t = calcTotals(pf); sumRev+=t.rev; sumPay+=t.payment; sumCost+=t.cost; sumPaid+=t.paid; sumGross+=t.gross; });
const fmt = (v) => v ? `<span class="font-medium">${money(v)}</span>` : '<span class="text-slate-300">—</span>';
const fmtDiff = (v) => { if (!v) return '<span class="text-slate-300">—</span>'; return `<span class="${v > 0 ? 'text-amber-600 font-medium' : 'text-green-600 font-medium'}">${money(Math.abs(v))}</span>`; };
return card(`<div class="flex justify-between items-center mb-3"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</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-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${allPfs.map(pf => { const t = calcTotals(pf); const payDiff = t.rev - t.payment; const costDiff = t.cost - t.paid; const cashflow = t.payment - t.paid; 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 text-center">${esc(pf.customer_name)}</td><td class="p-2 text-center text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(t.rev)}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(t.payment)}</td><td class="p-2 text-center align-middle">${fmtDiff(payDiff)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(t.cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(t.paid)}</td><td class="p-2 text-center align-middle">${fmtDiff(costDiff)}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`; }).join("")}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="2">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td><td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
return card(`<div class="grid grid-cols-6 gap-3 mb-3">${(()=>{const s=allPfs.reduce((a,p)=>a+(p.sign_amount||0),0);return[["签约金额",money(s),"text-slate-700"],["已确收",money(sumRev),"text-blue-700"],["执行率",sumRev&&s?Math.round(sumRev/s*100)+'%':'—',"text-slate-500"],["已回款",money(sumPay),"text-amber-700"],["回款率",sumRev&&sumPay?Math.round(sumPay/sumRev*100)+'%':'—',"text-slate-500"],["应付",money(sumCost),"text-rose-700"],["已付",money(sumPaid),"text-purple-700"],["付款率",sumCost&&sumPaid?Math.round(sumPaid/sumCost*100)+'%':'—',"text-slate-500"],["毛利",money(sumGross),"text-green-700"],["毛利率",sumRev&&sumGross?Math.round(sumGross/sumRev*100)+'%':'—',"text-slate-500"],["现金流",money(sumPay-sumPaid),sumPay-sumPaid>=0?"text-green-600":"text-red-600"]].map(([l,v,c])=>'<div class="metric-card p-3"><span class="flex items-center gap-2 text-xs text-slate-500">'+l+'</span><strong class="mt-2 block text-xl '+c+'">'+v+'</strong></div>').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-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle">签约金额</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle">执行率</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">付款率</th><th class="p-2 text-center font-semibold align-middle">毛利</th><th class="p-2 text-center font-semibold align-middle">毛利率</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${allPfs.map(pf => { const t = calcTotals(pf); const payDiff = t.rev - t.payment; const costDiff = t.cost - t.paid; const cashflow = t.payment - t.paid; 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 text-center align-middle">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${pf.status==='已签约'?'<span class="text-green-600">已签约</span>':pf.status==='流程中'?'<span class="text-blue-600">流程中</span>':'<span class="text-amber-600">待签约</span>'}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(t.rev)}</td><td class="p-2 text-center align-middle text-xs">${t.rev&&pf.sign_amount?Math.round(t.rev/pf.sign_amount*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(t.payment)}</td><td class="p-2 text-center align-middle text-xs">${t.rev&&t.payment?Math.round(t.payment/t.rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(t.cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(t.paid)}</td><td class="p-2 text-center align-middle text-xs">${t.cost&&t.paid?Math.round(t.paid/t.cost*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmt(t.gross)}</td><td class="p-2 text-center align-middle text-xs">${t.rev&&t.gross?Math.round(t.gross/t.rev*100)+'%':'<span class="text-slate-300">—</span>'}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`; }).join("")}</tbody></table></div>`, "p-4");
})()}
</div>`;
if (window.lucide) window.lucide.createIcons();

103
static/modules/purchase.js Normal file
View File

@@ -0,0 +1,103 @@
// purchase.js — 采购管理模块
function renderPurchase() {
const records = state.data.purchase || [];
const money = (v) => `¥ ${(Number(v || 0)).toLocaleString("zh-CN", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
const badge = (s) => {
const m = { "已审批": "green", "已采购": "blue", "已完成": "slate", "待审批": "amber" };
const c = m[s] || "amber";
return `<span class="text-xs px-2 py-0.5 rounded-full bg-${c}-100 text-${c}-700 font-medium">${s}</span>`;
};
const esc = (s) => (s || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const totalAmount = records.reduce((s, r) => s + (r.amount || 0), 0);
document.querySelector("#purchase").innerHTML = `<div class="grid gap-4">
<div class="grid grid-cols-3 gap-3">
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500">采购记录</span><strong class="mt-2 block text-2xl">${records.length}</strong></div>
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500">采购金额</span><strong class="mt-2 block text-2xl">${money(totalAmount)}</strong></div>
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500">待审批</span><strong class="mt-2 block text-2xl">${records.filter(r => r.status === '待审批').length}</strong></div>
</div>
<div class="flex justify-between items-center">
<h3 class="font-bold text-slate-700">采购列表 <span class="text-slate-400 font-normal">(${records.length})</span></h3>
<button class="btn btn-primary btn-sm" onclick="openPurchaseModal()">新增采购</button>
</div>
<div id="purchaseModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePurchaseModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-6 py-4 flex items-center justify-between"><h3 class="text-lg font-bold text-slate-800" id="purchaseModalTitle">新增采购</h3><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePurchaseModal()"><i data-lucide="x"></i></button></div><form onsubmit="savePurchase(event)" class="p-6 grid gap-4" novalidate><input type="hidden" name="purchase_id" id="purchase-id-input" value=""><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-xs text-slate-500 mb-1 block">项目名称 <span class="text-red-500">*</span></span><input name="project_name" required class="form-ctrl" placeholder="请输入项目名称"></label><label class="block"><span class="text-xs text-slate-500 mb-1 block">采购内容 <span class="text-red-500">*</span></span><input name="purchase_item" required class="form-ctrl" placeholder="请输入采购内容"></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-xs text-slate-500 mb-1 block">供应商</span><input name="supplier" class="form-ctrl" placeholder="请输入供应商"></label><label class="block"><span class="text-xs text-slate-500 mb-1 block">金额(元)</span><input name="amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-xs text-slate-500 mb-1 block">采购日期</span><input name="purchase_date" type="date" class="form-ctrl"></label><label class="block"><span class="text-xs text-slate-500 mb-1 block">状态</span><select name="status" class="form-ctrl bg-white"><option>待审批</option><option>已审批</option><option>已采购</option><option>已完成</option></select></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-xs text-slate-500 mb-1 block">分类</span><input name="category" class="form-ctrl" placeholder="如:办公用品、设备等"></label></div><div class="grid"><label class="block"><span class="text-xs text-slate-500 mb-1 block">备注</span><textarea name="notes" class="form-ctrl" rows="2" placeholder="备注信息"></textarea></label></div><div class="flex justify-end gap-2 pt-2 border-t border-slate-100"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="purchaseDeleteBtn" onclick="deletePurchaseItem()" type="button">删除</button><button class="btn btn-ghost btn-sm" onclick="closePurchaseModal()" type="button">取消</button><button class="btn btn-primary btn-sm" type="submit">保存</button></div></form></div></div>
<div class="overflow-x-auto card p-4"><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-right font-semibold">金额</th><th class="p-2 text-center font-semibold">采购日期</th><th class="p-2 text-center font-semibold">状态</th><th class="p-2 text-left font-semibold">分类</th><th class="p-2 text-left font-semibold">备注</th></tr></thead><tbody>${records.length ? records.map(r => `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPurchaseEdit(${r.id})"><td class="p-2 font-medium">${esc(r.project_name)}</td><td class="p-2">${esc(r.purchase_item)}</td><td class="p-2">${esc(r.supplier)}</td><td class="p-2 text-right font-medium">${money(r.amount)}</td><td class="p-2 text-center text-xs">${r.purchase_date || '—'}</td><td class="p-2 text-center">${badge(r.status)}</td><td class="p-2">${esc(r.category)}</td><td class="p-2 text-xs text-slate-500">${esc(r.notes) || '—'}</td></tr>`).join("") : '<tr><td colspan="8" class="p-6 text-center text-slate-400">暂无采购记录</td></tr>'}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2" colspan="3">合计(${records.length} 条)</td><td class="p-2 text-right">${money(totalAmount)}</td><td class="p-2" colspan="4"></td></tr></tbody></table></div>
</div>`;
if (window.lucide) window.lucide.createIcons();
}
window.openPurchaseModal = () => {
const form = document.querySelector("#purchaseModal form");
form.reset();
form.querySelector('[name="purchase_id"]').value = "";
document.querySelector("#purchaseModalTitle").textContent = "新增采购";
document.querySelector("#purchaseDeleteBtn").classList.add("hidden");
document.querySelector("#purchaseModal").classList.remove("hidden");
};
window.closePurchaseModal = () => {
document.querySelector("#purchaseModal").classList.add("hidden");
};
window.openPurchaseEdit = (id) => {
const r = (state.data.purchase || []).find(x => x.id === id);
if (!r) return;
const form = document.querySelector("#purchaseModal form");
form.reset();
const setVal = (name, val) => { const el = form.querySelector(`[name="${name}"]`); if (el) el.value = val || ""; };
setVal("purchase_id", r.id);
setVal("project_name", r.project_name);
setVal("purchase_item", r.purchase_item);
setVal("supplier", r.supplier);
setVal("amount", r.amount);
setVal("purchase_date", r.purchase_date);
setVal("status", r.status);
setVal("category", r.category);
setVal("notes", r.notes);
document.querySelector("#purchaseModalTitle").textContent = "编辑采购";
document.querySelector("#purchaseDeleteBtn").classList.remove("hidden");
document.querySelector("#purchaseModal").classList.remove("hidden");
};
window.savePurchase = async (event) => {
event.preventDefault();
const form = event.target;
const data = Object.fromEntries(new FormData(form).entries());
const isEdit = !!data.purchase_id;
delete data.purchase_id;
const url = isEdit
? `/api/purchase/${form.querySelector('[name="purchase_id"]').value}`
: "/api/purchase";
const method = isEdit ? "PUT" : "POST";
const resp = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: { ...data, tenant: state.tenant } }) });
if (!resp.ok) { alert("保存失败"); return; }
// Refresh data
const bResp = await fetch(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
if (bResp.ok) {
const bd = await bResp.json();
state.data = bd;
applyUserTenants();
renderPurchase();
}
closePurchaseModal();
};
window.deletePurchaseItem = async () => {
const id = document.querySelector("#purchaseModal form [name='purchase_id']").value;
if (!id || !confirm("确定删除?")) return;
const resp = await fetch(`/api/purchase/${id}`, { method: "DELETE" });
if (!resp.ok) { alert("删除失败"); return; }
const bResp = await fetch(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
if (bResp.ok) {
const bd = await bResp.json();
state.data = bd;
applyUserTenants();
renderPurchase();
}
closePurchaseModal();
};

View File

@@ -147,6 +147,7 @@ function render() {
renderProposals();
renderProducts();
renderFinance();
renderPurchase();
if (window.lucide) window.lucide.createIcons();
}

View File

@@ -53,6 +53,10 @@
<i data-lucide="briefcase-business" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">财务</span>
</div>
<div class="sidebar-tab" data-tab="purchase" onclick="switchTab('purchase')" title="采购管理">
<i data-lucide="shopping-cart" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">采购</span>
</div>
<div class="sidebar-tab" data-tab="projects" onclick="switchTab('projects')" title="重点工作与台账">
<i data-lucide="file-text" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">台账</span>
@@ -86,6 +90,7 @@
<section id="proposals" class="panel"></section>
<section id="products" class="panel"></section>
<section id="finance" class="panel"></section>
<section id="purchase" class="panel"></section>
</main>
</div><!-- 关闭主内容区 -->
</div><!-- 关闭 flex 容器 -->
@@ -114,6 +119,7 @@
<script src="{{ url_for('static', filename='modules/proposals.js') }}"></script>
<script src="{{ url_for('static', filename='modules/products.js') }}"></script>
<script src="{{ url_for('static', filename='modules/finance.js') }}"></script>
<script src="{{ url_for('static', filename='modules/purchase.js') }}"></script>
<script src="{{ url_for('static', filename='modules/drawer.js') }}"></script>
<script src="{{ url_for('static', filename='modules/admin.js') }}"></script>
<script src="{{ url_for('static', filename='app.js') }}"></script>