mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 14:24:47 +00:00
feat(ux): микрофон везде + документы у оператора + полировка интерфейса
🎤 голос: «Спросить Елену» и профиль (кабинет), заметка и задачи (CRM) — переиспользуемая кнопка-мик 📎 документы: вкладка «Документы» в CRM (просмотр+загрузка оператором), открытие/скачивание файлов, прикрепление файла к вопросу Елене (кабинет), backend /api/doc 🖥 интерфейс: модалка «Новый клиент» (имя/ниша/контакт/источник) вместо prompt(), inline-правка цены этапа, тосты вместо alert()
This commit is contained in:
parent
43fe230d58
commit
9c4f68cf55
@ -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");
|
||||
|
||||
124
docs/crm.html
124
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<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}`}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user