mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 15:44:45 +00:00
feat: предложения клиента — фиксация и триаж
Отдельный backlog «Предложения» (не отклонения): Елена ловит идею в диалоге (record_suggestion) + кнопка «Мои идеи» в кабинете. Статусы new/discussion/ accepted/rejected + решение. CRM-вкладка «Предложения» с триажем, клиент видит статус.
This commit is contained in:
parent
4be49a25fb
commit
5ded2c7312
@ -350,7 +350,7 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ
|
|||||||
|
|
||||||
<!-- Канал «Консультант» (живой) -->
|
<!-- Канал «Консультант» (живой) -->
|
||||||
<div class="sv" id="sv6">
|
<div class="sv" id="sv6">
|
||||||
<div class="hero"><div class="hero-ic">💬</div><div><div class="hero-tag">Личный консультант</div><div class="hero-h">Связаться с Русланом</div><div class="hero-d">Вопросы по проекту, срокам, оплате — напишите руководителю проекта напрямую</div></div></div>
|
<div class="hero"><div class="hero-ic">💬</div><div style="flex:1"><div class="hero-tag">Личный консультант</div><div class="hero-h">Связаться с Русланом</div><div class="hero-d">Вопросы по проекту, срокам, оплате — напишите напрямую</div></div><button onclick="openIdeas()" style="align-self:center;background:#ECFDF5;color:#047857;border:1.5px solid rgba(4,120,87,.25);border-radius:10px;padding:9px 14px;font-size:13px;font-weight:700;font-family:Inter;cursor:pointer;white-space:nowrap">💡 Мои идеи</button></div>
|
||||||
<div class="scroll"><div class="chat" id="opChat"></div></div>
|
<div class="scroll"><div class="chat" id="opChat"></div></div>
|
||||||
<div class="inbar">
|
<div class="inbar">
|
||||||
<textarea class="inp" id="opInp" rows="1" placeholder="Напишите консультанту…" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();opSend()}"></textarea>
|
<textarea class="inp" id="opInp" rows="1" placeholder="Напишите консультанту…" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();opSend()}"></textarea>
|
||||||
@ -471,6 +471,29 @@ let opPollTimer=null;
|
|||||||
async function opPollOnce(){try{const r=await fetch(`${API}/api/operator-chat?token=${encodeURIComponent(token)}`);const d=await r.json();if(d.messages&&d.messages.length!==(state.operator_chat||[]).length){state.operator_chat=d.messages;renderOpChat();}}catch(e){}}
|
async function opPollOnce(){try{const r=await fetch(`${API}/api/operator-chat?token=${encodeURIComponent(token)}`);const d=await r.json();if(d.messages&&d.messages.length!==(state.operator_chat||[]).length){state.operator_chat=d.messages;renderOpChat();}}catch(e){}}
|
||||||
function startOpPoll(){stopOpPoll();opPollOnce();opPollTimer=setInterval(opPollOnce,15000);}
|
function startOpPoll(){stopOpPoll();opPollOnce();opPollTimer=setInterval(opPollOnce,15000);}
|
||||||
function stopOpPoll(){if(opPollTimer){clearInterval(opPollTimer);opPollTimer=null;}}
|
function stopOpPoll(){if(opPollTimer){clearInterval(opPollTimer);opPollTimer=null;}}
|
||||||
|
/* ── Предложения клиента ── */
|
||||||
|
const SUG_ST={new:['🆕 Новое','#EFF6FF','#2563EB'],discussion:['💬 На обсуждении','#FEF3C7','#92400E'],accepted:['✅ Принято','#D1FAE5','#047857'],rejected:['❌ Отклонено','#FEE2E2','#B91C1C']};
|
||||||
|
function openIdeas(){
|
||||||
|
const sgs=state.suggestions||[];
|
||||||
|
const list=sgs.length?sgs.map(s=>{const st=SUG_ST[s.status]||SUG_ST.new;return `<div style="border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px"><div style="font-size:13px">${esc(s.text)}</div><div style="margin-top:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="font-size:11px;font-weight:700;color:${st[2]};background:${st[1]};padding:2px 8px;border-radius:6px">${st[0]}</span>${s.decision?`<span style="font-size:11px;color:#6B7280">— ${esc(s.decision)}</span>`:''}</div></div>`}).join(''):'<div style="font-size:13px;color:#9ca3af;text-align:center;padding:10px">Пока нет. Предложите первую идею — мы рассмотрим её.</div>';
|
||||||
|
const m=document.createElement('div');m.id='ideaModal';m.style.cssText='position:fixed;inset:0;background:rgba(15,15,26,.5);z-index:260;display:flex;align-items:center;justify-content:center;padding:18px';m.onclick=()=>m.remove();
|
||||||
|
m.innerHTML=`<div onclick="event.stopPropagation()" style="background:#fff;border-radius:16px;padding:22px;width:100%;max-width:420px;max-height:84vh;display:flex;flex-direction:column">
|
||||||
|
<div style="font-size:18px;font-weight:800;font-family:Montserrat,Inter;margin-bottom:4px">💡 Мои предложения</div>
|
||||||
|
<div style="font-size:12px;color:#6B7280;margin-bottom:12px">Ваши идеи по проекту. Консультант рассмотрит каждую и ответит решением.</div>
|
||||||
|
<div style="overflow-y:auto;flex:1;margin-bottom:12px">${list}</div>
|
||||||
|
<textarea id="ideaInp" rows="2" placeholder="Опишите идею или улучшение…" style="width:100%;border:1.5px solid var(--border);border-radius:10px;padding:10px 12px;font-size:13px;font-family:Inter;resize:vertical;outline:none;box-sizing:border-box"></textarea>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:10px"><button onclick="document.getElementById('ideaModal').remove()" style="flex:1;padding:11px;background:#F1F5F9;color:#475569;border:none;border-radius:10px;font-weight:700;font-family:Inter;cursor:pointer">Закрыть</button><button id="ideaOk" onclick="submitIdea()" style="flex:1.4;padding:11px;background:#047857;color:#fff;border:none;border-radius:10px;font-weight:700;font-family:Inter;cursor:pointer">Предложить →</button></div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(m);setTimeout(()=>{const e=document.getElementById('ideaInp');if(e)e.focus();},40);
|
||||||
|
}
|
||||||
|
async function submitIdea(){
|
||||||
|
const t=(document.getElementById('ideaInp').value||'').trim();if(!t)return;
|
||||||
|
const b=document.getElementById('ideaOk');b.disabled=true;b.textContent='Отправляю…';
|
||||||
|
try{const r=await fetch(`${API}/api/suggestion`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,text:t})});const d=await r.json();
|
||||||
|
if(d.suggestions)state.suggestions=d.suggestions;
|
||||||
|
const mm=document.getElementById('ideaModal');if(mm)mm.remove();openIdeas();
|
||||||
|
}catch(e){b.disabled=false;b.textContent='Предложить →';}
|
||||||
|
}
|
||||||
/* ── Спросить Елену (этапы 3-5) ── */
|
/* ── Спросить Елену (этапы 3-5) ── */
|
||||||
const STAGE_LBL={3:"о документах",4:"о стратегии и модели",5:"о ТЗ и плане"};
|
const STAGE_LBL={3:"о документах",4:"о стратегии и модели",5:"о ТЗ и плане"};
|
||||||
function toggleAsk(){document.getElementById("askDock").classList.toggle("open")}
|
function toggleAsk(){document.getElementById("askDock").classList.toggle("open")}
|
||||||
|
|||||||
@ -507,7 +507,7 @@ async function kDrop(e,pipe){
|
|||||||
await loadProjects();renderPipeline();
|
await loadProjects();renderPipeline();
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAINTABS=[{id:"deal",name:"Сделка",icon:"📇"},{id:"chat",name:"Чат с клиентом",icon:"💬"},{id:"pricing",name:"Ценообразование",icon:"💰"},{id:"payments",name:"Платежи",icon:"💳"},{id:"tasks",name:"Задачи",icon:"📌"},{id:"docs",name:"Документы",icon:"📎"},{id:"analysis",name:"Анализ",icon:"📊"},{id:"deviations",name:"Отклонения",icon:"⚠️"}];
|
const MAINTABS=[{id:"deal",name:"Сделка",icon:"📇"},{id:"chat",name:"Чат с клиентом",icon:"💬"},{id:"pricing",name:"Ценообразование",icon:"💰"},{id:"payments",name:"Платежи",icon:"💳"},{id:"tasks",name:"Задачи",icon:"📌"},{id:"docs",name:"Документы",icon:"📎"},{id:"analysis",name:"Анализ",icon:"📊"},{id:"deviations",name:"Отклонения",icon:"⚠️"},{id:"suggestions",name:"Предложения",icon:"💡"}];
|
||||||
function renderClient(){
|
function renderClient(){
|
||||||
const crm=state.crm||{pipeline:"lead",deal_amount:0,paid_amount:0,contact:"",source:"",note:""};
|
const crm=state.crm||{pipeline:"lead",deal_amount:0,paid_amount:0,contact:"",source:"",note:""};
|
||||||
const billing=crm.billing_type||"paid";
|
const billing=crm.billing_type||"paid";
|
||||||
@ -516,7 +516,8 @@ function renderClient(){
|
|||||||
const overdue=(state.tasks||[]).filter(t=>!t.done&&t.due&&t.due<today).length;
|
const overdue=(state.tasks||[]).filter(t=>!t.done&&t.due&&t.due<today).length;
|
||||||
const devN=(state.deviations||[]).length, docN=(state.documents||[]).length;
|
const devN=(state.deviations||[]).length, docN=(state.documents||[]).length;
|
||||||
const ocUnread=(state.operator_chat||[]).filter(m=>m.role==='user').length-(+localStorage.getItem('opseen_'+current)||0);
|
const ocUnread=(state.operator_chat||[]).filter(m=>m.role==='user').length-(+localStorage.getItem('opseen_'+current)||0);
|
||||||
const badge=id=>{if(id==="payments"&&deal>0&&left>0)return `<span class="badge">${money(left)}</span>`;if(id==="tasks"&&overdue)return `<span class="badge">${overdue}</span>`;if(id==="deviations"&&devN)return `<span class="badge">${devN}</span>`;if(id==="docs"&&docN)return `<span class="badge" style="background:#E5E7EB;color:#374151">${docN}</span>`;if(id==="chat"&&ocUnread>0)return `<span class="badge">${ocUnread}</span>`;return ''};
|
const sugNew=(state.suggestions||[]).filter(s=>s.status==='new').length;
|
||||||
|
const badge=id=>{if(id==="payments"&&deal>0&&left>0)return `<span class="badge">${money(left)}</span>`;if(id==="tasks"&&overdue)return `<span class="badge">${overdue}</span>`;if(id==="deviations"&&devN)return `<span class="badge">${devN}</span>`;if(id==="docs"&&docN)return `<span class="badge" style="background:#E5E7EB;color:#374151">${docN}</span>`;if(id==="chat"&&ocUnread>0)return `<span class="badge">${ocUnread}</span>`;if(id==="suggestions"&&sugNew)return `<span class="badge">${sugNew}</span>`;return ''};
|
||||||
document.getElementById("view").innerHTML=`
|
document.getElementById("view").innerHTML=`
|
||||||
<div class="cc-top"><div class="cc-av">${esc((state.client_name||'?')[0])}</div><div style="flex:1"><div class="cc-name">${esc(state.client_name||'Без имени')}</div><div class="cc-meta">${esc(state.niche||'')} · ${state.messages.length} сообщений</div></div>
|
<div class="cc-top"><div class="cc-av">${esc((state.client_name||'?')[0])}</div><div style="flex:1"><div class="cc-name">${esc(state.client_name||'Без имени')}</div><div class="cc-meta">${esc(state.niche||'')} · ${state.messages.length} сообщений</div></div>
|
||||||
<div style="display:flex;gap:4px;background:#F1F5F9;border-radius:10px;padding:3px">
|
<div style="display:flex;gap:4px;background:#F1F5F9;border-radius:10px;padding:3px">
|
||||||
@ -674,6 +675,29 @@ function renderMainPanel(){
|
|||||||
else if(mainTab==="docs"){p.innerHTML=renderDocsTab();}
|
else if(mainTab==="docs"){p.innerHTML=renderDocsTab();}
|
||||||
else if(mainTab==="chat"){p.innerHTML=`<div id="opChatBox"></div>`;renderOpChatTab();localStorage.setItem('opseen_'+current,(state.operator_chat||[]).filter(m=>m.role==='user').length);document.querySelectorAll('.mtab').forEach((b,i)=>{if(MAINTABS[i].id==='chat'){const bd=b.querySelector('.badge');if(bd)bd.remove();}});startCrmChatPoll();}
|
else if(mainTab==="chat"){p.innerHTML=`<div id="opChatBox"></div>`;renderOpChatTab();localStorage.setItem('opseen_'+current,(state.operator_chat||[]).filter(m=>m.role==='user').length);document.querySelectorAll('.mtab').forEach((b,i)=>{if(MAINTABS[i].id==='chat'){const bd=b.querySelector('.badge');if(bd)bd.remove();}});startCrmChatPoll();}
|
||||||
else if(mainTab==="deviations"){p.innerHTML=renderDeviations();}
|
else if(mainTab==="deviations"){p.innerHTML=renderDeviations();}
|
||||||
|
else if(mainTab==="suggestions"){p.innerHTML=renderSuggestions();}
|
||||||
|
}
|
||||||
|
const CRM_SUG_ST={new:['🆕','#EFF6FF','#2563EB'],discussion:['💬','#FEF3C7','#92400E'],accepted:['✅','#D1FAE5','#047857'],rejected:['❌','#FEE2E2','#B91C1C']};
|
||||||
|
const SUG_STATUSES=[['new','🆕 Новое'],['discussion','💬 Обсуждаем'],['accepted','✅ Принять'],['rejected','❌ Отклонить']];
|
||||||
|
function renderSuggestions(){
|
||||||
|
const sgs=state.suggestions||[];
|
||||||
|
if(!sgs.length)return `<div class="run-card" style="background:#fff;border:1px solid #E5E7EB;border-radius:12px;padding:22px;text-align:center"><div style="font-size:26px;margin-bottom:6px">💡</div><div style="font-size:14px;font-weight:700">Предложений нет</div><div style="font-size:12px;color:#6B7280;margin-top:4px">Идеи клиента появятся здесь — из кабинета («Мои идеи») или когда Елена поймает предложение в диалоге. Меняешь статус и пишешь решение — клиент видит его в кабинете.</div></div>`;
|
||||||
|
return `<div style="font-size:12px;color:#6B7280;margin-bottom:12px">Идеи клиента — не игнорируем, а рассматриваем. Статус и решение видны клиенту: чувствует, что услышан.</div>`+sgs.map(s=>{
|
||||||
|
const st=CRM_SUG_ST[s.status]||CRM_SUG_ST.new;
|
||||||
|
return `<div class="blk" style="border-left:3px solid ${st[2]};padding:13px 15px;margin-bottom:10px">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap"><span style="font-size:10px;font-weight:700;color:#6B7280;background:#F3F4F6;padding:2px 8px;border-radius:5px">${s.source==='client'?'👤 клиент':'✍ оператор'}${s.topic?' · '+esc(s.topic):''}</span><span style="margin-left:auto;font-size:10px;color:#9CA3AF">${(s.at||'').slice(0,10).split('-').reverse().join('.')}</span></div>
|
||||||
|
<div style="font-size:14px;font-weight:600;color:#1A1A2E;margin-bottom:10px">${esc(s.text)}</div>
|
||||||
|
<div style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px">${SUG_STATUSES.map(([k,n])=>`<button onclick="setSugStatus('${s.id}','${k}')" style="font-size:11px;font-weight:700;padding:5px 10px;border-radius:7px;cursor:pointer;border:1.5px solid ${s.status===k?CRM_SUG_ST[k][2]:'#E5E7EB'};background:${s.status===k?CRM_SUG_ST[k][1]:'#fff'};color:${s.status===k?CRM_SUG_ST[k][2]:'#6B7280'}">${n}</button>`).join('')}</div>
|
||||||
|
<textarea placeholder="Решение / обоснование для клиента…" onchange="setSugDecision('${s.id}',this.value)" style="width:100%;border:1.5px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;font-family:Inter;resize:vertical;min-height:38px;outline:none">${esc(s.decision||'')}</textarea>
|
||||||
|
</div>`}).join('');
|
||||||
|
}
|
||||||
|
function setSugStatus(id,status){sugUpdate(id,{status});}
|
||||||
|
function setSugDecision(id,decision){sugUpdate(id,{decision});}
|
||||||
|
async function sugUpdate(id,fields){
|
||||||
|
try{const r=await fetch(`${API}/api/suggestion/update`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({token:current,id},fields))});const d=await r.json();
|
||||||
|
if(d.error){toast('Ошибка: '+d.error,'err');return;}
|
||||||
|
state.suggestions=d.suggestions||state.suggestions;if(mainTab==='suggestions')renderClient();toast('Сохранено','ok');
|
||||||
|
}catch(e){toast('Ошибка: '+e.message,'err');}
|
||||||
}
|
}
|
||||||
function inviteTelegram(){const url=`https://t.me/wasrusgen1_consulting_bot?start=${current}`;navigator.clipboard.writeText(url).then(()=>alert("Ссылка для Telegram скопирована:\n\n"+url+"\n\nКлиент откроет кабинет прямо в Telegram.")).catch(()=>prompt("Ссылка для Telegram:",url));}
|
function inviteTelegram(){const url=`https://t.me/wasrusgen1_consulting_bot?start=${current}`;navigator.clipboard.writeText(url).then(()=>alert("Ссылка для Telegram скопирована:\n\n"+url+"\n\nКлиент откроет кабинет прямо в Telegram.")).catch(()=>prompt("Ссылка для Telegram:",url));}
|
||||||
async function setBilling(t){
|
async function setBilling(t){
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user