mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 15:44:45 +00:00
ui: сворачиваемые секции (динамика свёрнута) + фильтры клиентов и задач
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
984ead3f2f
commit
24bd0e29d5
@ -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{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:first-child{border-top:none}
|
||||||
.tbl-row:hover{background:#FAFBFC}
|
.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 */
|
/* Pipeline kanban */
|
||||||
.kanban{display:flex;gap:12px;overflow-x:auto;padding-bottom:8px;align-items:flex-start}
|
.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{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,"&").replace(/</g,"<").replace(/>/g,">")}
|
function esc(s){return (s||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}
|
||||||
function fmt(s){return esc(s).replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>")}
|
function fmt(s){return esc(s).replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>")}
|
||||||
function money(n){return (n||0).toLocaleString("ru-RU")+" ₽"}
|
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(){
|
async function loadProjects(){
|
||||||
const r=await fetch(`${API}/api/projects`);const d=await r.json();projects=d.projects;renderClientList();
|
const r=await fetch(`${API}/api/projects`);const d=await r.json();projects=d.projects;renderClientList();
|
||||||
@ -198,8 +222,21 @@ function renderDashboard(){
|
|||||||
</div>
|
</div>
|
||||||
${renderRevenueChart()}
|
${renderRevenueChart()}
|
||||||
${renderUpcomingTasks()}
|
${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:"ТЗ"}];
|
const STAGE_DEFS=[{key:"interview",name:"Интервью"},{key:"methods",name:"Методологии"},{key:"canvas",name:"Стратегия"},{key:"idef0",name:"Функции"},{key:"spec",name:"ТЗ"}];
|
||||||
function clientStages(p){
|
function clientStages(p){
|
||||||
@ -263,17 +300,27 @@ function renderRevenueChart(){
|
|||||||
const MN=["янв","фев","мар","апр","май","июн","июл","авг","сен","окт","ноя","дек"];
|
const MN=["янв","фев","мар","апр","май","июн","июл","авг","сен","окт","ноя","дек"];
|
||||||
const lbl=k=>{const[y,m]=k.split("-");return MN[+m-1]+" "+y.slice(2)};
|
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 totalRev=keys.reduce((s,k)=>s+months[k],0);
|
||||||
return `<div class="sec-h">📈 Выручка по месяцам · ${money(totalRev)}</div>
|
const head=secHead('revenue',`📈 Выручка по месяцам · ${money(totalRev)}`);
|
||||||
<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>`;
|
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(){
|
function renderUpcomingTasks(){
|
||||||
const today=new Date().toISOString().slice(0,10);
|
const today=new Date().toISOString().slice(0,10);
|
||||||
const all=[];
|
const all=[];
|
||||||
projects.forEach(p=>(p.tasks||[]).forEach(t=>{if(!t.done)all.push({...t,client:p.client_name,token:p.token})}));
|
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);
|
all.sort((a,b)=>(a.due||"9999")<(b.due||"9999")?-1:1);
|
||||||
const top=all.slice(0,6);
|
if(!all.length)return"";
|
||||||
if(!top.length)return"";
|
const f=ui.taskFilter;
|
||||||
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>`;
|
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(){
|
function renderPipeline(){
|
||||||
document.getElementById("view").innerHTML=`<div class="sec-h">Воронка продаж</div><div class="kanban">${PIPE.map(([k,name,col])=>{
|
document.getElementById("view").innerHTML=`<div class="sec-h">Воронка продаж</div><div class="kanban">${PIPE.map(([k,name,col])=>{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user