feat: payments registry — payment log, balance, status + finance KPIs on dashboard

This commit is contained in:
wasrusgen 2026-05-30 14:45:38 +03:00
parent c37491cbc7
commit 13db0cf9c9
2 changed files with 32 additions and 8 deletions

View File

@ -609,10 +609,13 @@ def update_crm():
return jsonify({"error": "project not found"}), 404 return jsonify({"error": "project not found"}), 404
crm = latest_artifact(proj["id"], "crm") or { crm = latest_artifact(proj["id"], "crm") or {
"pipeline": "lead", "deal_amount": 0, "paid_amount": 0, "pipeline": "lead", "deal_amount": 0, "paid_amount": 0,
"contact": "", "source": "", "note": "" "contact": "", "source": "", "note": "", "payments": []
} }
for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note"): for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments"):
if k in data: crm[k] = data[k] if k in data: crm[k] = data[k]
# paid_amount = сумма платежей (если есть реестр)
if "payments" in crm and isinstance(crm["payments"], list):
crm["paid_amount"] = sum(p.get("amount", 0) for p in crm["payments"])
save_artifact(proj["id"], "crm", crm) save_artifact(proj["id"], "crm", crm)
return jsonify({"ok": True, "crm": crm}) return jsonify({"ok": True, "crm": crm})

View File

