mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 16:44:46 +00:00
feat: смета проекта — гибридная модель оплаты (вход 0₽ + модули)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1a1dd83cbb
commit
5b146fb7e5
@ -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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
186
docs/crm.html
186
docs/crm.html
@ -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:"Простой · 1–3 процесса", medium:"Средний · 4–7", 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">
|
box.innerHTML=`<div class="blk" style="margin-bottom:14px">
|
||||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">
|
<div style="font-size:13px;font-weight:700;margin-bottom:6px">📋 Смета проекта</div>
|
||||||
<span style="font-size:13px;font-weight:700">💼 Прогресс оплаты по этапам</span>
|
<div style="font-size:12px;color:var(--muted);line-height:1.5;margin-bottom:14px">После диагностики сформируйте смету. Клиент увидит, <b>за что и сколько</b> платит. Интервью — бесплатный вход, дальше оплата помодульно. ТЗ выдаётся после полной оплаты.</div>
|
||||||
${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>'}
|
<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>
|
||||||
|
<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">
|
||||||
|
<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: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)} · нажмите цену чтобы изменить</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;
|
||||||
<div style="flex:1;min-width:0">
|
let badge='', sub='', action='';
|
||||||
<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>
|
if(isFree){
|
||||||
${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>`}
|
badge=`<span style="font-size:10px;font-weight:700;color:#047857;background:#D1FAE5;padding:1px 6px;border-radius:4px;margin-left:4px">Вход · бесплатно</span>`;
|
||||||
</div>
|
sub=`<div style="font-size:11px;color:var(--muted);margin-top:2px">${esc(s.desc)}</div>`;
|
||||||
${paid
|
action=`<span style="font-size:11px;font-weight:700;color:#9CA3AF;white-space:nowrap">0 ₽</span>`;
|
||||||
?`<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>`
|
} else if(paid){
|
||||||
:done
|
badge=`<span style="font-size:10px;font-weight:700;color:#047857;background:#D1FAE5;padding:1px 6px;border-radius:4px;margin-left:4px">✓ Оплачен</span>`;
|
||||||
?`<button class="cp-btn cp-a" style="white-space:nowrap" onclick="markStagePaid('${s.key}')">Отметить оплату</button>`
|
sub=`<div style="font-size:11px;color:#047857;margin-top:2px">Оплачено ${money(paid.amount)} · ${fmtDate(paid.date)}</div>`;
|
||||||
:`<span style="font-size:11px;color:#E5E7EB;font-weight:600">—</span>`
|
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="font-weight:700;font-size:13px;color:${done||isFree?'var(--text)':'#9CA3AF'}">${s.name}${badge}</div>
|
||||||
|
${sub}
|
||||||
|
</div>
|
||||||
|
${action}
|
||||||
</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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user