feat(ux): микрофон везде + документы у оператора + полировка интерфейса

🎤 голос: «Спросить Елену» и профиль (кабинет), заметка и задачи (CRM) — переиспользуемая кнопка-мик
📎 документы: вкладка «Документы» в CRM (просмотр+загрузка оператором), открытие/скачивание файлов,
   прикрепление файла к вопросу Елене (кабинет), backend /api/doc
🖥 интерфейс: модалка «Новый клиент» (имя/ниша/контакт/источник) вместо prompt(),
   inline-правка цены этапа, тосты вместо alert()
This commit is contained in:
wasrusgen 2026-06-03 12:17:48 +03:00
parent 43fe230d58
commit 9c4f68cf55
2 changed files with 138 additions and 28 deletions

View File

@ -297,7 +297,7 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ
<div style="max-width:600px">
<div style="margin-bottom:18px"><label class="pf-l">Ваше имя или название компании</label><input class="pf-i" id="pfName" placeholder="Например: Игорь / ООО «Автодом»"></div>
<div style="margin-bottom:18px"><label class="pf-l">Сфера деятельности</label><input class="pf-i" id="pfNiche" placeholder="Например: автосервис, нутрициология, швейное производство"></div>
<div style="margin-bottom:22px"><label class="pf-l">Описание деятельности</label><textarea class="pf-t" id="pfDesc" placeholder="Чем занимаетесь, сколько человек, как сейчас всё устроено, что беспокоит. Чем подробнее — тем точнее анализ."></textarea></div>
<div style="margin-bottom:22px"><label class="pf-l" style="display:flex;align-items:center;justify-content:space-between">Описание деятельности <button type="button" id="pfMic" class="mic" onclick="toggleMic('pfDesc','pfMic')" title="Надиктовать" style="width:30px;height:30px;border-radius:9px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer"><svg width="15" height="15" 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></label><textarea class="pf-t" id="pfDesc" placeholder="Чем занимаетесь, сколько человек, как сейчас всё устроено, что беспокоит. Можно надиктовать 🎤"></textarea></div>
<button class="btn btn-p" id="pfSave" onclick="saveProfile()" style="padding:12px 24px;font-size:14px">Сохранить и начать с Еленой →</button>
</div>
</div></div>
@ -353,6 +353,9 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ
<div class="askdock-thread" id="askThread"></div>
<div class="askdock-inbar">
<textarea id="askInp" rows="1" placeholder="Спросите Елену об этом этапе…" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();askElena()}"></textarea>
<input type="file" id="askFile" multiple style="display:none" onchange="askAttach(this.files)">
<button class="icon-btn" id="askAttachBtn" title="Приложить документ" onclick="document.getElementById('askFile').click()" style="background:var(--light);border:1.5px solid var(--border);color:var(--primary)"><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="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button>
<button class="icon-btn mic" id="askMic" title="Голосом" onclick="toggleMic('askInp','askMic')"><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="askSend" onclick="askElena()"><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>
@ -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<e.results.length;i++){const t=e.results[i][0].transcript;if(e.results[i].isFinal)fin+=t;else intr+=t}if(fin)base+=fin;inp.value=(base+intr).trim();inp.style.height="auto";inp.style.height=Math.min(inp.scrollHeight,120)+"px"};
recog.onstart=()=>{base=inp.value?inp.value+" ":""};
recog.onresult=e=>{let fin="",intr="";for(let i=e.resultIndex;i<e.results.length;i++){const t=e.results[i][0].transcript;if(e.results[i].isFinal)fin+=t;else intr+=t}if(fin)base+=fin;if(micTarget){micTarget.value=(base+intr).trim();if(micTarget.tagName==='TEXTAREA'){micTarget.style.height="auto";micTarget.style.height=Math.min(micTarget.scrollHeight,120)+"px";}}};
recog.onstart=()=>{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=>`<div style="display:flex;align-items:center;gap:10px;background:var(--white);border:1px solid var(--border);border-radius:10px;padding:11px 14px;margin-bottom:8px"><span style="font-size:18px">📄</span><div style="flex:1"><div style="font-size:13px;font-weight:600">${esc(d.filename)}</div><div style="font-size:11px;color:#9ca3af">${(d.size/1024).toFixed(0)} КБ · учтён в анализе</div></div><span style="color:var(--primary);font-weight:700;font-size:13px"></span></div>`).join(""):'<div style="text-align:center;color:#cbd5e1;font-size:13px;padding:10px">Документов пока нет</div>';
dl.innerHTML=docs.length?docs.map(d=>{const u=`${API}/api/doc?token=${encodeURIComponent(token)}&name=${encodeURIComponent(d.filename)}`;return `<div style="display:flex;align-items:center;gap:10px;background:var(--white);border:1px solid var(--border);border-radius:10px;padding:11px 14px;margin-bottom:8px"><span style="font-size:18px">📄</span><div style="flex:1;min-width:0"><a href="${u}" target="_blank" rel="noopener" style="font-size:13px;font-weight:600;color:var(--primary);text-decoration:none">${esc(d.filename)}</a><div style="font-size:11px;color:#9ca3af">${(d.size/1024).toFixed(0)} КБ · учтён в анализе</div></div><a href="${u}&dl=1" title="Скачать" style="color:#9ca3af;text-decoration:none;font-size:16px"></a></div>`}).join(""):'<div style="text-align:center;color:#cbd5e1;font-size:13px;padding:10px">Документов пока нет</div>';
}
async function handleFiles(files){
const dl=document.getElementById("docList");

View File

@ -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<e.results.length;i++){const t=e.results[i][0].transcript;if(e.results[i].isFinal)fin+=t;else intr+=t}if(fin)base+=fin;if(micTarget){micTarget.value=(base+intr).trim();if(micTarget.tagName==='TEXTAREA'){micTarget.style.height="auto";micTarget.style.height=Math.min(micTarget.scrollHeight,160)+"px";}}};
recog.onstart=()=>{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 `<button type="button" class="vmic" data-for="${targetId}" onclick="toggleMic('${targetId}',this)" title="Надиктовать" style="width:${s}px;height:${s}px;border:1.5px solid var(--border);background:#fff;border-radius:9px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:#047857;flex:0 0 auto"><svg width="15" height="15" 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>`;}
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 `<div class="cl ${p.token===current?'active':''}" onclick="openClient('${p.token}')"><div class="cl-av">${esc((p.client_name||'?')[0])}</div><div class="cl-n">${esc(p.client_name)}</div><div class="cl-dot" style="background:${pc[2]}"></div></div>`;
}).join("")||'<div style="color:rgba(255,255,255,.3);font-size:12px;padding:10px;text-align:center">Пока нет клиентов</div>';
}
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=`<div onclick="event.stopPropagation()" style="background:#fff;border-radius:16px;padding:24px 24px 20px;width:100%;max-width:380px;box-shadow:0 16px 50px rgba(0,0,0,.35)">
<div style="font-size:19px;font-weight:800;font-family:Montserrat;margin-bottom:14px">Новый клиент</div>
<label style="${lb}">Имя / компания *</label><input id="ncName" style="${fi}" onkeydown="if(event.key==='Enter')document.getElementById('ncNiche').focus()">
<label style="${lb}">Ниша</label><input id="ncNiche" placeholder="напр. швейное производство" style="${fi}">
<label style="${lb}">Контакт</label><input id="ncContact" placeholder="телефон / email" style="${fi}">
<label style="${lb}">Источник</label><input id="ncSource" placeholder="откуда пришёл" style="${fi}">
<div style="display:flex;gap:8px;margin-top:6px">
<button onclick="document.getElementById('ncModal').remove()" style="flex:1;padding:11px;background:#F1F5F9;color:#475569;border:none;border-radius:10px;font-size:14px;font-weight:700;font-family:Inter;cursor:pointer">Отмена</button>
<button id="ncOk" onclick="submitNewClient()" style="flex:1.4;padding:11px;background:#047857;color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:700;font-family:Inter;cursor:pointer">Создать →</button>
</div></div>`;
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<today).length;
const devN=(state.deviations||[]).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>`;return ''};
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 ''};
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 style="display:flex;gap:4px;background:#F1F5F9;border-radius:10px;padding:3px">
@ -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=`<div onclick="document.getElementById('opFile').click()" ondragover="event.preventDefault();this.style.background='#ECFDF5'" ondragleave="this.style.background='#fff'" ondrop="opDropFiles(event)" style="border:2px dashed var(--border);border-radius:12px;padding:20px;text-align:center;cursor:pointer;margin-bottom:14px;background:#fff;transition:background .15s">
<input type="file" id="opFile" multiple style="display:none" onchange="opUpload(this.files)">
<div style="font-size:24px">📎</div><div style="font-size:13px;font-weight:700;color:#1A1A2E">Загрузить документ клиента</div>
<div style="font-size:11px;color:#9ca3af;margin-top:2px">PDF · Word · Excel · txt — перетащите или нажмите. Елена учтёт в анализе.</div></div>`;
if(!docs.length)h+=`<div style="text-align:center;color:#cbd5e1;font-size:13px;padding:8px">Документов пока нет</div>`;
else h+=docs.map(d=>{const u=`${API}/api/doc?token=${encodeURIComponent(current)}&name=${encodeURIComponent(d.filename)}`;return `<div class="blk" style="display:flex;align-items:center;gap:10px;padding:11px 14px;margin-bottom:8px"><span style="font-size:18px">📄</span><div style="flex:1;min-width:0"><a href="${u}" target="_blank" rel="noopener" style="font-size:13px;font-weight:600;color:#047857;text-decoration:none">${esc(d.filename)}</a><div style="font-size:11px;color:#9ca3af">${(d.size/1024).toFixed(0)} КБ · учтён в анализе</div></div><a href="${u}&dl=1" title="Скачать" style="color:#9ca3af;text-decoration:none;font-size:17px"></a></div>`}).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(){
<div class="blk" style="margin:0">
<div class="cc-fl">Контакт</div>
<input class="cc-fi" id="cpContact" value="${esc(crm.contact||'')}" placeholder="телефон / email" onchange="saveCrm()" style="margin-bottom:12px">
<div class="cc-fl">Заметка</div>
<textarea id="cpNote" onchange="saveCrm()" placeholder="заметки по клиенту..." style="width:100%;border:none;outline:none;background:transparent;font-family:Inter;font-size:13px;resize:vertical;min-height:46px;color:var(--text)">${esc(crm.note||'')}</textarea>
<div class="cc-fl" style="display:flex;align-items:center;justify-content:space-between">Заметка ${micBtn('cpNote',26)}</div>
<textarea id="cpNote" onchange="saveCrm()" placeholder="заметки по клиенту… или 🎤" style="width:100%;border:none;outline:none;background:transparent;font-family:Inter;font-size:13px;resize:vertical;min-height:46px;color:var(--text)">${esc(crm.note||'')}</textarea>
</div>
</div>
</div>`;
@ -560,6 +633,7 @@ function renderMainPanel(){
else if(mainTab==="payments"){p.innerHTML=`<div id="planBox"></div><div id="paymentsBox"></div>`;renderPaymentPlan();renderPayments();}
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==="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=`<div onclick="event.stopPropagation()" style="background:#fff;border-radius:14px;padding:20px;width:100%;max-width:300px;box-shadow:0 14px 44px rgba(0,0,0,.3)">
<div style="font-size:14px;font-weight:700">Цена этапа</div>
<div style="font-size:12px;color:#6B7280;margin-bottom:12px">«${esc(nm)}»</div>
<div style="display:flex;align-items:center;gap:8px"><input id="spVal" type="number" min="0" step="500" value="${cur}" style="flex:1;border:1.5px solid var(--border);border-radius:9px;padding:10px 12px;font-size:15px;font-weight:700;font-family:Inter;outline:none" onkeydown="if(event.key==='Enter')saveStagePrice('${k}')"><span style="font-weight:700;color:#6B7280"></span></div>
<div style="display:flex;gap:8px;margin-top:14px">
<button onclick="document.getElementById('spModal').remove()" style="flex:1;padding:10px;background:#F1F5F9;color:#475569;border:none;border-radius:9px;font-weight:700;font-family:Inter;cursor:pointer">Отмена</button>
<button onclick="saveStagePrice('${k}')" style="flex:1;padding:10px;background:#047857;color:#fff;border:none;border-radius:9px;font-weight:700;font-family:Inter;cursor:pointer">OK</button>
</div></div>`;
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=`<div style="background:var(--white);border:1.5px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:18px">
<div style="font-size:13px;font-weight:700;margin-bottom:10px;display:flex;align-items:center;gap:8px">📌 Задачи по клиенту</div>
${tasks.map((t,i)=>`<div style="display:flex;align-items:center;gap:10px;padding:7px 0;border-top:1px solid var(--bg)"><input type="checkbox" ${t.done?'checked':''} onchange="toggleTask(${i})" style="width:16px;height:16px;cursor:pointer"><span style="flex:1;font-size:13px;${t.done?'text-decoration:line-through;color:#9ca3af':''}">${esc(t.text)}</span>${t.due?`<span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:6px;${!t.done&&t.due<today?'background:#FEF2F2;color:#DC2626':t.due===today?'background:#FEF3C7;color:#92400E':'background:#F1F5F9;color:#6B7280'}">${t.due===today?'сегодня':fmtDate(t.due)}</span>`:''}<button onclick="delTask(${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="newTask" placeholder="Новая задача..." style="flex:1;border:1.5px solid var(--border);border-radius:8px;padding:8px 11px;font-size:13px;font-family:Inter;outline:none" onkeydown="if(event.key==='Enter')addTask()"><input id="newTaskDue" type="date" style="border:1.5px solid var(--border);border-radius:8px;padding:8px;font-size:13px;font-family:Inter"><button class="cp-btn cp-a" onclick="addTask()">+ Добавить</button></div>
<div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap"><input id="newTask" placeholder="Новая задача… или 🎤" style="flex:1;min-width:140px;border:1.5px solid var(--border);border-radius:8px;padding:8px 11px;font-size:13px;font-family:Inter;outline:none" onkeydown="if(event.key==='Enter')addTask()">${micBtn('newTask',34)}<input id="newTaskDue" type="date" style="border:1.5px solid var(--border);border-radius:8px;padding:8px;font-size:13px;font-family:Inter"><button class="cp-btn cp-a" onclick="addTask()">+ Добавить</button></div>
</div>`;
}
function fmtDate(d){const[y,m,dd]=d.split("-");return `${dd}.${m}`}