@@ -6,7 +6,7 @@ function renderFinance() {
const pfs = state . data . projectFinances || [ ] ;
const ops = state . data . operations || [ ] ;
const fmTypesByTenant = {
"科普·无界" : [ "科普音频" , "科普视频" , "科普文章" , "全品类科普" , "调研问卷" ] ,
"科普·无界" : [ "科普音频" , "科普视频" , "科普文章" , "科普专访" , "患教会" , " 全品类科普" , "调研问卷" ] ,
"科研·无界" : [ "真实世界研究" , "调研问卷" , "病例征集" , "患者招募" ] ,
"医患·无界" : [ "医患运营" , "患者管理" , "患教会" , "创新支付" , "电商" , "其他" ] ,
} ;
@@ -110,26 +110,30 @@ function renderFinance() {
${ [ [ "本月确收" , 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-1" id="finViewToggle"><button class="btn btn-sm ${ state . finView === 'overview' ? 'btn-primary' : 'btn-ghost' } px-2 py-1.5" data-view="overview" onclick="setFinView('overview')" title="总视图"><i data-lucide="layout-dashboard" style="width:16px;height:16px"></i><span class="text-xs ml-1">总视图</span></button><button class="btn btn-sm ${ state . finView === 'monthly' ? 'btn-primary' : 'btn-ghost' } px-2 py-1.5" data-view="monthly" onclick="setFinView('monthly')" title="月度视图"><i data-lucide="calendar-days" style="width:16px;height:16px"></i><span class="text-xs ml-1">月度视图</span></button></div><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-5 xl 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 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-6 xl 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>
<button class="finance-tab" data-tab="budget" onclick="switchFinanceTab('budget')"><i data-lucide="calendar" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>月度流水</button>
<button class="finance-tab" data-tab="tasks" onclick="switchFinanceTab('tasks')"><i data-lucide="list-checks" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>任务管理</button>
</div>
<form onsubmit="createFinance(event)" class="p-8 grid gap-6"><input type="hidden" name="pf_id" id="pf-id-input" value="">
<form onsubmit="createFinance(event)" class="p-8 grid gap-6" novalidate ><input type="hidden" name="pf_id" id="pf-id-input" value="">
<div id="financeTabInfo">
<div class="grid grid-cols-2 gap-5">
<div class="fin-field-group">
<p class="fin-section-label">项目信息</p>
<div class="grid gap-4">
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value=" ${ state . tenant } "><input class="form-ctrl bg-slate-50 cursor-not-allowed" value=" ${ state . tenant } " disabled></label>
<label class="block"><span class="fin-label">业务类型</span><select name="business_type" class="form-ctrl bg-white"> ${ fmTypes . map ( t => ` <option> ${ t } </option> ` ) . join ( "" ) } </select></label >
<div class="grid grid-cols-2 gap-3" >
<label class="block"><span class="fin-label">项目编号</span><input name="project_code" class="form-ctrl" placeholder="如: KP-2026-001"></label>
<label class="block"><span class="fin-label">业务类型</span><select name="business_type" class="form-ctrl bg-white"> ${ fmTypes . map ( t => ` <option> ${ t } </option> ` ) . join ( "" ) } </select></label>
</div>
<label class="block"><span class="fin-label">项目名称 <span class="text-red-500">*</span></span><input name="customer_name" required class="form-ctrl" placeholder="请输入项目名称"></label>
</div>
</div>
<div class="fin-field-group">
<p class="fin-section-label">合同信息</p>
<div class="grid gap-4">
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" type="number" step="0.01" min="0.01" required class="form-ctrl" placeholder="必须大于 0"></label>
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" class="form-ctrl" placeholder="必须大于 0"></label>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option> ${ monthOptions ( '' ) } </select></label>
<label class="block"><span class="fin-label">项目状态</span><select name="status" class="form-ctrl bg-white"><option>已签约</option><option>流程中</option><option>待签约</option></select></label>
@@ -141,6 +145,15 @@ function renderFinance() {
</div>
</div>
</div>
<div class="fin-field-group mt-5">
<p class="fin-section-label">执行信息</p>
<div class="grid grid-cols-3 gap-4">
<label class="block"><span class="fin-label">开始时间</span><input name="start_date" type="date" class="form-ctrl"></label>
<label class="block"><span class="fin-label">结束时间</span><input name="end_date" type="date" class="form-ctrl"></label>
<label class="block"><span class="fin-label">项目经理</span><input name="project_manager" class="form-ctrl" placeholder="请输入项目经理"></label>
<label class="block"><span class="fin-label">合同服务费标准</span><select name="service_fee_standard" class="form-ctrl bg-white"> ${ Array . from ( { length : 21 } ,(_,i)=>{const v=i+5;return ` < option value = "${v}" $ { v === 5 ? 'selected' : '' } > $ { v } % < / o p t i o n > ` } ) . j o i n ( " " ) } < / s e l e c t > < / l a b e l >
< / d i v >
< / d i v >
< / d i v >
< div id = "financeTabBudget" class = "hidden" >
< div class = "grid grid-cols-5 gap-3 mb-4" id = "budgetSummary" >
@@ -171,6 +184,13 @@ function renderFinance() {
< / t a b l e >
< button type = "button" class = "btn btn-ghost btn-sm mt-3" onclick = "addBudgetRow()" > < i data - lucide = "plus" > < / i > 添 加 月 份 < / b u t t o n >
< / d i v >
< div id = "financeTabTasks" class = "hidden" >
< table class = "w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id = "taskTable" >
< thead > < tr class = "bg-slate-50 border-b border-slate-200" > < th class = "p-2.5 text-left font-medium text-slate-500" style = "min-width:120px" > 月份 < / t h > < t h c l a s s = " p - 2 . 5 t e x t - l e f t f o n t - m e d i u m t e x t - s l a t e - 5 0 0 " s t y l e = " m i n - w i d t h : 1 2 0 p x " > 任 务 类 型 < / t h > < t h c l a s s = " p - 2 . 5 t e x t - r i g h t f o n t - m e d i u m t e x t - s l a t e - 5 0 0 " > 任 务 数 量 < / t h > < t h c l a s s = " p - 2 . 5 t e x t - r i g h t f o n t - m e d i u m t e x t - s l a t e - 5 0 0 " > 已 执 行 < / t h > < t h c l a s s = " p - 2 . 5 t e x t - r i g h t f o n t - m e d i u m t e x t - s l a t e - 5 0 0 " > 差 额 < / t h > < t h c l a s s = " p - 2 . 5 t e x t - r i g h t f o n t - m e d i u m t e x t - s l a t e - 5 0 0 " > 单 价 < / t h > < t h c l a s s = " p - 2 . 5 t e x t - r i g h t f o n t - m e d i u m t e x t - s l a t e - 5 0 0 " > 执 行 金 额 < / t h > < t h c l a s s = " p - 2 . 5 t e x t - r i g h t f o n t - m e d i u m t e x t - s l a t e - 5 0 0 " > 未 执 行 金 额 < / t h > < t h c l a s s = " p - 2 . 5 w - 8 " > < / t h > < / t r > < / t h e a d >
< tbody id = "taskTbody" > < / t b o d y >
< / t a b l e >
< button type = "button" class = "btn btn-ghost btn-sm mt-3" onclick = "addTaskRow()" > < i data - lucide = "plus" > < / i > 添 加 任 务 < / b u t t o n >
< / d i v >
< div class = "flex justify-end gap-3 pt-2" > < button type = "button" class = "btn btn-ghost btn-sm px-6" onclick = "closeFinanceModal()" > 取消 < / b u t t o n > < b u t t o n t y p e = " s u b m i t " c l a s s = " b t n b t n - p r i m a r y b t n - s m p x - 8 " > 保 存 < / b u t t o n > < / d i v > < / f o r m > < / d i v > < / d i v >
$ { state . finView === 'monthly' ? ( ( ) => {
const allPfs = pfs . filter ( x => x . status === state . finFilter ) ;
@@ -230,6 +250,7 @@ window.openFinanceModal = () => {
const pfIdInput = form . querySelector ( '[name="pf_id"]' ) ;
if ( ! pfIdInput || ! pfIdInput . value ) {
initBudgetTable ( null ) ;
initTaskTable ( null ) ;
document . querySelector ( "#financeDeleteBtn" ) . classList . add ( "hidden" ) ;
}
modal . classList . remove ( "hidden" ) ;
@@ -284,6 +305,74 @@ window.initBudgetTable = (budgetData) => {
setTimeout ( ( ) => updateBudgetSummary ( ) , 50 ) ;
} ;
// ---------- 任务管理 tab ----------
const TASK _TYPES = [ "科普视频" , "科普专访" , "科普文章" ] ;
function taskMonthOptions ( selected ) {
const now = new Date ( ) ;
const opts = [ ] ;
for ( let i = - 2 ; i <= 12 ; i ++ ) {
const d = new Date ( now . getFullYear ( ) , now . getMonth ( ) + i , 1 ) ;
const v = d . getFullYear ( ) + "-" + String ( d . getMonth ( ) + 1 ) . padStart ( 2 , "0" ) ;
opts . push ( ` <option value=" ${ v } " ${ v === selected ? 'selected' : '' } > ${ v } </option> ` ) ;
}
return opts . join ( "" ) ;
}
window . addTaskRow = ( taskMonth = '' , taskType = '' , taskCount = '' , executedCount = '' , unitPrice = '' ) => {
const tbody = document . querySelector ( "#taskTbody" ) ;
if ( ! tbody ) return ;
const row = document . createElement ( "tr" ) ;
const defaultMonth = ( ( ) => { const n = new Date ( ) ; return n . getFullYear ( ) + "-" + String ( n . getMonth ( ) + 1 ) . padStart ( 2 , "0" ) ; } ) ( ) ;
row . innerHTML = ` <td><select name="task_month[]" class="form-ctrl form-ctrl-sm w-full"> ${ taskMonthOptions ( taskMonth || defaultMonth ) } </select></td>
<td><select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="updateTaskDiff(this)"><option value="">选择</option> ${ TASK _TYPES . map ( t => ` <option ${ t === taskType ? 'selected' : '' } > ${ t } </option> ` ) . join ( "" ) } </select></td>
<td><input name="task_count[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value=" ${ taskCount } " oninput="updateTaskDiff(this)"></td>
<td><input name="task_executed[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value=" ${ executedCount } " oninput="updateTaskDiff(this)"></td>
<td><input name="task_diff[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:80px" placeholder="—" disabled></td>
<td><input name="task_unit_price[]" type="number" step="0.01" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:90px" placeholder="0" value=" ${ unitPrice } " oninput="updateTaskDiff(this)"></td>
<td><input name="task_exec_amount[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:140px" placeholder="—" disabled></td>
<td><input name="task_unexec_amount[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:140px" placeholder="—" disabled></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 ( ) ;
updateRowCalc ( row ) ;
} ;
window . updateTaskDiff = ( el ) => {
const row = el . closest ( 'tr' ) ;
if ( ! row ) return ;
updateRowCalc ( row ) ;
} ;
function updateRowCalc ( row ) {
const countInput = row . querySelector ( '[name="task_count[]"]' ) ;
const execInput = row . querySelector ( '[name="task_executed[]"]' ) ;
const priceInput = row . querySelector ( '[name="task_unit_price[]"]' ) ;
const diffInput = row . querySelector ( '[name="task_diff[]"]' ) ;
const execAmtInput = row . querySelector ( '[name="task_exec_amount[]"]' ) ;
const unexecAmtInput = row . querySelector ( '[name="task_unexec_amount[]"]' ) ;
const c = parseFloat ( countInput . value ) || 0 ;
const e = parseFloat ( execInput . value ) || 0 ;
const p = parseFloat ( priceInput . value ) || 0 ;
const diff = c - e ;
// 差额
diffInput . value = ( ! c && ! e ) ? '' : diff ;
// 执行金额 = 单价 * 已执行
const execAmt = p * e ;
execAmtInput . value = execAmt ? execAmt . toFixed ( 2 ) : '' ;
// 未执行金额 = 单价 * 差额
const unexecAmt = p * diff ;
unexecAmtInput . value = unexecAmt ? unexecAmt . toFixed ( 2 ) : '' ;
}
window . initTaskTable = ( taskData ) => {
const tbody = document . querySelector ( "#taskTbody" ) ;
if ( ! tbody ) return ;
tbody . innerHTML = "" ;
const rows = taskData || [ ] ;
rows . forEach ( r => addTaskRow ( r . task _month || '' , r . task _type || '' , r . task _count || '' , r . task _executed || '' , r . unit _price || '' ) ) ;
} ;
window . closeFinanceModal = ( ) => {
const modal = document . querySelector ( "#financeModal" ) ;
modal . classList . add ( "hidden" ) ;
@@ -320,6 +409,7 @@ 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" ) ;
document . querySelector ( "#financeTabTasks" ) . classList . toggle ( "hidden" , tab !== "tasks" ) ;
} ;
window . openPfEditModal = ( pfId ) => {
@@ -334,6 +424,8 @@ window.openPfEditModal = (pfId) => {
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 || "" ;
const setVal = ( name , val ) => { const el = form . querySelector ( ` [name=" ${ name } "] ` ) ; if ( el ) el . value = val || "" ; } ;
setVal ( "project_code" , pf . project _code ) ;
form . querySelector ( '[name="sign_amount"]' ) . value = pf . sign _amount || "" ;
const signMonthValue = pf . sign _month || "" ;
const signMonthEl = form . querySelector ( '[name="sign_month"]' ) ;
@@ -344,9 +436,18 @@ window.openPfEditModal = (pfId) => {
form . querySelector ( '[name="status"]' ) . value = pf . status || "待签约" ;
form . querySelector ( '[name="sales_person"]' ) . value = pf . sales _person || "" ;
form . querySelector ( '[name="owner"]' ) . value = pf . owner || "" ;
setVal ( "start_date" , pf . start _date ) ;
setVal ( "end_date" , pf . end _date ) ;
setVal ( "task_type" , pf . task _type ) ;
setVal ( "task_count" , pf . task _count ) ;
setVal ( "service_fee_standard" , pf . service _fee _standard || 5 ) ;
setVal ( "project_manager" , pf . project _manager ) ;
let budgetData = [ ] ;
try { budgetData = JSON . parse ( pf . budget _data || "[]" ) ; } catch ( e ) { budgetData = [ ] ; }
initBudgetTable ( budgetData . length ? budgetData : null ) ;
let taskData = [ ] ;
try { taskData = JSON . parse ( pf . task _data || "[]" ) ; } catch ( e ) { taskData = [ ] ; }
initTaskTable ( taskData . length ? taskData : null ) ;
setTimeout ( ( ) => updateBudgetSummary ( ) , 100 ) ;
openFinanceModal ( ) ;
} ;
@@ -392,6 +493,29 @@ window.createFinance = async (event) => {
for ( const r of budgetRows ) { totalPayment += r . payment ; totalCost += r . cost ; }
data . total _payment = totalPayment ;
data . total _cost = totalCost ;
// 收集任务管理数据
const taskTypeInputs = form . querySelectorAll ( '[name="task_type[]"]' ) ;
const taskCountInputs = form . querySelectorAll ( '[name="task_count[]"]' ) ;
const taskExecInputs = form . querySelectorAll ( '[name="task_executed[]"]' ) ;
const taskRows = [ ] ;
for ( let i = 0 ; i < taskTypeInputs . length ; i ++ ) {
const tt = taskTypeInputs [ i ] . value . trim ( ) ;
if ( ! tt ) continue ;
taskRows . push ( {
task _month : form . querySelectorAll ( '[name="task_month[]"]' ) [ i ] . value || '' ,
task _type : tt ,
task _count : parseFloat ( taskCountInputs [ i ] . value ) || 0 ,
task _executed : parseFloat ( taskExecInputs [ i ] . value ) || 0 ,
unit _price : parseFloat ( form . querySelectorAll ( '[name="task_unit_price[]"]' ) [ i ] . value ) || 0 ,
} ) ;
}
data . task _data = JSON . stringify ( taskRows ) ;
// 清除数组命名的字段( FormData 会收集 task_type[] 等),避免后端写入不存在的列
delete data . task _type ;
delete data . task _count ;
for ( const key of Object . keys ( data ) ) {
if ( key . endsWith ( '[]' ) ) delete data [ key ] ;
}
const pfId = data . pf _id ;
delete data . pf _id ;
try {