diff --git a/backend/elena_app.py b/backend/elena_app.py
index 88e5a25..a669557 100644
--- a/backend/elena_app.py
+++ b/backend/elena_app.py
@@ -645,7 +645,7 @@ def update_crm():
"pipeline": "lead", "deal_amount": 0, "paid_amount": 0,
"contact": "", "source": "", "note": "", "payments": [], "billing_type": "paid"
}
- for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments", "billing_type", "payment_schedule", "stage_payments", "stage_prices", "complexity"):
+ for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments", "billing_type", "payment_schedule", "stage_payments", "stage_prices", "complexity", "stage_due"):
if k in data: crm[k] = data[k]
# paid_amount = сумма платежей (если есть реестр)
if "payments" in crm and isinstance(crm["payments"], list):
diff --git a/docs/cabinet.html b/docs/cabinet.html
index 1d1963f..476136b 100644
--- a/docs/cabinet.html
+++ b/docs/cabinet.html
@@ -531,6 +531,8 @@ function renderEstimateCard(){
const sp=crm.stage_prices;
if(!sp)return ''; // смета ещё не сформирована — не показываем
const pays=crm.stage_payments||{};
+ const due=crm.stage_due||{};
+ const fmtD=d=>{if(!d)return'';const a=d.split('-');return a[2]+'.'+a[1]+'.'+a[0].slice(2);};
const total=Object.values(sp).reduce((a,b)=>a+(+b||0),0);
const paid=Object.values(pays).reduce((s,p)=>s+(p.amount||0),0);
return `
@@ -538,14 +540,16 @@ function renderEstimateCard(){
Прозрачно: за что и сколько. Интервью — бесплатно, дальше помодульно. Готовое ТЗ — после полной оплаты.
${EST_STAGES.map(s=>{
const price=+sp[s.key]||0, isFree=price<=0, isPaid=!!pays[s.key], done=estStageDone(s.key);
+ const dd=due[s.key]||'';
let tag='';
if(isFree)tag='
бесплатно';
else if(isPaid)tag='
✓ оплачено';
else if(done)tag='
к оплате';
else tag='
в работе';
+ const dueChip=(dd&&!isPaid&&!isFree)?`
📅 до ${fmtD(dd)}`:'';
return `
${s.icon}
-
${s.name} ${tag}
${s.desc}
+
${s.name} ${tag} ${dueChip}
${s.desc}
${isFree?'0 ₽':money(price)}
`;
}).join('')}
diff --git a/docs/crm.html b/docs/crm.html
index c714726..7768722 100644
--- a/docs/crm.html
+++ b/docs/crm.html
@@ -450,9 +450,27 @@ function editStagePrice(k){
state.crm=state.crm||{};
state.crm.stage_prices=state.crm.stage_prices||{};
state.crm.stage_prices[k]=n;
- state.crm.deal_amount=recalcDeal(state.crm.stage_prices);
+ // сумму сделки НЕ трогаем — разница показывается как «нераспределено»
saveEstimate();renderClient();
}
+// ── Сроки этапов (плановая дата оплаты) ──
+function getStageDue(){return (state.crm&&state.crm.stage_due)||{};}
+function setStageDue(k,v){
+ state.crm=state.crm||{};state.crm.stage_due=state.crm.stage_due||{};
+ if(v)state.crm.stage_due[k]=v; else delete state.crm.stage_due[k];
+ fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,stage_due:state.crm.stage_due})}).then(loadProjects);
+}
+// ── Подогнать сумму сделки под смету ──
+function alignDealToEstimate(){
+ state.crm=state.crm||{};
+ state.crm.deal_amount=recalcDeal(getStagePrices()||{});
+ saveEstimate();renderClient();
+}
+// ── Способы оплаты ──
+const PAY_METHODS=[["bank","🏦 Безнал"],["cash","💵 Наличные"],["sbp","📱 СБП"],["card","💳 Карта"]];
+const METHOD_ICON={bank:"🏦",cash:"💵",sbp:"📱",card:"💳"};
+const METHOD_NAME=Object.fromEntries(PAY_METHODS.map(m=>[m[0],m[1]]));
+function methodSelect(id,cur){return `
`;}
async function saveEstimate(){
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,stage_prices:state.crm.stage_prices,complexity:state.crm.complexity,deal_amount:state.crm.deal_amount})});
await loadProjects();
@@ -477,7 +495,7 @@ function markStagePayInput(k){
const wrap=document.createElement('div');
wrap.id=fid;
wrap.style.cssText='display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;align-items:center;padding:10px 12px;background:var(--bg);border-radius:10px;border:1.5px solid #D1FAE5';
- wrap.innerHTML=`
`;
+ wrap.innerHTML=`
${methodSelect('spm-'+k,'bank')}
`;
const row=document.getElementById('stagerow-'+k);
if(row)row.parentNode.insertBefore(wrap,row.nextSibling);
}
@@ -485,14 +503,15 @@ async function confirmStagePay(k){
const amt=+document.getElementById('spa-'+k).value;
const date=document.getElementById('spd-'+k).value||new Date().toISOString().slice(0,10);
const note=document.getElementById('spn-'+k).value;
+ const method=(document.getElementById('spm-'+k)||{}).value||'bank';
if(!amt){alert('Укажите сумму');return;}
const pays=getStagePays();if(pays[k])return;
const stageName=CLIENT_STAGES.find(s=>s.key===k).name;
- pays[k]={amount:amt,date};
+ pays[k]={amount:amt,date,method};
state.crm=state.crm||{};
state.crm.stage_payments=pays;
state.crm.payments=state.crm.payments||[];
- state.crm.payments.push({date,amount:amt,note:note||'Этап: '+stageName,stage:k});
+ state.crm.payments.push({date,amount:amt,note:note||'Этап: '+stageName,stage:k,method});
await Promise.all([
fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,stage_payments:pays,payments:state.crm.payments})}),
loadProjects()
@@ -533,8 +552,12 @@ function renderPaymentPlan(){
return;
}
- const total=recalcDeal(sp);
- const isUnlocked=billing==="free"||(total>0&&paidTotal>=total);
+ const allocated=recalcDeal(sp);
+ const deal=(state.crm&&state.crm.deal_amount)||0;
+ const target=deal||allocated; // сумма сделки = цель оплаты
+ const unalloc=target-allocated; // нераспределённый остаток
+ const due=getStageDue();
+ const isUnlocked=billing==="free"||(target>0&&paidTotal>=target);
const cx=getComplexity();
// первый ещё не оплаченный платный выполненный модуль — «доступен к оплате»
box.innerHTML=`
@@ -543,7 +566,7 @@ function renderPaymentPlan(){
${CX_SHORT[cx]}
-
Получено ${money(paidTotal)} из ${money(total)} · ✎ — изменить цену · ↺ — отменить оплату · «↻ Пересчитать» сбрасывает к ставкам
+
Получено ${money(paidTotal)} из ${money(target)} · ✎ цена · ↺ отмена оплаты · 📅 срок оплаты этапа
${CLIENT_STAGES.map(s=>{
const done=stageIsDone(s.key);
const paid=pays[s.key];
@@ -567,23 +590,35 @@ function renderPaymentPlan(){
sub=`
${esc(s.desc)}
`;
action=`
${money(price)}`;
}
+ const dd=due[s.key]||'';
+ const dueChip=dd?`
📅 до ${fmtDate(dd)}`:'';
+ const dueInput=isFree?'':`
`;
return `
${s.icon}
-
${s.name}${badge}
+
${s.name}${badge}${dueChip}
${sub}
+ ${dueInput}
${action}✎${paid?`↺`:''}
`;
}).join("")}
-
-
Итого по проекту
-
${money(total)}
+
+ Распределено по этапам
+ ${money(allocated)}
+
+ Сумма сделки
+ ${money(target)}
+
+ ${unalloc!==0?`
+ ${unalloc>0?'⚠ Нераспределено':'⚠ Перебор сметы'}: ${money(Math.abs(unalloc))}
+
+
`:''}
${isUnlocked
?`✅ Оплата получена. Клиент может скачать ТЗ и печатные документы.`
- :`🔒 Правило: ТЗ (печать + выгрузка) выдаётся клиенту только после полной оплаты — ${money(total)}.`}
+ :`🔒 Правило: ТЗ (печать + выгрузка) выдаётся клиенту только после полной оплаты — ${money(target)}.`}
`;
}
@@ -619,6 +654,8 @@ function applyPrice(price){
function renderPayments(){
const crm=state.crm||{};const deal=crm.deal_amount||0;const pays=crm.payments||[];
const paid=pays.reduce((s,p)=>s+(p.amount||0),0);const left=deal-paid;
+ const cash=pays.filter(p=>p.method==='cash').reduce((s,p)=>s+(p.amount||0),0);
+ const noncash=paid-cash;
const st=paid<=0?["Не оплачено","#DC2626","#FEF2F2"]:left>0?["Частично","#92400E","#FEF3C7"]:["Оплачено","#047857","#ECFDF5"];
const sb=document.getElementById("payStatusBox");
if(sb)sb.innerHTML=`
Оплата
${money(paid)}${deal>0?`${st[0]}`:''}
`;
@@ -628,16 +665,18 @@ function renderPayments(){
${deal>0?`
Сделка ${money(deal)} · Получено ${money(paid)} · Остаток ${money(left)}`:'
'}
${left>0?`
`:''}
${pays.length?`
`:''}
+ ${paid>0?`