From 9c4f68cf5590e37c2446704feb6d7d382558f03d Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Wed, 3 Jun 2026 12:17:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(ux):=20=D0=BC=D0=B8=D0=BA=D1=80=D0=BE?= =?UTF-8?q?=D1=84=D0=BE=D0=BD=20=D0=B2=D0=B5=D0=B7=D0=B4=D0=B5=20+=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D1=83?= =?UTF-8?q?=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20+?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎤 голос: «Спросить Елену» и профиль (кабинет), заметка и задачи (CRM) — переиспользуемая кнопка-мик 📎 документы: вкладка «Документы» в CRM (просмотр+загрузка оператором), открытие/скачивание файлов, прикрепление файла к вопросу Елене (кабинет), backend /api/doc 🖥 интерфейс: модалка «Новый клиент» (имя/ниша/контакт/источник) вместо prompt(), inline-правка цены этапа, тосты вместо alert() --- docs/cabinet.html | 42 ++++++++++++---- docs/crm.html | 124 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 138 insertions(+), 28 deletions(-) diff --git a/docs/cabinet.html b/docs/cabinet.html index bbbbd7f..511a954 100644 --- a/docs/cabinet.html +++ b/docs/cabinet.html @@ -297,7 +297,7 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ
-
+
@@ -353,6 +353,9 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ
+ + +
@@ -418,18 +421,24 @@ async function saveProfile(){ }catch(e){alert("Ошибка: "+e.message);btn.disabled=false} } -/* ── Voice ── */ -let recog=null,recording=false; +/* ── Voice (переиспользуемый — любое поле) ── */ +let recog=null,recording=false,micTarget=null,micBtnEl=null; const SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(SR){recog=new SR();recog.lang="ru-RU";recog.continuous=true;recog.interimResults=true;let base=""; - recog.onresult=e=>{let fin="",intr="";for(let i=e.resultIndex;i{base=inp.value?inp.value+" ":""}; + recog.onresult=e=>{let fin="",intr="";for(let i=e.resultIndex;i{base=(micTarget&&micTarget.value)?micTarget.value+" ":""}; recog.onend=()=>{if(recording){try{recog.start()}catch(e){}}}; recog.onerror=e=>{if(e.error==="not-allowed"){alert("Разрешите доступ к микрофону");stopMic()}}; } -function toggleMic(){if(!recog){alert("Голосовой ввод работает в Chrome");return}recording?stopMic():startMic()} -function startMic(){recording=true;document.getElementById("micBtn").classList.add("rec");document.getElementById("micHint").classList.add("show");try{recog.start()}catch(e){}} -function stopMic(){recording=false;document.getElementById("micBtn").classList.remove("rec");document.getElementById("micHint").classList.remove("show");try{recog.stop()}catch(e){}inp.focus()} +function toggleMic(targetId,btnId){ + if(!recog){alert("Голосовой ввод работает в Chrome / Android. На iPhone — печатайте.");return} + const t=document.getElementById(targetId||'inp'), b=document.getElementById(btnId||'micBtn'); + if(recording&&micTarget===t){stopMic();return;} + if(recording)stopMic(); + micTarget=t;micBtnEl=b;startMic(); +} +function startMic(){recording=true;if(micBtnEl)micBtnEl.classList.add("rec");if(micBtnEl&&micBtnEl.id==='micBtn'){const h=document.getElementById("micHint");if(h)h.classList.add("show");}try{recog.start()}catch(e){}} +function stopMic(){recording=false;if(micBtnEl)micBtnEl.classList.remove("rec");const h=document.getElementById("micHint");if(h)h.classList.remove("show");try{recog.stop()}catch(e){}if(micTarget)micTarget.focus();} /* ── Chat ── */ // Скроллит реальный контейнер (.scroll), а не .chat (у него нет своего скролла). Мгновенно, без анимации. @@ -451,6 +460,21 @@ function renderAskThread(){ const t=document.getElementById("askThread");if(!t)return; t.innerHTML="";(state&&state.qa||[]).forEach(m=>addAsk(m.role==="user"?"user":"elena",m.content)); } +async function askAttach(files){ + document.getElementById("askDock").classList.add("open"); + for(const f of files){ + addAsk("user","📎 "+f.name); + try{ + const b64=await new Promise((res,rej)=>{const r=new FileReader();r.onload=()=>res(r.result);r.onerror=rej;r.readAsDataURL(f)}); + const r=await fetch(`${API}/api/upload`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token,filename:f.name,content:b64})}); + const d=await r.json(); + if(d.error){addAsk("elena","Не удалось загрузить «"+f.name+"»: "+d.error);continue;} + state.documents=state.documents||[];state.documents.push({filename:d.filename,size:d.size}); + addAsk("elena","📎 «"+f.name+"» приложен — учту его в ответах по проекту. Спрашивайте."); + }catch(e){addAsk("elena","Ошибка загрузки: "+e.message);} + } + document.getElementById("askFile").value=""; +} async function askElena(){ const inp=document.getElementById("askInp");const text=inp.value.trim();if(!text)return; inp.value="";inp.style.height="auto"; @@ -493,7 +517,7 @@ function fillProfile(){ function renderDocs(){ const dl=document.getElementById("docList");if(!dl)return; const docs=state.documents||[]; - dl.innerHTML=docs.length?docs.map(d=>`
📄
${esc(d.filename)}
${(d.size/1024).toFixed(0)} КБ · учтён в анализе
`).join(""):'
Документов пока нет
'; + dl.innerHTML=docs.length?docs.map(d=>{const u=`${API}/api/doc?token=${encodeURIComponent(token)}&name=${encodeURIComponent(d.filename)}`;return `
📄
${esc(d.filename)}
${(d.size/1024).toFixed(0)} КБ · учтён в анализе
`}).join(""):'
Документов пока нет
'; } async function handleFiles(files){ const dl=document.getElementById("docList"); diff --git a/docs/crm.html b/docs/crm.html index 7520165..248d20b 100644 --- a/docs/crm.html +++ b/docs/crm.html @@ -223,6 +223,27 @@ window.fetch=function(url,opts){ if(u.indexOf('/api/')>=0){opts.headers=Object.assign({},opts.headers||{},{'X-Operator-Token':OP_TOKEN||''});} return _origFetch(url,opts); }; +// ── Тосты (вместо alert) ── +function toast(msg,type){let w=document.getElementById('toastW');if(!w){w=document.createElement('div');w.id='toastW';w.style.cssText='position:fixed;bottom:22px;left:50%;transform:translateX(-50%);z-index:300;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none';document.body.appendChild(w);}const el=document.createElement('div');el.textContent=msg;el.style.cssText='background:'+(type==='err'?'#DC2626':type==='ok'?'#047857':'#0F0F1A')+';color:#fff;padding:10px 18px;border-radius:10px;font-size:13px;font-weight:600;box-shadow:0 6px 24px rgba(0,0,0,.28);opacity:0;transition:opacity .2s';w.appendChild(el);requestAnimationFrame(()=>el.style.opacity='1');setTimeout(()=>{el.style.opacity='0';setTimeout(()=>el.remove(),250)},2600);} +// ── Голосовой ввод (переиспользуемый) ── +let recog=null,recording=false,micTarget=null,micBtnEl=null; +const SR=window.SpeechRecognition||window.webkitSpeechRecognition; +if(SR){recog=new SR();recog.lang="ru-RU";recog.continuous=true;recog.interimResults=true;let base=""; + recog.onresult=e=>{let fin="",intr="";for(let i=e.resultIndex;i{base=(micTarget&&micTarget.value)?micTarget.value+" ":""}; + recog.onend=()=>{if(recording){try{recog.start()}catch(e){}}}; + recog.onerror=e=>{if(e.error==="not-allowed"){toast("Разрешите доступ к микрофону","err");stopMic()}}; +} +function micBtn(targetId,size){const s=size||30;return ``;} +function toggleMic(targetId,btn){ + if(!recog){toast("Голос работает в Chrome / Android","err");return} + const t=document.getElementById(targetId); + if(recording&&micTarget===t){stopMic();return;} + if(recording)stopMic(); + micTarget=t;micBtnEl=(typeof btn==='string')?document.getElementById(btn):btn;startMic(); +} +function startMic(){recording=true;if(micBtnEl){micBtnEl.style.background='#FEF2F2';micBtnEl.style.borderColor='#EF4444';micBtnEl.style.color='#EF4444';}try{recog.start()}catch(e){}} +function stopMic(){recording=false;if(micBtnEl){micBtnEl.style.background='#fff';micBtnEl.style.borderColor='var(--border)';micBtnEl.style.color='#047857';}try{recog.stop()}catch(e){}if(micTarget)micTarget.focus();} async function loadProjects(){ const r=await fetch(`${API}/api/projects`,{headers:opHeaders()}); if(r.status===401){return false;} @@ -264,13 +285,40 @@ function renderClientList(){ return `
${esc((p.client_name||'?')[0])}
${esc(p.client_name)}
`; }).join("")||'
Пока нет клиентов
'; } -async function newClient(){ - const name=prompt("Имя клиента / компания:");if(name===null)return; - const niche=prompt("Ниша:")||""; - const r=await fetch(`${API}/api/project/new`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({client_name:name,niche})}); - const d=await r.json(); - await fetch(`${API}/api/project/profile`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:d.token,client_name:name,niche,description:""})}); - await loadProjects();openClient(d.token); +function newClient(){ + const fi='width:100%;border:1.5px solid var(--border);border-radius:9px;padding:10px 12px;margin:4px 0 12px;font-size:14px;font-family:Inter;outline:none;box-sizing:border-box'; + const lb='font-size:11px;font-weight:700;color:#9ca3af;text-transform:uppercase;letter-spacing:.04em'; + const m=document.createElement('div');m.id='ncModal'; + m.style.cssText='position:fixed;inset:0;background:rgba(15,15,26,.55);z-index:250;display:flex;align-items:center;justify-content:center;padding:20px'; + m.onclick=()=>m.remove(); + m.innerHTML=`
+
Новый клиент
+ + + + +
+ + +
`; + document.body.appendChild(m); + setTimeout(()=>{const n=document.getElementById('ncName');if(n)n.focus();},40); +} +async function submitNewClient(){ + const name=(document.getElementById('ncName').value||'').trim(); + if(!name){toast('Укажите имя клиента','err');return;} + const niche=(document.getElementById('ncNiche').value||'').trim(); + const contact=(document.getElementById('ncContact').value||'').trim(); + const source=(document.getElementById('ncSource').value||'').trim(); + const btn=document.getElementById('ncOk');btn.disabled=true;btn.textContent='Создаю…'; + try{ + const r=await fetch(`${API}/api/project/new`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({client_name:name,niche})}); + const d=await r.json(); + await fetch(`${API}/api/project/profile`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:d.token,client_name:name,niche,description:""})}); + if(contact||source)await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:d.token,contact,source})}); + const mm=document.getElementById('ncModal');if(mm)mm.remove(); + await loadProjects();openClient(d.token);toast('Клиент «'+name+'» создан','ok'); + }catch(e){toast('Ошибка: '+e.message,'err');btn.disabled=false;btn.textContent='Создать →';} } function setView(v){view=v;current=null;if(window.innerWidth<=680)closeSb();document.getElementById("nav-dash").classList.toggle("active",v==="dashboard");document.getElementById("nav-pipe").classList.toggle("active",v==="pipeline");renderClientList();render();} async function openClient(token){ @@ -459,15 +507,15 @@ async function kDrop(e,pipe){ await loadProjects();renderPipeline(); } -const MAINTABS=[{id:"deal",name:"Сделка",icon:"📇"},{id:"pricing",name:"Ценообразование",icon:"💰"},{id:"payments",name:"Платежи",icon:"💳"},{id:"tasks",name:"Задачи",icon:"📌"},{id:"analysis",name:"Анализ",icon:"📊"},{id:"deviations",name:"Отклонения",icon:"⚠️"}]; +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:"⚠️"}]; function renderClient(){ const crm=state.crm||{pipeline:"lead",deal_amount:0,paid_amount:0,contact:"",source:"",note:""}; const billing=crm.billing_type||"paid"; const deal=crm.deal_amount||0, pays=crm.payments||[], paid=pays.reduce((s,p)=>s+(p.amount||0),0), left=deal-paid; const today=new Date().toISOString().slice(0,10); const overdue=(state.tasks||[]).filter(t=>!t.done&&t.due&&t.due{if(id==="payments"&&deal>0&&left>0)return `${money(left)}`;if(id==="tasks"&&overdue)return `${overdue}`;if(id==="deviations"&&devN)return `${devN}`;return ''}; + const devN=(state.deviations||[]).length, docN=(state.documents||[]).length; + const badge=id=>{if(id==="payments"&&deal>0&&left>0)return `${money(left)}`;if(id==="tasks"&&overdue)return `${overdue}`;if(id==="deviations"&&devN)return `${devN}`;if(id==="docs"&&docN)return `${docN}`;return ''}; document.getElementById("view").innerHTML=`
${esc((state.client_name||'?')[0])}
${esc(state.client_name||'Без имени')}
${esc(state.niche||'')} · ${state.messages.length} сообщений
@@ -479,6 +527,31 @@ function renderClient(){ renderMainPanel(); } function setMainTab(t){mainTab=t;editPayIdx=-1;renderMainPanel();document.querySelectorAll('.mtab').forEach((b,i)=>b.classList.toggle('active',MAINTABS[i].id===mainTab));} +function renderDocsTab(){ + const docs=state.documents||[]; + let h=`
+ +
📎
Загрузить документ клиента
+
PDF · Word · Excel · txt — перетащите или нажмите. Елена учтёт в анализе.
`; + if(!docs.length)h+=`
Документов пока нет
`; + else h+=docs.map(d=>{const u=`${API}/api/doc?token=${encodeURIComponent(current)}&name=${encodeURIComponent(d.filename)}`;return `
📄
${esc(d.filename)}
${(d.size/1024).toFixed(0)} КБ · учтён в анализе
`}).join(''); + return h; +} +async function opUpload(files){ + for(const f of files){ + toast("Загружаю "+f.name+"…"); + try{ + const b64=await new Promise((res,rej)=>{const r=new FileReader();r.onload=()=>res(r.result);r.onerror=rej;r.readAsDataURL(f)}); + const r=await fetch(`${API}/api/upload`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,filename:f.name,content:b64})}); + const d=await r.json(); + if(d.error){toast("Ошибка: "+d.error,"err");continue;} + state.documents=state.documents||[];state.documents.push({filename:d.filename,size:d.size}); + toast("«"+f.name+"» загружен","ok"); + }catch(e){toast("Ошибка: "+e.message,"err");} + } + renderClient(); +} +function opDropFiles(e){e.preventDefault();e.currentTarget.style.background="#fff";opUpload(e.dataTransfer.files);} const DEV_STAGE={canvas:"📊 Стратегия",idef0:"🔧 Функции",spec:"📋 ТЗ",documents:"📁 Документы",methods:"🎯 Методологии",interview:"💬 Интервью"}; function renderDeviations(){ const dev=state.deviations||[]; @@ -549,8 +622,8 @@ function renderMainPanel(){
Контакт
-
Заметка
- +
Заметка ${micBtn('cpNote',26)}
+
`; @@ -560,6 +633,7 @@ function renderMainPanel(){ else if(mainTab==="payments"){p.innerHTML=`
`;renderPaymentPlan();renderPayments();} else if(mainTab==="tasks"){p.innerHTML=`
`;renderTasks();} else if(mainTab==="analysis"){p.innerHTML=`
${TABS.map(t=>`
${t.icon} ${t.name}
`).join("")}
`;renderTab();} + else if(mainTab==="docs"){p.innerHTML=renderDocsTab();} 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));} @@ -606,13 +680,25 @@ async function setComplexity(cx){ } function editStagePrice(k){ const cur=(getStagePrices()||{})[k]||0; - const v=prompt(`Цена этапа «${CLIENT_STAGES.find(s=>s.key===k).name}» (₽):`,cur); - if(v===null)return; - const n=Math.max(0,Math.round(+v||0)); - state.crm=state.crm||{}; - state.crm.stage_prices=state.crm.stage_prices||{}; + const nm=CLIENT_STAGES.find(s=>s.key===k).name; + const m=document.createElement('div');m.id='spModal'; + m.style.cssText='position:fixed;inset:0;background:rgba(15,15,26,.5);z-index:250;display:flex;align-items:center;justify-content:center;padding:20px'; + m.onclick=()=>m.remove(); + m.innerHTML=`
+
Цена этапа
+
«${esc(nm)}»
+
+
+ + +
`; + document.body.appendChild(m);setTimeout(()=>{const e=document.getElementById('spVal');if(e){e.focus();e.select();}},40); +} +function saveStagePrice(k){ + const n=Math.max(0,Math.round(+(document.getElementById('spVal').value)||0)); + state.crm=state.crm||{};state.crm.stage_prices=state.crm.stage_prices||{}; state.crm.stage_prices[k]=n; - // сумму сделки НЕ трогаем — разница показывается как «нераспределено» + const m=document.getElementById('spModal');if(m)m.remove(); saveEstimate();syncPaymentReminders();renderClient(); } // ── Сроки этапов (плановая дата оплаты) ── @@ -976,7 +1062,7 @@ function renderTasks(){ box.innerHTML=`
📌 Задачи по клиенту
${tasks.map((t,i)=>`
${esc(t.text)}${t.due?`${t.due===today?'сегодня':fmtDate(t.due)}`:''}
`).join("")||'
Задач нет
'} -
+
${micBtn('newTask',34)}
`; } function fmtDate(d){const[y,m,dd]=d.split("-");return `${dd}.${m}`}