feat: смета проекта — гибридная модель оплаты (вход 0₽ + модули)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-06-01 07:27:31 +03:00
parent 1a1dd83cbb
commit 5b146fb7e5
2 changed files with 201 additions and 36 deletions

View File

@ -511,9 +511,54 @@ function renderSpecPane(){
pad.innerHTML=runCard("spec","📋","Техническое задание","Из модели бизнеса Елена спроектирует программу: роли, модули, экраны, данные.","Собрать ТЗ →");return} pad.innerHTML=runCard("spec","📋","Техническое задание","Из модели бизнеса Елена спроектирует программу: роли, модули, экраны, данные.","Собрать ТЗ →");return}
pad.innerHTML=renderSpec(state.spec)+renderExportBar(); pad.innerHTML=renderSpec(state.spec)+renderExportBar();
} }
const EST_STAGES=[
{key:"interview", name:"Интервью", icon:"💬", desc:"Диагностика «как есть» — карта проблем"},
{key:"methods", name:"Методологии", icon:"🎯", desc:"Подбор фреймворков под вашу задачу"},
{key:"canvas", name:"Стратегия", icon:"📊", desc:"Целевая модель процессов (TO-BE)"},
{key:"idef0", name:"Функции IDEF0", icon:"🔧", desc:"Декомпозиция функций + регламенты"},
{key:"spec", name:"Выдача ТЗ", icon:"📋", desc:"Готовое ТЗ к внедрению"},
];
function estStageDone(k){
if(k==="interview")return (state.messages||[]).length>0;
if(k==="methods") return !!state.selection;
if(k==="canvas") return !!state.canvas;
if(k==="idef0") return !!state.model;
if(k==="spec") return !!state.spec;
return false;
}
function renderEstimateCard(){
const crm=state.crm||{};
const sp=crm.stage_prices;
if(!sp)return ''; // смета ещё не сформирована — не показываем
const pays=crm.stage_payments||{};
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">
<div class="exp-h">🧾 Смета проекта</div>
<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);
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>';
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>
<span style="font-size:13px;font-weight:700;color:${isFree?'#9CA3AF':'#047857'};white-space:nowrap">${isFree?'0 ₽':money(price)}</span>
</div>`;
}).join('')}
<div style="display:flex;align-items:center;padding:11px 0 2px;border-top:2px solid rgba(0,0,0,.1);margin-top:3px">
<span style="font-size:13px;font-weight:800">Итого</span>
<span style="margin-left:auto;font-size:17px;font-weight:800;color:#047857">${money(total)}</span>
</div>
${paid>0?`<div style="font-size:11px;color:#6b7280;margin-top:4px">Оплачено: <b style="color:#047857">${money(paid)}</b> · остаток ${money(Math.max(0,total-paid))}</div>`:''}
</div>`;
}
function renderExportBar(){ function renderExportBar(){
if(state.unlocked){ if(state.unlocked){
return `<div class="exp-bar"><div class="exp-h">📦 Готовые документы</div> return renderEstimateCard()+`<div class="exp-bar"><div class="exp-h">📦 Готовые документы</div>
<div class="exp-d">Долг закрыт — документы и ТЗ доступны для печати и выгрузки.</div> <div class="exp-d">Долг закрыт — документы и ТЗ доступны для печати и выгрузки.</div>
<div class="exp-btns"> <div class="exp-btns">
<button class="btn btn-p" onclick="printDoc()">🖨 Печать / PDF</button> <button class="btn btn-p" onclick="printDoc()">🖨 Печать / PDF</button>
@ -522,10 +567,10 @@ function renderExportBar(){
</div></div>`; </div></div>`;
} }
const debt=state.debt||0, deal=state.deal_amount||0; const debt=state.debt||0, deal=state.deal_amount||0;
const money=deal>0?`Сумма: ${deal.toLocaleString('ru')} ₽${debt>0?` · к оплате: <b>${debt.toLocaleString('ru')} ₽</b>`:''}`:''; const moneyStr=deal>0?`Сумма: ${deal.toLocaleString('ru')} ₽${debt>0?` · к оплате: <b>${debt.toLocaleString('ru')} ₽</b>`:''}`:'';
return `<div class="exp-bar locked"> return renderEstimateCard()+`<div class="exp-bar locked">
<div class="exp-h">🔒 Документы готовы — доступны после оплаты</div> <div class="exp-h">🔒 Документы готовы — доступны после оплаты</div>
<div class="exp-d">Вы видите полный результат на экране. Печатный документ и выгрузка ТЗ для разработчика открываются после закрытия оплаты.<br>${money}</div> <div class="exp-d">Вы видите полный результат на экране. Печатный документ и выгрузка ТЗ для разработчика открываются после закрытия оплаты.<br>${moneyStr}</div>
<div class="exp-btns"><button class="btn btn-p" onclick="(window.showPayModal||function(){alert('Оплата скоро будет доступна')})()">Оплатить и забрать документы →</button></div> <div class="exp-btns"><button class="btn btn-p" onclick="(window.showPayModal||function(){alert('Оплата скоро будет доступна')})()">Оплатить и забрать документы →</button></div>
</div>`; </div>`;
} }

View File

@ -310,12 +310,51 @@ async function setBilling(t){
// Этапы = 5 этапов анализа. Отмечаем оплату по мере прохождения. // Этапы = 5 этапов анализа. Отмечаем оплату по мере прохождения.
// Правило: ТЗ (печать + выгрузка) только после 100% оплаты. // Правило: ТЗ (печать + выгрузка) только после 100% оплаты.
const CLIENT_STAGES=[ const CLIENT_STAGES=[
{key:"interview", name:"Интервью", icon:"💬"}, {key:"interview", name:"Интервью", icon:"💬", desc:"Диагностика «как есть» — карта проблем"},
{key:"methods", name:"Методологии", icon:"🎯"}, {key:"methods", name:"Методологии", icon:"🎯", desc:"Подбор фреймворков под вашу задачу"},
{key:"canvas", name:"Стратегия", icon:"📊"}, {key:"canvas", name:"Стратегия", icon:"📊", desc:"Целевая модель процессов (TO-BE)"},
{key:"idef0", name:"Функции IDEF0", icon:"🔧"}, {key:"idef0", name:"Функции IDEF0", icon:"🔧", desc:"Декомпозиция функций + регламенты"},
{key:"spec", name:"Выдача ТЗ", icon:"📋"}, {key:"spec", name:"Выдача ТЗ", icon:"📋", desc:"Готовое ТЗ к внедрению"},
]; ];
// ── Смета: базовые ставки модулей (интервью = вход 0₽) ──
const STAGE_BASE={interview:0, methods:8000, canvas:12000, idef0:15000, spec:5000};
const CX_COEF={low:1.0, medium:1.5, high:2.0};
const CX_LABEL={low:"Простой · 13 процесса", medium:"Средний · 47", high:"Сложный · 8+"};
const CX_SHORT={low:"Простой ×1.0", medium:"Средний ×1.5", high:"Сложный ×2.0"};
function getStagePrices(){return (state.crm&&state.crm.stage_prices)||null;}
function getComplexity(){return (state.crm&&state.crm.complexity)||"medium";}
function recalcDeal(sp){return Object.values(sp).reduce((a,b)=>a+(+b||0),0);}
function buildEstimate(){
const coef=CX_COEF[getComplexity()];
const sp={};
CLIENT_STAGES.forEach(s=>{sp[s.key]=Math.round((STAGE_BASE[s.key]||0)*coef/100)*100;});
state.crm=state.crm||{};
state.crm.stage_prices=sp;
state.crm.complexity=getComplexity();
state.crm.deal_amount=recalcDeal(sp);
saveEstimate();renderClient();
}
async function setComplexity(cx){
state.crm=state.crm||{};state.crm.complexity=cx;
if(state.crm.stage_prices){buildEstimate();return;}
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,complexity:cx})});
renderClient();
}
function editStagePrice(k){
const cur=(getStagePrices()||{})[k]||0;
const v=prompt(`Цена этапа «${CLIENT_STAGES.find(s=>s.key===k).name}» (₽):`,cur);
if(v===null)return;
const n=Math.max(0,Math.round(+v||0));
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();
}
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();
}
function stagePayKey(k){return "pay_"+k;} function stagePayKey(k){return "pay_"+k;}
function stageIsDone(k){ function stageIsDone(k){
if(k==="interview") return (state.messages||[]).length>0; if(k==="interview") return (state.messages||[]).length>0;
@ -326,18 +365,32 @@ function stageIsDone(k){
return false; return false;
} }
function getStagePays(){return (state.crm&&state.crm.stage_payments)||{};} function getStagePays(){return (state.crm&&state.crm.stage_payments)||{};}
async function markStagePaid(k){ function markStagePayInput(k){
const fid='spf-'+k;
const ex=document.getElementById(fid);if(ex){ex.remove();return;}
const deal=(state.crm&&state.crm.deal_amount)||0; const deal=(state.crm&&state.crm.deal_amount)||0;
const pays=getStagePays(); const sp=getStagePrices()||{};
if(pays[k])return; const suggested=sp[k]||(deal>0?Math.round(deal/5):'');
const amt=prompt(`Сумма оплаты за этап «${CLIENT_STAGES.find(s=>s.key===k).name}» (₽):`,deal>0?Math.round(deal/5):"");
if(!amt||isNaN(+amt))return;
const today=new Date().toISOString().slice(0,10); const today=new Date().toISOString().slice(0,10);
pays[k]={amount:+amt,date:today}; 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>`;
const row=document.getElementById('stagerow-'+k);
if(row)row.parentNode.insertBefore(wrap,row.nextSibling);
}
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;
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};
state.crm=state.crm||{}; state.crm=state.crm||{};
state.crm.stage_payments=pays; state.crm.stage_payments=pays;
state.crm.payments=state.crm.payments||[]; state.crm.payments=state.crm.payments||[];
state.crm.payments.push({date:today,amount:+amt,note:"Этап: "+CLIENT_STAGES.find(s=>s.key===k).name}); state.crm.payments.push({date,amount:amt,note:note||'Этап: '+stageName,stage:k});
await Promise.all([ 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})}), 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() loadProjects()
@ -346,37 +399,78 @@ async function markStagePaid(k){
} }
function renderPaymentPlan(){ function renderPaymentPlan(){
const box=document.getElementById("planBox");if(!box)return; const box=document.getElementById("planBox");if(!box)return;
const deal=(state.crm&&state.crm.deal_amount)||0; const billing=(state.crm&&state.crm.billing_type)||"paid";
const sp=getStagePrices();
const pays=getStagePays(); const pays=getStagePays();
const paidTotal=Object.values(pays).reduce((s,p)=>s+(p.amount||0),0); const paidTotal=Object.values(pays).reduce((s,p)=>s+(p.amount||0),0);
const billing=(state.crm&&state.crm.billing_type)||"paid";
const isUnlocked=billing==="free"||(deal>0&&paidTotal>=deal); // ── Сметы ещё нет → генератор ──
if(!sp){
const cx=getComplexity();
box.innerHTML=`<div class="blk" style="margin-bottom:14px">
<div style="font-size:13px;font-weight:700;margin-bottom:6px">📋 Смета проекта</div>
<div style="font-size:12px;color:var(--muted);line-height:1.5;margin-bottom:14px">После диагностики сформируйте смету. Клиент увидит, <b>за что и сколько</b> платит. Интервью — бесплатный вход, дальше оплата помодульно. ТЗ выдаётся после полной оплаты.</div>
<div style="font-size:12px;font-weight:600;margin-bottom:8px">Сложность проекта (из интервью):</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
${Object.keys(CX_COEF).map(k=>`<button class="cp-btn ${cx===k?'cp-a':'cp-r'}" style="padding:8px 12px" onclick="setComplexity('${k}')">${CX_LABEL[k]}</button>`).join('')}
</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:14px">Предварительно: ${money(recalcDeal(Object.fromEntries(CLIENT_STAGES.map(s=>[s.key,Math.round((STAGE_BASE[s.key]||0)*CX_COEF[cx]/100)*100]))))} за проект</div>
<button class="cp-btn cp-a" style="padding:9px 18px" onclick="buildEstimate()">Сформировать смету →</button>
</div>`;
return;
}
const total=recalcDeal(sp);
const isUnlocked=billing==="free"||(total>0&&paidTotal>=total);
const cx=getComplexity();
// первый ещё не оплаченный платный выполненный модуль — «доступен к оплате»
box.innerHTML=`<div class="blk" style="margin-bottom:14px"> box.innerHTML=`<div class="blk" style="margin-bottom:14px">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px"> <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;flex-wrap:wrap">
<span style="font-size:13px;font-weight:700">💼 Прогресс оплаты по этапам</span> <span style="font-size:13px;font-weight:700">📋 Смета проекта</span>
${deal>0?`<span style="margin-left:auto;font-size:12px;color:var(--muted)">Получено <b style="color:var(--primary)">${money(paidTotal)}</b> из ${money(deal)}</span>`:'<span style="margin-left:auto;font-size:11px;color:#f59e0b">⚠ Укажите сумму сделки во вкладке «Сделка»</span>'} <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>
<div style="font-size:11px;color:var(--muted);margin-bottom:12px">Получено <b style="color:var(--primary)">${money(paidTotal)}</b> из ${money(total)} · нажмите цену чтобы изменить</div>
${CLIENT_STAGES.map(s=>{ ${CLIENT_STAGES.map(s=>{
const done=stageIsDone(s.key); const done=stageIsDone(s.key);
const paid=pays[s.key]; const paid=pays[s.key];
return `<div style="display:flex;align-items:center;gap:12px;padding:11px 0;border-top:1px solid var(--bg)"> const price=sp[s.key]||0;
<span style="width:32px;height:32px;border-radius:8px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:15px;background:${done?'#ECFDF5':'#F9FAFB'};border:1.5px solid ${done?'#6EE7B7':'#E5E7EB'}">${s.icon}</span> const isFree=price<=0;
let badge='', sub='', action='';
if(isFree){
badge=`<span style="font-size:10px;font-weight:700;color:#047857;background:#D1FAE5;padding:1px 6px;border-radius:4px;margin-left:4px">Вход · бесплатно</span>`;
sub=`<div style="font-size:11px;color:var(--muted);margin-top:2px">${esc(s.desc)}</div>`;
action=`<span style="font-size:11px;font-weight:700;color:#9CA3AF;white-space:nowrap">0 ₽</span>`;
} else if(paid){
badge=`<span style="font-size:10px;font-weight:700;color:#047857;background:#D1FAE5;padding:1px 6px;border-radius:4px;margin-left:4px">✓ Оплачен</span>`;
sub=`<div style="font-size:11px;color:#047857;margin-top:2px">Оплачено ${money(paid.amount)} · ${fmtDate(paid.date)}</div>`;
action=`<span style="font-size:11px;font-weight:700;color:#047857;background:#ECFDF5;padding:5px 11px;border-radius:8px;white-space:nowrap">${money(price)}</span>`;
} else if(done){
badge=`<span style="font-size:10px;font-weight:700;color:#92400E;background:#FEF3C7;padding:1px 6px;border-radius:4px;margin-left:4px">Доступен к оплате</span>`;
sub=`<div style="font-size:11px;color:var(--muted);margin-top:2px">${esc(s.desc)}</div>`;
action=`<button class="cp-btn cp-a" style="white-space:nowrap" onclick="markStagePayInput('${s.key}')">Оплата · ${money(price)}</button>`;
} else {
badge=`<span style="font-size:10px;font-weight:700;color:#9CA3AF;background:#F3F4F6;padding:1px 6px;border-radius:4px;margin-left:4px">🔒 Ожидает</span>`;
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>`;
}
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="flex:1;min-width:0">
<div style="font-weight:700;font-size:13px;color:${done?'var(--text)':'#9CA3AF'}">${s.name}${done?' <span style="font-size:10px;font-weight:700;color:#047857;background:#D1FAE5;padding:1px 6px;border-radius:4px;margin-left:4px">✓ Выполнен</span>':''}</div> <div style="font-weight:700;font-size:13px;color:${done||isFree?'var(--text)':'#9CA3AF'}">${s.name}${badge}</div>
${paid?`<div style="font-size:11px;color:#047857;margin-top:2px">Оплачено ${money(paid.amount)} · ${fmtDate(paid.date)}</div>`:done?`<div style="font-size:11px;color:var(--muted);margin-top:2px">Этап выполнен — можно запросить оплату</div>`:`<div style="font-size:11px;color:#CBD5E1;margin-top:2px">Ожидается выполнение этапа</div>`} ${sub}
</div> </div>
${paid ${action}
?`<span style="font-size:11px;font-weight:700;color:#047857;background:#ECFDF5;padding:5px 11px;border-radius:8px;white-space:nowrap">${money(paid.amount)}</span>`
:done
?`<button class="cp-btn cp-a" style="white-space:nowrap" onclick="markStagePaid('${s.key}')">Отметить оплату</button>`
:`<span style="font-size:11px;color:#E5E7EB;font-weight:600"></span>`
}
</div>`; </div>`;
}).join("")} }).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>
<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'}"> <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 ${isUnlocked
?`✅ <b>Оплата получена.</b> Клиент может скачать ТЗ и печатные документы.` ?`✅ <b>Оплата получена.</b> Клиент может скачать ТЗ и печатные документы.`
:`🔒 <b>Правило:</b> ТЗ (печать + выгрузка) выдаётся клиенту только после получения полной суммы — ${money(deal)}.`} :`🔒 <b>Правило:</b> ТЗ (печать + выгрузка) выдаётся клиенту только после полной оплаты — ${money(total)}.`}
</div> </div>
</div>`; </div>`;
} }
@ -422,11 +516,37 @@ function renderPayments(){
${left>0?`<button class="cp-btn cp-r" style="padding:5px 10px" onclick="remindBalance(${left})">⏰ Задача на остаток</button>`:''} ${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> ${pays.length?`<button class="cp-btn cp-r" style="padding:5px 10px" onclick="exportPayments()">⬇ CSV</button>`:''}</div>
${pays.map((p,i)=>`<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||'Платёж')}</span><span style="font-size:13px;font-weight:700;color:#047857">${money(p.amount)}</span><button onclick="delPayment(${i})" 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>'} ${pays.map((p,i)=>`<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||'Платёж')}</span><span style="font-size:13px;font-weight:700;color:#047857">${money(p.amount)}</span><button onclick="delPayment(${i})" 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"><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: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()"><input id="payNote" placeholder="Назначение (аванс, постоплата...)" style="flex:1;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> <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()">
<select id="payStage" style="border:1.5px solid var(--border);border-radius:8px;padding:8px 10px;font-size:13px;font-family:Inter;background:var(--white);color:var(--text)">
<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>
<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>
</div>`; </div>`;
} }
async function savePayments(){await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,payments:state.crm.payments})});await loadProjects();} async function savePayments(){
function addPayment(){const amt=+document.getElementById("payAmt").value;if(!amt){alert("Укажите сумму");return}const date=document.getElementById("payDate").value;const note=document.getElementById("payNote").value;state.crm=state.crm||{};state.crm.payments=state.crm.payments||[];state.crm.payments.push({date,amount:amt,note});savePayments();renderClient();} await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,payments:state.crm.payments,stage_payments:state.crm.stage_payments||{}})});
await loadProjects();
}
function addPayment(){
const amt=+document.getElementById("payAmt").value;if(!amt){alert("Укажите сумму");return;}
const date=document.getElementById("payDate").value;
const note=document.getElementById("payNote").value;
const stageKey=document.getElementById("payStage")?.value||"";
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});
if(stageKey&&!getStagePays()[stageKey]){
state.crm.stage_payments=state.crm.stage_payments||{};
state.crm.stage_payments[stageKey]={amount:amt,date};
}
savePayments();renderClient();
}
function delPayment(i){state.crm.payments.splice(i,1);savePayments();renderClient();} function delPayment(i){state.crm.payments.splice(i,1);savePayments();renderClient();}
function remindBalance(left){ function remindBalance(left){
const due=new Date(Date.now()+7*864e5).toISOString().slice(0,10); const due=new Date(Date.now()+7*864e5).toISOString().slice(0,10);