feat: revenue chart by month + auto-task on balance + payments CSV export

This commit is contained in:
wasrusgen 2026-05-30 14:54:50 +03:00
parent 13db0cf9c9
commit a056f1a7eb

View File

@ -182,11 +182,24 @@ function renderDashboard(){
<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">${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>
${renderRevenueChart()}
${renderUpcomingTasks()} ${renderUpcomingTasks()}
<div class="sec-h">Все клиенты · ${total}</div> <div class="sec-h">Все клиенты · ${total}</div>
${projects.map(p=>{const pc=pipeMap[(p.crm&&p.crm.pipeline)||"lead"];return `<div class="blk" style="display:flex;align-items:center;gap:14px;cursor:pointer" onclick="openClient('${p.token}')"><div class="cl-av" style="width:36px;height:36px;border-radius:9px;font-size:14px">${esc((p.client_name||'?')[0])}</div><div style="flex:1"><div style="font-weight:700">${esc(p.client_name)}</div><div style="font-size:12px;color:var(--muted)">${esc(p.niche)} · ${p.msg_count} сообщений</div></div><span style="font-size:11px;font-weight:700;color:${pc[2]};background:${pc[2]}1a;padding:4px 10px;border-radius:20px">${pc[1]}</span><span style="font-weight:700;color:var(--primary)">${money((p.crm&&p.crm.deal_amount)||0)}</span></div>`}).join("")||'<div class="empty">Создайте первого клиента</div>'}`; ${projects.map(p=>{const pc=pipeMap[(p.crm&&p.crm.pipeline)||"lead"];return `<div class="blk" style="display:flex;align-items:center;gap:14px;cursor:pointer" onclick="openClient('${p.token}')"><div class="cl-av" style="width:36px;height:36px;border-radius:9px;font-size:14px">${esc((p.client_name||'?')[0])}</div><div style="flex:1"><div style="font-weight:700">${esc(p.client_name)}</div><div style="font-size:12px;color:var(--muted)">${esc(p.niche)} · ${p.msg_count} сообщений</div></div><span style="font-size:11px;font-weight:700;color:${pc[2]};background:${pc[2]}1a;padding:4px 10px;border-radius:20px">${pc[1]}</span><span style="font-weight:700;color:var(--primary)">${money((p.crm&&p.crm.deal_amount)||0)}</span></div>`}).join("")||'<div class="empty">Создайте первого клиента</div>'}`;
} }
function renderRevenueChart(){
const months={};
projects.forEach(p=>(p.crm&&p.crm.payments||[]).forEach(pay=>{const m=(pay.date||"").slice(0,7);if(m)months[m]=(months[m]||0)+(pay.amount||0)}));
const keys=Object.keys(months).sort();
if(!keys.length)return"";
const max=Math.max(...keys.map(k=>months[k]));
const MN=["янв","фев","мар","апр","май","июн","июл","авг","сен","окт","ноя","дек"];
const lbl=k=>{const[y,m]=k.split("-");return MN[+m-1]+" "+y.slice(2)};
const totalRev=keys.reduce((s,k)=>s+months[k],0);
return `<div class="sec-h">📈 Выручка по месяцам · ${money(totalRev)}</div>
<div class="blk" style="padding:18px"><div style="display:flex;align-items:flex-end;gap:14px;height:140px">${keys.map(k=>`<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%"><div style="font-size:11px;font-weight:700;color:#047857;margin-bottom:4px">${(months[k]/1000).toFixed(0)}к</div><div style="width:100%;max-width:48px;background:linear-gradient(180deg,#10B981,#047857);border-radius:6px 6px 0 0;height:${Math.max(6,months[k]/max*100)}%"></div><div style="font-size:11px;color:var(--muted);margin-top:6px">${lbl(k)}</div></div>`).join("")}</div></div>`;
}
function renderUpcomingTasks(){ function renderUpcomingTasks(){
const today=new Date().toISOString().slice(0,10); const today=new Date().toISOString().slice(0,10);
const all=[]; const all=[];
@ -230,8 +243,10 @@ function renderPayments(){
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>`; 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; 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"> 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> <div style="display:flex;align-items:center;margin-bottom:10px;flex-wrap:wrap;gap:8px"><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> ${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>
${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"><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>`; </div>`;
@ -239,6 +254,19 @@ function renderPayments(){
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(){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 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 delPayment(i){state.crm.payments.splice(i,1);renderPayments();savePayments();}
function remindBalance(left){
const due=new Date(Date.now()+7*864e5).toISOString().slice(0,10);
state.tasks=state.tasks||[];state.tasks.push({text:`Получить остаток ${money(left)} от ${state.client_name||'клиента'}`,due,done:false});
saveTasks();renderTasks();
alert(`Задача создана: «Получить остаток ${money(left)}»\nСрок: через 7 дней`);
}
function exportPayments(){
const pays=state.crm.payments||[];
let csv="Дата;Сумма;Назначение\n"+pays.map(p=>`${p.date||''};${p.amount||0};${(p.note||'').replace(/;/g,',')}`).join("\n");
csv=""+csv; // BOM для Excel
const blob=new Blob([csv],{type:"text/csv;charset=utf-8"});
const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=`Платежи_${(state.client_name||'клиент').replace(/[^а-яёa-z0-9]/gi,'_')}.csv`;a.click();
}
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||[];