@ -169,15 +169,17 @@ function renderDashboard(){
const total=projects.length; const total=projects.length;
const byPipe=k=>projects.filter(p=>((p.crm&&p.crm.pipeline)||"lead")===k).length; const byPipe=k=>projects.filter(p=>((p.crm&&p.crm.pipeline)||"lead")===k).length;
const active=byPipe("active"),done=byPipe("done"),leads=byPipe("lead"); const active=byPipe("active"),done=byPipe("done"),leads=byPipe("lead");
const revenue=projects.reduce((s,p)=>s+((p.crm&&p.crm.paid_amount)||0),0); const paidOf=p=>{const c=p.crm||{};return (c.payments||[]).reduce((s,x)=>s+(x.amount||0),0)||c.paid_amount||0};
const revenue=projects.reduce((s,p)=>s+paidOf(p),0);
const expected=projects.reduce((s,p)=>{const c=p.crm||{};return s+Math.max(0,(c.deal_amount||0)-paidOf(p))},0);
const inwork=projects.filter(p=>((p.crm&&p.crm.pipeline)||"")==="active").reduce((s,p)=>s+((p.crm&&p.crm.deal_amount)||0),0); const inwork=projects.filter(p=>((p.crm&&p.crm.pipeline)||"")==="active").reduce((s,p)=>s+((p.crm&&p.crm.deal_amount)||0),0);
const conv=total?Math.round(done/total*100):0; const conv=total?Math.round(done/total*100):0;
document.getElementById("view").innerHTML=` document.getElementById("view").innerHTML=`
<div class="sec-h">Дашборд</div> <div class="sec-h">Дашборд</div>
<div class="kpis"> <div class="kpis">
<div class="kpi"><div class="kpi-v">${leads}</div><div class="kpi-l">Новых лидов</div></div> <div class="kpi"><div class="kpi-v">${leads}</div><div class="kpi-l">Новых лидов</div><div class="kpi-sub">${active} в работе</div></div>
<div class="kpi"><div class="kpi-v">${active}</div><div class="kpi-l">Активных клиентов</div><div class="kpi-sub">${money(inwork)} в работе</div></div> <div class="kpi"><div class="kpi-v">${money(revenue)}</div><div class="kpi-l">Выручка (получено)</div></div>
<div class="kpi"><div class="kpi-v">${money(revenue)}</div><div class="kpi-l">Выручка (оплачено)</div></div> <div class="kpi"><div class="kpi-v">${money(expected)}</div><div class="kpi-l">Ожидается (остатки)</div><div class="kpi-sub">${money(inwork)} в активных сделках</div></div>
<div class="kpi"><div class="kpi-v">${conv}%</div><div class="kpi-l">Конверсия в сделку</div><div class="kpi-sub">${done} завершено</div></div> <div class="kpi"><div class="kpi-v">${conv}%</div><div class="kpi-l">Конверсия в сделку</div><div class="kpi-sub">${done} завершено</div></div>
</div> </div>
${renderUpcomingTasks()} ${renderUpcomingTasks()}
@ -208,16 +210,35 @@ function renderClient(){
<div class="cc-grid"> <div class="cc-grid">
<div class="cc-field"><div class="cc-fl">Статус воронки</div><select class="cc-sel" id="cpPipe" onchange="saveCrm()">${PIPE.map(([k,n])=>`<option value="${k}" ${crm.pipeline===k?'selected':''}>${n}</option>`).join("")}</select></div> <div class="cc-field"><div class="cc-fl">Статус воронки</div><select class="cc-sel" id="cpPipe" onchange="saveCrm()">${PIPE.map(([k,n])=>`<option value="${k}" ${crm.pipeline===k?'selected':''}>${n}</option>`).join("")}</select></div>
<div class="cc-field"><div class="cc-fl">Сумма сделки</div><input class="cc-fi" id="cpDeal" type="number" value="${crm.deal_amount||''}" placeholder="0" onchange="saveCrm()"></div> <div class="cc-field"><div class="cc-fl">Сумма сделки</div><input class="cc-fi" id="cpDeal" type="number" value="${crm.deal_amount||''}" placeholder="0" onchange="saveCrm()"></div>
<div class="cc-field"><div class="cc-fl">Оплачено</div><input class="cc-fi" id="cpPaid" type="number" value="${crm.paid_amount||''}" placeholder="0" onchange="saveCrm()"></div>
<div class="cc-field"><div class="cc-fl">Источник</div><input class="cc-fi" id="cpSrc" value="${esc(crm.source)}" placeholder="откуда пришёл" onchange="saveCrm()"></div> <div class="cc-field"><div class="cc-fl">Источник</div><input class="cc-fi" id="cpSrc" value="${esc(crm.source)}" placeholder="откуда пришёл" onchange="saveCrm()"></div>
<div class="cc-field" id="payStatusBox"></div>
</div> </div>
<div id="paymentsBox"></div>
<div class="cc-actions"><button class="btn btn-p" onclick="inviteLink()">🔗 Ссылка клиенту</button><a class="btn btn-g" href="cabinet.html?t=${current}" target="_blank">👁 Открыть кабинет</a><button class="btn btn-g" style="margin-left:auto;border-color:#FECACA;color:#DC2626" onclick="deleteClient()">🗑 Удалить клиента</button></div> <div class="cc-actions"><button class="btn btn-p" onclick="inviteLink()">🔗 Ссылка клиенту</button><a class="btn btn-g" href="cabinet.html?t=${current}" target="_blank">👁 Открыть кабинет</a><button class="btn btn-g" style="margin-left:auto;border-color:#FECACA;color:#DC2626" onclick="deleteClient()">🗑 Удалить клиента</button></div>
<div id="tasksBox"></div> <div id="tasksBox"></div>
<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 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>`; <div id="tabContent"></div>`;
renderTasks(); renderTasks();
renderPayments();
renderTab(); renderTab();
} }
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 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>`;
const box=document.getElementById("paymentsBox");if(!box)return;
box.innerHTML=`<div style="background:var(--white);border:1.5px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:18px">
<div style="display:flex;align-items:center;margin-bottom:10px"><span style="font-size:13px;font-weight:700">💰 Платежи</span>
${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>`:''}</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>`;
}
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();}
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});renderPayments();savePayments();}
function delPayment(i){state.crm.payments.splice(i,1);renderPayments();savePayments();}
function renderTasks(){ function renderTasks(){
const box=document.getElementById("tasksBox");if(!box)return; const box=document.getElementById("tasksBox");if(!box)return;
const tasks=state.tasks||[]; const tasks=state.tasks||[];
@ -234,7 +255,7 @@ function addTask(){const t=document.getElementById("newTask").value.trim();if(!t
function toggleTask(i){state.tasks[i].done=!state.tasks[i].done;renderTasks();saveTasks();} function toggleTask(i){state.tasks[i].done=!state.tasks[i].done;renderTasks();saveTasks();}
function delTask(i){state.tasks.splice(i,1);renderTasks();saveTasks();} function delTask(i){state.tasks.splice(i,1);renderTasks();saveTasks();}
async function saveCrm(){ async function saveCrm(){
const crm={pipeline:document.getElementById("cpPipe").value,deal_amount:+document.getElementById("cpDeal").value||0,paid_amount:+document.getElementById("cpPaid").value||0,source:document.getElementById("cpSrc").value}; const crm={pipeline:document.getElementById("cpPipe").value,deal_amount:+document.getElementById("cpDeal").value||0,source:document.getElementById("cpSrc").value};
state.crm={...state.crm,...crm}; state.crm={...state.crm,...crm};
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,...crm})}); await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,...crm})});
await loadProjects(); await loadProjects();