feat: живой канал «Консультант» (клиент ↔ руководитель проекта)

Отдельный человеческий канал рядом с Еленой (AI):
- backend: /api/operator-chat (channel=operator), уведомления в Telegram обеим сторонам
- кабинет: раздел «💬 Консультант» (чат с Русланом, мик)
- CRM: вкладка «Чат с клиентом» + бейдж непрочитанных + мик
This commit is contained in:
wasrusgen 2026-06-03 12:36:32 +03:00
parent 9c4f68cf55
commit 865b87e664
2 changed files with 50 additions and 2 deletions

View File

@ -285,6 +285,8 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ
<div class="si" id="si3" onclick="go(3)"><div class="si-num">3</div><div class="si-body"><div class="si-lbl">Этап 3</div><div class="si-name">Документы</div><div class="si-sub">Материалы</div></div></div> <div class="si" id="si3" onclick="go(3)"><div class="si-num">3</div><div class="si-body"><div class="si-lbl">Этап 3</div><div class="si-name">Документы</div><div class="si-sub">Материалы</div></div></div>
<div class="si" id="si4" onclick="go(4)"><div class="si-num">4</div><div class="si-body"><div class="si-lbl">Этап 4</div><div class="si-name">Анализ</div><div class="si-sub">Модель бизнеса</div></div></div> <div class="si" id="si4" onclick="go(4)"><div class="si-num">4</div><div class="si-body"><div class="si-lbl">Этап 4</div><div class="si-name">Анализ</div><div class="si-sub">Модель бизнеса</div></div></div>
<div class="si" id="si5" onclick="go(5)"><div class="si-num">5</div><div class="si-body"><div class="si-lbl">Этап 5</div><div class="si-name">План</div><div class="si-sub">ТЗ на программу</div></div></div> <div class="si" id="si5" onclick="go(5)"><div class="si-num">5</div><div class="si-body"><div class="si-lbl">Этап 5</div><div class="si-name">План</div><div class="si-sub">ТЗ на программу</div></div></div>
<div style="height:1px;background:rgba(255,255,255,.06);margin:8px 18px"></div>
<div class="si" id="si6" onclick="go(6)"><div class="si-num" style="background:rgba(4,120,87,.25);color:#10B981">💬</div><div class="si-body"><div class="si-name">Консультант</div><div class="si-sub">Связаться с Русланом</div></div></div>
</div> </div>
<div class="sb-foot"><div class="pb-box"><div class="pb-row"><span class="pb-l">Прогресс</span><span class="pb-p" id="pbPct">20%</span></div><div class="pb-track"><div class="pb-fill" id="pbFill" style="width:20%"></div></div></div></div> <div class="sb-foot"><div class="pb-box"><div class="pb-row"><span class="pb-l">Прогресс</span><span class="pb-p" id="pbPct">20%</span></div><div class="pb-track"><div class="pb-fill" id="pbFill" style="width:20%"></div></div></div></div>
</aside> </aside>
@ -346,6 +348,17 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ
<div class="scroll"><div class="pad" id="specPad"></div></div> <div class="scroll"><div class="pad" id="specPad"></div></div>
</div> </div>
<!-- Канал «Консультант» (живой) -->
<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="scroll"><div class="chat" id="opChat"></div></div>
<div class="inbar">
<textarea class="inp" id="opInp" rows="1" placeholder="Напишите консультанту…" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();opSend()}"></textarea>
<button class="icon-btn mic" id="opMic" title="Голосом" onclick="toggleMic('opInp','opMic')"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg></button>
<button class="icon-btn send" id="opSendBtn" onclick="opSend()"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg></button>
</div>
</div>
<!-- Спросить Елену — док на этапах 3-5 --> <!-- Спросить Елену — док на этапах 3-5 -->
<div class="askdock" id="askDock"> <div class="askdock" id="askDock">
<div class="askdock-head" onclick="toggleAsk()">💬 Спросить Елену <span class="ad-sub" id="adSub">об этом этапе</span><svg class="ad-chev" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></div> <div class="askdock-head" onclick="toggleAsk()">💬 Спросить Елену <span class="ad-sub" id="adSub">об этом этапе</span><svg class="ad-chev" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></div>
@ -393,6 +406,7 @@ function go(n){
document.getElementById('si'+n).classList.add('active'); document.getElementById('si'+n).classList.add('active');
if(PCTS[n]!=null){document.getElementById('pbPct').textContent=PCTS[n]+'%';document.getElementById('pbFill').style.width=PCTS[n]+'%';} if(PCTS[n]!=null){document.getElementById('pbPct').textContent=PCTS[n]+'%';document.getElementById('pbFill').style.width=PCTS[n]+'%';}
if(n===1)requestAnimationFrame(scrollChatBottom); // sv1 уже видим — высота посчитана, прыгаем вниз без «пролёта» if(n===1)requestAnimationFrame(scrollChatBottom); // sv1 уже видим — высота посчитана, прыгаем вниз без «пролёта»
if(n===6)renderOpChat();
if(n===3)renderDocs(); if(n===3)renderDocs();
if(n===4)renderAnalysis(); if(n===4)renderAnalysis();
if(n===5)renderSpecPane(); if(n===5)renderSpecPane();
@ -447,6 +461,12 @@ function addMsg(role,text){const m=document.createElement("div");m.className="ms
function showTyping(){const t=document.createElement("div");t.className="msg";t.id="typing";t.innerHTML=`<div class="av e">Е</div><div class="typing"><span></span><span></span><span></span></div>`;chat.appendChild(t);scrollChatBottom()} function showTyping(){const t=document.createElement("div");t.className="msg";t.id="typing";t.innerHTML=`<div class="av e">Е</div><div class="typing"><span></span><span></span><span></span></div>`;chat.appendChild(t);scrollChatBottom()}
function hideTyping(){const t=document.getElementById("typing");if(t)t.remove()} function hideTyping(){const t=document.getElementById("typing");if(t)t.remove()}
/* ── Канал «Консультант» (живой) ── */
function opMsg(role,text){const c=document.getElementById("opChat");if(!c)return;const m=document.createElement("div");m.className="msg "+(role==="user"?"user":"");m.innerHTML=`<div class="av ${role==='user'?'u':'e'}">${role==='user'?'Я':'Р'}</div><div class="bb ${role==='user'?'out':'in'}">${fmt(text)}</div>`;c.appendChild(m);const sc=c.parentElement;if(sc)sc.scrollTop=sc.scrollHeight;}
function renderOpChat(){const c=document.getElementById("opChat");if(!c)return;c.innerHTML="";const m=state&&state.operator_chat||[];if(!m.length){c.innerHTML='<div style="text-align:center;color:#cbd5e1;font-size:13px;padding:24px 16px">Напишите консультанту — Руслан ответит здесь и пришлёт уведомление в Telegram.</div>';return;}m.forEach(x=>opMsg(x.role==="user"?"user":"elena",x.content));}
async function opSend(){const inp=document.getElementById("opInp");const t=inp.value.trim();if(!t)return;inp.value="";inp.style.height="auto";opMsg("user",t);state.operator_chat=state.operator_chat||[];state.operator_chat.push({role:"user",content:t});
try{const r=await fetch(`${API}/api/operator-chat`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token,message:t})});const d=await r.json();if(d.messages)state.operator_chat=d.messages;}catch(e){opMsg("elena","Не доставлено: "+e.message);}
}
/* ── Спросить Елену (этапы 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")}

View File

@ -507,7 +507,7 @@ async function kDrop(e,pipe){
await loadProjects();renderPipeline(); await loadProjects();renderPipeline();
} }
const MAINTABS=[{id:"deal",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:"⚠️"}];
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";
@ -515,7 +515,8 @@ function renderClient(){
const today=new Date().toISOString().slice(0,10); const today=new Date().toISOString().slice(0,10);
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 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>`;return ''}; 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 ''};
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">
@ -552,6 +553,32 @@ async function opUpload(files){
renderClient(); renderClient();
} }
function opDropFiles(e){e.preventDefault();e.currentTarget.style.background="#fff";opUpload(e.dataTransfer.files);} function opDropFiles(e){e.preventDefault();e.currentTarget.style.background="#fff";opUpload(e.dataTransfer.files);}
// ── Чат с клиентом (живой канал «Консультант») ──
function opBubble(m){const me=m.role==='assistant';return `<div style="display:flex;gap:8px;${me?'flex-direction:row-reverse':''}"><div style="width:26px;height:26px;border-radius:50%;flex:0 0 26px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff;background:${me?'#047857':'#6366F1'}">${me?'Я':'К'}</div><div style="background:${me?'#047857':'#F1F5F9'};color:${me?'#fff':'#1A1A2E'};border-radius:11px;padding:8px 12px;font-size:13px;line-height:1.45;max-width:80%;white-space:pre-wrap">${esc(m.content)}</div></div>`;}
function renderOpChatTab(){
const box=document.getElementById("opChatBox");if(!box)return;
const msgs=state.operator_chat||[];
box.innerHTML=`<div style="font-size:12px;color:#6B7280;margin-bottom:10px">Живой канал с клиентом. Он видит это в кабинете → «💬 Консультант» и получает уведомление в Telegram.</div>
<div style="background:#fff;border:1.5px solid var(--border);border-radius:12px;overflow:hidden">
<div id="opThread" style="max-height:400px;overflow-y:auto;padding:14px 16px;display:flex;flex-direction:column;gap:10px">
${msgs.length?msgs.map(opBubble).join(''):'<div style="text-align:center;color:#cbd5e1;font-size:13px;padding:16px">Сообщений нет. Напишите клиенту первым.</div>'}
</div>
<div style="display:flex;gap:8px;padding:10px 12px;border-top:1px solid var(--border)">
<textarea id="opChatInp" rows="1" placeholder="Сообщение клиенту… или 🎤" style="flex:1;border:1.5px solid var(--border);border-radius:9px;padding:9px 12px;font-size:13px;font-family:Inter;resize:none;outline:none;max-height:120px" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();opChatSend()}"></textarea>
${micBtn('opChatInp',36)}
<button class="cp-btn cp-a" onclick="opChatSend()">Отправить</button>
</div></div>`;
const t=document.getElementById('opThread');if(t)t.scrollTop=t.scrollHeight;
}
async function opChatSend(){
const inp=document.getElementById('opChatInp');const t=inp.value.trim();if(!t)return;
inp.value='';
try{const r=await fetch(`${API}/api/operator-chat`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:current,message:t})});const d=await r.json();
if(d.error){toast('Ошибка: '+d.error,'err');return;}
state.operator_chat=d.messages||state.operator_chat;renderOpChatTab();
localStorage.setItem('opseen_'+current,(state.operator_chat||[]).filter(m=>m.role==='user').length);
}catch(e){toast('Ошибка: '+e.message,'err');}
}
const DEV_STAGE={canvas:"📊 Стратегия",idef0:"🔧 Функции",spec:"📋 ТЗ",documents:"📁 Документы",methods:"🎯 Методологии",interview:"💬 Интервью"}; const DEV_STAGE={canvas:"📊 Стратегия",idef0:"🔧 Функции",spec:"📋 ТЗ",documents:"📁 Документы",methods:"🎯 Методологии",interview:"💬 Интервью"};
function renderDeviations(){ function renderDeviations(){
const dev=state.deviations||[]; const dev=state.deviations||[];
@ -634,6 +661,7 @@ function renderMainPanel(){
else if(mainTab==="tasks"){p.innerHTML=`<div id="tasksBox"></div>`;renderTasks();} else if(mainTab==="tasks"){p.innerHTML=`<div id="tasksBox"></div>`;renderTasks();}
else if(mainTab==="analysis"){p.innerHTML=`<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>`;renderTab();} else if(mainTab==="analysis"){p.innerHTML=`<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>`;renderTab();}
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();}});}
else if(mainTab==="deviations"){p.innerHTML=renderDeviations();} else if(mainTab==="deviations"){p.innerHTML=renderDeviations();}
} }
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));}