@@ -1,12 +1,13 @@
// finance.js — 经营管理(财务)模块
const moneyInt = ( v ) => ` ${ Math . round ( Number ( v || 0 ) ) . toLocaleString ( "zh-CN" ) } 元 ` ;
const moneyWan = ( v ) => ` ${ ( Number ( v || 0 ) / 10000 ) . toFixed ( 1 ) } 万 ` ;
function renderFinance ( ) {
const pfs = state . data . projectFinances || [ ] ;
const ops = state . data . operations || [ ] ;
const fmTypesByTenant = {
"科普·无界" : [ "科普音频" , "科普视频" , "科普文章" , "全品类科普" , "调研问卷" ] ,
"科普·无界" : [ "科普音频" , "科普视频" , "科普文章" , "科普专访" , "患教会" , " 全品类科普" , "调研问卷" ] ,
"科研·无界" : [ "真实世界研究" , "调研问卷" , "病例征集" , "患者招募" ] ,
"医患·无界" : [ "医患运营" , "患者管理" , "患教会" , "创新支付" , "电商" , "其他" ] ,
} ;
@@ -73,7 +74,7 @@ function renderFinance() {
const budget = JSON . parse ( pf . budget _data || "[]" ) ;
budget . forEach ( b => { budgetMap [ ( b . month || "" ) . replace ( "-" , "_" ) ] = b ; } ) ;
} catch ( e ) { }
const isRevView = state . finView !== "cashflow" && state . finView !== "overview" && state . finView !== "monthly" ;
const isRevView = state . finView !== "cashflow" && state . finView !== "overview" && state . finView !== "monthly" && state . finView !== "quarterly" ;
const mCols = months . map ( m => {
const b = budgetMap [ m ] || { } ;
if ( isRevView ) {
@@ -99,46 +100,61 @@ 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><td class="p-2 text-sm text-center"> ${ esc ( pf . business _type ) } </td><td class="p-2 text-sm text-center"> ${ pf . status === "已签约" ? badge ( "已签约" ) : pf . status === "流程中" ? badge ( "流程中" , "blue" ) : badge ( "待签约" , "amber" ) } </td> ${ signMonthCell } <td class="p-2 text-center text-sm"> ${ money ( pf . sign _amount ) } </td> ${ mCols } ${ totalCol } </tr> ` ;
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 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('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') 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" ] , [ "签约金额" , moneyInt ( sumSign ) , "coins" ] , [ "待签项目" , "" + pending . length , "file-question" ] , [ "待签金额" , moneyInt ( 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 ( "" ) }
${ [ [ "已签项目" , "" + 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-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="s etFinView('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="open FinanceModal()"><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><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-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="del ete FinanceItem()"><i data-lucide="trash-2"></i>删除 </button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0 " onclick="close FinanceModal()"><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="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="exec" onclick="switchFinanceTab('exec')"><i data-lucide="play-circle" 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>
<button class="finance-tab" data-tab="activity" onclick="switchFinanceTab('activity')"><i data-lucide="message-square" 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="finance-form" novalidate ><input type="hidden" name="pf_id" id="pf-id-input" value="">
<div class="finance-tab-body">
<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>
<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 class="grid gap-4 ">
<div class="grid grid-cols-3 gap-3 ">
<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><input name="project_code" class="form-ctrl" placeholder="如: KP-2026-001"></label >
<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 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>
<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>
</div >
<div class="grid grid-cols-2 gap-3" >
<label class="block"><span class="fin-label">商务负责人 <span class="text-red-500">*</span></span><input name="sales_person" required class="form-ctrl" placeholder="请输入商务负责人"></label >
<label class="block"><span class="fin-label">经营负责人 <span class="text-red-500">*</span></span><input name="owner" required class="form-ctrl" placeholder="请输入经营负责人"></label >
</div >
</div >
<div class="grid grid-cols-3 gap-3 ">
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" class="form-ctrl" placeholder="必须大于 0"></label >
<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>
</div >
<div class="grid grid-cols-3 gap-3" >
<label class="block"><span class="fin-label">商务负责人 <span class="text-red-500">*</span></span><input name="sales_person" required class="form-ctrl" placeholder="请输入商务负责人" ></label>
<label class="block"><span class="fin-label">经营负责人 <span class="text-red-500">*</span></span><input name="owner" required class="form-ctrl" placeholder="请输入经营负责人"></label >
<label class="block"><span class="fin-label">业务联系人</span><input name="contact_name" class="form-ctrl" placeholder="请输入业务联系人"></label >
</div >
<div class="grid grid-cols-3 gap-3" >
<label class="block"><span class="fin-label">联系电话</span><input name="contact_phone" class="form-ctrl" placeholder="请输入联系电话"></label >
<label class="block"><span class="fin-label">其他</span><input name="other_info" class="form-ctrl" placeholder="备注信息"></label >
</div>
<div class="block"><span class="fin-label">业务类型</span><div class="flex flex-wrap gap-2 mt-1" id="businessTypeChecks"> ${ fmTypes . map ( t => ` <label class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-slate-200 cursor-pointer hover:border-blue-300 text-sm bt-chip" onclick="toggleBtChip(this)"><input type="checkbox" name="business_type[]" value=" ${ t } " class="hidden"><span> ${ t } </span></label> ` ) . join ( "" ) } </div></div>
</div>
</div>
<div id="financeTabExec" class="hidden">
<div class="fin-field-group">
<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 >
@@ -171,7 +187,37 @@ 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 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>
< 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 id = "financeTabActivity" class = "hidden" >
< div class = "grid gap-2" id = "finActivityList" > < / d i v >
< div class = "comment-box mt-3" >
< div class = "squire-toolbar" >
< button type = "button" class = "squire-btn" onmousedown = "event.preventDefault();squireCmd('bold')" title = "加粗" > < i data - lucide = "bold" > < / i > < / b u t t o n >
< button type = "button" class = "squire-btn" onmousedown = "event.preventDefault();squireCmd('italic')" title = "斜体" > < i data - lucide = "italic" > < / i > < / b u t t o n >
< button type = "button" class = "squire-btn" onmousedown = "event.preventDefault();squireCmd('underline')" title = "下划线" > < i data - lucide = "underline" > < / i > < / b u t t o n >
< button type = "button" class = "squire-btn" onmousedown = "event.preventDefault();squireCmd('strikethrough')" title = "删除线" > < i data - lucide = "strikethrough" > < / i > < / b u t t o n >
< span class = "squire-sep" > < / s p a n >
< button type = "button" class = "squire-btn" onmousedown = "event.preventDefault();squireCmd('makeUnorderedList')" title = "无序列表" > < i data - lucide = "list" > < / i > < / b u t t o n >
< button type = "button" class = "squire-btn" onmousedown = "event.preventDefault();squireCmd('makeOrderedList')" title = "有序列表" > < i data - lucide = "list-ordered" > < / i > < / b u t t o n >
< button type = "button" class = "squire-btn" onmousedown = "event.preventDefault();squireCmd('blockquote')" title = "引用" > < i data - lucide = "quote" > < / i > < / b u t t o n >
< span class = "squire-sep" > < / s p a n >
< button type = "button" class = "squire-btn" onmousedown = "event.preventDefault();squireCmd('undo')" title = "撤销" > < i data - lucide = "undo" > < / i > < / b u t t o n >
< button type = "button" class = "squire-btn" onmousedown = "event.preventDefault();squireCmd('redo')" title = "重做" > < i data - lucide = "redo" > < / i > < / b u t t o n >
< / d i v >
< div class = "squire-editor" id = "squire_finance" placeholder = "添加评论" > < / d i v >
< div class = "comment-toolbar" >
< span class = "comment-hint" > 支持富文本编辑 < / s p a n >
< button class = "btn btn-primary btn-sm comment-submit" type = "button" onclick = "submitFinComment()" > 评论 < / b u t t o n >
< / d i v >
< / d i v >
< / d i v >
< / d i v > < d i v c l a s s = " f l e x j u s t i f y - e n d g a p - 3 p t - 2 f i n a n c e - f o r m - a c t i o n s " > < b u t t o n t y p e = " b u t t o n " c l a s s = " b t n b t n - g h o s t b t n - s m p x - 6 " o n c l i c k = " c l o s e F i n a n c e M o d a l ( ) " > 取 消 < / 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 ) ;
const now = new Date ( ) ;
@@ -199,9 +245,45 @@ function renderFinance() {
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-sm text-center text-slate-500"> ${ esc ( pf . business _type ) } </td><td class="p-2 text-sm text-center "> ${ pf . status === '已签约' ? badge ( '已签约' ) : pf . status === '流程中' ? badge ( '流程中' , 'blue' ) : badge ( '待签约' , 'amber' ) } </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> ` ) ;
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> ` ) ;
} ) ;
return card ( ` <div class="flex items-center justify-between mb-3"><h3 class="font-bold text-slate-700">月度视图 <span class="text-slate-400 font-normal">( ${ allPfs . length } 项目)</span></h3></div><div class="flex items-center justify-between mb-3"><div class="flex gap-2"> ${ [ [ "已签约" , "已签约" ] , [ "流程中" , "流程中" ] , [ "待签约" , "待签约" ] ] . 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><label class="inline-flex items-center gap-1 text-sm text-slate-500 cursor-pointer relative"><i data-lucide="calendar" style="width:14px;height:14px"></i ><select onchange="state.finMonth=this.value;renderFinance()" class="bg-transparent border-0 text-sm text-slate-600 font-medium cursor-pointer" style="appearance:none;-webkit-appearance:none;-moz-appearance:none;outline:none;background-image:none;padding-right:4 px"> ${ monthOpts } </select></label> </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 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="10 " 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="3 ">合计</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 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('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') no-repeat right 4px center;padding-right:22px;min-height:30 px"> ${ 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" ) ;
} ) ( ) : 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 ] ] ;
const qLabels = [ "Q1 (1-3月)" , "Q2 (4-6月)" , "Q3 (7-9月)" , "Q4 (10-12月)" ] ;
const now = new Date ( ) ;
if ( state . finQuarter === undefined ) {
const saved = localStorage . getItem ( "opc-fin-quarter" ) ;
state . finQuarter = saved !== null ? parseInt ( saved ) : Math . floor ( now . getMonth ( ) / 3 ) ;
}
const selQ = state . finQuarter ;
const qRange = qRanges [ selQ ] ;
const quarterOpts = qLabels . map ( ( l , i ) => ` <option value=" ${ i } " ${ i === selQ ? 'selected' : '' } > ${ l } </option> ` ) . join ( "" ) ;
const sumBudget = ( pf , field ) => {
let total = 0 ;
try { JSON . parse ( pf . budget _data || "[]" ) . forEach ( b => {
const m = parseInt ( ( b . month || "" ) . substring ( 5 ) ) || 0 ;
if ( qRange . includes ( m ) ) total += parseFloat ( b [ field ] || 0 ) ;
} ) ; } catch ( e ) { }
return total ;
} ;
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 ;
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 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> ` ) ;
} ) ;
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('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') 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" ) ;
} ) ( ) : ( ( ) => {
const calcTotals = ( pf ) => {
let budget = [ ] ; try { budget = JSON . parse ( pf . budget _data || "[]" ) ; } catch ( e ) { }
@@ -215,7 +297,7 @@ function renderFinance() {
allPfs . forEach ( pf => { const t = calcTotals ( pf ) ; sumRev += t . rev ; sumPay += t . payment ; sumCost += t . cost ; sumPaid += t . paid ; } ) ;
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 items-center justify-between mb-3"><h3 class="font-bold text-slate-700">总视图 <span class="text-slate-400 font-normal">( ${ allPfs . length } )</span></h3></div><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-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 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-sm text-center text-slate-500"> ${ esc ( pf . business _type ) } </td><td class="p-2 text-sm text-center "> ${ pf . status === '已签约' ? badge ( '已签约' ) : pf . status === '流程中' ? badge ( '流程中' , 'blue' ) : badge ( '待签约' , 'amber' ) } </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="3 ">合计</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 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" ) ;
} ) ( ) }
< / d i v > ` ;
if ( window . lucide ) window . lucide . createIcons ( ) ;
@@ -230,6 +312,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 +367,101 @@ 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" ) ; } ) ( ) ;
const isPreset = TASK _TYPES . includes ( taskType ) ;
const typeCell = isPreset || ! taskType
? ` <select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="onTaskTypeChange(this)"><option value="">选择</option> ${ TASK _TYPES . map ( t => ` <option ${ t === taskType ? 'selected' : '' } > ${ t } </option> ` ) . join ( "" ) } <option value="__custom__" ${ ! isPreset && taskType ? 'selected' : '' } >自定义...</option></select> `
: ` <input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" value=" ${ esc ( taskType ) } " placeholder="输入自定义类型"><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button> ` ;
row . innerHTML = ` <td><select name="task_month[]" class="form-ctrl form-ctrl-sm w-full"> ${ taskMonthOptions ( taskMonth || defaultMonth ) } </select></td>
<td class="flex items-center"> ${ typeCell } </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 . toggleBtChip = ( chip ) => {
const cb = chip . querySelector ( 'input' ) ;
cb . checked = ! cb . checked ;
chip . classList . toggle ( 'bg-blue-50' , cb . checked ) ;
chip . classList . toggle ( 'border-blue-400' , cb . checked ) ;
chip . classList . toggle ( 'text-blue-600' , cb . checked ) ;
} ;
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 . onTaskTypeChange = ( sel ) => {
if ( sel . value !== '__custom__' ) return ;
const td = sel . parentElement ;
const oldVal = sel . value ;
td . innerHTML = ` <input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="输入自定义类型" autofocus><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button> ` ;
if ( window . lucide ) window . lucide . createIcons ( ) ;
td . querySelector ( 'input' ) . focus ( ) ;
} ;
window . revertTaskType = ( btn ) => {
const td = btn . parentElement ;
td . innerHTML = ` <select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="onTaskTypeChange(this)"><option value="">选择</option> ${ TASK _TYPES . map ( t => ` <option> ${ t } </option> ` ) . join ( "" ) } <option value="__custom__">自定义...</option></select> ` ;
if ( window . lucide ) window . lucide . createIcons ( ) ;
} ;
window . closeFinanceModal = ( ) => {
const modal = document . querySelector ( "#financeModal" ) ;
modal . classList . add ( "hidden" ) ;
@@ -320,6 +498,63 @@ 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 ( "#financeTabExec" ) . classList . toggle ( "hidden" , tab !== "exec" ) ;
document . querySelector ( "#financeTabTasks" ) . classList . toggle ( "hidden" , tab !== "tasks" ) ;
document . querySelector ( "#financeTabActivity" ) . classList . toggle ( "hidden" , tab !== "activity" ) ;
document . querySelector ( ".finance-form-actions" ) . classList . toggle ( "hidden" , tab === "activity" ) ;
if ( tab === "activity" ) initFinSquire ( ) ;
} ;
// ---------- 活动与跟进 ----------
async function loadFinFollowups ( pfId ) {
const list = document . querySelector ( "#finActivityList" ) ;
if ( ! list || ! pfId ) return ;
try {
const fups = await api ( ` /api/followups/project_finance/ ${ pfId } ` ) ;
list . innerHTML = fups . length
? fups . 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> ${ esc ( f . follower ) } · ${ esc ( f . follow _up _method ) } </span><span> ${ esc ( 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" type="button" onclick="deleteFinFollowup(event, ${ f . id } )" title="删除评论"><i data-lucide="trash-2"></i></button></div> ` ) . join ( "" )
: '<p class="text-sm text-slate-400 py-4 text-center">暂无跟进记录</p>' ;
if ( window . lucide ) window . lucide . createIcons ( ) ;
list . querySelectorAll ( ".rich-content" ) . forEach ( el => {
const html = el . dataset . html ;
if ( html ) el . innerHTML = decodeURIComponent ( html ) ;
} ) ;
} catch ( e ) { /* ignore */ }
}
function initFinSquire ( ) {
const ed = document . querySelector ( "#squire_finance" ) ;
if ( ! ed || ! window . Squire ) return ;
if ( window . squireInstances [ "squire_finance" ] ) { window . squireInstances [ "squire_finance" ] . destroy ( ) ; }
const sq = new Squire ( ed , { blockTag : "P" } ) ;
window . squireInstances [ "squire_finance" ] = sq ;
ed . addEventListener ( "focus" , ( ) => ed . classList . add ( "focused" ) ) ;
ed . addEventListener ( "blur" , ( ) => { if ( ! ed . textContent . trim ( ) ) ed . classList . remove ( "focused" ) ; } ) ;
}
window . submitFinComment = async ( ) => {
const pfId = document . querySelector ( "#pf-id-input" ) . value ;
if ( ! pfId ) return ;
const sq = window . squireInstances [ "squire_finance" ] ;
const content = sq ? sq . getHTML ( ) . trim ( ) : "" ;
if ( ! content || content === "<div><br></div>" || content === "<p><br></p>" ) return ;
const btn = document . querySelector ( "#financeTabActivity .comment-submit" ) ;
btn . disabled = true ;
btn . textContent = "发送中…" ;
await api ( ` /api/followups/project_finance/ ${ pfId } ` , { method : "POST" , body : JSON . stringify ( { data : { content } } ) } ) ;
sq . setHTML ( "" ) ;
btn . disabled = false ;
btn . textContent = "评论" ;
await loadFinFollowups ( pfId ) ;
} ;
window . deleteFinFollowup = async ( event , followupId ) => {
event . stopPropagation ( ) ;
if ( ! confirm ( "确认删除这条评论?" ) ) return ;
await api ( ` /api/followups/ ${ followupId } ` , { method : "DELETE" } ) ;
const pfId = document . querySelector ( "#pf-id-input" ) . value ;
if ( pfId ) await loadFinFollowups ( pfId ) ;
} ;
window . openPfEditModal = ( pfId ) => {
@@ -332,8 +567,20 @@ window.openPfEditModal = (pfId) => {
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 || "" ;
// 回填业务类型多选
const btValues = ( pf . business _type || "" ) . split ( /[,, ]/ ) . map ( s => s . trim ( ) ) . filter ( Boolean ) ;
form . querySelectorAll ( '[name="business_type[]"]' ) . forEach ( cb => {
cb . checked = btValues . includes ( cb . value ) ;
const chip = cb . closest ( '.bt-chip' ) ;
if ( chip ) {
chip . classList . toggle ( 'bg-blue-50' , cb . checked ) ;
chip . classList . toggle ( 'border-blue-400' , cb . checked ) ;
chip . classList . toggle ( 'text-blue-600' , cb . checked ) ;
}
} ) ;
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,10 +591,23 @@ 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 ) ;
setVal ( "contact_name" , pf . contact _name ) ;
setVal ( "contact_phone" , pf . contact _phone ) ;
setVal ( "other_info" , pf . other _info ) ;
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 ) ;
loadFinFollowups ( pf . id ) ;
openFinanceModal ( ) ;
} ;
@@ -355,6 +615,9 @@ window.createFinance = async (event) => {
event . preventDefault ( ) ;
const form = event . currentTarget ;
const data = Object . fromEntries ( new FormData ( form ) . entries ( ) ) ;
// 业务类型多选:合并为逗号分隔
const btChecked = form . querySelectorAll ( '[name="business_type[]"]:checked' ) ;
data . business _type = Array . from ( btChecked ) . map ( cb => cb . value ) . join ( "," ) ;
data . tenant = state . tenant ;
// 必填校验
if ( ! data . customer _name || ! data . customer _name . trim ( ) ) { toast ( "项目名称必填" , "error" ) ; return ; }
@@ -392,6 +655,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 {