@@ -61,9 +61,8 @@ function switchTab(tab) {
function render ( ) {
if ( ! state . data ) return ;
renderHome ( ) ;
renderSale s ( ) ;
renderProject s ( ) ;
renderProposals ( ) ;
renderOperations ( ) ;
renderProducts ( ) ;
renderFinance ( ) ;
if ( window . lucide ) window . lucide . createIcons ( ) ;
@@ -98,10 +97,10 @@ function renderHome() {
<div class="grid gap-5">
<div class="grid grid-cols-4 gap-3">
${ [
[ "P0 客户数" , m . p0 _customers , "sale s" ] ,
[ "跟进中销售机会" , m . active _sales , "sale s" ] ,
[ "已签约执行项目" , m . execution _projects , "operation s" ] ,
[ "有风险项目" , m . risk _projects , "operation s" ] ,
[ "P0 客户数" , m . p0 _customers , "project s" ] ,
[ "跟进中销售机会" , m . active _sales , "project s" ] ,
[ "已签约执行项目" , m . execution _projects , "project s" ] ,
[ "有风险项目" , m . risk _projects , "project s" ] ,
[ "本月收入" , money ( m . monthly _revenue ) , "finance" ] ,
[ "本月净利" , money ( m . monthly _net _profit ) , "finance" ] ,
[ "即将上线版本" , m . upcoming _products , "products" ] ,
@@ -110,8 +109,8 @@ function renderHome() {
</div>
<div class="grid grid-cols-4 gap-3">
${ [
[ "已签约合同总额" , money ( m . signed _amount ) , "operation s" ] ,
[ "合同流程中" , money ( m . pipeline _amount ) , "operation s" ] ,
[ "已签约合同总额" , money ( m . signed _amount ) , "project s" ] ,
[ "合同流程中" , money ( m . pipeline _amount ) , "project s" ] ,
[ "年度累计确收" , money ( m . revenue _annual ) , "finance" ] ,
[ "Q2 累计确收" , money ( m . revenue _q2 ) , "finance" ] ,
[ "年度累计毛利" , money ( m . gross _annual ) , "finance" ] ,
@@ -174,77 +173,50 @@ window.createProduct = (event) => createResource(event, "products");
window . createFinance = ( event ) => createResource ( event , "finance" ) ;
window . switchTab = switchTab ;
function renderSale s ( ) {
const rows = state . data . sales . map ( ( x ) => [ x . target _customer , badge ( x . priority ) , badge ( x . status ) , text ( x . latest _follow _up _record ) ] ) ;
const salesClick s = state . data . sales . map ( ( x ) => ( { resource : "sales" , id : x . id } ) ) ;
document . querySelector ( "#sales" ) . innerHTML = ` <div class="grid gap-4">
function renderProject s ( ) {
// Merge sales_leads and operation_projects into one table
const salesItem s = state . data . sales . map ( ( x ) => ( {
name : x . target _customer ,
version : "" ,
type : "opportunity" ,
status : x . status ,
amount : 0 ,
stage : "" ,
files : 0 ,
followup : x . latest _follow _up _record ,
resource : "sales" ,
id : x . id ,
} ) ) ;
const opItems = state . data . operations . map ( ( x ) => ( {
name : x . project _name ,
version : x . project _version ,
type : x . project _type ,
status : x . project _status ,
amount : x . expected _contract _amount || 0 ,
stage : x . current _stage || x . sop _stage ,
files : x . files . length ,
followup : x . latest _follow _up _record ,
resource : "operations" ,
id : x . id ,
} ) ) ;
const allItems = [ ... salesItems , ... opItems ] ;
const items = state . opFilter === "all" ? allItems : allItems . filter ( ( x ) => x . type === state . opFilter || ( state . opFilter === "opportunity" && x . type === "opportunity" ) ) ;
const rows = items . map ( ( x ) => [ ` <strong> ${ x . name } </strong> ${ x . version ? ` <p class="text-xs text-slate-500"> ${ x . version } </p> ` : "" } ` , badge ( x . type ) , badge ( x . status ) , x . amount ? money ( x . amount ) : "—" , text ( x . stage ) , text ( x . followup ) ] ) ;
const clicks = items . map ( ( x ) => ( { resource : x . resource , id : x . id } ) ) ;
document . querySelector ( "#projects" ) . innerHTML = ` <div class="grid gap-4">
${ card ( formHtml ( [
{ label : "业务机会" , input : ` <input name="target_customer" required placeholder="客户名称"> ` } ,
{ label: "优先级", input: ` < select name = "priority" > < option > P0 < / o p t i o n > < o p t i o n s e l e c t e d > P 1 < / o p t i o n > < o p t i o n > P 2 < / o p t i o n > < o p t i o n > P 3 < / o p t i o n > < / s e l e c t > ` } ,
{ label : "状态" , input : ` <select name="status"><option>待跟进</option><option>跟进中</option><option>方案中</option><option>商务谈判</option><option>已签约</option><option>暂缓</option><option>已丢单</option></select> ` } ,
] , { handler : "createSales" , text : ` <i data-lucide="plus"></i>新增业务机会 ` } ) , "p-4" ) }
$ { renderTable ( [ "业务机会" , "优先级" , "状态" , "最新跟进记录" ] , rows , salesClicks ) }
< / d i v > ` ;
}
function renderProposals ( ) {
const categories = [ "方案" , "成本" , "SOP" , "财务流程" ] ;
const proposalRows = state . data . proposals . map ( ( p ) => [ p . customer _or _project _name , p . version , badge ( p . status ) , p . files . length + " 个" ] ) ;
const proposalClicks = state . data . proposals . map ( ( p ) => ( { resource : "proposals" , id : p . id } ) ) ;
document . querySelector ( "#proposals" ) . innerHTML = ` <div class="grid gap-4">
${ card ( formHtml ( [
{ label : "客户/项目" , input : ` <input name="customer_or_project_name" required placeholder="如:信达生物"> ` } ,
{ label: "版本号", input: ` < input name = "version" required placeholder = "v1.0" > ` },
{ label: "状态", input: ` < select name = "status" > < option > 草稿 < / o p t i o n > < o p t i o n > 内 部 评 审 < / o p t i o n > < o p t i o n s e l e c t e d > 已 提 交 客 户 < / o p t i o n > < o p t i o n > 客 户 反 馈 中 < / o p t i o n > < o p t i o n > 已 确 认 < / o p t i o n > < o p t i o n > 已 归 档 < / o p t i o n > < / s e l e c t > ` } ,
] , { handler : "createProposal" , text : "新增版本" } ) , "p-4" ) }
$ { renderTable ( [ "客户/项目" , "版本号" , "状态" , "文件数" ] , proposalRows , proposalClicks ) }
< / d i v > ` ;
}
function fileGroup ( module , ownerId , version , category , files ) {
return ` <div class="rounded-md border border-slate-200 px-3 py-2">
<div class="flex items-center justify-between gap-3"><p class="text-[13px] font-semibold text-slate-800"> ${ category } </p><label class="inline-flex cursor-pointer items-center gap-1 rounded-md border border-slate-200 px-2 py-1 text-[12px] font-medium text-slate-600 hover:bg-slate-50"><i data-lucide="upload"></i>上传<input class="hidden" type="file" onchange="uploadFile(event,' ${ module } ', ${ ownerId } ,' ${ version } ',' ${ category } ')"></label></div>
<div class="mt-2 grid gap-1.5"> ${ files . length ? files . map ( fileItem ) . join ( "" ) : ` <p class="text-[12px] text-slate-400">暂无文件</p> ` } </div>
</div> ` ;
}
function fileItem ( file ) {
return ` <div class="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-1.5 text-[13px]"><div class="min-w-0 flex-1"><p class="truncate font-medium text-slate-800"> ${ file . file _name } </p><div class="mt-0.5 flex gap-3"><a class="file-link inline-flex items-center gap-1" target="_blank" href="/api/files/ ${ file . id } /content?inline=true"><i data-lucide="eye"></i>预览</a><a class="file-link inline-flex items-center gap-1 text-slate-600" href="/api/files/ ${ file . id } /content?inline=false"><i data-lucide="download"></i>下载</a></div></div><button class="btn btn-ghost btn-sm text-red-600" onclick="deleteFile( ${ file . id } )" title="删除"><i data-lucide="trash-2"></i></button></div> ` ;
}
window . deleteFile = async ( fileId ) => {
if ( ! confirm ( "确认删除此文件?" ) ) return ;
await api ( ` /api/files/ ${ fileId } ` , { method : "DELETE" } ) ;
await load ( ) ;
closeDrawer ( ) ;
} ;
window . uploadFile = async ( event , module , ownerId , version , category ) => {
const file = event . target . files [ 0 ] ;
if ( ! file ) return ;
const form = new FormData ( ) ;
form . append ( "module" , module ) ;
form . append ( "owner_id" , ownerId ) ;
form . append ( "owner_version" , version ) ;
form . append ( "file_category" , category ) ;
form . append ( "file" , file ) ;
await api ( "/api/files/upload" , { method : "POST" , body : form } ) ;
await load ( ) ;
} ;
function renderOperations ( ) {
const items = state . opFilter === "all" ? state . data . operations : state . data . operations . filter ( ( x ) => x . project _type === state . opFilter ) ;
const opRows = items . map ( ( x ) => [ ` <strong> ${ x . project _name } </strong><p class="text-xs text-slate-500"> ${ x . project _version } </p> ` , badge ( x . project _type ) , badge ( x . project _status ) , x . expected _contract _amount ? money ( x . expected _contract _amount ) : "—" , text ( x . current _stage || x . sop _stage ) , ` ${ x . files . length } 个 ` , text ( x . latest _follow _up _record ) ] ) ;
const opClicks = items . map ( ( x ) => ( { resource : "operations" , id : x . id } ) ) ;
document . querySelector ( "#operations" ) . innerHTML = ` <div class="grid gap-4">
$ { card ( formHtml ( [
{ label : "项目名称" , input : ` <input name="project_name" required> ` } ,
{ label : "项目版本" , input : ` <input name="project_version" value="v1.0"> ` } ,
{ label : "项目类型" , input : ` <select name="project_type"><option value="opportunity">业务机会项目</option><option value="execution">已签约执行项目</option></select> ` } ,
{ label : "状态" , input : ` <input name="project_status" value="线索发现"> ` } ,
] , { handler : "createOperation" , text : "新增项目" } ) , "p-4" ) }
< div class = "flex gap-2" > $ { [ [ "all" , "全部项目 " ] , [ "opportunity" , "业务机会项目 " ] , [ "execution" , "已签约执行项目 " ] ] . map ( ( [ k , v ] ) => ` <button class="btn ${ state . opFilter === k ? "btn-primary" : "btn-ghost" } " onclick="state.opFilter=' ${ k } '; renderOperation s()"> ${ v } </button> ` ) . join ( "" ) } < / d i v >
$ { renderTable ( [ "项目名称 " , "类型" , "状态" , "金额" , "当前阶段" , "交付文件" , "最新跟进" ] , opR ows, opC licks) }
< div class = "flex gap-2" > $ { [ [ "all" , "全部" ] , [ "opportunity" , "业务机会" ] , [ "execution" , "已签约执行" ] ] . map ( ( [ k , v ] ) => ` <button class="btn ${ state . opFilter === k ? "btn-primary" : "btn-ghost" } " onclick="state.opFilter=' ${ k } '; renderProject s()"> ${ v } </button> ` ) . join ( "" ) } < / d i v >
$ { renderTable ( [ "项目/客户 " , "类型" , "状态" , "金额" , "当前阶段" , "最新跟进" ] , r ows, c licks) }
< / d i v > ` ;
}
@@ -338,6 +310,20 @@ function openDrawer(resource, id) {
<form id="drawerForm" class="drawer-fields"> ${ fields . map ( ( [ key , label ] ) => drawerField ( fieldIcons [ key ] || "circle" , label , key , item [ key ] , multilineFields . includes ( key ) ) ) . join ( "" ) } </form>
</section>
${ resource === "proposals" ? ` <section><h3 class="drawer-section-title">方案文件</h3><div class="grid gap-2"> ${ [ "方案" , "成本" , "SOP" , "财务流程" ] . map ( ( cat ) => fileGroup ( "proposal" , item . id , item . version , cat , item . files . filter ( ( f ) => f . file _category === cat ) ) ) . join ( "" ) } </div></section> ` : "" }
${ resource === "operations" ? ( ( ) => {
const tasks = ( state . data . tasks || [ ] ) . filter ( ( t ) => t . project _id === id ) ;
if ( ! tasks . length ) return "" ;
const phases = [ ... new Set ( tasks . map ( ( t ) => t . phase ) ) ] ;
return ` <section><h3 class="drawer-section-title">项目任务</h3><div class="grid gap-3"> ${ phases . map ( ( phase ) => {
const pt = tasks . filter ( ( t ) => t . phase === phase ) ;
return ` <div class="rounded-md border border-slate-200 p-3"><p class="text-[13px] font-semibold text-slate-700 mb-2"><i data-lucide="layers" style="width:14px;height:14px;display:inline;vertical-align:-2px;margin-right:4px"></i> ${ phase } </p><div class="grid gap-1.5"> ${ pt . map ( ( t ) => {
const due = t . due _date ? ` <span class="text-[11px] text-slate-400 ml-1">📅 ${ t . due _date } </span> ` : "" ;
const owner = t . owner ? ` <span class="text-[11px] text-slate-500 ml-1">👤 ${ t . owner } </span> ` : "" ;
const blocker = t . blockers ? ` <p class="text-[11px] text-red-600 mt-0.5">⚠ ${ t . blockers } </p> ` : "" ;
return ` <div class="rounded bg-slate-50 px-2.5 py-1.5"><p class="text-[12px] text-slate-800"><strong> ${ t . milestone ? t . milestone + ": " : "" } </strong> ${ t . task } ${ due } ${ owner } </p> ${ blocker } </div> ` ;
} ).join("")}</div></div> ` ;
} ).join("")}</div></section> ` ;
} )() : ""}
${ followupTarget ? ` <section>
<h3 class="drawer-section-title">活动 / 跟进</h3>
<div class="grid gap-2"> ${ ( item . followups || [ ] ) . map ( ( f ) => ` <div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span> ${ f . follower } · ${ f . follow _up _method } </span><span> ${ f . followed _at } </span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html=" ${ encodeURIComponent ( f . content || '' ) } "></div> ${ f . next _action ? ` <p class="mt-1 leading-5 text-blue-700">下一步: ${ text ( f . next _action ) } </p> ` : "" } </div><button class="activity-delete" onclick="deleteFollowup(event, ${ f . id } , ' ${ resource } ', ${ item . id } )" title="删除评论"><i data-lucide="trash-2"></i></button></div> ` ) . join ( "" ) } </div>