crm dashboard: client rows show billing type + 5-stage stepper + current stage; backend adds has_selection

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-31 00:01:07 +03:00
parent 37c7486cc0
commit 9a349fe21e
3 changed files with 81 additions and 2 deletions

View File

@ -191,7 +191,46 @@ function renderDashboard(){
${renderRevenueChart()}
${renderUpcomingTasks()}
<div class="sec-h">Все клиенты · ${total}</div>
${projects.map(p=>{const pc=pipeMap[(p.crm&&p.crm.pipeline)||"lead"];return `<div class="blk" style="display:flex;align-items:center;gap:14px;cursor:pointer" onclick="openClient('${p.token}')"><div class="cl-av" style="width:36px;height:36px;border-radius:9px;font-size:14px">${esc((p.client_name||'?')[0])}</div><div style="flex:1"><div style="font-weight:700">${esc(p.client_name)}</div><div style="font-size:12px;color:var(--muted)">${esc(p.niche)} · ${p.msg_count} сообщений</div></div><span style="font-size:11px;font-weight:700;color:${pc[2]};background:${pc[2]}1a;padding:4px 10px;border-radius:20px">${pc[1]}</span><span style="font-weight:700;color:var(--primary)">${money((p.crm&&p.crm.deal_amount)||0)}</span></div>`}).join("")||'<div class="empty">Создайте первого клиента</div>'}`;
${projects.map(p=>renderClientRow(p)).join("")||'<div class="empty">Создайте первого клиента</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 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 style="font-size:10px;font-weight:700;color:#6366F1;background:#EEF2FF;padding:2px 8px;border-radius:6px">🎁 Бесплатный</span>`
:`<span style="font-size:10px;font-weight:700;color:#047857;background:#ECFDF5;padding:2px 8px;border-radius:6px">💰 Платный</span>`;
return `<div class="blk" style="display:flex;align-items:flex-start;gap:14px;cursor:pointer" onclick="openClient('${p.token}')">
<div class="cl-av" style="width:36px;height:36px;border-radius:9px;font-size:14px;margin-top:2px">${esc((p.client_name||'?')[0])}</div>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="font-weight:700">${esc(p.client_name)}</span>${bChip}</div>
<div style="font-size:12px;color:var(--muted);margin-top:1px">${esc(p.niche)} · ${p.msg_count} сообщений</div>
${stepper(p)}
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px"><span style="font-size:11px;font-weight:700;color:${pc[2]};background:${pc[2]}1a;padding:4px 10px;border-radius:20px;white-space:nowrap">${pc[1]}</span><span style="font-weight:700;color:var(--primary)">${money((p.crm&&p.crm.deal_amount)||0)}</span></div>
</div>`;
}
function renderRevenueChart(){

View File

@ -952,6 +952,7 @@ def list_projects():
"token": r["token"], "client_name": r["client_name"] or "Без имени",
"niche": r["niche"] or "", "status": r["status"],
"created_at": r["created_at"], "msg_count": r["msg_count"],
"has_selection": latest_artifact(pid, "selection") is not None,
"has_canvas": latest_artifact(pid, "canvas") is not None,
"has_idef0": db().execute("SELECT 1 FROM models WHERE project_id=? LIMIT 1", (pid,)).fetchone() is not None,
"has_spec": latest_artifact(pid, "spec") is not None,

View File

@ -191,7 +191,46 @@ function renderDashboard(){
${renderRevenueChart()}
${renderUpcomingTasks()}
<div class="sec-h">Все клиенты · ${total}</div>
${projects.map(p=>{const pc=pipeMap[(p.crm&&p.crm.pipeline)||"lead"];return `<div class="blk" style="display:flex;align-items:center;gap:14px;cursor:pointer" onclick="openClient('${p.token}')"><div class="cl-av" style="width:36px;height:36px;border-radius:9px;font-size:14px">${esc((p.client_name||'?')[0])}</div><div style="flex:1"><div style="font-weight:700">${esc(p.client_name)}</div><div style="font-size:12px;color:var(--muted)">${esc(p.niche)} · ${p.msg_count} сообщений</div></div><span style="font-size:11px;font-weight:700;color:${pc[2]};background:${pc[2]}1a;padding:4px 10px;border-radius:20px">${pc[1]}</span><span style="font-weight:700;color:var(--primary)">${money((p.crm&&p.crm.deal_amount)||0)}</span></div>`}).join("")||'<div class="empty">Создайте первого клиента</div>'}`;
${projects.map(p=>renderClientRow(p)).join("")||'<div class="empty">Создайте первого клиента</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 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 style="font-size:10px;font-weight:700;color:#6366F1;background:#EEF2FF;padding:2px 8px;border-radius:6px">🎁 Бесплатный</span>`
:`<span style="font-size:10px;font-weight:700;color:#047857;background:#ECFDF5;padding:2px 8px;border-radius:6px">💰 Платный</span>`;
return `<div class="blk" style="display:flex;align-items:flex-start;gap:14px;cursor:pointer" onclick="openClient('${p.token}')">
<div class="cl-av" style="width:36px;height:36px;border-radius:9px;font-size:14px;margin-top:2px">${esc((p.client_name||'?')[0])}</div>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="font-weight:700">${esc(p.client_name)}</span>${bChip}</div>
<div style="font-size:12px;color:var(--muted);margin-top:1px">${esc(p.niche)} · ${p.msg_count} сообщений</div>
${stepper(p)}
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px"><span style="font-size:11px;font-weight:700;color:${pc[2]};background:${pc[2]}1a;padding:4px 10px;border-radius:20px;white-space:nowrap">${pc[1]}</span><span style="font-weight:700;color:var(--primary)">${money((p.crm&&p.crm.deal_amount)||0)}</span></div>
</div>`;
}
function renderRevenueChart(){