feat: орг.слой в CRM + Организация в смете + Phase 3 (ТЗ под клиента) + выгрузка

1. Операторская CRM: вкладка «Организация» (оргструктура+должностные) с пересборкой
2. Смета: «Организация» — оплачиваемый этап (org:10000) во всех массивах стадий
3. Phase 3: /api/build-spec-client — ТЗ под вариант клиента (учёт отклонений+орг),
   переключатель «Эталон Елены / Вариант клиента» в ТЗ-вкладке CRM
4. Должностные+оргструктура в выгрузку ТЗ (PDF в CRM, MD+JSON в кабинете)
This commit is contained in:
wasrusgen 2026-06-02 00:14:52 +03:00
parent 024c3fb500
commit 5e67ddb286
3 changed files with 134 additions and 14 deletions

View File

@ -877,6 +877,42 @@ def build_spec():
con = db(); con.execute("UPDATE projects SET status='spec_ready' WHERE id=?", (proj["id"],)); con.commit()
return jsonify({"spec": result, "usage": usage})
@app.route("/api/build-spec-client", methods=["POST"])
def build_spec_client():
"""ТЗ под вариант клиента: полная пересборка с учётом зафиксированных отклонений и орг. слоя."""
data = request.get_json(force=True) or {}
proj = get_project(data.get("token"))
if not proj:
return jsonify({"error": "project not found"}), 404
pid = proj["id"]
model_row = db().execute("SELECT blocks_json FROM models WHERE project_id=? ORDER BY id DESC LIMIT 1", (pid,)).fetchone()
if not model_row:
return jsonify({"error": "сначала постройте IDEF0 модель"}), 400
devs = get_deviations(pid)
if not devs:
return jsonify({"error": "нет зафиксированных отклонений — вариант клиента совпадает с эталоном"}), 400
idef0 = model_row["blocks_json"]
extra = []
org = latest_artifact(pid, "orgchart")
if org:
extra.append("ЦЕЛЕВАЯ ОРГСТРУКТУРА:\n" + json.dumps(org, ensure_ascii=False)[:3000])
jobs = latest_artifact(pid, "jobs")
if jobs:
extra.append("ДОЛЖНОСТНЫЕ ИНСТРУКЦИИ:\n" + json.dumps(jobs, ensure_ascii=False)[:3000])
extra.append("ЗАФИКСИРОВАННЫЕ ОТКЛОНЕНИЯ КЛИЕНТА (реализуем ИХ вариант, не эталон):\n" + json.dumps(devs, ensure_ascii=False)[:3000])
instruction = ("Собери ТЗ на программу под РЕАЛЬНЫЙ вариант клиента (с его отклонениями), а не под методологический эталон.\n"
"Базис — интервью и IDEF0, НО там, где клиент настоял на своём (см. отклонения) — проектируй под выбор клиента.\n"
"Например, если клиент оставил совмещение склада и пошива — модули и роли должны это отражать (один человек, общий доступ), "
"но в open_questions честно вынеси риски, о которых предупреждала Елена.\n"
"МАППИНГ: функция → модуль; Input → данные; Output → показ; Control → правила; Mechanism → роли; хранилища → таблицы.\n"
"Думай как проектировщик ПО. Вызови build_tech_spec.\n\n"
f"IDEF0-МОДЕЛЬ:\n{idef0}\n\n" + "\n\n".join(extra))
result, usage = run_tool(pid, SPEC_TOOL, "build_tech_spec", instruction, max_tokens=8192)
if result is None:
return jsonify({"error": usage}), 500
save_artifact(pid, "spec_client", result)
return jsonify({"spec_client": result, "usage": usage})
@app.route("/api/project/crm", methods=["POST"])
def update_crm():
data = request.get_json(force=True) or {}
@ -1298,6 +1334,7 @@ def list_projects():
"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_org": latest_artifact(pid, "orgchart") is not None,
"has_spec": latest_artifact(pid, "spec") is not None,
"approvals": latest_artifact(pid, "approvals") or {},
"crm": latest_artifact(pid, "crm") or {"pipeline":"lead","deal_amount":0,"paid_amount":0,"contact":"","source":"","note":""},
@ -1434,6 +1471,7 @@ def get_project_state(token):
"orgchart": latest_artifact(proj["id"], "orgchart"),
"jobs": latest_artifact(proj["id"], "jobs"),
"spec": latest_artifact(proj["id"], "spec"),
"spec_client": latest_artifact(proj["id"], "spec_client"),
"approvals": latest_artifact(proj["id"], "approvals") or {},
"crm": crm,
"unlocked": unlocked,

