From 066a628695b029379f9b1aab312148a50787a920 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Mon, 1 Jun 2026 08:54:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?):=20=D0=BD=D0=B5=D1=80=D0=B0=D1=81=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=91=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D0=BA,=20=D1=81=D1=80=D0=BE=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF=D0=BE=D0=B2=20(=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=BA),=20=D0=BD=D0=B0=D0=BB/=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB,=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B4=D0=B8=D1=82=20=D0=B4=D0=B0=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/elena_app.py | 2 +- docs/cabinet.html | 6 +++- docs/crm.html | 76 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/backend/elena_app.py b/backend/elena_app.py index 88e5a25..a669557 100644 --- a/backend/elena_app.py +++ b/backend/elena_app.py @@ -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): diff --git a/docs/cabinet.html b/docs/cabinet.html index 1d1963f..476136b 100644 --- a/docs/cabinet.html +++ b/docs/cabinet.html @@ -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 `
@@ -538,14 +540,16 @@ function renderEstimateCard(){
Прозрачно: за что и сколько. Интервью — бесплатно, дальше помодульно. Готовое ТЗ — после полной оплаты.
${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='бесплатно'; else if(isPaid)tag='✓ оплачено'; else if(done)tag='к оплате'; else tag='в работе'; + const dueChip=(dd&&!isPaid&&!isFree)?`📅 до ${fmtD(dd)}`:''; return `
${s.icon} -
${s.name} ${tag}
${s.desc}
+
${s.name} ${tag} ${dueChip}
${s.desc}
${isFree?'0 ₽':money(price)}
`; }).join('')} diff --git a/docs/crm.html b/docs/crm.html index c714726..7768722 100644 --- a/docs/crm.html +++ b/docs/crm.html @@ -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 ``;} 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=``; + wrap.innerHTML=`${methodSelect('spm-'+k,'bank')}`; 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=`
@@ -543,7 +566,7 @@ function renderPaymentPlan(){ ${CX_SHORT[cx]}
-
Получено ${money(paidTotal)} из ${money(total)} · — изменить цену · — отменить оплату · «↻ Пересчитать» сбрасывает к ставкам
+
Получено ${money(paidTotal)} из ${money(target)} · цена · отмена оплаты · 📅 срок оплаты этапа
${CLIENT_STAGES.map(s=>{ const done=stageIsDone(s.key); const paid=pays[s.key]; @@ -567,23 +590,35 @@ function renderPaymentPlan(){ sub=`
${esc(s.desc)}
`; action=`${money(price)}`; } + const dd=due[s.key]||''; + const dueChip=dd?`📅 до ${fmtDate(dd)}`:''; + const dueInput=isFree?'':``; return `
${s.icon}
-
${s.name}${badge}
+
${s.name}${badge}${dueChip}
${sub}
+ ${dueInput}
${action}${paid?``:''}
`; }).join("")} -
- Итого по проекту - ${money(total)} +
+ Распределено по этапам + ${money(allocated)}
+
+ Сумма сделки + ${money(target)} +
+ ${unalloc!==0?`
+ ${unalloc>0?'⚠ Нераспределено':'⚠ Перебор сметы'}: ${money(Math.abs(unalloc))} + +
`:''}
${isUnlocked ?`✅ Оплата получена. Клиент может скачать ТЗ и печатные документы.` - :`🔒 Правило: ТЗ (печать + выгрузка) выдаётся клиенту только после полной оплаты — ${money(total)}.`} + :`🔒 Правило: ТЗ (печать + выгрузка) выдаётся клиенту только после полной оплаты — ${money(target)}.`}
`; } @@ -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=`
Оплата
${money(paid)}${deal>0?`${st[0]}`:''}
`; @@ -628,16 +665,18 @@ function renderPayments(){ ${deal>0?`Сделка ${money(deal)} · Получено ${money(paid)} · Остаток ${money(left)}`:''} ${left>0?``:''} ${pays.length?``:''}
+ ${paid>0?`
💵 Наличные ${money(cash)}🏦 Безнал ${money(noncash)}
`:''} ${pays.map((p,i)=>i===editPayIdx ?`
+ ${methodSelect('epm-'+i,p.method||'bank')} ${p.stage?`этап: ${esc((CLIENT_STAGES.find(s=>s.key===p.stage)||{}).name||p.stage)}`:''}
` - :`
${esc(p.date||'')}${esc(p.note||'Платёж')}${p.stage?` · этап`:''}${money(p.amount)}
`).join("")||'
Платежей нет
'} + :`
${esc(p.date||'')}${METHOD_ICON[p.method||'bank']||'🏦'}${esc(p.note||'Платёж')}${p.stage?` · этап`:''}${money(p.amount)}
`).join("")||'
Платежей нет
'}
@@ -645,6 +684,7 @@ function renderPayments(){ ${CLIENT_STAGES.filter(s=>stageIsDone(s.key)&&!getStagePays()[s.key]&&((getStagePrices()||{})[s.key]||0)>0).map(s=>``).join('')} + ${methodSelect('payMethod','bank')}
@@ -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(); }