mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 15:04:47 +00:00
Оператор при внедрении видит расхождения: решение клиента (внедряем) + рекомендация Елены (флажок) + причина клиента. Бейдж со счётчиком.
1035 lines
102 KiB
HTML
1035 lines
102 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>CRM консультанта · @wasrusgen1 | КОНСАЛТИНГ</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Montserrat:wght@700;800&display=swap" rel="stylesheet">
|
||
<style>
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
:root{--primary:#047857;--dark:#064E3B;--mid:#10B981;--light:#ECFDF5;--ink:#0F0F1A;--bg:#F5F6F8;--white:#fff;--border:#E5E7EB;--text:#1A1A2E;--muted:#6B7280}
|
||
html,body{height:100%;overflow:hidden}
|
||
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);display:flex;flex-direction:column}
|
||
.hdr{height:54px;background:var(--dark);display:flex;align-items:center;padding:0 20px;gap:12px;flex-shrink:0;z-index:10}
|
||
.hdr-ic{width:30px;height:30px;background:var(--primary);border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:'Montserrat';font-weight:800;color:#fff;font-size:16px}
|
||
.hdr-t{font-family:'Montserrat';font-weight:700;font-size:14px;color:rgba(255,255,255,.6);display:flex;align-items:center;gap:9px;letter-spacing:-.2px}
|
||
.hdr-sep{width:1.5px;height:15px;background:rgba(255,255,255,.25);flex-shrink:0}
|
||
.hdr-t b{font-weight:800;color:#fff}
|
||
.hdr-badge{background:rgba(16,185,129,.15);border:1px solid rgba(16,185,129,.25);color:var(--mid);font-size:10px;font-weight:700;letter-spacing:.05em;border-radius:6px;padding:2px 8px}
|
||
.hdr-r{margin-left:auto;display:flex;align-items:center;gap:8px;color:rgba(255,255,255,.6);font-size:13px}
|
||
.layout{flex:1;display:flex;overflow:hidden}
|
||
.sb{width:240px;flex-shrink:0;background:var(--ink);display:flex;flex-direction:column;overflow:hidden}
|
||
.sb-nav{padding:12px 10px}
|
||
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:9px;cursor:pointer;color:rgba(255,255,255,.6);font-size:13px;font-weight:600;margin-bottom:3px}
|
||
.nav-item:hover{background:rgba(255,255,255,.05);color:#fff}
|
||
.nav-item.active{background:rgba(4,120,87,.2);color:#fff}
|
||
.nav-item .ic{font-size:16px}
|
||
.sb-cap{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:rgba(255,255,255,.25);padding:14px 14px 6px}
|
||
.sb-list{flex:1;overflow-y:auto;padding:0 10px}
|
||
.cl{padding:10px;border-radius:9px;cursor:pointer;margin-bottom:3px;display:flex;align-items:center;gap:9px}
|
||
.cl:hover{background:rgba(255,255,255,.04)}
|
||
.cl.active{background:rgba(4,120,87,.18)}
|
||
.cl-av{width:28px;height:28px;border-radius:7px;background:#6366F1;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:11px;color:#fff;flex-shrink:0}
|
||
.cl-n{font-size:12px;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
|
||
.cl-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||
.sb-new{margin:8px 10px;display:flex;align-items:center;justify-content:center;gap:7px;padding:10px;border-radius:9px;background:var(--primary);color:#fff;border:none;cursor:pointer;font-family:'Inter';font-weight:700;font-size:13px}
|
||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
|
||
.scroll{flex:1;overflow-y:auto;padding:24px 28px}
|
||
/* Dashboard */
|
||
.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px}
|
||
.kpi{background:var(--white);border:1.5px solid var(--border);border-radius:10px;padding:13px 16px}
|
||
.kpi-v{font-family:'Montserrat';font-weight:800;font-size:23px;color:var(--ink);letter-spacing:-.5px;line-height:1.1}
|
||
.kpi-l{font-size:11px;color:var(--muted);margin-top:3px}
|
||
.kpi-sub{font-size:10.5px;color:var(--primary);font-weight:600;margin-top:4px}
|
||
.sec-h{font-family:'Montserrat';font-weight:800;font-size:15px;color:var(--ink);margin-bottom:9px;margin-top:6px}
|
||
.cl-row{display:flex;align-items:center;gap:12px;cursor:pointer;padding:10px 14px;border-top:1px solid var(--bg)}
|
||
.cl-row:first-child{border-top:none}
|
||
.cl-row:hover{background:#FAFBFC}
|
||
.mini-chip{font-size:10px;font-weight:700;padding:2px 8px;border-radius:6px;white-space:nowrap;display:inline-block}
|
||
.tbl{background:var(--white);border:1px solid var(--border);border-radius:12px;padding:0;margin-bottom:14px;overflow:hidden}
|
||
.tbl-row{display:flex;align-items:center;gap:12px;cursor:pointer;padding:9px 14px;border-top:1px solid var(--bg)}
|
||
.tbl-row:first-child{border-top:none}
|
||
.tbl-row:hover{background:#FAFBFC}
|
||
.fchip{font-size:11px;font-weight:600;font-family:'Inter';padding:4px 11px;border-radius:7px;border:1px solid var(--border);background:var(--white);color:var(--muted);cursor:pointer;margin-left:6px}
|
||
.fchip:hover{border-color:var(--primary);color:var(--primary)}
|
||
.fchip.on{background:var(--primary);color:#fff;border-color:var(--primary)}
|
||
.sec-h.collapsible{display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none}
|
||
.sec-chev{display:inline-block;transition:transform .15s;font-size:11px;color:var(--muted)}
|
||
/* Pipeline kanban */
|
||
.kanban{display:flex;gap:12px;overflow-x:auto;padding-bottom:8px;align-items:flex-start}
|
||
.kcol{flex:1;min-width:200px;background:#eef0f3;border-radius:12px;padding:10px}
|
||
.kcol-h{font-size:12px;font-weight:700;color:var(--text);padding:6px 8px;display:flex;justify-content:space-between;align-items:center}
|
||
.kcol-c{font-size:11px;font-weight:700;color:#9ca3af;background:var(--white);border-radius:10px;padding:1px 8px}
|
||
.kcard{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:12px;margin-top:8px;cursor:pointer;transition:box-shadow .15s}
|
||
.kcard:hover{box-shadow:0 4px 12px rgba(0,0,0,.08)}
|
||
.kcard-n{font-size:13px;font-weight:700;margin-bottom:3px}
|
||
.kcard-m{font-size:11px;color:var(--muted)}
|
||
.kcard-amt{font-size:12px;font-weight:700;color:var(--primary);margin-top:6px}
|
||
.kcol-sum{font-size:11px;font-weight:700;color:var(--primary);padding:0 8px 4px}
|
||
.kcard[draggable=true]{cursor:grab}
|
||
.kcard.dragging{opacity:.45;cursor:grabbing}
|
||
.kcol.drop-hot{background:#ECFDF5;outline:2px dashed var(--mid);outline-offset:-2px}
|
||
/* Client card */
|
||
.cc-top{display:flex;align-items:center;gap:14px;margin-bottom:18px}
|
||
.cc-av{width:48px;height:48px;border-radius:12px;background:#6366F1;display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;font-size:20px}
|
||
.cc-name{font-size:20px;font-weight:800;font-family:'Montserrat'}
|
||
.cc-meta{font-size:13px;color:var(--muted);margin-top:2px}
|
||
.cc-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px}
|
||
.cc-field{background:var(--white);border:1.5px solid var(--border);border-radius:11px;padding:12px 14px}
|
||
.cc-fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#9ca3af;margin-bottom:5px}
|
||
.cc-fi{width:100%;border:none;font-size:14px;font-weight:600;font-family:'Inter';color:var(--text);outline:none;background:transparent}
|
||
.cc-fi::placeholder{color:#cbd5e1;font-weight:400}
|
||
.cc-sel{width:100%;border:none;font-size:14px;font-weight:600;font-family:'Inter';outline:none;background:transparent;cursor:pointer}
|
||
.cc-actions{display:flex;gap:10px;margin-bottom:18px}
|
||
.btn{font-family:'Inter';font-weight:600;border:none;cursor:pointer;border-radius:9px;display:inline-flex;align-items:center;gap:7px;padding:9px 16px;font-size:13px}
|
||
.btn-p{background:var(--primary);color:#fff}.btn-p:hover{background:var(--dark)}
|
||
.btn-g{background:var(--white);border:1.5px solid var(--border);color:var(--muted)}.btn-g:hover{border-color:var(--primary);color:var(--primary)}
|
||
/* Main tabs (functional CRM nav) */
|
||
.mtabs{display:flex;gap:4px;background:#F1F5F9;border-radius:12px;padding:4px;margin-bottom:18px;flex-wrap:wrap}
|
||
.mtab{flex:1;min-width:96px;padding:10px 14px;font-size:13px;font-weight:700;font-family:'Inter';color:var(--muted);cursor:pointer;border:none;background:transparent;border-radius:9px;display:flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap;transition:all .15s}
|
||
.mtab:hover{color:var(--text)}
|
||
.mtab.active{background:var(--white);color:var(--primary);box-shadow:0 1px 5px rgba(0,0,0,.07)}
|
||
.mtab .badge{font-size:10px;font-weight:800;background:#DC2626;color:#fff;border-radius:10px;padding:1px 7px;line-height:1.5}
|
||
/* Tabs (analysis) */
|
||
.tabs{display:flex;gap:2px;border-bottom:1.5px solid var(--border);margin-bottom:18px;overflow-x:auto}
|
||
.tab{padding:11px 16px;font-size:13px;font-weight:600;color:var(--muted);cursor:pointer;border-bottom:2.5px solid transparent;display:flex;align-items:center;gap:6px}
|
||
.tab:hover{color:var(--text)}.tab.active{color:var(--primary);border-bottom-color:var(--primary)}
|
||
.tab.done::after{content:'✓';color:var(--mid);font-weight:800}
|
||
.run-card{background:var(--white);border:1.5px solid var(--border);border-radius:14px;padding:26px;text-align:center;max-width:520px;margin:8px auto 14px}
|
||
.run-ic{font-size:32px;margin-bottom:12px}.run-t{font-family:'Montserrat';font-weight:800;font-size:18px;margin-bottom:8px}
|
||
.run-d{font-size:14px;color:var(--muted);line-height:1.5;margin-bottom:18px}
|
||
.run-btn{padding:11px 22px;border-radius:10px;background:linear-gradient(135deg,var(--dark),var(--primary));color:#fff;border:none;cursor:pointer;font-weight:700;font-size:14px;font-family:'Inter'}
|
||
.cpbar{background:var(--white);border:1.5px solid var(--border);border-radius:12px;padding:13px 16px;display:flex;align-items:center;gap:12px;margin-top:18px}
|
||
.cpbar.appr{background:#F0FDF4;border-color:rgba(16,185,129,.3)}
|
||
.cpbar-t{flex:1;font-size:13px;font-weight:600}
|
||
.cp-btn{padding:8px 16px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:none;font-family:'Inter'}
|
||
.cp-a{background:var(--primary);color:#fff}.cp-r{background:transparent;border:1px solid var(--border);color:var(--muted)}
|
||
.blk{background:var(--white);border:1px solid var(--border);border-radius:12px;padding:14px;margin-bottom:10px}
|
||
.blk-pain{font-size:12px;color:#92400e;padding-left:14px;position:relative;margin-bottom:3px;line-height:1.4}.blk-pain::before{content:'•';position:absolute;left:3px;color:#f59e0b}
|
||
.canvas-grid{display:grid;grid-template-columns:repeat(5,1fr);grid-auto-rows:minmax(80px,auto);gap:8px}
|
||
.cv{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:10px}
|
||
.cv-h{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--primary);margin-bottom:5px;display:flex;justify-content:space-between}.cv-h span{color:#9ca3af}
|
||
.cv li{font-size:11px;color:#374151;line-height:1.3;list-style:none;padding-left:9px;position:relative;margin-bottom:2px}.cv li::before{content:'';position:absolute;left:2px;top:6px;width:3px;height:3px;border-radius:50%;background:var(--mid)}
|
||
.cv.vp{grid-column:3;grid-row:span 2;border-color:var(--primary)}.cv.kp{grid-row:span 2}.cv.cs{grid-column:5;grid-row:span 2}
|
||
.cv-ins{grid-column:1/-1;background:var(--ink);color:#fff;border-radius:10px;padding:13px 16px;font-size:13px;line-height:1.5}.cv-ins b{color:var(--mid)}
|
||
.idef-lbl{font-size:11px;font-weight:700;text-transform:uppercase;color:#9ca3af;margin:14px 0 8px}
|
||
.idef{margin-bottom:12px}.idef-c,.idef-m{display:flex;flex-wrap:wrap;gap:4px;justify-content:center;padding:4px 0}
|
||
.idef-c{border-bottom:2px dashed #cbd5e1}.idef-m{border-top:2px dashed #cbd5e1}
|
||
.idef-mid{display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:stretch}
|
||
.idef-i,.idef-o{display:flex;flex-direction:column;gap:4px;justify-content:center;max-width:130px}
|
||
.idef-fn{background:var(--white);border:2px solid var(--primary);border-radius:10px;padding:10px 9px;text-align:center;font-size:12px;font-weight:700;color:var(--dark);display:flex;flex-direction:column;gap:3px;justify-content:center}
|
||
.idef-fn b{font-size:10px;color:var(--primary);font-family:'Montserrat'}.idef-fn i{font-style:normal;font-size:10px;font-weight:700}
|
||
.ar{font-size:10px;padding:3px 7px;border-radius:6px;background:#F1F5F9;color:#475569}
|
||
.idef-c .ar{background:#FEF3C7;color:#92400E}.idef-c .ar.nomiss{background:#FEF2F2;color:#DC2626;border:1px dashed #FECACA}
|
||
.idef-i .ar{background:#EFF6FF;color:#1E40AF}.idef-o .ar{background:#ECFDF5;color:#047857}.idef-o .ar.dead{background:#FEF2F2;color:#DC2626}.idef-m .ar{background:#F5F3FF;color:#6D28D9}
|
||
.spec-h{font-family:'Montserrat';font-weight:800;font-size:15px;margin:16px 0 10px;display:flex;align-items:center;gap:8px}.spec-h .pl{width:22px;height:22px;border-radius:6px;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800}
|
||
.mod{background:var(--white);border:1px solid var(--border);border-radius:11px;padding:14px;margin-bottom:9px}
|
||
.mod-node{font-size:10px;font-weight:700;color:var(--primary);background:var(--light);padding:2px 7px;border-radius:5px}
|
||
.scr{font-size:11px;background:#EFF6FF;color:#1E40AF;border:1px solid #BFDBFE;border-radius:6px;padding:3px 8px;display:inline-block;margin:3px 3px 0 0}
|
||
.ent{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:7px}
|
||
.ent-fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:5px;margin-bottom:7px}
|
||
.fld{font-size:11px;background:var(--bg);border-radius:5px;padding:4px 7px}.fld em{font-style:normal;color:#6366F1;font-size:10px}
|
||
.ent-ex{font-size:10px;color:#6b7280;font-family:monospace;background:var(--bg);border-radius:5px;padding:6px 8px}
|
||
.spin{display:inline-block;animation:sp 1s linear infinite}@keyframes sp{to{transform:rotate(360deg)}}
|
||
.empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#cbd5e1;gap:10px;height:100%}
|
||
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:4px}
|
||
/* ── Мобильный (CRM с телефона) ── */
|
||
.hdr-burger{display:none;background:none;border:none;color:#fff;font-size:22px;cursor:pointer;padding:0 2px;line-height:1;flex-shrink:0}
|
||
.sb-backdrop{display:none;position:fixed;inset:54px 0 0 0;background:rgba(0,0,0,.55);z-index:40}
|
||
.sb-backdrop.show{display:block}
|
||
@media(max-width:680px){
|
||
.hdr{padding:0 12px;gap:8px}
|
||
.hdr-burger{display:block}
|
||
.sb{position:fixed;left:-260px;top:54px;bottom:0;z-index:50;width:240px;transition:left .25s ease;box-shadow:3px 0 18px rgba(0,0,0,.45)}
|
||
.sb.open{left:0}
|
||
.main{width:100%}
|
||
.scroll{padding:14px}
|
||
.kpis{grid-template-columns:1fr 1fr;gap:8px}
|
||
.kpi{padding:11px 13px}
|
||
.kpi-v{font-size:21px}
|
||
.cc-grid{grid-template-columns:1fr 1fr}
|
||
.deal-overview{grid-template-columns:1fr!important}
|
||
.canvas-grid{grid-template-columns:1fr 1fr}
|
||
.mtabs{flex-wrap:nowrap;overflow-x:auto}
|
||
.mtab{min-width:auto;padding:9px 12px}
|
||
.cc-top{flex-wrap:wrap}
|
||
.cc-name{font-size:17px}
|
||
.sec-h{font-size:14px}
|
||
.kcol{min-width:168px}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="hdr"><button class="hdr-burger" onclick="toggleSb()" aria-label="Меню">☰</button><div class="hdr-ic">@</div><div class="hdr-t">wasrusgen1<span class="hdr-sep"></span><b>КОНСАЛТИНГ</b></div><div class="hdr-badge">CRM</div><div class="hdr-r"><span style="width:8px;height:8px;border-radius:50%;background:var(--mid)"></span>Руслан</div></header>
|
||
<div class="layout">
|
||
<div class="sb-backdrop" id="sbBackdrop" onclick="toggleSb()"></div>
|
||
<aside class="sb" id="sbNav">
|
||
<div class="sb-nav">
|
||
<div class="nav-item active" id="nav-dash" onclick="setView('dashboard')"><span class="ic">📊</span> Дашборд</div>
|
||
<div class="nav-item" id="nav-pipe" onclick="setView('pipeline')"><span class="ic">🎯</span> Воронка</div>
|
||
</div>
|
||
<button class="sb-new" onclick="newClient()">+ Новый клиент</button>
|
||
<div class="sb-cap">Клиенты</div>
|
||
<div class="sb-list" id="clientList"></div>
|
||
</aside>
|
||
<main class="main"><div class="scroll" id="view"></div></main>
|
||
</div>
|
||
|
||
<script>
|
||
const API=location.hostname.indexOf("wasrusgen1.ru")>=0?"/consulting":"https://claude-83-172-150-111.sslip.io/elena";
|
||
let projects=[], current=null, state=null, view="dashboard", activeTab="interview", mainTab="deal", editPayIdx=-1;
|
||
const PIPE=[["lead","Лид","#9ca3af"],["qualified","Квалификация","#3B82F6"],["proposal","Предложение","#8B5CF6"],["active","В работе","#047857"],["done","Завершён","#10B981"]];
|
||
const pipeMap=Object.fromEntries(PIPE.map(p=>[p[0],p]));
|
||
function esc(s){return (s||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}
|
||
function fmt(s){return esc(s).replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>")}
|
||
function money(n){return (n||0).toLocaleString("ru-RU")+" ₽"}
|
||
// ── Мобильное меню ──
|
||
function toggleSb(){const sb=document.getElementById('sbNav'),bd=document.getElementById('sbBackdrop');const o=sb.classList.toggle('open');bd.classList.toggle('show',o);}
|
||
function closeSb(){const sb=document.getElementById('sbNav'),bd=document.getElementById('sbBackdrop');if(sb)sb.classList.remove('open');if(bd)bd.classList.remove('show');}
|
||
// ── UI-состояние: сворачивание секций + фильтры (запоминается) ──
|
||
const ui=(()=>{try{return JSON.parse(localStorage.getItem('crm_ui'))||{}}catch(e){return{}}})();
|
||
ui.collapsed=ui.collapsed||{revenue:true}; // динамика свёрнута по умолчанию
|
||
if(ui.collapsed.revenue===undefined)ui.collapsed.revenue=true;
|
||
ui.clientFilter=ui.clientFilter||'all';
|
||
ui.taskFilter=ui.taskFilter||'all';
|
||
function saveUi(){try{localStorage.setItem('crm_ui',JSON.stringify(ui))}catch(e){}}
|
||
function toggleSec(k){ui.collapsed[k]=!ui.collapsed[k];saveUi();renderDashboard();}
|
||
function setClientFilter(f){ui.clientFilter=f;saveUi();renderDashboard();}
|
||
function setTaskFilter(f){ui.taskFilter=f;saveUi();renderDashboard();}
|
||
function setClientSearch(v){ui.clientSearch=v;renderDashboard();const el=document.getElementById('clientSearch');if(el){el.focus();const L=el.value.length;try{el.setSelectionRange(L,L);}catch(e){}}}
|
||
function setClientSort(k){if(ui.clientSort===k)ui.clientSortDir=(ui.clientSortDir||1)*-1;else{ui.clientSort=k;ui.clientSortDir=1;}saveUi();renderDashboard();}
|
||
function projStageCnt(p){return [p.msg_count>0,!!p.has_selection,!!p.has_canvas,!!p.has_idef0,!!p.has_spec].filter(Boolean).length;}
|
||
function secHead(k,title,right){
|
||
const col=!!ui.collapsed[k];
|
||
return `<div class="sec-h collapsible" onclick="toggleSec('${k}')">
|
||
<span class="sec-chev" style="transform:rotate(${col?-90:0}deg)">▼</span>
|
||
<span>${title}</span>
|
||
${right?`<span style="margin-left:auto;display:flex;align-items:center;flex-wrap:wrap" onclick="event.stopPropagation()">${right}</span>`:''}
|
||
</div>`;
|
||
}
|
||
function chips(cur,opts,fn){return opts.map(([k,n])=>`<button class="fchip ${cur===k?'on':''}" onclick="${fn}('${k}')">${n}</button>`).join('');}
|
||
|
||
async function loadProjects(){
|
||
const r=await fetch(`${API}/api/projects`);const d=await r.json();projects=d.projects;renderClientList();
|
||
}
|
||
function renderClientList(){
|
||
document.getElementById("clientList").innerHTML=projects.map(p=>{
|
||
const pc=pipeMap[(p.crm&&p.crm.pipeline)||"lead"];
|
||
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 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){
|
||
current=token;view="client";mainTab="deal";editPayIdx=-1;if(window.innerWidth<=680)closeSb();document.querySelectorAll(".nav-item").forEach(n=>n.classList.remove("active"));
|
||
const r=await fetch(`${API}/api/project/${token}`);state=await r.json();renderClientList();render();syncPaymentReminders();
|
||
}
|
||
function render(){
|
||
if(view==="dashboard")renderDashboard();
|
||
else if(view==="pipeline")renderPipeline();
|
||
else if(view==="client")renderClient();
|
||
}
|
||
|
||
function renderDashboard(){
|
||
const total=projects.length;
|
||
const byPipe=k=>projects.filter(p=>((p.crm&&p.crm.pipeline)||"lead")===k).length;
|
||
const active=byPipe("active"),done=byPipe("done"),leads=byPipe("lead");
|
||
const paidOf=p=>{const c=p.crm||{};return (c.payments||[]).reduce((s,x)=>s+(x.amount||0),0)||c.paid_amount||0};
|
||
const revenue=projects.reduce((s,p)=>s+paidOf(p),0);
|
||
const expected=projects.reduce((s,p)=>{const c=p.crm||{};return s+Math.max(0,(c.deal_amount||0)-paidOf(p))},0);
|
||
const inwork=projects.filter(p=>((p.crm&&p.crm.pipeline)||"")==="active").reduce((s,p)=>s+((p.crm&&p.crm.deal_amount)||0),0);
|
||
const conv=total?Math.round(done/total*100):0;
|
||
document.getElementById("view").innerHTML=`
|
||
<div class="sec-h">Дашборд</div>
|
||
<div class="kpis">
|
||
<div class="kpi"><div class="kpi-v">${leads}</div><div class="kpi-l">Новых лидов</div><div class="kpi-sub">${active} в работе</div></div>
|
||
<div class="kpi"><div class="kpi-v">${money(revenue)}</div><div class="kpi-l">Выручка (получено)</div></div>
|
||
<div class="kpi"><div class="kpi-v">${money(expected)}</div><div class="kpi-l">Ожидается (остатки)</div><div class="kpi-sub">${money(inwork)} в активных сделках</div></div>
|
||
<div class="kpi"><div class="kpi-v">${conv}%</div><div class="kpi-l">Конверсия в сделку</div><div class="kpi-sub">${done} завершено</div></div>
|
||
</div>
|
||
${renderRevenueChart()}
|
||
${renderUpcomingTasks()}
|
||
${(()=>{
|
||
const cf=ui.clientFilter;
|
||
const pipeOf=p=>(p.crm&&p.crm.pipeline)||'lead';
|
||
let cl=projects;
|
||
if(cf==='lead')cl=projects.filter(p=>pipeOf(p)==='lead');
|
||
else if(cf==='active')cl=projects.filter(p=>pipeOf(p)==='active');
|
||
else if(cf==='done')cl=projects.filter(p=>pipeOf(p)==='done');
|
||
else if(cf==='free')cl=projects.filter(p=>((p.crm&&p.crm.billing_type))==='free');
|
||
const q=(ui.clientSearch||'').toLowerCase().trim();
|
||
if(q)cl=cl.filter(p=>(((p.client_name||'')+' '+(p.niche||'')).toLowerCase().indexOf(q)>=0));
|
||
const sk=ui.clientSort||'name', dir=ui.clientSortDir||1;
|
||
const pipeOrder={lead:0,qualified:1,proposal:2,active:3,done:4};
|
||
cl=cl.slice().sort((a,b)=>{let av,bv;
|
||
if(sk==='amount'){av=(a.crm&&a.crm.deal_amount)||0;bv=(b.crm&&b.crm.deal_amount)||0;}
|
||
else if(sk==='stage'){av=projStageCnt(a);bv=projStageCnt(b);}
|
||
else if(sk==='status'){av=pipeOrder[pipeOf(a)]||0;bv=pipeOrder[pipeOf(b)]||0;}
|
||
else {av=(a.client_name||'').toLowerCase();bv=(b.client_name||'').toLowerCase();}
|
||
if(av<bv)return -dir;if(av>bv)return dir;return 0;});
|
||
const cChips=chips(cf,[['all','Все'],['lead','Лиды'],['active','В работе'],['done','Завершён'],['free','Бесплатные']],'setClientFilter');
|
||
const head=secHead('clients',`Все клиенты · ${cl.length}`,cChips);
|
||
if(ui.collapsed.clients)return head;
|
||
if(!projects.length)return head+'<div class="empty">Создайте первого клиента</div>';
|
||
const arrow=k=>ui.clientSort===k?(dir<0?' ↓':' ↑'):'';
|
||
const bar=`<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
|
||
<input id="clientSearch" value="${esc(ui.clientSearch||'')}" oninput="setClientSearch(this.value)" placeholder="🔍 Поиск по имени или нише…" style="flex:1;min-width:170px;border:1.5px solid var(--border);border-radius:8px;padding:7px 11px;font-size:13px;font-family:Inter">
|
||
<span style="font-size:11px;color:var(--muted)">Сортировка:</span>
|
||
${[['name','Имя'],['amount','Сумма'],['stage','Этап'],['status','Статус']].map(([k,n])=>`<button class="fchip ${ui.clientSort===k?'on':''}" onclick="setClientSort('${k}')">${n}${arrow(k)}</button>`).join('')}
|
||
</div>`;
|
||
if(!cl.length)return head+bar+'<div class="tbl"><div class="cl-row" style="color:#9CA3AF;font-size:12px;justify-content:center">Ничего не найдено</div></div>';
|
||
return head+bar+`<div class="tbl">${cl.map(p=>renderClientRow(p)).join("")}</div>`;
|
||
})()}`;
|
||
}
|
||
const STAGE_DEFS=[{key:"interview",name:"Интервью"},{key:"methods",name:"Методологии"},{key:"canvas",name:"Стратегия"},{key:"idef0",name:"Функции"},{key:"spec",name:"ТЗ"}];
|
||
function clientStages(p){
|
||
const done=[p.msg_count>0,!!p.has_selection,!!p.has_canvas,!!p.has_idef0,!!p.has_spec];
|
||
const cnt=done.filter(Boolean).length;
|
||
let cur=done.findIndex(d=>!d); // первый незавершённый
|
||
const all=cur===-1;
|
||
const label=all?"Готово · ТЗ собрано":STAGE_DEFS[cur].name;
|
||
return {done,cnt,cur:all?STAGE_DEFS.length:cur,all,label};
|
||
}
|
||
function stepper(p){
|
||
const st=clientStages(p);
|
||
let dots="";
|
||
STAGE_DEFS.forEach((s,i)=>{
|
||
const isDone=st.done[i], isCur=!st.all&&i===st.cur;
|
||
const bg=isDone?"#047857":isCur?"#10B981":"#E5E7EB";
|
||
const ring=isCur?"box-shadow:0 0 0 3px rgba(16,185,129,.25);":"";
|
||
dots+=`<span title="${s.name}${isDone?' ✓':isCur?' — текущий':''}" style="width:11px;height:11px;border-radius:50%;background:${bg};${ring}flex-shrink:0;display:inline-block"></span>`;
|
||
if(i<STAGE_DEFS.length-1){const lc=st.done[i]?"#047857":"#E5E7EB";dots+=`<span style="height:2px;flex:1;min-width:14px;background:${lc};display:inline-block"></span>`;}
|
||
});
|
||
const col=st.all?"#047857":"#10B981";
|
||
return `<div style="display:flex;align-items:center;gap:0;margin-top:8px;max-width:360px">${dots}</div>
|
||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Этап: <b style="color:${col}">${st.label}</b> · ${st.cnt}/5</div>`;
|
||
}
|
||
function stepperInline(p,st){
|
||
st=st||clientStages(p);
|
||
let dots="";
|
||
STAGE_DEFS.forEach((s,i)=>{
|
||
const isDone=st.done[i], isCur=!st.all&&i===st.cur;
|
||
const bg=isDone?"#047857":isCur?"#10B981":"#E5E7EB";
|
||
const ring=isCur?"box-shadow:0 0 0 2.5px rgba(16,185,129,.25);":"";
|
||
dots+=`<span title="${s.name}${isDone?' ✓':isCur?' — текущий':''}" style="width:9px;height:9px;border-radius:50%;background:${bg};${ring}flex-shrink:0;display:inline-block"></span>`;
|
||
if(i<STAGE_DEFS.length-1){const lc=st.done[i]?"#047857":"#E5E7EB";dots+=`<span style="height:2px;width:13px;background:${lc};display:inline-block"></span>`;}
|
||
});
|
||
const col=st.all?"#047857":"#10B981";
|
||
return `<div style="display:flex;align-items:center;gap:9px;margin-top:5px"><span style="display:flex;align-items:center">${dots}</span><span style="font-size:11px;color:var(--muted);white-space:nowrap">Этап: <b style="color:${col}">${st.label}</b> · ${st.cnt}/5</span></div>`;
|
||
}
|
||
function renderClientRow(p){
|
||
const pc=pipeMap[(p.crm&&p.crm.pipeline)||"lead"];
|
||
const billing=(p.crm&&p.crm.billing_type)||"paid";
|
||
const bChip=billing==="free"
|
||
?`<span class="mini-chip" style="color:#6366F1;background:#EEF2FF">🎁 Беспл.</span>`
|
||
:`<span class="mini-chip" style="color:#047857;background:#ECFDF5">💰 Платный</span>`;
|
||
return `<div class="cl-row" onclick="openClient('${p.token}')">
|
||
<div class="cl-av" style="width:32px;height:32px;border-radius:8px;font-size:13px;flex-shrink:0">${esc((p.client_name||'?')[0])}</div>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="display:flex;align-items:center;gap:7px;flex-wrap:wrap"><span style="font-weight:700;font-size:13.5px">${esc(p.client_name)}</span>${bChip}<span style="font-size:11.5px;color:var(--muted)">${esc(p.niche)} · ${p.msg_count} сообщ.</span></div>
|
||
${stepperInline(p)}
|
||
</div>
|
||
<span class="mini-chip" style="color:${pc[2]};background:${pc[2]}1a">${pc[1]}</span>
|
||
<span style="font-weight:800;color:var(--primary);min-width:80px;text-align:right;font-size:14px">${money((p.crm&&p.crm.deal_amount)||0)}</span>
|
||
</div>`;
|
||
}
|
||
|
||
function renderRevenueChart(){
|
||
const months={};
|
||
projects.forEach(p=>(p.crm&&p.crm.payments||[]).forEach(pay=>{const m=(pay.date||"").slice(0,7);if(m)months[m]=(months[m]||0)+(pay.amount||0)}));
|
||
const keys=Object.keys(months).sort();
|
||
if(!keys.length)return"";
|
||
const max=Math.max(...keys.map(k=>months[k]));
|
||
const MN=["янв","фев","мар","апр","май","июн","июл","авг","сен","окт","ноя","дек"];
|
||
const lbl=k=>{const[y,m]=k.split("-");return MN[+m-1]+" "+y.slice(2)};
|
||
const totalRev=keys.reduce((s,k)=>s+months[k],0);
|
||
const head=secHead('revenue',`📈 Выручка по месяцам · ${money(totalRev)}`);
|
||
if(ui.collapsed.revenue)return head;
|
||
return head+`<div class="blk" style="padding:14px 16px;margin-bottom:14px"><div style="display:flex;align-items:flex-end;justify-content:flex-start;gap:18px;height:104px">${keys.map(k=>`<div style="flex:0 0 56px;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%"><div style="font-size:11px;font-weight:700;color:#047857;margin-bottom:4px">${(months[k]/1000).toFixed(0)}к</div><div style="width:100%;background:linear-gradient(180deg,#10B981,#047857);border-radius:6px 6px 0 0;height:${Math.max(8,months[k]/max*100)}%"></div><div style="font-size:11px;color:var(--muted);margin-top:6px">${lbl(k)}</div></div>`).join("")}</div></div>`;
|
||
}
|
||
function renderUpcomingTasks(){
|
||
const today=new Date().toISOString().slice(0,10);
|
||
const all=[];
|
||
projects.forEach(p=>(p.tasks||[]).forEach(t=>{if(!t.done)all.push({...t,client:p.client_name,token:p.token})}));
|
||
all.sort((a,b)=>(a.due||"9999")<(b.due||"9999")?-1:1);
|
||
if(!all.length)return"";
|
||
const f=ui.taskFilter;
|
||
let list=all;
|
||
if(f==='overdue')list=all.filter(t=>t.due&&t.due<today);
|
||
else if(f==='today')list=all.filter(t=>t.due===today);
|
||
const overdueN=all.filter(t=>t.due&&t.due<today).length;
|
||
const filterChips=chips(f,[['all','Все'],['today','Сегодня'],['overdue',`Просроченные${overdueN?' '+overdueN:''}`]],'setTaskFilter');
|
||
const head=secHead('tasks',`📌 Задачи · ${all.length}`,filterChips);
|
||
if(ui.collapsed.tasks)return head;
|
||
const top=list.slice(0,10);
|
||
if(!top.length)return head+'<div class="tbl"><div class="tbl-row" style="color:#9CA3AF;font-size:12px;justify-content:center">Нет задач по фильтру</div></div>';
|
||
return head+`<div class="tbl">${top.map(t=>`<div class="tbl-row" onclick="openClient('${t.token}')"><span style="font-size:13px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.text)}</span><span style="font-size:12px;color:var(--muted);white-space:nowrap">${esc(t.client)}</span>${t.due?`<span style="font-size:11px;font-weight:700;padding:3px 9px;border-radius:6px;white-space:nowrap;${t.due<today?'background:#FEF2F2;color:#DC2626':t.due===today?'background:#FEF3C7;color:#92400E':'background:#F1F5F9;color:#6B7280'}">${t.due===today?'сегодня':t.due<today?'просрочено':fmtDate(t.due)}</span>`:''}</div>`).join("")}</div>`;
|
||
}
|
||
function renderPipeline(){
|
||
document.getElementById("view").innerHTML=`<div class="sec-h">Воронка продаж</div>
|
||
<div style="font-size:12px;color:var(--muted);margin:-3px 0 10px">Перетащите карточку между колонками, чтобы сменить этап сделки</div>
|
||
<div class="kanban">${PIPE.map(([k,name,col])=>{
|
||
const items=projects.filter(p=>((p.crm&&p.crm.pipeline)||"lead")===k);
|
||
const sum=items.reduce((s,p)=>s+((p.crm&&p.crm.deal_amount)||0),0);
|
||
return `<div class="kcol" data-pipe="${k}" ondragover="kDragOver(event)" ondragleave="kDragLeave(event)" ondrop="kDrop(event,'${k}')">
|
||
<div class="kcol-h"><span style="color:${col}">${name}</span><span class="kcol-c">${items.length}</span></div>
|
||
${sum>0?`<div class="kcol-sum">${money(sum)}</div>`:''}
|
||
${items.map(p=>kCard(p)).join("")||'<div style="font-size:11px;color:#cbd5e1;text-align:center;padding:16px 4px">пусто</div>'}
|
||
</div>`;
|
||
}).join("")}</div>`;
|
||
}
|
||
function kCard(p){
|
||
const billing=(p.crm&&p.crm.billing_type)||"paid";
|
||
const bch=billing==='free'?'<span class="mini-chip" style="color:#6366F1;background:#EEF2FF">🎁</span>':'<span class="mini-chip" style="color:#047857;background:#ECFDF5">💰</span>';
|
||
const st=clientStages(p);
|
||
let dots="";STAGE_DEFS.forEach((s,i)=>{const d=st.done[i],c=!st.all&&i===st.cur;dots+=`<span title="${s.name}" style="width:7px;height:7px;border-radius:50%;background:${d?'#047857':c?'#10B981':'#E5E7EB'};display:inline-block"></span>`;});
|
||
return `<div class="kcard" draggable="true" ondragstart="kDragStart(event,'${p.token}')" ondragend="kDragEnd(event)" onclick="openClient('${p.token}')">
|
||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px"><span class="kcard-n" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(p.client_name)}</span>${bch}</div>
|
||
<div class="kcard-m">${esc(p.niche)}</div>
|
||
<div style="display:flex;align-items:center;gap:3px;margin-top:7px">${dots}<span style="font-size:10px;color:#9ca3af;margin-left:4px">${st.cnt}/5</span></div>
|
||
${(p.crm&&p.crm.deal_amount)?`<div class="kcard-amt">${money(p.crm.deal_amount)}</div>`:''}
|
||
</div>`;
|
||
}
|
||
let kDragToken=null;
|
||
function kDragStart(e,token){kDragToken=token;e.currentTarget.classList.add('dragging');try{e.dataTransfer.effectAllowed='move';e.dataTransfer.setData('text/plain',token);}catch(_){}}
|
||
function kDragEnd(e){e.currentTarget.classList.remove('dragging');document.querySelectorAll('.kcol').forEach(c=>c.classList.remove('drop-hot'));}
|
||
function kDragOver(e){e.preventDefault();e.currentTarget.classList.add('drop-hot');try{e.dataTransfer.dropEffect='move';}catch(_){}}
|
||
function kDragLeave(e){e.currentTarget.classList.remove('drop-hot');}
|
||
async function kDrop(e,pipe){
|
||
e.preventDefault();e.currentTarget.classList.remove('drop-hot');
|
||
const tok=kDragToken||(e.dataTransfer&&e.dataTransfer.getData('text/plain'));kDragToken=null;
|
||
if(!tok)return;
|
||
const p=projects.find(x=>x.token===tok);if(!p)return;
|
||
if(((p.crm&&p.crm.pipeline)||'lead')===pipe){renderPipeline();return;}
|
||
p.crm=p.crm||{};p.crm.pipeline=pipe;
|
||
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:tok,pipeline: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:"⚠️"}];
|
||
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 ''};
|
||
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">
|
||
<button onclick="setBilling('paid')" style="padding:7px 14px;border-radius:8px;border:none;cursor:pointer;font-size:13px;font-weight:700;font-family:Inter;${billing==='paid'?'background:#047857;color:#fff':'background:transparent;color:#6B7280'}">💰 Платный</button>
|
||
<button onclick="setBilling('free')" style="padding:7px 14px;border-radius:8px;border:none;cursor:pointer;font-size:13px;font-weight:700;font-family:Inter;${billing==='free'?'background:#6366F1;color:#fff':'background:transparent;color:#6B7280'}">🎁 Бесплатный</button>
|
||
</div></div>
|
||
<div class="mtabs">${MAINTABS.map(t=>`<button class="mtab ${t.id===mainTab?'active':''}" onclick="setMainTab('${t.id}')">${t.icon} ${t.name}${badge(t.id)}</button>`).join("")}</div>
|
||
<div id="mainPanel"></div>`;
|
||
renderMainPanel();
|
||
}
|
||
function setMainTab(t){mainTab=t;editPayIdx=-1;renderMainPanel();document.querySelectorAll('.mtab').forEach((b,i)=>b.classList.toggle('active',MAINTABS[i].id===mainTab));}
|
||
const DEV_STAGE={canvas:"📊 Стратегия",idef0:"🔧 Функции",spec:"📋 ТЗ",documents:"📁 Документы",methods:"🎯 Методологии",interview:"💬 Интервью"};
|
||
function renderDeviations(){
|
||
const dev=state.deviations||[];
|
||
if(!dev.length)return `<div class="run-card" style="background:#fff;border:1px solid #E5E7EB;border-radius:12px;padding:22px;text-align:center">
|
||
<div style="font-size:26px;margin-bottom:6px">✅</div>
|
||
<div style="font-size:14px;font-weight:700">Отклонений нет</div>
|
||
<div style="font-size:12px;color:#6B7280;margin-top:4px">Клиент пока не настаивал на изменениях. Когда в кабинете на этапах 3–5 он скажет Елене «мне так удобно» — расхождение появится здесь: эталон против выбора клиента.</div></div>`;
|
||
const fmtD=d=>{if(!d)return'';const a=String(d).slice(0,10).split('-');return a.length===3?a[2]+'.'+a[1]+'.'+a[0].slice(2):'';};
|
||
return `<div style="font-size:12px;color:#6B7280;margin-bottom:12px;line-height:1.5">При внедрении реализуем <b style="color:#1A1A2E">решение клиента</b> (он за него отвечает), но рекомендация Елены остаётся флажком — это и подстраховка, и карта что дотянуть позже.</div>`+
|
||
dev.map(d=>`<div style="background:#fff;border:1px solid #E5E7EB;border-left:3px solid #F59E0B;border-radius:12px;padding:14px 16px;margin-bottom:10px">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:9px;flex-wrap:wrap">
|
||
<span style="font-size:10px;font-weight:700;color:#6B7280;background:#F3F4F6;padding:2px 8px;border-radius:5px">${DEV_STAGE[d.stage]||esc(d.stage||'этап')}</span>
|
||
<span style="font-size:13px;font-weight:800">${esc(d.node||'')}</span>
|
||
${d.at?`<span style="margin-left:auto;font-size:10px;color:#9CA3AF">${fmtD(d.at)}</span>`:''}
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:flex-start;margin-bottom:7px">
|
||
<span style="font-size:13px;flex:0 0 18px">✅</span>
|
||
<div><div style="font-size:10px;font-weight:700;color:#047857;text-transform:uppercase;letter-spacing:.3px">Решение клиента · внедряем</div><div style="font-size:13px;font-weight:600;color:#1A1A2E">${esc(d.client_choice||'')}</div></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:flex-start;margin-bottom:7px">
|
||
<span style="font-size:13px;flex:0 0 18px">⚠️</span>
|
||
<div><div style="font-size:10px;font-weight:700;color:#92400E;text-transform:uppercase;letter-spacing:.3px">Елена рекомендовала</div><div style="font-size:13px;color:#374151">${esc(d.elena_rec||'')}</div></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:flex-start;background:#F9FAFB;border-radius:8px;padding:8px 10px;margin-top:8px">
|
||
<span style="font-size:13px;flex:0 0 18px">💬</span>
|
||
<div><div style="font-size:10px;font-weight:700;color:#6B7280;text-transform:uppercase;letter-spacing:.3px">Причина клиента</div><div style="font-size:12px;color:#4B5563;font-style:italic">${esc(d.reason||'')}</div></div>
|
||
</div>
|
||
</div>`).join('');
|
||
}
|
||
function renderMainPanel(){
|
||
const p=document.getElementById("mainPanel");if(!p)return;
|
||
const crm=state.crm||{pipeline:"lead",deal_amount:0,source:""};
|
||
if(mainTab==="deal"){
|
||
const deal=crm.deal_amount||0;
|
||
const pays=crm.payments||[]; const paid=pays.reduce((s,x)=>s+(x.amount||0),0); const left=Math.max(0,deal-paid);
|
||
const doneArr=CLIENT_STAGES.map(s=>stageIsDone(s.key));
|
||
const firstUndone=doneArr.findIndex(d=>!d);
|
||
const doneCnt=doneArr.filter(Boolean).length;
|
||
p.innerHTML=`
|
||
<div class="cc-grid">
|
||
<div class="cc-field"><div class="cc-fl">Статус воронки</div><select class="cc-sel" id="cpPipe" onchange="saveCrm()">${PIPE.map(([k,n])=>`<option value="${k}" ${crm.pipeline===k?'selected':''}>${n}</option>`).join("")}</select></div>
|
||
<div class="cc-field"><div class="cc-fl">Сумма сделки</div><input class="cc-fi" id="cpDeal" type="number" value="${crm.deal_amount||''}" placeholder="0" onchange="saveCrm()"></div>
|
||
<div class="cc-field"><div class="cc-fl">Источник</div><input class="cc-fi" id="cpSrc" value="${esc(crm.source||'')}" placeholder="откуда пришёл" onchange="saveCrm()"></div>
|
||
<div class="cc-field" id="payStatusBox"></div>
|
||
</div>
|
||
<div class="cc-actions"><button class="btn btn-p" onclick="inviteLink()">🔗 Ссылка клиенту</button><button class="btn btn-p" style="background:#0088cc" onclick="inviteTelegram()">✈ В Telegram</button><a class="btn btn-g" href="cabinet.html?t=${current}" target="_blank">👁 Открыть кабинет</a><button class="btn btn-g" style="margin-left:auto;border-color:#FECACA;color:#DC2626" onclick="deleteClient()">🗑 Удалить</button></div>
|
||
<div class="deal-overview" style="display:grid;grid-template-columns:1.35fr 1fr;gap:14px;align-items:start">
|
||
<div class="blk" style="margin:0">
|
||
<div style="display:flex;align-items:center;margin-bottom:6px"><b style="font-size:13px">📍 Прогресс проекта</b><span style="margin-left:auto;font-size:11px;color:var(--muted)">${doneCnt}/5 этапов</span></div>
|
||
${CLIENT_STAGES.map((s,i)=>{
|
||
const done=doneArr[i], cur=!done&&i===firstUndone;
|
||
const brd=done?'#047857':cur?'#10B981':'#E5E7EB';
|
||
const tag=done?'<span style="font-size:10px;font-weight:700;color:#047857;background:#ECFDF5;padding:1px 7px;border-radius:5px">✓ готово</span>':cur?'<span style="font-size:10px;font-weight:700;color:#92400E;background:#FEF3C7;padding:1px 7px;border-radius:5px">● в работе</span>':'<span style="font-size:10px;font-weight:700;color:#9CA3AF;background:#F3F4F6;padding:1px 7px;border-radius:5px">ожидает</span>';
|
||
return `<div style="display:flex;align-items:center;gap:10px;padding:7px 0;border-top:1px solid var(--bg)">
|
||
<span style="width:24px;height:24px;border-radius:6px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;background:${done||cur?'#ECFDF5':'#F9FAFB'};border:1.5px solid ${brd}">${s.icon}</span>
|
||
<div style="flex:1;min-width:0"><div style="font-size:12.5px;font-weight:700;color:${done||cur?'var(--text)':'#9CA3AF'}">${s.name}</div><div style="font-size:11px;color:var(--muted)">${esc(s.desc)}</div></div>
|
||
${tag}
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:14px">
|
||
<div class="blk" style="margin:0">
|
||
<div style="font-size:13px;font-weight:700;margin-bottom:8px">💰 Финансы</div>
|
||
<div style="display:flex;justify-content:space-between;padding:5px 0;font-size:13px"><span style="color:var(--muted)">Смета</span><b>${deal>0?money(deal):'—'}</b></div>
|
||
<div style="display:flex;justify-content:space-between;padding:5px 0;font-size:13px;border-top:1px solid var(--bg)"><span style="color:var(--muted)">Получено</span><b style="color:#047857">${money(paid)}</b></div>
|
||
<div style="display:flex;justify-content:space-between;padding:5px 0;font-size:13px;border-top:1px solid var(--bg)"><span style="color:var(--muted)">Остаток</span><b style="color:${left>0?'#DC2626':'#047857'}">${money(left)}</b></div>
|
||
</div>
|
||
<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>
|
||
</div>
|
||
</div>`;
|
||
renderPayments();
|
||
}
|
||
else if(mainTab==="pricing"){p.innerHTML=`<div id="pricingBox"></div>`;renderPricing();}
|
||
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==="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));}
|
||
async function setBilling(t){
|
||
state.crm=state.crm||{};state.crm.billing_type=t;
|
||
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,billing_type:t})});
|
||
renderClient();await loadProjects();
|
||
}
|
||
// ── План оплаты (привязка к вехам анализа) ──
|
||
// ── Этапы оплаты — от прогресса клиента ──────────────────
|
||
// Этапы = 5 этапов анализа. Отмечаем оплату по мере прохождения.
|
||
// Правило: ТЗ (печать + выгрузка) только после 100% оплаты.
|
||
const CLIENT_STAGES=[
|
||
{key:"interview", name:"Интервью", icon:"💬", desc:"Диагностика «как есть» — карта проблем"},
|
||
{key:"methods", name:"Методологии", icon:"🎯", desc:"Подбор фреймворков под вашу задачу"},
|
||
{key:"canvas", name:"Стратегия", icon:"📊", desc:"Целевая модель процессов (TO-BE)"},
|
||
{key:"idef0", name:"Функции IDEF0", icon:"🔧", desc:"Декомпозиция функций + регламенты"},
|
||
{key:"spec", name:"Выдача ТЗ", icon:"📋", desc:"Готовое ТЗ к внедрению"},
|
||
];
|
||
// ── Смета: базовые ставки модулей (интервью = вход 0₽) ──
|
||
const STAGE_BASE={interview:0, methods:8000, canvas:12000, idef0:15000, spec:5000};
|
||
const CX_COEF={low:1.0, medium:1.5, high:2.0};
|
||
const CX_LABEL={low:"Простой · 1–3 процесса", medium:"Средний · 4–7", high:"Сложный · 8+"};
|
||
const CX_SHORT={low:"Простой ×1.0", medium:"Средний ×1.5", high:"Сложный ×2.0"};
|
||
function getStagePrices(){return (state.crm&&state.crm.stage_prices)||null;}
|
||
function getComplexity(){return (state.crm&&state.crm.complexity)||"medium";}
|
||
function recalcDeal(sp){return Object.values(sp).reduce((a,b)=>a+(+b||0),0);}
|
||
function buildEstimate(){
|
||
const coef=CX_COEF[getComplexity()];
|
||
const sp={};
|
||
CLIENT_STAGES.forEach(s=>{sp[s.key]=Math.round((STAGE_BASE[s.key]||0)*coef/100)*100;});
|
||
state.crm=state.crm||{};
|
||
state.crm.stage_prices=sp;
|
||
state.crm.complexity=getComplexity();
|
||
state.crm.deal_amount=recalcDeal(sp);
|
||
saveEstimate();renderClient();
|
||
}
|
||
async function setComplexity(cx){
|
||
state.crm=state.crm||{};state.crm.complexity=cx;
|
||
if(state.crm.stage_prices){buildEstimate();return;}
|
||
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,complexity:cx})});
|
||
renderClient();
|
||
}
|
||
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||{};
|
||
state.crm.stage_prices[k]=n;
|
||
// сумму сделки НЕ трогаем — разница показывается как «нераспределено»
|
||
saveEstimate();syncPaymentReminders();renderClient();
|
||
}
|
||
// ── Сроки этапов (плановая дата оплаты) ──
|
||
function getStageDue(){return (state.crm&&state.crm.stage_due)||{};}
|
||
function setStageDue(k,v){
|
||
state.crm=state.crm||{};state.crm.stage_due=state.crm.stage_due||{};
|
||
if(v)state.crm.stage_due[k]=v; else delete state.crm.stage_due[k];
|
||
fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,stage_due:state.crm.stage_due})})
|
||
.then(()=>{syncPaymentReminders();return loadProjects();});
|
||
}
|
||
// ── Автозадачи-напоминания о сроках оплаты этапов (идемпотентно) ──
|
||
function syncPaymentReminders(){
|
||
if(!state||!state.crm)return;
|
||
const due=getStageDue(), sp=getStagePrices()||{}, pays=getStagePays();
|
||
state.tasks=state.tasks||[];
|
||
let changed=false;
|
||
CLIENT_STAGES.forEach(s=>{
|
||
const marker='pay_'+s.key;
|
||
const need=due[s.key]&&(sp[s.key]||0)>0&&!pays[s.key];
|
||
const idx=state.tasks.findIndex(t=>t.auto===marker);
|
||
if(need){
|
||
const text=`💳 Получить оплату за «${s.name}» — ${money(sp[s.key])}`;
|
||
if(idx<0){state.tasks.push({text,due:due[s.key],done:false,auto:marker});changed=true;}
|
||
else if(state.tasks[idx].due!==due[s.key]||state.tasks[idx].text!==text||state.tasks[idx].done){
|
||
state.tasks[idx].due=due[s.key];state.tasks[idx].text=text;state.tasks[idx].done=false;changed=true;}
|
||
} else if(idx>=0){state.tasks.splice(idx,1);changed=true;}
|
||
});
|
||
if(changed)saveTasks();
|
||
}
|
||
// ── Подогнать сумму сделки под смету ──
|
||
function alignDealToEstimate(){
|
||
state.crm=state.crm||{};
|
||
state.crm.deal_amount=recalcDeal(getStagePrices()||{});
|
||
saveEstimate();renderClient();
|
||
}
|
||
// ── Способы оплаты ──
|
||
const PAY_METHODS=[["bank","🏦 Безнал"],["cash","💵 Наличные"],["sbp","📱 СБП"],["card","💳 Карта"]];
|
||
const METHOD_ICON={bank:"🏦",cash:"💵",sbp:"📱",card:"💳"};
|
||
const METHOD_NAME=Object.fromEntries(PAY_METHODS.map(m=>[m[0],m[1]]));
|
||
function methodSelect(id,cur){return `<select id="${id}" style="border:1.5px solid var(--border);border-radius:8px;padding:7px 9px;font-size:13px;font-family:Inter;background:var(--white)">${PAY_METHODS.map(([k,n])=>`<option value="${k}" ${cur===k?'selected':''}>${n}</option>`).join('')}</select>`;}
|
||
async function saveEstimate(){
|
||
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,stage_prices:state.crm.stage_prices,complexity:state.crm.complexity,deal_amount:state.crm.deal_amount})});
|
||
await loadProjects();
|
||
}
|
||
function stagePayKey(k){return "pay_"+k;}
|
||
function stageIsDone(k){
|
||
if(k==="interview") return (state.messages||[]).length>0;
|
||
if(k==="methods") return !!state.selection;
|
||
if(k==="canvas") return !!state.canvas;
|
||
if(k==="idef0") return !!(state.model);
|
||
if(k==="spec") return !!state.spec;
|
||
return false;
|
||
}
|
||
function getStagePays(){return (state.crm&&state.crm.stage_payments)||{};}
|
||
function markStagePayInput(k){
|
||
const fid='spf-'+k;
|
||
const ex=document.getElementById(fid);if(ex){ex.remove();return;}
|
||
const deal=(state.crm&&state.crm.deal_amount)||0;
|
||
const sp=getStagePrices()||{};
|
||
const suggested=sp[k]||(deal>0?Math.round(deal/5):'');
|
||
const today=new Date().toISOString().slice(0,10);
|
||
const wrap=document.createElement('div');
|
||
wrap.id=fid;
|
||
wrap.style.cssText='display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;align-items:center;padding:10px 12px;background:var(--bg);border-radius:10px;border:1.5px solid #D1FAE5';
|
||
wrap.innerHTML=`<input type="number" id="spa-${k}" placeholder="Сумма ₽" value="${suggested}" style="width:110px;border:1.5px solid var(--border);border-radius:8px;padding:7px 10px;font-size:13px;font-family:Inter"><input type="date" id="spd-${k}" value="${today}" style="border:1.5px solid var(--border);border-radius:8px;padding:7px 10px;font-size:13px;font-family:Inter">${methodSelect('spm-'+k,'bank')}<input id="spn-${k}" placeholder="Назначение" style="flex:1;min-width:120px;border:1.5px solid var(--border);border-radius:8px;padding:7px 10px;font-size:13px;font-family:Inter"><button class="cp-btn cp-a" style="padding:7px 14px" onclick="confirmStagePay('${k}')">Сохранить</button><button class="cp-btn cp-r" style="padding:7px 12px" onclick="document.getElementById('${fid}').remove()">✕</button>`;
|
||
const row=document.getElementById('stagerow-'+k);
|
||
if(row)row.parentNode.insertBefore(wrap,row.nextSibling);
|
||
}
|
||
async function confirmStagePay(k){
|
||
const amt=+document.getElementById('spa-'+k).value;
|
||
const date=document.getElementById('spd-'+k).value||new Date().toISOString().slice(0,10);
|
||
const note=document.getElementById('spn-'+k).value;
|
||
const method=(document.getElementById('spm-'+k)||{}).value||'bank';
|
||
if(!amt){alert('Укажите сумму');return;}
|
||
const pays=getStagePays();if(pays[k])return;
|
||
const stageName=CLIENT_STAGES.find(s=>s.key===k).name;
|
||
pays[k]={amount:amt,date,method};
|
||
state.crm=state.crm||{};
|
||
state.crm.stage_payments=pays;
|
||
state.crm.payments=state.crm.payments||[];
|
||
state.crm.payments.push({date,amount:amt,note:note||'Этап: '+stageName,stage:k,method});
|
||
await Promise.all([
|
||
fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,stage_payments:pays,payments:state.crm.payments})}),
|
||
loadProjects()
|
||
]);
|
||
syncPaymentReminders();renderClient();
|
||
}
|
||
// ── СБП QR (Сбер) ──
|
||
async function sberQR(k){
|
||
const sp=getStagePrices()||{}; const amount=sp[k]||0;
|
||
if(!amount){alert('У этапа нет цены');return;}
|
||
const name=CLIENT_STAGES.find(s=>s.key===k).name;
|
||
try{
|
||
const r=await fetch(`${API}/api/payment/sber-qr`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:current,stage_key:k,amount,description:'Этап: '+name})});
|
||
const d=await r.json();
|
||
if(d.error){alert('Ошибка СБП: '+(d.detail||d.error));return;}
|
||
showQRModal(d,name,amount);
|
||
}catch(e){alert('Ошибка: '+e.message);}
|
||
}
|
||
function showQRModal(d,name,amount){
|
||
const payload=d.qr_payload||d.qr_url||'';
|
||
const ov=document.createElement('div');
|
||
ov.id='qrModal';
|
||
ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:9999';
|
||
const qrBox=d.qr_image?`<img src="${d.qr_image}" style="width:220px;height:220px;border-radius:12px">`:`<div style="width:220px;height:220px;margin:0 auto;border:2px dashed #A7F3D0;border-radius:12px;display:flex;align-items:center;justify-content:center;text-align:center;color:#6B7280;font-size:12px;padding:16px">QR придёт от Сбера<br>после настройки реквизитов<br><br>(демо-режим)</div>`;
|
||
ov.innerHTML=`<div style="background:#fff;border-radius:16px;padding:24px;max-width:340px;width:90%;text-align:center;font-family:Inter">
|
||
<div style="font-size:15px;font-weight:800;font-family:Montserrat;margin-bottom:4px">📱 СБП · ${esc(name)}</div>
|
||
<div style="font-size:22px;font-weight:800;color:var(--primary);margin-bottom:14px">${money(amount)}</div>
|
||
${qrBox}
|
||
${d.demo?`<div style="font-size:11px;color:#92400E;background:#FEF3C7;border-radius:8px;padding:8px;margin-top:12px">${esc(d.message||'Демо-режим')}</div>`:''}
|
||
${payload?`<div style="margin-top:12px"><a href="${esc(payload)}" target="_blank" style="font-size:11px;color:var(--primary);word-break:break-all">${esc(payload)}</a></div>`:''}
|
||
<div style="display:flex;gap:8px;margin-top:16px">
|
||
<button class="cp-btn cp-a" style="flex:1" onclick="confirmSberQR('${d.order_id}')">✓ Оплата получена</button>
|
||
<button class="cp-btn cp-r" onclick="document.getElementById('qrModal').remove()">Закрыть</button>
|
||
</div>
|
||
</div>`;
|
||
ov.onclick=e=>{if(e.target===ov)ov.remove();};
|
||
document.body.appendChild(ov);
|
||
}
|
||
async function confirmSberQR(orderId){
|
||
try{await fetch(`${API}/api/payment/sber-confirm`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:current,order_id:orderId})});}catch(e){}
|
||
const m=document.getElementById('qrModal');if(m)m.remove();
|
||
await openClient(current); mainTab='payments'; renderMainPanel();
|
||
}
|
||
async function unStagePay(k){
|
||
const pays=getStagePays();if(!pays[k])return;
|
||
if(!confirm('Отменить оплату этапа «'+CLIENT_STAGES.find(s=>s.key===k).name+'»? Связанный платёж удалится из реестра.'))return;
|
||
delete pays[k];
|
||
state.crm.stage_payments=pays;
|
||
const arr=state.crm.payments||[];
|
||
for(let i=arr.length-1;i>=0;i--){if(arr[i].stage===k){arr.splice(i,1);break;}}
|
||
state.crm.payments=arr;
|
||
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,stage_payments:pays,payments:arr})});
|
||
await loadProjects();syncPaymentReminders();renderClient();
|
||
}
|
||
function renderPaymentPlan(){
|
||
const box=document.getElementById("planBox");if(!box)return;
|
||
const billing=(state.crm&&state.crm.billing_type)||"paid";
|
||
const sp=getStagePrices();
|
||
const pays=getStagePays();
|
||
// «Получено» и разблокировка — по фактически полученным деньгам (весь реестр), не только по этап-привязанным
|
||
const paidTotal=((state.crm&&state.crm.payments)||[]).reduce((s,p)=>s+(p.amount||0),0);
|
||
|
||
// ── Сметы ещё нет → генератор ──
|
||
if(!sp){
|
||
const cx=getComplexity();
|
||
box.innerHTML=`<div class="blk" style="margin-bottom:14px">
|
||
<div style="font-size:13px;font-weight:700;margin-bottom:6px">📋 Смета проекта</div>
|
||
<div style="font-size:12px;color:var(--muted);line-height:1.5;margin-bottom:14px">После диагностики сформируйте смету. Клиент увидит, <b>за что и сколько</b> платит. Интервью — бесплатный вход, дальше оплата помодульно. ТЗ выдаётся после полной оплаты.</div>
|
||
<div style="font-size:12px;font-weight:600;margin-bottom:8px">Сложность проекта (из интервью):</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
||
${Object.keys(CX_COEF).map(k=>`<button class="cp-btn ${cx===k?'cp-a':'cp-r'}" style="padding:8px 12px" onclick="setComplexity('${k}')">${CX_LABEL[k]}</button>`).join('')}
|
||
</div>
|
||
<div style="font-size:11px;color:var(--muted);margin-bottom:14px">Предварительно: ${money(recalcDeal(Object.fromEntries(CLIENT_STAGES.map(s=>[s.key,Math.round((STAGE_BASE[s.key]||0)*CX_COEF[cx]/100)*100]))))} за проект</div>
|
||
<button class="cp-btn cp-a" style="padding:9px 18px" onclick="buildEstimate()">Сформировать смету →</button>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
const allocated=recalcDeal(sp);
|
||
const deal=(state.crm&&state.crm.deal_amount)||0;
|
||
const target=deal||allocated; // сумма сделки = цель оплаты
|
||
const unalloc=target-allocated; // нераспределённый остаток
|
||
const due=getStageDue();
|
||
const isUnlocked=billing==="free"||(target>0&&paidTotal>=target);
|
||
const cx=getComplexity();
|
||
// первый ещё не оплаченный платный выполненный модуль — «доступен к оплате»
|
||
box.innerHTML=`<div class="blk" style="margin-bottom:14px">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;flex-wrap:wrap">
|
||
<span style="font-size:13px;font-weight:700">📋 Смета проекта</span>
|
||
<span style="font-size:11px;font-weight:700;color:var(--primary);background:#ECFDF5;padding:3px 9px;border-radius:6px">${CX_SHORT[cx]}</span>
|
||
<button class="cp-btn cp-r" style="padding:4px 9px;margin-left:auto" onclick="buildEstimate()">↻ Пересчитать</button>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--muted);margin-bottom:12px">Получено <b style="color:var(--primary)">${money(paidTotal)}</b> из ${money(target)} · <b>✎</b> цена · <b style="color:#DC2626">↺</b> отмена оплаты · 📅 срок оплаты этапа</div>
|
||
${CLIENT_STAGES.map(s=>{
|
||
const done=stageIsDone(s.key);
|
||
const paid=pays[s.key];
|
||
const price=sp[s.key]||0;
|
||
const isFree=price<=0;
|
||
let badge='', sub='', action='';
|
||
if(isFree){
|
||
badge=`<span style="font-size:10px;font-weight:700;color:#047857;background:#D1FAE5;padding:1px 6px;border-radius:4px;margin-left:4px">Вход · бесплатно</span>`;
|
||
sub=`<div style="font-size:11px;color:var(--muted);margin-top:2px">${esc(s.desc)}</div>`;
|
||
action=`<span style="font-size:11px;font-weight:700;color:#9CA3AF;white-space:nowrap">0 ₽</span>`;
|
||
} else if(paid){
|
||
badge=`<span style="font-size:10px;font-weight:700;color:#047857;background:#D1FAE5;padding:1px 6px;border-radius:4px;margin-left:4px">✓ Оплачен</span>`;
|
||
sub=`<div style="font-size:11px;color:#047857;margin-top:2px">Оплачено ${money(paid.amount)} · ${fmtDate(paid.date)}</div>`;
|
||
action=`<span style="font-size:11px;font-weight:700;color:#047857;background:#ECFDF5;padding:5px 11px;border-radius:8px;white-space:nowrap">${money(price)}</span>`;
|
||
} else if(done){
|
||
badge=`<span style="font-size:10px;font-weight:700;color:#92400E;background:#FEF3C7;padding:1px 6px;border-radius:4px;margin-left:4px">Доступен к оплате</span>`;
|
||
sub=`<div style="font-size:11px;color:var(--muted);margin-top:2px">${esc(s.desc)}</div>`;
|
||
action=`<button class="cp-btn cp-r" style="white-space:nowrap;padding:7px 11px" onclick="sberQR('${s.key}')" title="Выставить СБП QR">📱 QR</button><button class="cp-btn cp-a" style="white-space:nowrap" onclick="markStagePayInput('${s.key}')">Оплата · ${money(price)}</button>`;
|
||
} else {
|
||
badge=`<span style="font-size:10px;font-weight:700;color:#9CA3AF;background:#F3F4F6;padding:1px 6px;border-radius:4px;margin-left:4px">🔒 Ожидает</span>`;
|
||
sub=`<div style="font-size:11px;color:#CBD5E1;margin-top:2px">${esc(s.desc)}</div>`;
|
||
action=`<span onclick="editStagePrice('${s.key}')" style="cursor:pointer;font-size:11px;font-weight:700;color:#9CA3AF;white-space:nowrap;border-bottom:1px dashed #CBD5E1">${money(price)}</span>`;
|
||
}
|
||
const dd=due[s.key]||'';
|
||
const dueChip=dd?`<span style="font-size:10px;font-weight:600;color:#2563EB;background:#EFF6FF;padding:1px 6px;border-radius:4px;margin-left:6px">📅 до ${fmtDate(dd)}</span>`:'';
|
||
const dueInput=isFree?'':`<input type="date" value="${dd}" title="Срок оплаты этапа" onchange="setStageDue('${s.key}',this.value)" style="border:1.5px solid var(--border);border-radius:7px;padding:5px 7px;font-size:11px;font-family:Inter;color:#6B7280;width:130px">`;
|
||
return `<div id="stagerow-${s.key}" style="display:flex;align-items:center;gap:12px;padding:11px 0;border-top:1px solid var(--bg)">
|
||
<span style="width:32px;height:32px;border-radius:8px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:15px;background:${paid?'#D1FAE5':done||isFree?'#ECFDF5':'#F9FAFB'};border:1.5px solid ${paid?'#6EE7B7':done||isFree?'#A7F3D0':'#E5E7EB'}">${s.icon}</span>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:700;font-size:13px;color:${done||isFree?'var(--text)':'#9CA3AF'}">${s.name}${badge}${dueChip}</div>
|
||
${sub}
|
||
</div>
|
||
${dueInput}
|
||
<div style="display:flex;align-items:center;gap:7px">${action}<span onclick="event.stopPropagation();editStagePrice('${s.key}')" title="Изменить цену этапа" style="cursor:pointer;color:#9CA3AF;font-size:14px;line-height:1">✎</span>${paid?`<span onclick="event.stopPropagation();unStagePay('${s.key}')" title="Отменить оплату" style="cursor:pointer;color:#DC2626;font-size:14px;line-height:1">↺</span>`:''}</div>
|
||
</div>`;
|
||
}).join("")}
|
||
<div style="display:flex;align-items:center;gap:10px;padding:12px 0 4px;border-top:2px solid var(--bg);margin-top:4px">
|
||
<span style="font-size:13px;font-weight:800;font-family:Montserrat">Распределено по этапам</span>
|
||
<span style="margin-left:auto;font-size:16px;font-weight:800;color:var(--text)">${money(allocated)}</span>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:10px;padding:4px 0;font-size:12px;color:var(--muted)">
|
||
<span>Сумма сделки</span>
|
||
<span style="margin-left:auto;font-weight:700;color:var(--primary)">${money(target)}</span>
|
||
</div>
|
||
${unalloc!==0?`<div style="display:flex;align-items:center;gap:10px;padding:8px 12px;margin-top:4px;border-radius:9px;background:${unalloc>0?'#FFF7ED':'#FEF2F2'};border:1px solid ${unalloc>0?'#FDE68A':'#FECACA'}">
|
||
<span style="font-size:12px;font-weight:700;color:${unalloc>0?'#92400E':'#DC2626'}">${unalloc>0?'⚠ Нераспределено':'⚠ Перебор сметы'}: ${money(Math.abs(unalloc))}</span>
|
||
<button class="cp-btn cp-r" style="margin-left:auto;padding:4px 10px" onclick="alignDealToEstimate()">Подогнать сделку под смету</button>
|
||
</div>`:''}
|
||
<div style="margin-top:12px;padding:11px 14px;border-radius:10px;font-size:12px;line-height:1.5;${isUnlocked?'background:#ECFDF5;border:1px solid #6EE7B7;color:#047857':'background:#FFF7ED;border:1px solid #FDE68A;color:#92400E'}">
|
||
${isUnlocked
|
||
?`✅ <b>Оплата получена.</b> Клиент может скачать ТЗ и печатные документы.`
|
||
:`🔒 <b>Правило:</b> ТЗ (печать + выгрузка) выдаётся клиенту только после полной оплаты — ${money(target)}.`}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
function renderPricing(){
|
||
const box=document.getElementById("pricingBox");if(!box)return;
|
||
const billing=(state.crm||{}).billing_type||"paid";
|
||
const freeNote=billing==="free"?`<div class="blk" style="background:#EEF2FF;border-color:#C7D2FE;padding:12px 14px;margin-bottom:14px;font-size:13px;line-height:1.5"><b style="color:#6366F1">🎁 Бесплатный клиент.</b> Расчёт цены ниже — справочно: показывает рыночную стоимость и аргументацию ценности. Решение об оплате остаётся на ваше усмотрение.</div>`:'';
|
||
const p=state.pricing;
|
||
if(!p){box.innerHTML=freeNote+`<div class="run-card" style="margin:0 0 18px"><div class="run-ic">💰</div><div class="run-t">Ценовое предложение</div><div class="run-d">Елена оценит масштаб работы, проанализирует рынок и предложит пакеты с аргументами цены.</div><button class="run-btn" id="rb-pricing" onclick="buildPricing()">Рассчитать цену →</button></div>`;return}
|
||
box.innerHTML=freeNote+pricingCardHTML(p);
|
||
}
|
||
function pricingCardHTML(p){
|
||
const SZ={micro:"Микро",small:"Малый",medium:"Средний",large:"Крупный"};const CX={low:"низкая",medium:"средняя",high:"высокая"};
|
||
return `<div class="blk" style="margin-bottom:14px"><div style="display:flex;align-items:center;margin-bottom:10px"><b style="font-size:14px">💰 Ценовое предложение</b><button class="cp-btn cp-r" style="margin-left:auto;padding:5px 10px" onclick="buildPricing()">↻ Пересчитать</button></div>
|
||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap"><span style="font-size:12px;background:#F1F5F9;border-radius:6px;padding:4px 10px">Масштаб: <b>${SZ[p.scale.size]||p.scale.size}</b></span><span style="font-size:12px;background:#F1F5F9;border-radius:6px;padding:4px 10px">Сложность: <b>${CX[p.scale.complexity]||p.scale.complexity}</b></span><span style="font-size:12px;background:#F1F5F9;border-radius:6px;padding:4px 10px">${esc(p.scale.effort_estimate)}</span></div>
|
||
<div style="font-size:12px;color:var(--muted);margin-bottom:14px;padding:10px;background:var(--bg);border-radius:8px"><b>Рынок:</b> ${esc(p.market)}</div>
|
||
<div style="display:grid;grid-template-columns:repeat(${p.packages.length},1fr);gap:10px">${p.packages.map((pk,i)=>`<div style="border:1.5px solid ${p.recommended&&p.recommended.indexOf(pk.name)>=0?'var(--primary)':'var(--border)'};border-radius:12px;padding:14px;position:relative">${p.recommended&&p.recommended.indexOf(pk.name)>=0?'<span style="position:absolute;top:-9px;left:14px;background:var(--primary);color:#fff;font-size:9px;font-weight:800;padding:2px 8px;border-radius:10px">РЕКОМЕНДУЮ</span>':''}<div style="font-size:14px;font-weight:800;font-family:Montserrat;margin-bottom:4px">${esc(pk.name)}</div><div style="font-size:22px;font-weight:800;color:var(--primary);margin-bottom:2px">${money(pk.price)}</div><div style="font-size:11px;color:#9ca3af;margin-bottom:8px">${esc(pk.duration)}</div><div style="margin-bottom:8px">${pk.scope.map(s=>`<div style="font-size:11.5px;color:#374151;padding-left:14px;position:relative;margin-bottom:3px;line-height:1.35"><span style="position:absolute;left:2px;color:var(--mid)">✓</span>${esc(s)}</div>`).join("")}</div><div style="font-size:11px;color:#6b7280;font-style:italic;border-top:1px solid var(--bg);padding-top:6px">${esc(pk.argument)}</div><button class="cp-btn cp-a" style="width:100%;margin-top:8px" onclick="applyPrice(${pk.price})">Выбрать → ${money(pk.price)}</button></div>`).join("")}</div>
|
||
<div style="margin-top:14px;padding:12px 14px;background:linear-gradient(135deg,rgba(4,120,87,.06),rgba(16,185,129,.04));border:1px solid rgba(4,120,87,.15);border-radius:10px;font-size:13px;line-height:1.5"><b style="color:var(--primary)">Аргумент для клиента:</b> ${esc(p.rationale)}</div>
|
||
</div>`;
|
||
}
|
||
async function buildPricing(){
|
||
const btn=document.getElementById("rb-pricing");if(btn){btn.disabled=true;btn.innerHTML='<span class="spin">⏳</span> Елена анализирует рынок...'}
|
||
try{const r=await fetch(`${API}/api/build-pricing`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current})});const d=await r.json();
|
||
if(d.error){alert("Ошибка: "+d.error);if(btn){btn.disabled=false;btn.textContent="Повторить"}return}
|
||
state.pricing=d.pricing;renderPricing();
|
||
}catch(e){alert("Ошибка: "+e.message);if(btn)btn.disabled=false}
|
||
}
|
||
function applyPrice(price){
|
||
state.crm=state.crm||{};state.crm.deal_amount=price;
|
||
const el=document.getElementById("cpDeal");if(el)el.value=price;
|
||
fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,deal_amount:price})}).then(()=>{loadProjects();alert("Сумма сделки установлена: "+money(price)+"\nПлатежи — во вкладке «Платежи».")});
|
||
}
|
||
function renderPayments(){
|
||
const crm=state.crm||{};const deal=crm.deal_amount||0;const pays=crm.payments||[];
|
||
const paid=pays.reduce((s,p)=>s+(p.amount||0),0);const left=deal-paid;
|
||
const cash=pays.filter(p=>p.method==='cash').reduce((s,p)=>s+(p.amount||0),0);
|
||
const noncash=paid-cash;
|
||
const st=paid<=0?["Не оплачено","#DC2626","#FEF2F2"]:left>0?["Частично","#92400E","#FEF3C7"]:["Оплачено","#047857","#ECFDF5"];
|
||
const sb=document.getElementById("payStatusBox");
|
||
if(sb)sb.innerHTML=`<div class="cc-fl">Оплата</div><div style="display:flex;align-items:center;gap:8px"><span style="font-size:14px;font-weight:700;color:${st[1]}">${money(paid)}</span>${deal>0?`<span style="font-size:11px;font-weight:700;color:${st[1]};background:${st[2]};padding:2px 8px;border-radius:6px">${st[0]}</span>`:''}</div>`;
|
||
const box=document.getElementById("paymentsBox");if(!box)return;
|
||
box.innerHTML=`<div style="background:var(--white);border:1.5px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:18px">
|
||
<div style="display:flex;align-items:center;margin-bottom:10px;flex-wrap:wrap;gap:8px"><span style="font-size:13px;font-weight:700">💰 Платежи</span>
|
||
${deal>0?`<span style="margin-left:auto;font-size:12px;color:var(--muted)">Сделка ${money(deal)} · Получено ${money(paid)} · <b style="color:${left>0?'#DC2626':'#047857'}">Остаток ${money(left)}</b></span>`:'<span style="margin-left:auto"></span>'}
|
||
${left>0?`<button class="cp-btn cp-r" style="padding:5px 10px" onclick="remindBalance(${left})">⏰ Задача на остаток</button>`:''}
|
||
${pays.length?`<button class="cp-btn cp-r" style="padding:5px 10px" onclick="exportPayments()">⬇ CSV</button>`:''}</div>
|
||
${paid>0?`<div style="display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap"><span style="font-size:11px;font-weight:700;color:#047857;background:#ECFDF5;padding:3px 10px;border-radius:6px">💵 Наличные ${money(cash)}</span><span style="font-size:11px;font-weight:700;color:#2563EB;background:#EFF6FF;padding:3px 10px;border-radius:6px">🏦 Безнал ${money(noncash)}</span></div>`:''}
|
||
${pays.map((p,i)=>i===editPayIdx
|
||
?`<div style="display:flex;align-items:center;gap:8px;padding:9px 10px;border-top:1px solid var(--bg);flex-wrap:wrap;background:var(--bg);border-radius:8px;margin-top:4px">
|
||
<input type="date" id="epd-${i}" value="${esc(p.date||'')}" style="border:1.5px solid var(--border);border-radius:8px;padding:6px 8px;font-size:13px;font-family:Inter">
|
||
<input type="number" id="epa-${i}" value="${p.amount||''}" style="width:100px;border:1.5px solid var(--border);border-radius:8px;padding:6px 9px;font-size:13px;font-family:Inter">
|
||
${methodSelect('epm-'+i,p.method||'bank')}
|
||
<input id="epn-${i}" value="${esc(p.note||'')}" placeholder="Назначение" style="flex:1;min-width:120px;border:1.5px solid var(--border);border-radius:8px;padding:6px 9px;font-size:13px;font-family:Inter">
|
||
${p.stage?`<span style="font-size:10px;color:#9CA3AF;white-space:nowrap">этап: ${esc((CLIENT_STAGES.find(s=>s.key===p.stage)||{}).name||p.stage)}</span>`:''}
|
||
<button class="cp-btn cp-a" style="padding:6px 12px" onclick="confirmEditPayment(${i})">Сохранить</button>
|
||
<button class="cp-btn cp-r" style="padding:6px 10px" onclick="cancelEditPayment()">✕</button>
|
||
</div>`
|
||
:`<div style="display:flex;align-items:center;gap:10px;padding:7px 0;border-top:1px solid var(--bg)"><span style="font-size:12px;color:var(--muted);min-width:70px">${esc(p.date||'')}</span><span title="${esc(METHOD_NAME[p.method||'bank']||'')}" style="font-size:13px">${METHOD_ICON[p.method||'bank']||'🏦'}</span><span style="flex:1;font-size:13px">${esc(p.note||'Платёж')}${p.stage?` <span style="font-size:10px;color:#9CA3AF">· этап</span>`:''}</span><span style="font-size:13px;font-weight:700;color:#047857">${money(p.amount)}</span><span onclick="editPayment(${i})" title="Изменить" style="cursor:pointer;color:#9CA3AF;font-size:13px">✎</span><button onclick="delPayment(${i})" title="Удалить" 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;flex-wrap:wrap;align-items:center">
|
||
<input id="payDate" type="date" value="${new Date().toISOString().slice(0,10)}" style="border:1.5px solid var(--border);border-radius:8px;padding:8px;font-size:13px;font-family:Inter">
|
||
<input id="payAmt" type="number" placeholder="Сумма ₽" style="width:110px;border:1.5px solid var(--border);border-radius:8px;padding:8px 11px;font-size:13px;font-family:Inter" onkeydown="if(event.key==='Enter')addPayment()">
|
||
<select id="payStage" style="border:1.5px solid var(--border);border-radius:8px;padding:8px 10px;font-size:13px;font-family:Inter;background:var(--white);color:var(--text)">
|
||
<option value="">— без этапа —</option>
|
||
${CLIENT_STAGES.filter(s=>stageIsDone(s.key)&&!getStagePays()[s.key]&&((getStagePrices()||{})[s.key]||0)>0).map(s=>`<option value="${s.key}">${s.name} · ${money((getStagePrices()||{})[s.key])}</option>`).join('')}
|
||
</select>
|
||
${methodSelect('payMethod','bank')}
|
||
<input id="payNote" placeholder="Назначение" style="flex:1;min-width:120px;border:1.5px solid var(--border);border-radius:8px;padding:8px 11px;font-size:13px;font-family:Inter" onkeydown="if(event.key==='Enter')addPayment()">
|
||
<button class="cp-btn cp-a" onclick="addPayment()">+ Платёж</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
async function savePayments(){
|
||
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,payments:state.crm.payments,stage_payments:state.crm.stage_payments||{}})});
|
||
await loadProjects();
|
||
}
|
||
function editPayment(i){editPayIdx=i;renderClient();}
|
||
function cancelEditPayment(){editPayIdx=-1;renderClient();}
|
||
function confirmEditPayment(i){
|
||
const p=state.crm.payments[i];if(!p)return;
|
||
const amt=+document.getElementById('epa-'+i).value;
|
||
if(!amt){alert('Укажите сумму');return;}
|
||
const date=document.getElementById('epd-'+i).value||p.date;
|
||
const note=document.getElementById('epn-'+i).value;
|
||
const method=(document.getElementById('epm-'+i)||{}).value||p.method||'bank';
|
||
p.amount=amt;p.date=date;p.note=note||'Платёж';p.method=method;
|
||
if(p.stage&&state.crm.stage_payments&&state.crm.stage_payments[p.stage]){
|
||
state.crm.stage_payments[p.stage]={amount:amt,date,method};
|
||
}
|
||
editPayIdx=-1;
|
||
savePayments();renderClient();
|
||
}
|
||
function addPayment(){
|
||
const amt=+document.getElementById("payAmt").value;if(!amt){alert("Укажите сумму");return;}
|
||
const date=document.getElementById("payDate").value;
|
||
const note=document.getElementById("payNote").value;
|
||
const stageKey=document.getElementById("payStage")?.value||"";
|
||
const method=document.getElementById("payMethod")?.value||"bank";
|
||
const stageName=stageKey?CLIENT_STAGES.find(s=>s.key===stageKey)?.name:"";
|
||
state.crm=state.crm||{};
|
||
state.crm.payments=state.crm.payments||[];
|
||
state.crm.payments.push({date,amount:amt,note:note||(stageName?"Этап: "+stageName:"Платёж"),stage:stageKey||null,method});
|
||
if(stageKey&&!getStagePays()[stageKey]){
|
||
state.crm.stage_payments=state.crm.stage_payments||{};
|
||
state.crm.stage_payments[stageKey]={amount:amt,date,method};
|
||
}
|
||
savePayments();renderClient();
|
||
}
|
||
function delPayment(i){
|
||
const removed=state.crm.payments[i];
|
||
state.crm.payments.splice(i,1);
|
||
if(removed&&removed.stage&&state.crm.stage_payments){
|
||
const stillPaid=state.crm.payments.some(p=>p.stage===removed.stage);
|
||
if(!stillPaid)delete state.crm.stage_payments[removed.stage];
|
||
}
|
||
savePayments();renderClient();
|
||
}
|
||
function remindBalance(left){
|
||
const due=new Date(Date.now()+7*864e5).toISOString().slice(0,10);
|
||
state.tasks=state.tasks||[];state.tasks.push({text:`Получить остаток ${money(left)} от ${state.client_name||'клиента'}`,due,done:false});
|
||
saveTasks();renderClient();
|
||
alert(`Задача создана: «Получить остаток ${money(left)}»\nСрок: через 7 дней`);
|
||
}
|
||
function exportPayments(){
|
||
const pays=state.crm.payments||[];
|
||
let csv="Дата;Сумма;Назначение\n"+pays.map(p=>`${p.date||''};${p.amount||0};${(p.note||'').replace(/;/g,',')}`).join("\n");
|
||
csv=""+csv; // BOM для Excel
|
||
const blob=new Blob([csv],{type:"text/csv;charset=utf-8"});
|
||
const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=`Платежи_${(state.client_name||'клиент').replace(/[^а-яёa-z0-9]/gi,'_')}.csv`;a.click();
|
||
}
|
||
function renderTasks(){
|
||
const box=document.getElementById("tasksBox");if(!box)return;
|
||
const tasks=state.tasks||[];
|
||
const today=new Date().toISOString().slice(0,10);
|
||
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>`;
|
||
}
|
||
function fmtDate(d){const[y,m,dd]=d.split("-");return `${dd}.${m}`}
|
||
async function saveTasks(){await fetch(`${API}/api/project/tasks`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,tasks:state.tasks})});await loadProjects();}
|
||
function addTask(){const t=document.getElementById("newTask").value.trim();if(!t)return;const due=document.getElementById("newTaskDue").value;state.tasks=state.tasks||[];state.tasks.push({text:t,due,done:false});saveTasks();renderClient();}
|
||
function toggleTask(i){state.tasks[i].done=!state.tasks[i].done;saveTasks();renderClient();}
|
||
function delTask(i){state.tasks.splice(i,1);saveTasks();renderClient();}
|
||
async function saveCrm(){
|
||
const g=id=>document.getElementById(id);
|
||
const crm={pipeline:g("cpPipe").value,deal_amount:+g("cpDeal").value||0,source:g("cpSrc").value};
|
||
if(g("cpContact"))crm.contact=g("cpContact").value;
|
||
if(g("cpNote"))crm.note=g("cpNote").value;
|
||
state.crm={...state.crm,...crm};
|
||
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,...crm})});
|
||
await loadProjects();
|
||
}
|
||
function inviteLink(){const url=`${location.origin}${location.pathname.replace('crm.html','cabinet.html')}?t=${current}`;navigator.clipboard.writeText(url).then(()=>alert("Ссылка скопирована:\n\n"+url)).catch(()=>prompt("Ссылка:",url));}
|
||
async function deleteClient(){
|
||
const nm=state.client_name||"клиента";
|
||
if(!confirm(`Удалить «${nm}» со всеми данными (интервью, анализ, ТЗ, документы)?\n\nЭто действие необратимо.`))return;
|
||
await fetch(`${API}/api/project/delete`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current})});
|
||
current=null;await loadProjects();setView("dashboard");
|
||
}
|
||
|
||
function exportSpecPDF(){
|
||
const s=state.spec;if(!s){alert("ТЗ ещё не собрано");return}
|
||
const cn=esc(state.client_name||"Клиент"),nm=esc(state.niche||"");
|
||
const w=window.open("","_blank");
|
||
let body=`<h2>1. Обзор системы</h2><p>${esc(s.overview)}</p>`;
|
||
body+=`<h2>2. Роли пользователей</h2>`;s.roles.forEach(r=>body+=`<div class="item"><b>${esc(r.name)}</b> — ${esc(r.does)}<div class="muted">Доступ: ${esc(r.access)}</div></div>`);
|
||
body+=`<h2>3. Модули системы</h2>`;s.modules.forEach(m=>{body+=`<div class="item"><div><span class="tag">${esc(m.source_node)}</span> <b>${esc(m.name)}</b></div><div class="muted">${esc(m.purpose)}</div><div style="margin:6px 0"><b>Экраны:</b> ${m.screens.map(esc).join(", ")}</div><div><b>Вход:</b> ${esc(m.inputs_data)}</div><div><b>Выход:</b> ${esc(m.outputs_data)}</div>${m.rules.length?`<div style="margin-top:5px"><b>Бизнес-правила:</b><ul>${m.rules.map(r=>`<li>${esc(r)}</li>`).join("")}</ul></div>`:''}</div>`});
|
||
body+=`<h2>4. Модель данных</h2>`;s.entities.forEach(e=>{body+=`<div class="item"><b>◆ ${esc(e.name)}</b><table><tr><th>Поле</th><th>Тип</th></tr>${e.fields.map(f=>`<tr><td>${esc(f.field)}</td><td>${esc(f.type)}</td></tr>`).join("")}</table>${e.relations.length?`<div class="muted">Связи: ${e.relations.map(esc).join("; ")}</div>`:''}<div class="ex">${esc(e.example)}</div></div>`});
|
||
if(s.open_questions&&s.open_questions.length){body+=`<h2>5. Уточнить перед разработкой</h2><ul>${s.open_questions.map(q=>`<li>${esc(q)}</li>`).join("")}</ul>`}
|
||
w.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>ТЗ — ${cn}</title><style>
|
||
@page{margin:18mm}body{font-family:'Segoe UI',Arial,sans-serif;color:#1A1A2E;line-height:1.5;max-width:780px;margin:0 auto;padding:20px}
|
||
.cover{border-bottom:3px solid #047857;padding-bottom:16px;margin-bottom:24px}
|
||
.brand{font-size:12px;font-weight:700;color:#047857;letter-spacing:.05em}.title{font-size:26px;font-weight:800;margin:8px 0}.sub{color:#6B7280}
|
||
h2{font-size:17px;color:#064E3B;border-left:4px solid #047857;padding-left:10px;margin:24px 0 12px}
|
||
.item{border:1px solid #E5E7EB;border-radius:8px;padding:12px;margin-bottom:10px;page-break-inside:avoid}
|
||
.muted{color:#6B7280;font-size:13px;margin-top:3px}.tag{background:#ECFDF5;color:#047857;font-size:11px;font-weight:700;padding:2px 7px;border-radius:5px}
|
||
table{width:100%;border-collapse:collapse;margin:8px 0;font-size:13px}th,td{border:1px solid #E5E7EB;padding:5px 9px;text-align:left}th{background:#F5F6F8}
|
||
.ex{font-family:monospace;font-size:11px;background:#F5F6F8;padding:6px 9px;border-radius:5px;margin-top:6px;color:#374151}
|
||
ul{margin:4px 0 4px 20px}li{margin-bottom:3px}
|
||
@media print{.noprint{display:none}}
|
||
</style></head><body>
|
||
<div class="cover"><div class="brand">@wasrusgen1 · КОНСАЛТИНГ</div><div class="title">Техническое задание</div><div class="sub">${cn}${nm?" · "+nm:""} · ${new Date().toLocaleDateString("ru-RU")}</div></div>
|
||
${body}
|
||
<button class="noprint" onclick="window.print()" style="position:fixed;bottom:20px;right:20px;padding:12px 22px;background:#047857;color:#fff;border:none;border-radius:9px;font-size:14px;font-weight:700;cursor:pointer">⬇ Сохранить в PDF</button>
|
||
</body></html>`);
|
||
w.document.close();
|
||
}
|
||
|
||
async function designScreen(module,idx){
|
||
const box=document.getElementById(`screen-${idx}`);box.innerHTML='<div style="font-size:12px;color:#9ca3af"><span class="spin">⏳</span> Елена рисует экран...</div>';
|
||
try{const r=await fetch(`${API}/api/design-screen`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,module})});const d=await r.json();
|
||
if(d.error){box.innerHTML=`<div style="color:#DC2626;font-size:12px">Ошибка: ${d.error}</div>`;return}
|
||
box.innerHTML=renderScreen(d.screen);
|
||
}catch(e){box.innerHTML=`<div style="color:#DC2626">${e.message}</div>`}
|
||
}
|
||
function renderScreen(s){
|
||
const frame=inner=>`<div style="border:1.5px solid var(--border);border-radius:12px;overflow:hidden;background:#fff;width:100%"><div style="background:#0F0F1A;padding:8px 14px;display:flex;align-items:center;gap:8px"><span style="display:flex;gap:5px"><span style="width:9px;height:9px;border-radius:50%;background:#FF5F57"></span><span style="width:9px;height:9px;border-radius:50%;background:#FEBC2E"></span><span style="width:9px;height:9px;border-radius:50%;background:#28C840"></span></span><span style="color:rgba(255,255,255,.6);font-size:11px;margin-left:6px">${esc(s.title)}</span></div><div style="padding:16px">${inner}</div></div>`;
|
||
const acts=`<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">${(s.actions||[]).map((a,i)=>`<span style="font-size:12px;font-weight:600;padding:7px 13px;border-radius:8px;${i===0?'background:#047857;color:#fff':'background:#F1F5F9;color:#475569'}">${esc(a)}</span>`).join("")}</div>`;
|
||
let inner="";
|
||
if(s.type==="list"){
|
||
inner=`<div style="overflow-x:auto;-webkit-overflow-scrolling:touch"><table style="width:100%;border-collapse:collapse;font-size:12px;min-width:max-content"><tr>${(s.columns||[]).map(c=>`<th style="text-align:left;padding:7px 11px;background:#F5F6F8;border-bottom:2px solid #E5E7EB;font-size:10px;text-transform:uppercase;color:#9ca3af;letter-spacing:.04em;white-space:nowrap">${esc(c)}</th>`).join("")}</tr>${(s.rows||[]).map(r=>`<tr>${r.map(c=>`<td style="padding:8px 11px;border-bottom:1px solid #F1F5F9;white-space:nowrap">${esc(c)}</td>`).join("")}</tr>`).join("")}</table></div>`+acts;
|
||
}else if(s.type==="kanban"){
|
||
inner=`<div style="display:flex;gap:8px;overflow-x:auto">${(s.columns||[]).map(c=>`<div style="flex:1;min-width:120px;background:#F1F5F9;border-radius:8px;padding:8px"><div style="font-size:11px;font-weight:700;margin-bottom:6px">${esc(c)}</div><div style="background:#fff;border:1px solid #E5E7EB;border-radius:6px;padding:8px;font-size:11px">${(s.rows&&s.rows[0]&&s.rows[0][0])||'Карточка'}</div></div>`).join("")}</div>`+acts;
|
||
}else if(s.type==="dashboard"){
|
||
inner=`<div style="display:grid;grid-template-columns:repeat(${Math.min(4,(s.kpis||[]).length||1)},1fr);gap:10px">${(s.kpis||[]).map(k=>`<div style="background:#F5F6F8;border-radius:9px;padding:12px"><div style="font-size:22px;font-weight:800;font-family:Montserrat;color:#047857">${esc(k.value)}</div><div style="font-size:11px;color:#6B7280;margin-top:3px">${esc(k.label)}</div></div>`).join("")}</div>`+acts;
|
||
}else{ // form/card
|
||
inner=`<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">${(s.fields||[]).map(f=>`<div><div style="font-size:11px;font-weight:600;color:#6B7280;margin-bottom:4px">${esc(f.label)}</div><div style="border:1.5px solid #E5E7EB;border-radius:7px;padding:8px 11px;font-size:12px;color:#9ca3af;background:#FAFBFC">${f.kind==='select'?'▾ выбрать':f.kind==='status'?'● статус':f.kind==='money'?'0 ₽':f.kind==='date'?'дд.мм.гггг':'значение'}</div></div>`).join("")}</div>`+acts;
|
||
}
|
||
return frame(inner);
|
||
}
|
||
|
||
const TABS=[{id:"interview",name:"Интервью",icon:"💬"},{id:"methods",name:"Методологии",icon:"🎯"},{id:"canvas",name:"Стратегия",icon:"📊"},{id:"idef0",name:"Функции",icon:"🔧"},{id:"spec",name:"ТЗ",icon:"📋"}];
|
||
function approved(s){return state.approvals&&state.approvals[s]}
|
||
async function approve(s){await fetch(`${API}/api/project/approve`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,stage:s,approved:true})});state.approvals=state.approvals||{};state.approvals[s]=1;renderClient();}
|
||
async function unapprove(s){await fetch(`${API}/api/project/approve`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,stage:s,approved:false})});if(state.approvals)delete state.approvals[s];renderClient();}
|
||
function setTab(t){activeTab=t;renderClient();}
|
||
function cpBar(s,l){if(approved(s))return `<div class="cpbar appr"><div class="cpbar-t">${l}</div><span style="color:var(--primary);font-weight:700;font-size:13px">✓ Утверждено</span><button class="cp-btn cp-r" onclick="unapprove('${s}')">Снять</button></div>`;return `<div class="cpbar"><div class="cpbar-t">✋ ${l}</div><button class="cp-btn cp-r" onclick="rerun('${s}')">Перестроить</button><button class="cp-btn cp-a" onclick="approve('${s}')">Утвердить →</button></div>`;}
|
||
function runCard(s,ic,t,d,b){return `<div class="run-card"><div class="run-ic">${ic}</div><div class="run-t">${t}</div><div class="run-d">${d}</div><button class="run-btn" id="rb-${s}" onclick="rerun('${s}')">${b}</button></div>`;}
|
||
|
||
function renderTab(){const c=document.getElementById("tabContent");
|
||
if(activeTab==="interview"){c.innerHTML=`<div style="max-width:720px">${state.messages.map(m=>`<div style="display:flex;gap:9px;margin-bottom:12px;${m.role==='user'?'flex-direction:row-reverse':''}"><div class="cl-av" style="border-radius:50%;background:${m.role==='user'?'#6366F1':'#047857'}">${m.role==='user'?'К':'Е'}</div><div style="padding:10px 14px;border-radius:13px;font-size:13px;line-height:1.5;max-width:78%;white-space:pre-wrap;${m.role==='user'?'background:#047857;color:#fff':'background:#fff;border:1px solid #E5E7EB'}">${fmt(m.content)}</div></div>`).join("")||'<div class="empty" style="height:200px">Интервью не начато</div>'}</div>`;}
|
||
else if(activeTab==="methods"){if(!state.selection){c.innerHTML=runCard("methods","🎯","Подбор методологий","Елена предложит набор методологий под тип бизнеса.","Подобрать →");return}const s=state.selection;c.innerHTML=`<div class="blk"><div style="font-size:10px;font-weight:700;text-transform:uppercase;color:#9ca3af;margin-bottom:4px">Тип бизнеса</div><div style="font-size:16px;font-weight:700;margin-bottom:12px">${esc(s.business_type)}</div>${s.recommended.map(r=>`<div style="display:flex;gap:10px;padding:8px 0;border-top:1px solid #f1f5f9"><span style="font-size:16px">${r.use?'✅':'⬜'}</span><div style="flex:1"><b>${r.method.toUpperCase()}</b> <span style="font-size:11px;color:#9ca3af">[${r.depth}]</span><div style="font-size:12px;color:var(--muted)">${esc(r.reason)}</div></div></div>`).join("")}<div style="margin-top:12px;padding:10px;background:var(--light);border-radius:8px;font-size:13px">${esc(s.rationale)}</div></div>${cpBar("methods","Согласны с набором?")}`;}
|
||
else if(activeTab==="canvas"){if(!state.canvas){c.innerHTML=runCard("canvas","📊","Business Model Canvas","Стратегия — 9 блоков.","Построить →");return}c.innerHTML=renderCanvas(state.canvas)+cpBar("canvas","Стратегия верна?");}
|
||
else if(activeTab==="idef0"){if(!state.model){c.innerHTML=runCard("model","🔧","Функциональная модель IDEF0","Функции, входы/выходы, нормы, разрывы.","Построить →");return}c.innerHTML=renderIdef(state.model)+cpBar("idef0","Модель верна?");}
|
||
else if(activeTab==="spec"){if(!state.spec){if(!state.model){c.innerHTML=`<div class="run-card"><div class="run-ic">⚠️</div><div class="run-t">Сначала IDEF0</div><div class="run-d">ТЗ собирается из функциональной модели.</div></div>`;return}c.innerHTML=runCard("spec","📋","Техническое задание","Роли, модули, экраны, данные.","Собрать ТЗ →");return}c.innerHTML=`<div style="text-align:right;margin-bottom:12px"><button class="btn btn-p" onclick="exportSpecPDF()">⬇ Скачать ТЗ (PDF)</button></div>`+renderSpec(state.spec)+cpBar("spec","ТЗ готово к разработке?");}
|
||
}
|
||
const BUILD={methods:["select-methodologies","selection"],canvas:["build-canvas","canvas"],model:["build-model","model"],idef0:["build-model","model"],spec:["build-spec","spec"]};
|
||
async function rerun(stage){const [ep,key]=BUILD[stage];const btn=document.getElementById(`rb-${stage}`);if(btn){btn.disabled=true;btn.innerHTML='<span class="spin">⏳</span> Елена анализирует...'}if(approved(stage))unapprove(stage);
|
||
try{const r=await fetch(`${API}/api/${ep}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current})});const d=await r.json();if(d.error){alert("Ошибка: "+d.error);if(btn){btn.disabled=false;btn.textContent="Повторить"}return}state[key]=d[key];await loadProjects();renderClient();}catch(e){alert("Ошибка: "+e.message);if(btn)btn.disabled=false}}
|
||
|
||
function renderCanvas(c){const B=(k,cls,l)=>{const b=c[k];return `<div class="cv ${cls}"><div class="cv-h">${l}<span>${b.completeness}%</span></div><ul>${b.items.map(i=>`<li>${esc(i)}</li>`).join("")}</ul></div>`};return `<div class="canvas-grid">${B("key_partners","kp","Партнёры")}${B("key_activities","ka","Активности")}${B("value_propositions","vp","Ценность")}${B("customer_relationships","cr","Отношения")}${B("customer_segments","cs","Сегменты")}${B("key_resources","kr","Ресурсы")}${B("channels","ch","Каналы")}${B("cost_structure","co","Издержки")}${B("revenue_streams","rev","Доходы")}<div class="cv-ins"><b>Вывод:</b> ${esc(c.insight)}</div></div>`;}
|
||
function renderIdef(m){const box=(fn,ct,ins,outs,me,id,pct)=>{const C=(ct&&ct.length)?ct.map(c=>`<span class="ar">${esc(c.name)}</span>`).join(""):`<span class="ar nomiss">нет управления</span>`;const I=(ins||[]).map(a=>`<span class="ar">${esc(a.name)}</span>`).join("")||'<span class="ar">—</span>';const O=(outs||[]).map(a=>`<span class="ar ${a.target==='НИКУДА'?'dead':''}">${esc(a.name)}${a.target==='НИКУДА'?' ⊘':''}</span>`).join("")||'<span class="ar">—</span>';const M=(me||[]).map(x=>`<span class="ar">${esc(x.name)}</span>`).join("")||'<span class="ar">—</span>';return `<div class="idef"><div class="idef-c">${C}</div><div class="idef-mid"><div class="idef-i">${I}</div><div class="idef-fn">${id?`<b>${id}</b>`:''}${esc(fn)}${pct!=null?`<i style="color:${pct>=70?'#047857':pct>=45?'#F59E0B':'#EF4444'}">${pct}%</i>`:''}</div><div class="idef-o">${O}</div></div><div class="idef-m">${M}</div></div>`};let h=`<div style="background:var(--ink);color:#fff;border-radius:10px;padding:11px 15px;margin-bottom:12px;font-size:13px"><b style="color:var(--mid)">Паттерн:</b> ${esc(m.business_pattern)}</div>`;if(m.context)h+=`<div class="idef-lbl">A-0 Контекст</div>`+box(m.context.function,m.context.controls,m.context.inputs,m.context.outputs,m.context.mechanisms,"A0");h+=`<div class="idef-lbl">Декомпозиция · ${m.activities.length}</div>`;m.activities.forEach(a=>h+=box(a.function,a.controls,a.inputs,a.outputs,a.mechanisms,a.node_id,a.completeness));if(m.arrow_issues&&m.arrow_issues.length){h+=`<div class="idef-lbl">Разрывы · ${m.arrow_issues.length}</div>`;m.arrow_issues.forEach(g=>{const col=g.severity==='critical'?'#DC2626':g.severity==='high'?'#92400E':'#1E40AF';h+=`<div class="blk" style="border-left:3px solid ${col};padding:10px 13px"><div style="font-size:10px;font-weight:700;color:#9ca3af">${esc(g.node_id)} · ${g.type}</div><div style="font-size:13px;font-weight:700">${esc(g.title)}</div><div style="font-size:12px;color:#6b7280;margin-top:3px">${esc(g.description)}</div></div>`})}return h;}
|
||
function renderSpec(s){let h=`<div class="spec-h"><span class="pl">A</span>Обзор</div><div class="blk">${esc(s.overview)}</div><div class="spec-h"><span class="pl">A</span>Роли (${s.roles.length})</div>`;s.roles.forEach(r=>h+=`<div class="blk" style="padding:11px 14px"><b>${esc(r.name)}</b> — ${esc(r.does)}<div style="font-size:11px;color:#9ca3af;margin-top:3px">Доступ: ${esc(r.access)}</div></div>`);h+=`<div class="spec-h"><span class="pl">B</span>Модули (${s.modules.length})</div>`;s.modules.forEach((m,mi)=>h+=`<div class="mod"><div style="display:flex;align-items:center;gap:8px;margin-bottom:5px"><span class="mod-node">${esc(m.source_node)}</span><b>${esc(m.name)}</b><button class="cp-btn cp-r" style="margin-left:auto;padding:5px 11px" onclick="designScreen('${esc(m.name).replace(/'/g,"")}',${mi})">🖼 Экран</button></div><div style="font-size:12px;color:var(--muted)">${esc(m.purpose)}</div><div style="margin:5px 0">${m.screens.map(x=>`<span class="scr">${esc(x)}</span>`).join("")}</div><div style="font-size:12px;color:#374151">📥 ${esc(m.inputs_data)} · 📤 ${esc(m.outputs_data)}</div>${m.rules.length?`<div style="margin-top:5px">${m.rules.map(r=>`<div class="blk-pain">${esc(r)}</div>`).join("")}</div>`:''}<div id="screen-${mi}" style="margin-top:10px"></div></div>`);h+=`<div class="spec-h"><span class="pl">C</span>Данные (${s.entities.length} таблиц)</div>`;s.entities.forEach(e=>h+=`<div class="ent"><b style="font-family:Montserrat">◆ ${esc(e.name)}</b><div class="ent-fields" style="margin-top:7px">${e.fields.map(f=>`<div class="fld"><b>${esc(f.field)}</b> <em>${esc(f.type)}</em></div>`).join("")}</div>${e.relations.length?`<div style="font-size:11px;color:#6b7280;margin-bottom:5px">🔗 ${e.relations.map(esc).join(" · ")}</div>`:''}<div class="ent-ex">${esc(e.example)}</div></div>`);if(s.open_questions&&s.open_questions.length){h+=`<div class="blk" style="border-color:#FDE68A;background:#FFFBEB"><b>Уточнить перед разработкой</b>${s.open_questions.map(q=>`<div class="blk-pain" style="margin-top:6px">${esc(q)}</div>`).join("")}</div>`}return h;}
|
||
|
||
loadProjects().then(render);
|
||
</script>
|
||
</body>
|
||
</html>
|