View File

@ -665,6 +665,7 @@ const EST_STAGES=[
{key:"methods", name:"Методологии", icon:"🎯", desc:"Подбор фреймворков под вашу задачу"},
{key:"canvas", name:"Стратегия", icon:"📊", desc:"Целевая модель процессов (TO-BE)"},
{key:"idef0", name:"Функции IDEF0", icon:"🔧", desc:"Декомпозиция функций + регламенты"},
{key:"org", name:"Организация", icon:"🏢", desc:"Оргструктура + должностные инструкции"},
{key:"spec", name:"Выдача ТЗ", icon:"📋", desc:"Готовое ТЗ к внедрению"},
];
function estStageDone(k){
@ -672,6 +673,7 @@ function estStageDone(k){
if(k==="methods") return !!state.selection;
if(k==="canvas") return !!state.canvas;
if(k==="idef0") return !!state.model;
if(k==="org") return !!(state.orgchart && state.jobs);
if(k==="spec") return !!state.spec;
return false;
}
@ -732,13 +734,15 @@ function buildTZmd(s){
(s.roles||[]).forEach(r=>m+=`- **${r.name}** — ${r.does} (доступ: ${r.access})\n`);
m+=`\n## B. Модули\n`;(s.modules||[]).forEach(x=>{m+=`### ${x.name} [${x.source_node}]\n${x.purpose}\nЭкраны: ${(x.screens||[]).join(', ')}\nДанные: вход — ${x.inputs_data}; выход — ${x.outputs_data}\n`;(x.rules||[]).forEach(r=>m+=`- правило: ${r}\n`);m+=`\n`});
m+=`## C. Модель данных\n`;(s.entities||[]).forEach(e=>{m+=`### ${e.name}\n`;(e.fields||[]).forEach(f=>m+=`- ${f.field}: ${f.type}\n`);if((e.relations||[]).length)m+=`Связи: ${e.relations.join(' · ')}\n`;m+=`Пример: ${e.example}\n\n`});
if(state.orgchart&&(state.orgchart.units||[]).length){m+=`\n## D. Оргструктура\n${state.orgchart.insight||''}\n`;state.orgchart.units.forEach(u=>{m+=`- **${u.role}** (${(+u.headcount||1)} чел.)${u.reports_to&&u.reports_to!=='—'?` ↑ ${u.reports_to}`:''}${(u.owns_functions||[]).length?` — отвечает: ${u.owns_functions.join(', ')}`:''}${u.note?` ⚠ ${u.note}`:''}\n`})}
if(state.jobs&&(state.jobs.roles||[]).length){m+=`\n## E. Должностные инструкции\n`;state.jobs.roles.forEach(r=>{m+=`### ${r.role}\n${r.purpose||''}${r.reports_to?` (подчинение: ${r.reports_to})`:''}\n`;(r.responsibilities||[]).forEach(x=>m+=`- ${x}\n`);if((r.kpis||[]).length)m+=`KPI: ${r.kpis.join(' · ')}\n`;if((r.authority||[]).length)m+=`Полномочия: ${r.authority.join(' · ')}\n`;if(r.deviation_note)m+=`⚠ Учтено пожелание клиента: ${r.deviation_note}\n`;m+=`\n`})}
if((s.open_questions||[]).length){m+=`## Уточнить перед разработкой\n`;s.open_questions.forEach(q=>m+=`- ${q}\n`)}
return m;
}
function dl(name,text,type){const b=new Blob([text],{type:type||'text/plain;charset=utf-8'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),2000)}
function printDoc(){if(!state.unlocked)return alert('Доступно после оплаты');window.print()}
function downloadTZ(){if(!state.unlocked)return alert('Доступно после оплаты');dl(`ТЗ_${(state.client_name||'проект').replace(/\s+/g,'_')}.md`,buildTZmd(state.spec),'text/markdown;charset=utf-8')}
function exportDev(){if(!state.unlocked)return alert('Доступно после оплаты');dl(`ТЗ_${(state.client_name||'проект').replace(/\s+/g,'_')}.json`,JSON.stringify({client:state.client_name,niche:state.niche,model:state.model,spec:state.spec},null,2),'application/json')}
function exportDev(){if(!state.unlocked)return alert('Доступно после оплаты');dl(`ТЗ_${(state.client_name||'проект').replace(/\s+/g,'_')}.json`,JSON.stringify({client:state.client_name,niche:state.niche,model:state.model,orgchart:state.orgchart,jobs:state.jobs,spec:state.spec},null,2),'application/json')}
function runCard(stage,ic,t,d,btn,custom){
const id=stage?`id="rb-${stage}"`:'';
const onclick=custom?'':`onclick="runBuild('${stage}')"`;

View File

@ -199,7 +199,7 @@ 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 projStageCnt(p){return [p.msg_count>0,!!p.has_selection,!!p.has_canvas,!!p.has_idef0,!!p.has_org,!!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}')">
@ -289,9 +289,9 @@ function renderDashboard(){
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:"ТЗ"}];
const STAGE_DEFS=[{key:"interview",name:"Интервью"},{key:"methods",name:"Методологии"},{key:"canvas",name:"Стратегия"},{key:"idef0",name:"Функции"},{key:"org",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 done=[p.msg_count>0,!!p.has_selection,!!p.has_canvas,!!p.has_idef0,!!p.has_org,!!p.has_spec];
const cnt=done.filter(Boolean).length;
let cur=done.findIndex(d=>!d); // первый незавершённый
const all=cur===-1;
@ -532,10 +532,11 @@ const CLIENT_STAGES=[
{key:"methods", name:"Методологии", icon:"🎯", desc:"Подбор фреймворков под вашу задачу"},
{key:"canvas", name:"Стратегия", icon:"📊", desc:"Целевая модель процессов (TO-BE)"},
{key:"idef0", name:"Функции IDEF0", icon:"🔧", desc:"Декомпозиция функций + регламенты"},
{key:"org", name:"Организация", icon:"🏢", desc:"Оргструктура + должностные инструкции"},
{key:"spec", name:"Выдача ТЗ", icon:"📋", desc:"Готовое ТЗ к внедрению"},
];
// ── Смета: базовые ставки модулей (интервью = вход 0₽) ──
const STAGE_BASE={interview:0, methods:8000, canvas:12000, idef0:15000, spec:5000};
const STAGE_BASE={interview:0, methods:8000, canvas:12000, idef0:15000, org:10000, spec:5000};
const CX_COEF={low:1.0, medium:1.5, high:2.0};
const CX_LABEL={low:"Простой · 13 процесса", medium:"Средний · 47", high:"Сложный · 8+"};
const CX_SHORT={low:"Простой ×1.0", medium:"Средний ×1.5", high:"Сложный ×2.0"};
@ -617,6 +618,7 @@ function stageIsDone(k){
if(k==="methods") return !!state.selection;
if(k==="canvas") return !!state.canvas;
if(k==="idef0") return !!(state.model);
if(k==="org") return !!(state.orgchart && state.jobs);
if(k==="spec") return !!state.spec;
return false;
}
@ -955,14 +957,20 @@ async function deleteClient(){
}
function exportSpecPDF(){
const s=state.spec;if(!s){alert("ТЗ ещё не собрано");return}
const isClient=(specVariant==="client"&&state.spec_client);
const s=isClient?state.spec_client: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>`}
let n=0;const H=t=>`<h2>${++n}. ${t}</h2>`;
let body=H("Обзор системы")+`<p>${esc(s.overview)}</p>`;
body+=H("Роли пользователей");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+=H("Модули системы");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+=H("Модель данных");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(state.orgchart&&(state.orgchart.units||[]).length){body+=H("Оргструктура");body+=`<div class="item muted">${esc(state.orgchart.insight||'')}</div>`;state.orgchart.units.forEach(u=>{body+=`<div class="item"><b>${esc(u.role)}</b> <span class="tag">${(+u.headcount||1)} чел.</span>${u.reports_to&&u.reports_to!=='—'?` <span class="muted">↑ ${esc(u.reports_to)}</span>`:''}${(u.owns_functions&&u.owns_functions.length)?`<div class="muted">Отвечает: ${u.owns_functions.map(esc).join(", ")}</div>`:''}${u.note?`<div class="muted">⚠ ${esc(u.note)}</div>`:''}</div>`});}
// Должностные инструкции
if(state.jobs&&(state.jobs.roles||[]).length){body+=H("Должностные инструкции");state.jobs.roles.forEach(r=>{body+=`<div class="item"><b>${esc(r.role)}</b><div class="muted">${esc(r.purpose||'')}${r.reports_to?` · ↑ ${esc(r.reports_to)}`:''}</div>${(r.responsibilities&&r.responsibilities.length)?`<div style="margin-top:5px"><b>Ответственность:</b><ul>${r.responsibilities.map(x=>`<li>${esc(x)}</li>`).join("")}</ul></div>`:''}${(r.kpis&&r.kpis.length)?`<div><b>KPI:</b> ${r.kpis.map(esc).join(" · ")}</div>`:''}${(r.authority&&r.authority.length)?`<div class="muted">Полномочия: ${r.authority.map(esc).join(" · ")}</div>`:''}${r.deviation_note?`<div class="muted">⚠ Учтено пожелание клиента: ${esc(r.deviation_note)}</div>`:''}</div>`});}
if(s.open_questions&&s.open_questions.length){body+=H("Уточнить перед разработкой")+`<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}
@ -1005,7 +1013,33 @@ function renderScreen(s){
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 renderOrgChart(o){
const units=o.units||[];
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(o.insight||'')}</div>`;
h+=units.map(u=>`<div class="blk" style="padding:11px 14px;margin-bottom:8px">
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap">
<span style="font-size:14px;font-weight:800">${esc(u.role||'')}</span>
<span style="font-size:11px;font-weight:700;color:#047857;background:#ECFDF5;padding:1px 8px;border-radius:5px">${(+u.headcount||1)} чел.</span>
${u.reports_to&&u.reports_to!=='—'?`<span style="font-size:11px;color:#9ca3af">↑ ${esc(u.reports_to)}</span>`:'<span style="font-size:10px;font-weight:700;color:#6366F1;background:#EEF2FF;padding:1px 8px;border-radius:5px">руководство</span>'}
</div>
${(u.owns_functions&&u.owns_functions.length)?`<div style="font-size:11px;color:#6b7280;margin-top:6px">Отвечает: ${u.owns_functions.map(f=>`<span style="background:#F1F5F9;padding:1px 6px;border-radius:4px;margin-right:3px;display:inline-block">${esc(f)}</span>`).join('')}</div>`:''}
${u.note?`<div style="font-size:11px;color:#92400E;background:#FEF3C7;border-radius:6px;padding:5px 9px;margin-top:6px">⚠ ${esc(u.note)}</div>`:''}
</div>`).join('');
return h;
}
function renderJobs(j){
const roles=j.roles||[];
return roles.map(r=>`<div class="blk" style="padding:13px 15px;margin-bottom:9px">
<div style="font-size:14px;font-weight:800">${esc(r.role||'')}</div>
<div style="font-size:12px;color:#6b7280;margin:3px 0 9px">${esc(r.purpose||'')}${r.reports_to?` · ↑ ${esc(r.reports_to)}`:''}</div>
${(r.responsibilities&&r.responsibilities.length)?`<div style="font-size:11px;font-weight:700;color:#374151;margin-bottom:3px">Ответственность</div><ul style="margin:0 0 9px;padding-left:18px;font-size:12px;color:#4B5563">${r.responsibilities.map(x=>`<li>${esc(x)}</li>`).join('')}</ul>`:''}
${(r.kpis&&r.kpis.length)?`<div style="font-size:11px;font-weight:700;color:#047857;margin-bottom:3px">KPI</div><div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:9px">${r.kpis.map(k=>`<span style="font-size:11px;background:#ECFDF5;color:#047857;padding:2px 8px;border-radius:5px">${esc(k)}</span>`).join('')}</div>`:''}
${(r.authority&&r.authority.length)?`<div style="font-size:11px;color:#6b7280">Полномочия: ${r.authority.map(a=>esc(a)).join(' · ')}</div>`:''}
${r.deviation_note?`<div style="font-size:11px;color:#92400E;background:#FEF3C7;border-radius:6px;padding:5px 9px;margin-top:8px">⚠ Учтено пожелание клиента: ${esc(r.deviation_note)}</div>`:''}
</div>`).join('');
}
const TABS=[{id:"interview",name:"Интервью",icon:"💬"},{id:"methods",name:"Методологии",icon:"🎯"},{id:"canvas",name:"Стратегия",icon:"📊"},{id:"idef0",name:"Функции",icon:"🔧"},{id:"org",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();}
@ -1018,9 +1052,53 @@ function renderTab(){const c=document.getElementById("tabContent");
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","ТЗ готово к разработке?");}
else if(activeTab==="org"){
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}
let h='';
if(!state.orgchart)h+=runCard("orgchart","🏢","Целевая оргструктура","Из модели: роли, штат, подчинённость, узкие места, конфликты интересов.","Построить оргструктуру →");
else h+=renderOrgChart(state.orgchart)+`<div style="text-align:right;margin:8px 0 16px"><button class="run-btn" id="rb-orgchart" onclick="rerun('orgchart')" style="font-size:12px">↻ Пересобрать оргструктуру</button></div>`;
if(state.orgchart){
if(!state.jobs)h+=runCard("jobs","📋","Должностные инструкции","По ролям: ответственность, KPI, полномочия. С учётом отклонений клиента.","Собрать инструкции →");
else h+=renderJobs(state.jobs)+`<div style="text-align:right;margin-top:8px"><button class="run-btn" id="rb-jobs" onclick="rerun('jobs')" style="font-size:12px">↻ Пересобрать инструкции</button></div>`;
}
const BUILD={methods:["select-methodologies","selection"],canvas:["build-canvas","canvas"],model:["build-model","model"],idef0:["build-model","model"],spec:["build-spec","spec"]};
c.innerHTML=h;
}
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=renderSpecTab();}
}
let specVariant="elena"; // elena | client — Phase 3 переключатель ТЗ
function renderSpecTab(){
const devN=(state.deviations||[]).length, hasClient=!!state.spec_client;
const showClient=specVariant==="client"&&hasClient;
const s=showClient?state.spec_client:state.spec;
let head=`<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap">`;
if(devN>0||hasClient){
head+=`<div style="display:flex;gap:3px;background:#F1F5F9;border-radius:9px;padding:3px">
<button onclick="setSpecVariant('elena')" style="padding:6px 12px;border:none;border-radius:7px;cursor:pointer;font-size:12px;font-weight:700;font-family:Inter;${specVariant==='elena'?'background:#047857;color:#fff':'background:transparent;color:#6B7280'}">📐 Эталон Елены</button>
<button onclick="setSpecVariant('client')" style="padding:6px 12px;border:none;border-radius:7px;cursor:pointer;font-size:12px;font-weight:700;font-family:Inter;${specVariant==='client'?'background:#92400E;color:#fff':'background:transparent;color:#6B7280'}">⚠️ Вариант клиента${hasClient?'':' (нет)'}</button>
</div>`;
}
head+=`<button class="btn btn-p" style="margin-left:auto" onclick="exportSpecPDF()">⬇ Скачать ТЗ (PDF)</button></div>`;
// Кнопка пересборки под вариант клиента (Phase 3) — только если есть отклонения
let phase3='';
if(devN>0){
phase3=`<div style="background:#FFFBEB;border:1px solid #FDE68A;border-radius:10px;padding:11px 14px;margin-bottom:14px;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<span style="font-size:18px">⚠️</span>
<div style="flex:1;min-width:160px;font-size:12px;color:#92400E">Зафиксировано отклонений: <b>${devN}</b>. ${hasClient?'Вариант клиента собран — переключай выше.':'Можно собрать отдельное ТЗ под реальный вариант клиента.'}</div>
<button class="run-btn" id="rb-specclient" onclick="buildSpecClient()" style="font-size:12px;background:#92400E">${hasClient?'↻ Пересобрать вариант клиента':'🔧 Собрать ТЗ под вариант клиента'}</button>
</div>`;
}
const banner=showClient?`<div style="background:#FEF3C7;color:#92400E;font-size:12px;font-weight:700;padding:8px 12px;border-radius:8px;margin-bottom:12px">⚠️ Вариант клиента — реализуем то, на чём настоял клиент. Риски вынесены в «Уточнить перед разработкой».</div>`:'';
return head+phase3+banner+renderSpec(s)+cpBar("spec","ТЗ готово к разработке?");
}
function setSpecVariant(v){specVariant=v;renderTab();}
async function buildSpecClient(){
const btn=document.getElementById("rb-specclient");if(btn){btn.disabled=true;btn.innerHTML='<span class="spin"></span> Елена пересобирает ТЗ под клиента...'}
try{const r=await fetch(`${API}/api/build-spec-client`,{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}return}
state.spec_client=d.spec_client;specVariant="client";renderTab();
}catch(e){alert("Ошибка: "+e.message);if(btn)btn.disabled=false}
}
const BUILD={methods:["select-methodologies","selection"],canvas:["build-canvas","canvas"],model:["build-model","model"],idef0:["build-model","model"],spec:["build-spec","spec"],orgchart:["build-orgchart","orgchart"],jobs:["build-jobs","jobs"]};
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}}