mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 16:24:47 +00:00
feat: payment plan (schedule) bound to analysis milestones — flexible per-client schemes (100%/50-50/staged), mark stage paid creates registry payment; backend whitelists payment_schedule
This commit is contained in:
parent
9a349fe21e
commit
78b3e22eb6
@ -295,7 +295,7 @@ function renderMainPanel(){
|
|||||||
renderPayments();
|
renderPayments();
|
||||||
}
|
}
|
||||||
else if(mainTab==="pricing"){p.innerHTML=`<div id="pricingBox"></div>`;renderPricing();}
|
else if(mainTab==="pricing"){p.innerHTML=`<div id="pricingBox"></div>`;renderPricing();}
|
||||||
else if(mainTab==="payments"){p.innerHTML=`<div id="paymentsBox"></div>`;renderPayments();}
|
else if(mainTab==="payments"){p.innerHTML=`<div id="planBox"></div><div id="paymentsBox"></div>`;renderPaymentPlan();renderPayments();}
|
||||||
else if(mainTab==="tasks"){p.innerHTML=`<div id="tasksBox"></div>`;renderTasks();}
|
else if(mainTab==="tasks"){p.innerHTML=`<div id="tasksBox"></div>`;renderTasks();}
|
||||||
else if(mainTab==="analysis"){p.innerHTML=`<div class="tabs">${TABS.map(t=>`<div class="tab ${t.id===activeTab?'active':''} ${approved(t.id)?'done':''}" onclick="setTab('${t.id}')">${t.icon} ${t.name}</div>`).join("")}</div><div id="tabContent"></div>`;renderTab();}
|
else if(mainTab==="analysis"){p.innerHTML=`<div class="tabs">${TABS.map(t=>`<div class="tab ${t.id===activeTab?'active':''} ${approved(t.id)?'done':''}" onclick="setTab('${t.id}')">${t.icon} ${t.name}</div>`).join("")}</div><div id="tabContent"></div>`;renderTab();}
|
||||||
}
|
}
|
||||||
@ -305,6 +305,54 @@ async function setBilling(t){
|
|||||||
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,billing_type:t})});
|
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,billing_type:t})});
|
||||||
renderClient();await loadProjects();
|
renderClient();await loadProjects();
|
||||||
}
|
}
|
||||||
|
// ── План оплаты (привязка к вехам анализа) ──
|
||||||
|
const PAY_TRIGGERS=[{key:"start",name:"Старт работы"},{key:"methods",name:"Согл. методологий"},{key:"canvas",name:"Согл. стратегии"},{key:"idef0",name:"Согл. модели IDEF0"},{key:"spec",name:"Выдача ТЗ"}];
|
||||||
|
function trigName(k){const t=PAY_TRIGGERS.find(x=>x.key===k);return t?t.name:k;}
|
||||||
|
function genSchedule(type){
|
||||||
|
const deal=(state.crm&&state.crm.deal_amount)||0;
|
||||||
|
if(!deal){alert("Сначала укажите сумму сделки (вкладка «Сделка» или «Ценообразование»).");return;}
|
||||||
|
let arr;
|
||||||
|
if(type==="full")arr=[{name:"Предоплата 100%",amount:deal,trigger:"start",status:"pending"}];
|
||||||
|
else if(type==="half"){const a=Math.round(deal/2);arr=[{name:"Аванс 50%",amount:a,trigger:"start",status:"pending"},{name:"Постоплата 50%",amount:deal-a,trigger:"spec",status:"pending"}];}
|
||||||
|
else{const a=Math.round(deal*0.4),b=Math.round(deal*0.3);arr=[{name:"Аванс 40%",amount:a,trigger:"start",status:"pending"},{name:"Промежуточный 30%",amount:b,trigger:"idef0",status:"pending"},{name:"Финал 30%",amount:deal-a-b,trigger:"spec",status:"pending"}];}
|
||||||
|
arr.forEach((s,i)=>s.id="s"+i);
|
||||||
|
state.crm=state.crm||{};state.crm.payment_schedule=arr;
|
||||||
|
saveSchedule();renderClient();
|
||||||
|
}
|
||||||
|
async function saveSchedule(){await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,payment_schedule:state.crm.payment_schedule})});await loadProjects();}
|
||||||
|
function clearSchedule(){if(!confirm("Сменить схему плана оплаты? Реестр фактических платежей не затрагивается."))return;state.crm.payment_schedule=[];saveSchedule();renderClient();}
|
||||||
|
function markStagePaid(i){
|
||||||
|
const s=state.crm.payment_schedule[i];if(!s||s.status==="paid")return;
|
||||||
|
s.status="paid";s.paid_at=new Date().toISOString().slice(0,10);
|
||||||
|
state.crm.payments=state.crm.payments||[];
|
||||||
|
state.crm.payments.push({date:s.paid_at,amount:s.amount,note:"Этап: "+s.name});
|
||||||
|
Promise.all([saveSchedule(),savePayments()]).then(()=>renderClient());
|
||||||
|
}
|
||||||
|
function renderPaymentPlan(){
|
||||||
|
const box=document.getElementById("planBox");if(!box)return;
|
||||||
|
const sch=(state.crm&&state.crm.payment_schedule)||[];
|
||||||
|
if(!sch.length){
|
||||||
|
box.innerHTML=`<div class="blk" style="margin-bottom:14px"><div style="font-size:13px;font-weight:700;margin-bottom:4px">💼 План оплаты</div><div style="font-size:12px;color:var(--muted);margin-bottom:12px">Выберите схему — система раскидает суммы от суммы сделки и привяжет к вехам анализа.</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="cp-btn cp-a" onclick="genSchedule('full')">100% предоплата</button>
|
||||||
|
<button class="cp-btn cp-a" onclick="genSchedule('half')">Аванс 50% + 50%</button>
|
||||||
|
<button class="cp-btn cp-a" onclick="genSchedule('staged')">Поэтапно (3 платежа)</button>
|
||||||
|
</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const total=sch.reduce((s,x)=>s+(x.amount||0),0);
|
||||||
|
const paid=sch.filter(x=>x.status==="paid").reduce((s,x)=>s+(x.amount||0),0);
|
||||||
|
box.innerHTML=`<div class="blk" style="margin-bottom:14px">
|
||||||
|
<div style="display:flex;align-items:center;margin-bottom:6px"><span style="font-size:13px;font-weight:700">💼 План оплаты</span><span style="margin-left:auto;font-size:12px;color:var(--muted)">Оплачено ${money(paid)} из ${money(total)}</span><button class="cp-btn cp-r" style="margin-left:10px;padding:5px 10px" onclick="clearSchedule()">↻ Сменить</button></div>
|
||||||
|
${sch.map((s,i)=>{const isPaid=s.status==="paid";return `<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-top:1px solid var(--bg)">
|
||||||
|
<span style="width:24px;height:24px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;background:${isPaid?'#047857':'#F1F5F9'};color:${isPaid?'#fff':'#9ca3af'}">${isPaid?'✓':i+1}</span>
|
||||||
|
<div style="flex:1;min-width:0"><div style="font-weight:700;font-size:13px">${esc(s.name)}</div><div style="font-size:11px;color:var(--muted)">Веха: ${esc(trigName(s.trigger))}${isPaid&&s.paid_at?` · оплачен ${fmtDate(s.paid_at)}`:''}</div></div>
|
||||||
|
<span style="font-weight:700;color:var(--primary);white-space:nowrap">${money(s.amount)}</span>
|
||||||
|
${isPaid?`<span style="font-size:11px;font-weight:700;color:#047857;background:#ECFDF5;padding:4px 10px;border-radius:6px;white-space:nowrap">Оплачен</span>`:`<button class="cp-btn cp-a" style="padding:6px 11px;white-space:nowrap" onclick="markStagePaid(${i})">Отметить оплату</button>`}
|
||||||
|
</div>`}).join("")}
|
||||||
|
<div style="margin-top:10px;padding:9px 12px;background:linear-gradient(135deg,rgba(4,120,87,.05),transparent);border-radius:8px;font-size:11.5px;color:var(--muted)">📄 ТЗ выдаётся клиенту (печать + выгрузка) после полной оплаты — финальный этап «Выдача ТЗ».</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
function renderPricing(){
|
function renderPricing(){
|
||||||
const box=document.getElementById("pricingBox");if(!box)return;
|
const box=document.getElementById("pricingBox");if(!box)return;
|
||||||
const billing=(state.crm||{}).billing_type||"paid";
|
const billing=(state.crm||{}).billing_type||"paid";
|
||||||
|
|||||||
@ -645,7 +645,7 @@ def update_crm():
|
|||||||
"pipeline": "lead", "deal_amount": 0, "paid_amount": 0,
|
"pipeline": "lead", "deal_amount": 0, "paid_amount": 0,
|
||||||
"contact": "", "source": "", "note": "", "payments": [], "billing_type": "paid"
|
"contact": "", "source": "", "note": "", "payments": [], "billing_type": "paid"
|
||||||
}
|
}
|
||||||
for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments", "billing_type"):
|
for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments", "billing_type", "payment_schedule"):
|
||||||
if k in data: crm[k] = data[k]
|
if k in data: crm[k] = data[k]
|
||||||
# paid_amount = сумма платежей (если есть реестр)
|
# paid_amount = сумма платежей (если есть реестр)
|
||||||
if "payments" in crm and isinstance(crm["payments"], list):
|
if "payments" in crm and isinstance(crm["payments"], list):
|
||||||
|
|||||||
@ -295,7 +295,7 @@ function renderMainPanel(){
|
|||||||
renderPayments();
|
renderPayments();
|
||||||
}
|
}
|
||||||
else if(mainTab==="pricing"){p.innerHTML=`<div id="pricingBox"></div>`;renderPricing();}
|
else if(mainTab==="pricing"){p.innerHTML=`<div id="pricingBox"></div>`;renderPricing();}
|
||||||
else if(mainTab==="payments"){p.innerHTML=`<div id="paymentsBox"></div>`;renderPayments();}
|
else if(mainTab==="payments"){p.innerHTML=`<div id="planBox"></div><div id="paymentsBox"></div>`;renderPaymentPlan();renderPayments();}
|
||||||
else if(mainTab==="tasks"){p.innerHTML=`<div id="tasksBox"></div>`;renderTasks();}
|
else if(mainTab==="tasks"){p.innerHTML=`<div id="tasksBox"></div>`;renderTasks();}
|
||||||
else if(mainTab==="analysis"){p.innerHTML=`<div class="tabs">${TABS.map(t=>`<div class="tab ${t.id===activeTab?'active':''} ${approved(t.id)?'done':''}" onclick="setTab('${t.id}')">${t.icon} ${t.name}</div>`).join("")}</div><div id="tabContent"></div>`;renderTab();}
|
else if(mainTab==="analysis"){p.innerHTML=`<div class="tabs">${TABS.map(t=>`<div class="tab ${t.id===activeTab?'active':''} ${approved(t.id)?'done':''}" onclick="setTab('${t.id}')">${t.icon} ${t.name}</div>`).join("")}</div><div id="tabContent"></div>`;renderTab();}
|
||||||
}
|
}
|
||||||
@ -305,6 +305,54 @@ async function setBilling(t){
|
|||||||
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,billing_type:t})});
|
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,billing_type:t})});
|
||||||
renderClient();await loadProjects();
|
renderClient();await loadProjects();
|
||||||
}
|
}
|
||||||
|
// ── План оплаты (привязка к вехам анализа) ──
|
||||||
|
const PAY_TRIGGERS=[{key:"start",name:"Старт работы"},{key:"methods",name:"Согл. методологий"},{key:"canvas",name:"Согл. стратегии"},{key:"idef0",name:"Согл. модели IDEF0"},{key:"spec",name:"Выдача ТЗ"}];
|
||||||
|
function trigName(k){const t=PAY_TRIGGERS.find(x=>x.key===k);return t?t.name:k;}
|
||||||
|
function genSchedule(type){
|
||||||
|
const deal=(state.crm&&state.crm.deal_amount)||0;
|
||||||
|
if(!deal){alert("Сначала укажите сумму сделки (вкладка «Сделка» или «Ценообразование»).");return;}
|
||||||
|
let arr;
|
||||||
|
if(type==="full")arr=[{name:"Предоплата 100%",amount:deal,trigger:"start",status:"pending"}];
|
||||||
|
else if(type==="half"){const a=Math.round(deal/2);arr=[{name:"Аванс 50%",amount:a,trigger:"start",status:"pending"},{name:"Постоплата 50%",amount:deal-a,trigger:"spec",status:"pending"}];}
|
||||||
|
else{const a=Math.round(deal*0.4),b=Math.round(deal*0.3);arr=[{name:"Аванс 40%",amount:a,trigger:"start",status:"pending"},{name:"Промежуточный 30%",amount:b,trigger:"idef0",status:"pending"},{name:"Финал 30%",amount:deal-a-b,trigger:"spec",status:"pending"}];}
|
||||||
|
arr.forEach((s,i)=>s.id="s"+i);
|
||||||
|
state.crm=state.crm||{};state.crm.payment_schedule=arr;
|
||||||
|
saveSchedule();renderClient();
|
||||||
|
}
|
||||||
|
async function saveSchedule(){await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,payment_schedule:state.crm.payment_schedule})});await loadProjects();}
|
||||||
|
function clearSchedule(){if(!confirm("Сменить схему плана оплаты? Реестр фактических платежей не затрагивается."))return;state.crm.payment_schedule=[];saveSchedule();renderClient();}
|
||||||
|
function markStagePaid(i){
|
||||||
|
const s=state.crm.payment_schedule[i];if(!s||s.status==="paid")return;
|
||||||
|
s.status="paid";s.paid_at=new Date().toISOString().slice(0,10);
|
||||||
|
state.crm.payments=state.crm.payments||[];
|
||||||
|
state.crm.payments.push({date:s.paid_at,amount:s.amount,note:"Этап: "+s.name});
|
||||||
|
Promise.all([saveSchedule(),savePayments()]).then(()=>renderClient());
|
||||||
|
}
|
||||||
|
function renderPaymentPlan(){
|
||||||
|
const box=document.getElementById("planBox");if(!box)return;
|
||||||
|
const sch=(state.crm&&state.crm.payment_schedule)||[];
|
||||||
|
if(!sch.length){
|
||||||
|
box.innerHTML=`<div class="blk" style="margin-bottom:14px"><div style="font-size:13px;font-weight:700;margin-bottom:4px">💼 План оплаты</div><div style="font-size:12px;color:var(--muted);margin-bottom:12px">Выберите схему — система раскидает суммы от суммы сделки и привяжет к вехам анализа.</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="cp-btn cp-a" onclick="genSchedule('full')">100% предоплата</button>
|
||||||
|
<button class="cp-btn cp-a" onclick="genSchedule('half')">Аванс 50% + 50%</button>
|
||||||
|
<button class="cp-btn cp-a" onclick="genSchedule('staged')">Поэтапно (3 платежа)</button>
|
||||||
|
</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const total=sch.reduce((s,x)=>s+(x.amount||0),0);
|
||||||
|
const paid=sch.filter(x=>x.status==="paid").reduce((s,x)=>s+(x.amount||0),0);
|
||||||
|
box.innerHTML=`<div class="blk" style="margin-bottom:14px">
|
||||||
|
<div style="display:flex;align-items:center;margin-bottom:6px"><span style="font-size:13px;font-weight:700">💼 План оплаты</span><span style="margin-left:auto;font-size:12px;color:var(--muted)">Оплачено ${money(paid)} из ${money(total)}</span><button class="cp-btn cp-r" style="margin-left:10px;padding:5px 10px" onclick="clearSchedule()">↻ Сменить</button></div>
|
||||||
|
${sch.map((s,i)=>{const isPaid=s.status==="paid";return `<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-top:1px solid var(--bg)">
|
||||||
|
<span style="width:24px;height:24px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;background:${isPaid?'#047857':'#F1F5F9'};color:${isPaid?'#fff':'#9ca3af'}">${isPaid?'✓':i+1}</span>
|
||||||
|
<div style="flex:1;min-width:0"><div style="font-weight:700;font-size:13px">${esc(s.name)}</div><div style="font-size:11px;color:var(--muted)">Веха: ${esc(trigName(s.trigger))}${isPaid&&s.paid_at?` · оплачен ${fmtDate(s.paid_at)}`:''}</div></div>
|
||||||
|
<span style="font-weight:700;color:var(--primary);white-space:nowrap">${money(s.amount)}</span>
|
||||||
|
${isPaid?`<span style="font-size:11px;font-weight:700;color:#047857;background:#ECFDF5;padding:4px 10px;border-radius:6px;white-space:nowrap">Оплачен</span>`:`<button class="cp-btn cp-a" style="padding:6px 11px;white-space:nowrap" onclick="markStagePaid(${i})">Отметить оплату</button>`}
|
||||||
|
</div>`}).join("")}
|
||||||
|
<div style="margin-top:10px;padding:9px 12px;background:linear-gradient(135deg,rgba(4,120,87,.05),transparent);border-radius:8px;font-size:11.5px;color:var(--muted)">📄 ТЗ выдаётся клиенту (печать + выгрузка) после полной оплаты — финальный этап «Выдача ТЗ».</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
function renderPricing(){
|
function renderPricing(){
|
||||||
const box=document.getElementById("pricingBox");if(!box)return;
|
const box=document.getElementById("pricingBox");if(!box)return;
|
||||||
const billing=(state.crm||{}).billing_type||"paid";
|
const billing=(state.crm||{}).billing_type||"paid";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user