ui: сворачиваемые секции (динамика свёрнута) + фильтры клиентов и задач

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-06-01 07:55:10 +03:00
parent 984ead3f2f
commit 24bd0e29d5

View File

@ -51,6 +51,11 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);displ
.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}
@ -150,6 +155,25 @@ const pipeMap=Object.fromEntries(PIPE.map(p=>[p[0],p]));
function esc(s){return (s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}
function fmt(s){return esc(s).replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>")}
function money(n){return (n||0).toLocaleString("ru-RU")+" ₽"}
// ── 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 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();
@ -198,8 +222,21 @@ function renderDashboard(){
</div>
${renderRevenueChart()}
${renderUpcomingTasks()}
<div class="sec-h">Все клиенты · ${total}</div>
${projects.length?`<div class="tbl">${projects.map(p=>renderClientRow(p)).join("")}</div>`:'<div class="empty">Создайте первого клиента</div>'}`;
${(()=>{
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 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>';
if(!cl.length)return head+'<div class="tbl"><div class="cl-row" style="color:#9CA3AF;font-size:12px;justify-content:center">Нет клиентов по фильтру</div></div>';
return head+`<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){
@ -263,17 +300,27 @@ function renderRevenueChart(){
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);
return `<div class="sec-h">📈 Выручка по месяцам · ${money(totalRev)}</div>
<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>`;
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);
const top=all.slice(0,6);
if(!top.length)return"";
return `<div class="sec-h">📌 Ближайшие задачи · ${all.length}</div><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>`;
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 class="kanban">${PIPE.map(([k,name,col])=>{