feat(платежи): нераспределённый остаток, сроки этапов (график), нал/безнал, клиент видит даты

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-06-01 08:54:07 +03:00
parent b965d689d8
commit 066a628695
3 changed files with 65 additions and 19 deletions

View File

@ -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):

View File

@ -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 `<div class="exp-bar" style="margin-bottom:14px">
@ -538,14 +540,16 @@ function renderEstimateCard(){
<div class="exp-d" style="margin-bottom:10px">Прозрачно: за что и сколько. Интервью — бесплатно, дальше помодульно. Готовое ТЗ — после полной оплаты.</div>
${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='<span style="font-size:10px;font-weight:700;color:#047857;background:#D1FAE5;padding:1px 7px;border-radius:5px">бесплатно</span>';
else if(isPaid)tag='<span style="font-size:10px;font-weight:700;color:#047857;background:#D1FAE5;padding:1px 7px;border-radius:5px">✓ оплачено</span>';
else if(done)tag='<span style="font-size:10px;font-weight:700;color:#92400E;background:#FEF3C7;padding:1px 7px;border-radius:5px">к оплате</span>';
else tag='<span style="font-size:10px;font-weight:700;color:#9CA3AF;background:#F3F4F6;padding:1px 7px;border-radius:5px">в работе</span>';
const dueChip=(dd&&!isPaid&&!isFree)?`<span style="font-size:10px;font-weight:600;color:#2563EB;background:#EFF6FF;padding:1px 7px;border-radius:5px">📅 до ${fmtD(dd)}</span>`:'';
return `<div style="display:flex;align-items:center;gap:10px;padding:9px 0;border-top:1px solid rgba(0,0,0,.06)">
<span style="font-size:15px">${s.icon}</span>
<div style="flex:1;min-width:0"><div style="font-size:13px;font-weight:700;display:flex;align-items:center;gap:7px;flex-wrap:wrap">${s.name} ${tag}</div><div style="font-size:11px;color:#6b7280;margin-top:1px">${s.desc}</div></div>
<div style="flex:1;min-width:0"><div style="font-size:13px;font-weight:700;display:flex;align-items:center;gap:7px;flex-wrap:wrap">${s.name} ${tag} ${dueChip}</div><div style="font-size:11px;color:#6b7280;margin-top:1px">${s.desc}</div></div>
<span style="font-size:13px;font-weight:700;color:${isFree?'#9CA3AF':'#047857'};white-space:nowrap">${isFree?'0 ₽':money(price)}</span>
</div>`;
}).join('')}

View File

@ -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 `<select id="${id}" style="border:1.5px solid var(--border);border-radius:8px;padding:7px 9px;font-size:13px;font-family:Inter;background:var(--white)">${PAY_METHODS.map(([k,n])=>`<option value="${k}" ${cur===k?'selected':''}>${n}</option>`).join('')}</select>`;}
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=`<input type="number" id="spa-${k}" placeholder="Сумма ₽" value="${suggested}" style="width:110px;border:1.5px solid var(--border);border-radius:8px;padding:7px 10px;font-size:13px;font-family:Inter"><input type="date" id="spd-${k}" value="${today}" style="border:1.5px solid var(--border);border-radius:8px;padding:7px 10px;font-size:13px;font-family:Inter"><input id="spn-${k}" placeholder="Назначение" style="flex:1;min-width:120px;border:1.5px solid var(--border);border-radius:8px;padding:7px 10px;font-size:13px;font-family:Inter"><button class="cp-btn cp-a" style="padding:7px 14px" onclick="confirmStagePay('${k}')">Сохранить</button><button class="cp-btn cp-r" style="padding:7px 12px" onclick="document.getElementById('${fid}').remove()"></button>`;
wrap.innerHTML=`<input type="number" id="spa-${k}" placeholder="Сумма ₽" value="${suggested}" style="width:110px;border:1.5px solid var(--border);border-radius:8px;padding:7px 10px;font-size:13px;font-family:Inter"><input type="date" id="spd-${k}" value="${today}" style="border:1.5px solid var(--border);border-radius:8px;padding:7px 10px;font-size:13px;font-family:Inter">${methodSelect('spm-'+k,'bank')}<input id="spn-${k}" placeholder="Назначение" style="flex:1;min-width:120px;border:1.5px solid var(--border);border-radius:8px;padding:7px 10px;font-size:13px;font-family:Inter"><button class="cp-btn cp-a" style="padding:7px 14px" onclick="confirmStagePay('${k}')">Сохранить</button><button class="cp-btn cp-r" style="padding:7px 12px" onclick="document.getElementById('${fid}').remove()"></button>`;
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=`<div class="blk" style="margin-bottom:14px">
@ -543,7 +566,7 @@ function renderPaymentPlan(){
<span style="font-size:11px;font-weight:700;color:var(--primary);background:#ECFDF5;padding:3px 9px;border-radius:6px">${CX_SHORT[cx]}</span>
<button class="cp-btn cp-r" style="padding:4px 9px;margin-left:auto" onclick="buildEstimate()">↻ Пересчитать</button>
</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:12px">Получено <b style="color:var(--primary)">${money(paidTotal)}</b> из ${money(total)} · <b></b> — изменить цену · <b style="color:#DC2626"></b> — отменить оплату · «↻ Пересчитать» сбрасывает к ставкам</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:12px">Получено <b style="color:var(--primary)">${money(paidTotal)}</b> из ${money(target)} · <b></b> цена · <b style="color:#DC2626"></b> отмена оплаты · 📅 срок оплаты этапа</div>
${CLIENT_STAGES.map(s=>{
const done=stageIsDone(s.key);
const paid=pays[s.key];
@ -567,23 +590,35 @@ function renderPaymentPlan(){
sub=`<div style="font-size:11px;color:#CBD5E1;margin-top:2px">${esc(s.desc)}</div>`;
action=`<span onclick="editStagePrice('${s.key}')" style="cursor:pointer;font-size:11px;font-weight:700;color:#9CA3AF;white-space:nowrap;border-bottom:1px dashed #CBD5E1">${money(price)}</span>`;
}
const dd=due[s.key]||'';
const dueChip=dd?`<span style="font-size:10px;font-weight:600;color:#2563EB;background:#EFF6FF;padding:1px 6px;border-radius:4px;margin-left:6px">📅 до ${fmtDate(dd)}</span>`:'';
const dueInput=isFree?'':`<input type="date" value="${dd}" title="Срок оплаты этапа" onchange="setStageDue('${s.key}',this.value)" style="border:1.5px solid var(--border);border-radius:7px;padding:5px 7px;font-size:11px;font-family:Inter;color:#6B7280;width:130px">`;
return `<div id="stagerow-${s.key}" style="display:flex;align-items:center;gap:12px;padding:11px 0;border-top:1px solid var(--bg)">
<span style="width:32px;height:32px;border-radius:8px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:15px;background:${paid?'#D1FAE5':done||isFree?'#ECFDF5':'#F9FAFB'};border:1.5px solid ${paid?'#6EE7B7':done||isFree?'#A7F3D0':'#E5E7EB'}">${s.icon}</span>
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:13px;color:${done||isFree?'var(--text)':'#9CA3AF'}">${s.name}${badge}</div>
<div style="font-weight:700;font-size:13px;color:${done||isFree?'var(--text)':'#9CA3AF'}">${s.name}${badge}${dueChip}</div>
${sub}
</div>
${dueInput}
<div style="display:flex;align-items:center;gap:7px">${action}<span onclick="event.stopPropagation();editStagePrice('${s.key}')" title="Изменить цену этапа" style="cursor:pointer;color:#9CA3AF;font-size:14px;line-height:1"></span>${paid?`<span onclick="event.stopPropagation();unStagePay('${s.key}')" title="Отменить оплату" style="cursor:pointer;color:#DC2626;font-size:14px;line-height:1"></span>`:''}</div>
</div>`;
}).join("")}
<div style="display:flex;align-items:center;gap:10px;padding:12px 0 2px;border-top:2px solid var(--bg);margin-top:4px">
<span style="font-size:13px;font-weight:800;font-family:Montserrat">Итого по проекту</span>
<span style="margin-left:auto;font-size:18px;font-weight:800;color:var(--primary)">${money(total)}</span>
<div style="display:flex;align-items:center;gap:10px;padding:12px 0 4px;border-top:2px solid var(--bg);margin-top:4px">
<span style="font-size:13px;font-weight:800;font-family:Montserrat">Распределено по этапам</span>
<span style="margin-left:auto;font-size:16px;font-weight:800;color:var(--text)">${money(allocated)}</span>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:4px 0;font-size:12px;color:var(--muted)">
<span>Сумма сделки</span>
<span style="margin-left:auto;font-weight:700;color:var(--primary)">${money(target)}</span>
</div>
${unalloc!==0?`<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;margin-top:4px;border-radius:9px;background:${unalloc>0?'#FFF7ED':'#FEF2F2'};border:1px solid ${unalloc>0?'#FDE68A':'#FECACA'}">
<span style="font-size:12px;font-weight:700;color:${unalloc>0?'#92400E':'#DC2626'}">${unalloc>0?'⚠ Нераспределено':'⚠ Перебор сметы'}: ${money(Math.abs(unalloc))}</span>
<button class="cp-btn cp-r" style="margin-left:auto;padding:4px 10px" onclick="alignDealToEstimate()">Подогнать сделку под смету</button>
</div>`:''}
<div style="margin-top:12px;padding:11px 14px;border-radius:10px;font-size:12px;line-height:1.5;${isUnlocked?'background:#ECFDF5;border:1px solid #6EE7B7;color:#047857':'background:#FFF7ED;border:1px solid #FDE68A;color:#92400E'}">
${isUnlocked
?`✅ <b>Оплата получена.</b> Клиент может скачать ТЗ и печатные документы.`
:`🔒 <b>Правило:</b> ТЗ (печать + выгрузка) выдаётся клиенту только после полной оплаты — ${money(total)}.`}
:`🔒 <b>Правило:</b> ТЗ (печать + выгрузка) выдаётся клиенту только после полной оплаты — ${money(target)}.`}
</div>
</div>`;
}
@ -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=`<div class="cc-fl">Оплата</div><div style="display:flex;align-items:center;gap:8px"><span style="font-size:14px;font-weight:700;color:${st[1]}">${money(paid)}</span>${deal>0?`<span style="font-size:11px;font-weight:700;color:${st[1]};background:${st[2]};padding:2px 8px;border-radius:6px">${st[0]}</span>`:''}</div>`;
@ -628,16 +665,18 @@ function renderPayments(){
${deal>0?`<span style="margin-left:auto;font-size:12px;color:var(--muted)">Сделка ${money(deal)} · Получено ${money(paid)} · <b style="color:${left>0?'#DC2626':'#047857'}">Остаток ${money(left)}</b></span>`:'<span style="margin-left:auto"></span>'}
${left>0?`<button class="cp-btn cp-r" style="padding:5px 10px" onclick="remindBalance(${left})">⏰ Задача на остаток</button>`:''}
${pays.length?`<button class="cp-btn cp-r" style="padding:5px 10px" onclick="exportPayments()">⬇ CSV</button>`:''}</div>
${paid>0?`<div style="display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap"><span style="font-size:11px;font-weight:700;color:#047857;background:#ECFDF5;padding:3px 10px;border-radius:6px">💵 Наличные ${money(cash)}</span><span style="font-size:11px;font-weight:700;color:#2563EB;background:#EFF6FF;padding:3px 10px;border-radius:6px">🏦 Безнал ${money(noncash)}</span></div>`:''}
${pays.map((p,i)=>i===editPayIdx
?`<div style="display:flex;align-items:center;gap:8px;padding:9px 10px;border-top:1px solid var(--bg);flex-wrap:wrap;background:var(--bg);border-radius:8px;margin-top:4px">
<input type="date" id="epd-${i}" value="${esc(p.date||'')}" style="border:1.5px solid var(--border);border-radius:8px;padding:6px 8px;font-size:13px;font-family:Inter">
<input type="number" id="epa-${i}" value="${p.amount||''}" style="width:100px;border:1.5px solid var(--border);border-radius:8px;padding:6px 9px;font-size:13px;font-family:Inter">
${methodSelect('epm-'+i,p.method||'bank')}
<input id="epn-${i}" value="${esc(p.note||'')}" placeholder="Назначение" style="flex:1;min-width:120px;border:1.5px solid var(--border);border-radius:8px;padding:6px 9px;font-size:13px;font-family:Inter">
${p.stage?`<span style="font-size:10px;color:#9CA3AF;white-space:nowrap">этап: ${esc((CLIENT_STAGES.find(s=>s.key===p.stage)||{}).name||p.stage)}</span>`:''}
<button class="cp-btn cp-a" style="padding:6px 12px" onclick="confirmEditPayment(${i})">Сохранить</button>
<button class="cp-btn cp-r" style="padding:6px 10px" onclick="cancelEditPayment()"></button>
</div>`
:`<div style="display:flex;align-items:center;gap:10px;padding:7px 0;border-top:1px solid var(--bg)"><span style="font-size:12px;color:var(--muted);min-width:70px">${esc(p.date||'')}</span><span style="flex:1;font-size:13px">${esc(p.note||'Платёж')}${p.stage?` <span style="font-size:10px;color:#9CA3AF">· этап</span>`:''}</span><span style="font-size:13px;font-weight:700;color:#047857">${money(p.amount)}</span><span onclick="editPayment(${i})" title="Изменить" style="cursor:pointer;color:#9CA3AF;font-size:13px"></span><button onclick="delPayment(${i})" title="Удалить" style="border:none;background:none;cursor:pointer;color:#cbd5e1;font-size:14px"></button></div>`).join("")||'<div style="font-size:12px;color:#cbd5e1;padding:4px">Платежей нет</div>'}
:`<div style="display:flex;align-items:center;gap:10px;padding:7px 0;border-top:1px solid var(--bg)"><span style="font-size:12px;color:var(--muted);min-width:70px">${esc(p.date||'')}</span><span title="${esc(METHOD_NAME[p.method||'bank']||'')}" style="font-size:13px">${METHOD_ICON[p.method||'bank']||'🏦'}</span><span style="flex:1;font-size:13px">${esc(p.note||'Платёж')}${p.stage?` <span style="font-size:10px;color:#9CA3AF">· этап</span>`:''}</span><span style="font-size:13px;font-weight:700;color:#047857">${money(p.amount)}</span><span onclick="editPayment(${i})" title="Изменить" style="cursor:pointer;color:#9CA3AF;font-size:13px"></span><button onclick="delPayment(${i})" title="Удалить" style="border:none;background:none;cursor:pointer;color:#cbd5e1;font-size:14px"></button></div>`).join("")||'<div style="font-size:12px;color:#cbd5e1;padding:4px">Платежей нет</div>'}
<div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;align-items:center">
<input id="payDate" type="date" value="${new Date().toISOString().slice(0,10)}" style="border:1.5px solid var(--border);border-radius:8px;padding:8px;font-size:13px;font-family:Inter">
<input id="payAmt" type="number" placeholder="Сумма ₽" style="width:110px;border:1.5px solid var(--border);border-radius:8px;padding:8px 11px;font-size:13px;font-family:Inter" onkeydown="if(event.key==='Enter')addPayment()">
@ -645,6 +684,7 @@ function renderPayments(){
<option value="">— без этапа —</option>
${CLIENT_STAGES.filter(s=>stageIsDone(s.key)&&!getStagePays()[s.key]&&((getStagePrices()||{})[s.key]||0)>0).map(s=>`<option value="${s.key}">${s.name} · ${money((getStagePrices()||{})[s.key])}</option>`).join('')}
</select>
${methodSelect('payMethod','bank')}
<input id="payNote" placeholder="Назначение" style="flex:1;min-width:120px;border:1.5px solid var(--border);border-radius:8px;padding:8px 11px;font-size:13px;font-family:Inter" onkeydown="if(event.key==='Enter')addPayment()">
<button class="cp-btn cp-a" onclick="addPayment()">+ Платёж</button>
</div>
@ -662,9 +702,10 @@ function confirmEditPayment(i){
if(!amt){alert('Укажите сумму');return;}
const date=document.getElementById('epd-'+i).value||p.date;
const note=document.getElementById('epn-'+i).value;
p.amount=amt;p.date=date;p.note=note||'Платёж';
const method=(document.getElementById('epm-'+i)||{}).value||p.method||'bank';
p.amount=amt;p.date=date;p.note=note||'Платёж';p.method=method;
if(p.stage&&state.crm.stage_payments&&state.crm.stage_payments[p.stage]){
state.crm.stage_payments[p.stage]={amount:amt,date};
state.crm.stage_payments[p.stage]={amount:amt,date,method};
}
editPayIdx=-1;
savePayments();renderClient();
@ -674,13 +715,14 @@ function addPayment(){
const date=document.getElementById("payDate").value;
const note=document.getElementById("payNote").value;
const stageKey=document.getElementById("payStage")?.value||"";
const method=document.getElementById("payMethod")?.value||"bank";
const stageName=stageKey?CLIENT_STAGES.find(s=>s.key===stageKey)?.name:"";
state.crm=state.crm||{};
state.crm.payments=state.crm.payments||[];
state.crm.payments.push({date,amount:amt,note:note||(stageName?"Этап: "+stageName:"Платёж"),stage:stageKey||null});
state.crm.payments.push({date,amount:amt,note:note||(stageName?"Этап: "+stageName:"Платёж"),stage:stageKey||null,method});
if(stageKey&&!getStagePays()[stageKey]){
state.crm.stage_payments=state.crm.stage_payments||{};
state.crm.stage_payments[stageKey]={amount:amt,date};
state.crm.stage_payments[stageKey]={amount:amt,date,method};
}
savePayments();renderClient();
}