Orçador SaaS (Interno) — v5.1

v5.1: inclui exportação .docx real (OpenXML) via JSZip (carregado automaticamente por CDN).
Se seu ambiente bloquear internet, você pode baixar o arquivo JSZip e referenciar localmente (comentário no código).
Projetos
v5.1 com squads + presets + export DOCX.
Auto-salvo
Configuração do projeto
Multi-squad (capacidade + rate)
Cada squad tem capacidade e taxa/hora. O sistema calcula hora média ponderada + capacidade efetiva.
Hora média ponderada:
Capacidade efetiva: h/mês
Regras automáticas de risco
Risco efetivo = base + deltas selecionados (clamp 1.0 a 3.5).
Risco efetivo:
Presets de escopo
Aplica um “starter pack” de módulos conforme o tipo do SaaS.
Dica: aplique preset em projeto vazio (ou escolha “Adicionar sem apagar”).
Fase ativa
Adicionar itens manualmente vai para essa fase.
Biblioteca
Itens avulsos + templates (selecionar e aplicar).
Adicionar item manual
Técnica
Comercial
Estratégica
Escopo e cronograma
Fase
Cronograma automático
Baseado em horas por fase e capacidade efetiva dos squads.
Dica: arraste e solte itens para mudar a fase (ou use o select na linha).
Item Fase Categoria Pontos Horas Camada
Distribuição (estimada)
Observações
OK
Salvo.
*/ const LS_KEY = "orcador_saas_v51"; const APP_VERSION = 5.1; const $ = (id)=>document.getElementById(id); const fmtBRL = (n)=>Number(n||0).toLocaleString("pt-BR",{style:"currency",currency:"BRL"}); const clamp = (n,min,max)=>Math.max(min,Math.min(max,n)); const pad2 = (n)=>String(n).padStart(2,"0"); function uid(){ return crypto?.randomUUID ? crypto.randomUUID() : String(Date.now())+"_"+Math.random().toString(16).slice(2); } function toast(title,msg){ const t=$("toast"); $("toastTitle").textContent=title; $("toastMsg").textContent=msg; t.classList.add("show"); setTimeout(()=>t.classList.remove("show"), 1700); } function escapeHtml(str){ return String(str||"") .replaceAll("&","&").replaceAll("<","<").replaceAll(">",">") .replaceAll('"',""").replaceAll("'","'"); } function groupBy(arr, fn){ return arr.reduce((acc, x)=>{ const k=fn(x); (acc[k]=acc[k]||[]).push(x); return acc; }, {}); } function addDays(date, days){ const d=new Date(date.getTime()); d.setDate(d.getDate()+days); return d; } function formatDate(d){ if(!d) return "—"; const dt=new Date(d); return `${pad2(dt.getDate())}/${pad2(dt.getMonth()+1)}/${dt.getFullYear()}`; } /* ---------- Defaults ---------- */ function defaultRiskRules(){ return [ { key:"scope_unclear", label:"Escopo pouco claro / sujeito a mudanças", delta:0.20, checked:false }, { key:"third_party", label:"Dependência de terceiros (APIs externas/ERP/WhatsApp)", delta:0.20, checked:false }, { key:"payments", label:"Pagamentos/assinaturas/webhooks/antifraude", delta:0.25, checked:false }, { key:"multi_tenant", label:"Multi-tenant + planos/limites/billing", delta:0.25, checked:false }, { key:"realtime", label:"Tempo real / filas / eventos / assíncrono", delta:0.15, checked:false }, { key:"security", label:"Dados sensíveis / LGPD / auditoria forte", delta:0.15, checked:false }, { key:"tight_deadline", label:"Prazo apertado", delta:0.20, checked:false }, { key:"first_time", label:"Primeira vez com stack/API crítica", delta:0.15, checked:false }, ]; } function defaultPhases(){ return [ { id: uid(), name:"MVP", order:1, isDefault:true, payPct:40, fixedDays:null }, { id: uid(), name:"V1", order:2, isDefault:false, payPct:30, fixedDays:null }, { id: uid(), name:"V2", order:3, isDefault:false, payPct:30, fixedDays:null }, ]; } function defaultSquads(){ return [{ id: uid(), name: "Squad Principal", usagePct: 100, capacityPerMonth: 120, rates: { jr:120, pl:180, sr:260 }, mix: { jr:20, pl:60, sr:20 } }]; } function libDefaultItems(){ const mk=(name, category, complexity, extras, layer, desc)=>({ id: uid(), name, category, complexity, extras, layer, desc: desc||"", qty:1, points:(complexity+extras) }); return [ mk("Autenticação (login + recuperação)", "Autenticação", 2, 1, "BE", "Login, reset, tokens."), mk("RBAC / Permissões por papel", "Autenticação", 3, 1, "BE", "Perfis, permissões, guards."), mk("CRUD com filtros + paginação", "CRUD", 2, 1, "Full", "Listagem, criação, edição, exclusão."), mk("Upload de arquivos (S3/local)", "Integrações", 2, 2, "BE", "Upload + validação + storage."), mk("Dashboard (KPIs + gráfico)", "Dashboard/BI", 3, 1, "Full", "KPIs e gráfico básico."), mk("Integração API externa (webhooks)", "Integrações", 2, 2, "BE", "Consumo + webhooks + retries."), mk("Assinatura / cobrança recorrente", "Pagamentos", 3, 4, "BE", "Planos, webhooks, cancelamento."), mk("Notificações (email)", "Notificações", 2, 1, "BE", "Eventos e logs."), mk("DevOps: deploy docker + SSL", "Infra/DevOps", 3, 2, "DevOps", "Compose, proxy, ssl, envs."), mk("LGPD: exportação e exclusão", "Segurança/LGPD", 3, 1, "BE", "Exportar dados e apagar."), mk("Tela responsiva (UI)", "UI/UX", 2, 0, "FE", "Layout e componentes."), ]; } function defaultTemplates(){ const tplItem=(name, category, complexity, qty, extras, layer, desc)=>({ id: uid(), name, category, layer, desc: desc||"", qty:Number(qty||1), complexity:Number(complexity||1), extras:Number(extras||0), points: (Number(complexity)+Number(extras||0))*Number(qty||1), }); return [ { id: uid(), name: "SaaS Base (MVP)", desc: "Base mínima: auth + UI + CRUD + dashboard + infra.", items: [ tplItem("Autenticação + Permissões (RBAC)", "Autenticação", 3, 1, 1, "BE", "Login, recuperação, RBAC, convite."), tplItem("Layout + Navegação + UI Base", "UI/UX", 2, 1, 1, "FE", "Layout, menu, componentes, responsivo."), tplItem("CRUD Principal (cadastros)", "CRUD", 2, 4, 1, "Full", "Cadastros com filtros e validação."), tplItem("Dashboard Inicial (KPIs)", "Dashboard/BI", 3, 1, 1, "Full", "KPIs e gráficos básicos."), tplItem("Infra (deploy, ssl, backups)", "Infra/DevOps", 3, 1, 2, "DevOps", "Docker, envs, SSL, backup, logs.") ] }, { id: uid(), name: "SaaS Pagamentos + Multi-tenant", desc: "Cobrança, planos, limites e multi-tenant.", items: [ tplItem("Multi-tenant + Billing", "Pagamentos", 5, 1, 5, "BE", "Isolamento por cliente, planos, limites e cobrança."), tplItem("Gateway de pagamento", "Pagamentos", 3, 1, 4, "BE", "Checkout/assinatura, webhooks, status."), tplItem("Notificações (email/whatsapp)", "Notificações", 3, 1, 2, "Full", "Eventos e logs de envio."), tplItem("Auditoria / LGPD", "Segurança/LGPD", 3, 1, 1, "BE", "Logs, auditoria, políticas e export.") ] }, { id: uid(), name: "Admin + Backoffice", desc: "Admin, permissões, auditoria e relatórios.", items: [ tplItem("Admin (usuários + permissões)", "Admin", 3, 1, 1, "Full", "CRUD + RBAC + logs."), tplItem("Relatórios operacionais", "Dashboard/BI", 3, 2, 1, "Full", "Filtros, exportação CSV."), tplItem("Auditoria (log de ações)", "Segurança/LGPD", 3, 1, 1, "BE", "Audit trail.") ] } ]; } /* ---------- Presets ---------- */ function defaultPresets(){ return [ { id:"preset_saas_base", name:"SaaS • Base (MVP)", addTemplates:["SaaS Base (MVP)"], toggleRisks:["scope_unclear","third_party"] }, { id:"preset_saas_recorrencia", name:"SaaS • Assinatura + Multi-tenant", addTemplates:["SaaS Base (MVP)","SaaS Pagamentos + Multi-tenant"], toggleRisks:["payments","multi_tenant","third_party","security"] }, { id:"preset_interno_crud", name:"Sistema Interno • CRUD + Admin + Relatórios", addTemplates:["SaaS Base (MVP)","Admin + Backoffice"], toggleRisks:["scope_unclear","security"] } ]; } function defaultProject(){ const phases = defaultPhases(); const defPhaseId = phases.find(p=>p.isDefault)?.id || phases[0].id; const today = new Date(); const yyyy = today.getFullYear(); const mm = pad2(today.getMonth()+1); const dd = pad2(today.getDate()); return { id: uid(), name: "Novo Orçamento", type: "SaaS", hoursPerPoint: 6, marginPct: 15, riskBase: 1.70, riskRules: defaultRiskRules(), startDate: `${yyyy}-${mm}-${dd}`, billingModel: "per_phase", notes: "", phases, activePhaseId: defPhaseId, items: [], proposalText: "", squads: defaultSquads() }; } function defaultStore(){ const p = defaultProject(); return { version: APP_VERSION, activeProjectId: p.id, projects: [p], templates: defaultTemplates(), library: { items: libDefaultItems() }, presets: defaultPresets(), ui: { tab:"tech", discountPct: 0, bufferPct: 0, itemSearch: "", filterPhase: "", filterCategory: "", filterLayer: "", libSearch: "", libFilterType: "all", libFilterCat: "", libSelected: {}, presetId: "preset_saas_base" } }; } /* ---------- Load/Save ---------- */ let store = loadStore(); function loadStore(){ try{ const raw = localStorage.getItem(LS_KEY); if(!raw) return defaultStore(); const parsed = JSON.parse(raw); if(!parsed || typeof parsed!=="object") return defaultStore(); const d = defaultStore(); const out = { version: APP_VERSION, activeProjectId: parsed.activeProjectId || d.activeProjectId, projects: Array.isArray(parsed.projects) ? parsed.projects : d.projects, templates: Array.isArray(parsed.templates) ? parsed.templates : d.templates, library: parsed.library && Array.isArray(parsed.library.items) ? parsed.library : d.library, presets: Array.isArray(parsed.presets) ? parsed.presets : d.presets, ui: { ...d.ui, ...(parsed.ui||{}) } }; out.templates = out.templates.map(t=>({ id: t.id || uid(), name: t.name || "Template", desc: t.desc || "", items: Array.isArray(t.items) ? t.items.map(it=>({ id: it.id || uid(), name: it.name || "Item", category: it.category || "Outros", layer: it.layer || "Full", desc: it.desc || "", qty: Number(it.qty||1), complexity: Number(it.complexity||1), extras: Number(it.extras||0), points: Number(it.points||0), })) : [] })); out.library.items = out.library.items.map(it=>({ id: it.id || uid(), name: it.name || "Item", category: it.category || "Outros", layer: it.layer || "Full", desc: it.desc || "", qty: Number(it.qty||1), complexity: Number(it.complexity||1), extras: Number(it.extras||0), points: Number(it.points||0), })); out.projects = out.projects.map(pr=>{ const base = defaultProject(); let phases = Array.isArray(pr.phases) && pr.phases.length ? pr.phases : defaultPhases(); phases = phases.map((ph,i)=>({ id: ph.id || uid(), name: ph.name || `Fase ${i+1}`, order: Number(ph.order ?? (i+1)), isDefault: !!ph.isDefault, payPct: Number(ph.payPct ?? (i===0?40:(i===1?30:30))), fixedDays: (ph.fixedDays===0 || ph.fixedDays) ? Number(ph.fixedDays) : null })).sort((a,b)=>a.order-b.order); if(!phases.some(x=>x.isDefault)) phases[0].isDefault = true; const activePhaseId = pr.activePhaseId && phases.some(x=>x.id===pr.activePhaseId) ? pr.activePhaseId : (phases.find(x=>x.isDefault)?.id || phases[0].id); const rr = Array.isArray(pr.riskRules) && pr.riskRules.length ? pr.riskRules : defaultRiskRules(); const normalizedRules = defaultRiskRules().map(def=>{ const found = rr.find(r=>r.key===def.key); return { ...def, ...(found||{}) }; }); let squads = Array.isArray(pr.squads) && pr.squads.length ? pr.squads : null; if(!squads){ const cap = Number(pr.capacityPerMonth || 120); const rates = pr.rates || { jr:120, pl:180, sr:260 }; const mix = pr.mix || { jr:20, pl:60, sr:20 }; squads = [{ id: uid(), name: "Squad Principal", usagePct: 100, capacityPerMonth: cap, rates: { jr:Number(rates.jr||0), pl:Number(rates.pl||0), sr:Number(rates.sr||0) }, mix: { jr:Number(mix.jr||0), pl:Number(mix.pl||0), sr:Number(mix.sr||0) } }]; } squads = squads.map(s=>({ id: s.id || uid(), name: s.name || "Squad", usagePct: clamp(Number(s.usagePct ?? 100), 0, 1000), capacityPerMonth: Math.max(1, Number(s.capacityPerMonth||120)), rates: { jr:Number(s.rates?.jr||0), pl:Number(s.rates?.pl||0), sr:Number(s.rates?.sr||0) }, mix: { jr:Number(s.mix?.jr||0), pl:Number(s.mix?.pl||0), sr:Number(s.mix?.sr||0) } })); const items = Array.isArray(pr.items) ? pr.items.map(it=>({ id: it.id || uid(), name: it.name || "Item", category: it.category || "Outros", layer: it.layer || "Full", desc: it.desc || "", qty: Number(it.qty||1), complexity: Number(it.complexity||1), extras: Number(it.extras||0), points: Number(it.points||0), phaseId: it.phaseId && phases.some(x=>x.id===it.phaseId) ? it.phaseId : activePhaseId })) : []; return { ...base, ...pr, phases, activePhaseId, items, riskRules: normalizedRules, squads }; }); if(!out.projects.find(p=>p.id===out.activeProjectId)){ out.activeProjectId = out.projects[0]?.id || defaultProject().id; } if(!Array.isArray(out.presets) || !out.presets.length) out.presets = defaultPresets(); return out; }catch{ return defaultStore(); } } function saveStore(){ localStorage.setItem(LS_KEY, JSON.stringify(store)); $("savePill").className="pill ok"; $("savePill").textContent="Salvo"; setTimeout(()=>{ $("savePill").className="pill"; $("savePill").textContent="Auto-salvo"; }, 900); } /* ---------- Active helpers ---------- */ function getActiveProject(){ return store.projects.find(p=>p.id===store.activeProjectId) || store.projects[0]; } function getPhaseById(p,id){ return p.phases.find(x=>x.id===id); } function getActivePhase(p){ return getPhaseById(p,p.activePhaseId) || p.phases[0]; } function setActiveProject(id){ if(store.projects.some(p=>p.id===id)){ store.activeProjectId=id; saveStore(); syncUIFromProject(); render(); } } function setActivePhase(p,phaseId){ if(p.phases.some(x=>x.id===phaseId)){ p.activePhaseId=phaseId; saveStore(); syncPhaseSelect(p); render(); } } /* ---------- Compute (multi-squad) ---------- */ function weightedRateForSquad(s){ const r=s.rates||{}, m=s.mix||{}; const rawSum = Number(m.jr||0)+Number(m.pl||0)+Number(m.sr||0); const totalMix = rawSum>0 ? rawSum : 100; const w = (Number(r.jr||0)*(Number(m.jr||0)/totalMix)) + (Number(r.pl||0)*(Number(m.pl||0)/totalMix)) + (Number(r.sr||0)*(Number(m.sr||0)/totalMix)); return { value:w, rawSum }; } function blendedSquads(p){ const squads = Array.isArray(p.squads) && p.squads.length ? p.squads : defaultSquads(); const usageSum = squads.reduce((a,b)=>a+Number(b.usagePct||0),0) || 1; let capEff = 0; let rateNum = 0; let mixWarns = []; squads.forEach(s=>{ const u = Number(s.usagePct||0)/usageSum; capEff += Math.max(0, Number(s.capacityPerMonth||0)) * u; const wr = weightedRateForSquad(s); rateNum += wr.value * u; if(wr.rawSum !== 100) mixWarns.push(`${s.name} mix=${wr.rawSum}%`); }); return { hourRate: rateNum, capEff: Math.max(1, capEff), mixWarns, usageSum }; } function riskEffective(p){ const base=Number(p.riskBase||1); const sum=(p.riskRules||[]).filter(r=>r.checked).reduce((a,b)=>a+Number(b.delta||0),0); const eff=clamp(base+sum, 1.0, 3.5); return { base, sum, eff }; } function computeProject(p){ const hpp = Number(p.hoursPerPoint||0); const marginPct = Number(p.marginPct||0); const sq = blendedSquads(p); const hourRate = sq.hourRate; const cap = sq.capEff; const rEff = riskEffective(p); const totals = { points:0, hours:0, baseCost:0, riskCost:0, marginValue:0, finalPrice:0 }; const dist = { FE:0, BE:0, DevOps:0, QA:0, UX:0 }; for(const it of p.items){ const pts=Number(it.points||0); const hrs=pts*hpp; totals.points += pts; totals.hours += hrs; if(it.layer==="Full"){ dist.FE += hrs*0.45; dist.BE += hrs*0.45; dist.QA += hrs*0.10; }else if(dist[it.layer]!==undefined){ dist[it.layer] += hrs; }else{ dist.BE += hrs; } } totals.baseCost = totals.hours * hourRate; totals.riskCost = totals.baseCost * rEff.eff; totals.marginValue = totals.riskCost * (marginPct/100); totals.finalPrice = totals.riskCost + totals.marginValue; const months = totals.hours / cap; const perPhase = {}; for(const ph of p.phases){ perPhase[ph.id] = { phase:ph, points:0, hours:0, baseCost:0, riskCost:0, marginValue:0, finalPrice:0 }; } for(const it of p.items){ const phId=it.phaseId; if(!perPhase[phId]) continue; const pts=Number(it.points||0); const hrs=pts*hpp; perPhase[phId].points += pts; perPhase[phId].hours += hrs; } for(const phId of Object.keys(perPhase)){ const x=perPhase[phId]; x.baseCost = x.hours * hourRate; x.riskCost = x.baseCost * rEff.eff; x.marginValue = x.riskCost * (marginPct/100); x.finalPrice = x.riskCost + x.marginValue; } return { totals, dist, hourRate, months, risk:rEff, perPhase, capEff:cap, mixWarns:sq.mixWarns, squads:p.squads||[] }; } /* ---------- Schedule ---------- */ function scheduleByPhase(p, computed){ const start = p.startDate ? new Date(p.startDate+"T00:00:00") : new Date(); const capPerMonth = Math.max(1, Number(computed.capEff||1)); const hoursPerDay = capPerMonth / 22; const phases = p.phases.slice().sort((a,b)=>a.order-b.order); let cursor = new Date(start.getTime()); const lines = []; const schedule = []; for(const ph of phases){ const x = computed.perPhase[ph.id]; const hours = Number(x?.hours||0); let durationDays; if(ph.fixedDays && Number(ph.fixedDays)>0){ durationDays = Math.max(1, Math.round(Number(ph.fixedDays))); }else{ durationDays = Math.max(1, Math.round(hours / Math.max(1, hoursPerDay))); } const startD = new Date(cursor.getTime()); const endD = addDays(startD, durationDays-1); schedule.push({ phase:ph, start:startD, end:endD, durationDays, hours }); const pay = (computed.totals.finalPrice * (Number(ph.payPct||0)/100)); lines.push(`• ${ph.name} — ${Math.round(hours)}h — ${durationDays} dias (est.) — ${formatDate(startD)} → ${formatDate(endD)} — Pagamento: ${Number(ph.payPct||0)}% (${fmtBRL(pay)})`); cursor = addDays(endD, 1); } return { schedule, text: lines.join("\n") }; } /* ---------- Proposal ---------- */ function buildProposalText(p, computed, scheduleText){ const { totals, hourRate, months, perPhase, risk, capEff } = computed; const checkedRules = (p.riskRules||[]).filter(r=>r.checked) .map(r=>`- ${r.label} (${r.delta>=0?"+":""}${r.delta.toFixed(2)})`).join("\n") || "- Nenhuma"; const effTxt = `Base ${risk.base.toFixed(2)} + deltas ${risk.sum.toFixed(2)} = efetivo ${risk.eff.toFixed(2)}`; const phaseLines = p.phases.slice().sort((a,b)=>a.order-b.order).map(ph=>{ const x = perPhase[ph.id]; const m = (x.hours / Math.max(1, capEff)).toFixed(2); const payValue = totals.finalPrice * (Number(ph.payPct||0)/100); return `- ${ph.name}: ${x.points.toFixed(1)} pts (~${Math.round(x.hours)}h) • ${fmtBRL(x.finalPrice)} • ~${m} meses • Pagamento ${Number(ph.payPct||0)}% (${fmtBRL(payValue)})`; }).join("\n"); const byCat = groupBy(p.items, it=>it.category); const catLines = Object.keys(byCat).sort().map(cat=>{ const pts = byCat[cat].reduce((a,b)=>a+Number(b.points||0),0); const hrs = pts * Number(p.hoursPerPoint||0); return `- ${cat}: ${pts.toFixed(1)} pts (~${Math.round(hrs)}h)`; }).join("\n"); const squadsTxt = (p.squads||[]).map(s=>{ const wr = weightedRateForSquad(s).value; return `- ${s.name}: uso ${Number(s.usagePct||0)}% • cap ${Math.round(s.capacityPerMonth)}h/mês • hora média ${fmtBRL(wr)}`; }).join("\n") || "- (1 squad padrão)"; return `PROPOSTA DE DESENVOLVIMENTO — ${p.name} (${p.type}) 1) Entregas por fase (escopo e pagamento) ${phaseLines || "- (sem fases)"} 2) Cronograma estimado (datas) Início: ${p.startDate || "—"} ${scheduleText ? scheduleText : "- (sem cronograma)"} 3) Escopo (resumo por categoria) ${catLines || "- (sem itens cadastrados)"} 4) Premissas (estimativa) - Estimativa baseada em pontuação convertida para horas (1 ponto = ${p.hoursPerPoint}h). - Multi-squad: ${squadsTxt} - Hora média ponderada do projeto: ${fmtBRL(hourRate)}. - Capacidade efetiva: ${Math.round(capEff)}h/mês. - Risco efetivo: x${risk.eff.toFixed(2)} (${effTxt}). - Margem aplicada: ${p.marginPct}%. - Prazo total estimado: ~${months.toFixed(1)} meses. 5) Riscos considerados ${checkedRules} 6) Investimento (total) - Custo técnico (base): ${fmtBRL(totals.baseCost)} - Subtotal com risco: ${fmtBRL(totals.riskCost)} - Margem: ${fmtBRL(totals.marginValue)} - Valor final: ${fmtBRL(totals.finalPrice)} 7) Condições (sugestão) - Pagamento: conforme % por fase acima. - Suporte e manutenção: contratados à parte (mensalidade). - Itens fora do escopo serão orçados separadamente. - Mudanças relevantes de escopo/prazos reabrem estimativa. Observações: ${(p.notes||"—").trim()} `; } /* ---------- UI sync ---------- */ function syncProjectSelect(){ const sel=$("projectSelect"); sel.innerHTML = store.projects.map(p=>``).join(""); sel.value = store.activeProjectId; } function syncPhaseSelect(p){ const opts = p.phases.slice().sort((a,b)=>a.order-b.order) .map(ph=>``).join(""); $("phaseSelect").innerHTML = opts; $("phaseSelect").value = p.activePhaseId; $("libApplyPhaseSelect").innerHTML = opts; $("libApplyPhaseSelect").value = p.activePhaseId; $("filterPhase").innerHTML = `` + opts; $("phasePill").textContent = `Fase: ${getActivePhase(p)?.name || "—"}`; } function renderRiskRules(p){ const box=$("riskRulesBox"); box.innerHTML = (p.riskRules||[]).map(r=>` `).join(""); box.querySelectorAll("input[type=checkbox][data-risk]").forEach(cb=>{ cb.addEventListener("change", ()=>{ const key=cb.getAttribute("data-risk"); const rr=p.riskRules.find(x=>x.key===key); if(rr) rr.checked=cb.checked; saveStore(); render(); }); }); } function syncPresets(){ const sel=$("presetSelect"); sel.innerHTML = (store.presets||[]).map(pr=>``).join(""); sel.value = store.ui.presetId || (store.presets?.[0]?.id || ""); } function syncUIFromProject(){ const p=getActiveProject(); $("projectName").value=p.name||""; $("projectType").value=p.type||"SaaS"; $("hoursPerPoint").value=p.hoursPerPoint ?? 6; $("marginPct").value=p.marginPct ?? 15; $("riskBase").value=p.riskBase ?? 1.7; $("startDate").value=p.startDate || ""; $("billingModel").value=p.billingModel || "per_phase"; $("projectNotes").value=p.notes || ""; $("discountPct").value = store.ui.discountPct ?? 0; $("bufferPct").value = store.ui.bufferPct ?? 0; $("itemSearch").value = store.ui.itemSearch ?? ""; $("filterPhase").value = store.ui.filterPhase ?? ""; $("filterCategory").value = store.ui.filterCategory ?? ""; $("filterLayer").value = store.ui.filterLayer ?? ""; syncProjectSelect(); syncPhaseSelect(p); syncPresets(); renderRiskRules(p); } function pullUIToProject(){ const p=getActiveProject(); p.name=$("projectName").value||"—"; p.type=$("projectType").value; p.hoursPerPoint=Number($("hoursPerPoint").value||0); p.marginPct=Number($("marginPct").value||0); p.riskBase=Number($("riskBase").value||1); p.startDate=$("startDate").value || p.startDate; p.billingModel=$("billingModel").value || "per_phase"; p.notes=$("projectNotes").value || ""; store.ui.discountPct = Number($("discountPct").value||0); store.ui.bufferPct = Number($("bufferPct").value||0); store.ui.itemSearch = $("itemSearch").value||""; store.ui.filterPhase = $("filterPhase").value||""; store.ui.filterCategory = $("filterCategory").value||""; store.ui.filterLayer = $("filterLayer").value||""; store.ui.presetId = $("presetSelect").value || store.ui.presetId; } /* ---------- Projects ---------- */ function newProject(){ const p=defaultProject(); p.name=`Orçamento ${store.projects.length+1}`; store.projects.unshift(p); store.activeProjectId=p.id; saveStore(); syncUIFromProject(); render(); toast("Novo","Projeto criado."); } function duplicateProject(){ const src=getActiveProject(); const copy=JSON.parse(JSON.stringify(src)); copy.id=uid(); copy.name=`${src.name} (Cópia)`; const map={}; copy.phases = (copy.phases||[]).map(ph=>{ const newId=uid(); map[ph.id]=newId; return {...ph, id:newId}; }); copy.activePhaseId = map[src.activePhaseId] || copy.phases[0]?.id; copy.items = (copy.items||[]).map(it=>({ ...it, id:uid(), phaseId: map[it.phaseId] || copy.activePhaseId })); copy.squads = (copy.squads||[]).map(s=>({ ...s, id:uid() })); store.projects.unshift(copy); store.activeProjectId=copy.id; saveStore(); syncUIFromProject(); render(); toast("Duplicado","Projeto duplicado."); } function deleteProject(){ if(store.projects.length<=1){ toast("Bloqueado","Mantenha ao menos 1 projeto."); return; } const p=getActiveProject(); if(!confirm(`Excluir "${p.name}"? Isso não pode ser desfeito.`)) return; store.projects = store.projects.filter(x=>x.id!==p.id); store.activeProjectId = store.projects[0].id; saveStore(); syncUIFromProject(); render(); toast("Excluído","Projeto removido."); } function exportAll(){ const blob=new Blob([JSON.stringify(store,null,2)],{type:"application/json"}); const url=URL.createObjectURL(blob); const a=document.createElement("a"); a.href=url; a.download=`orcador_v51_backup_${new Date().toISOString().slice(0,10)}.json`; a.click(); URL.revokeObjectURL(url); toast("Exportado","Backup baixado."); } function exportProject(){ const p=getActiveProject(); const blob=new Blob([JSON.stringify({version:APP_VERSION, project:p},null,2)],{type:"application/json"}); const url=URL.createObjectURL(blob); const a=document.createElement("a"); a.href=url; a.download=`${(p.name||"projeto").replace(/[^\w\-]+/g,"_").toLowerCase()}_projeto_v51.json`; a.click(); URL.revokeObjectURL(url); toast("Exportado","Projeto baixado."); } function importAll(file){ const reader=new FileReader(); reader.onload=()=>{ try{ const data=JSON.parse(reader.result); if(data && data.projects && Array.isArray(data.projects)){ store=data; localStorage.setItem(LS_KEY, JSON.stringify(store)); store=loadStore(); saveStore(); syncUIFromProject(); render(); toast("Importado","Backup carregado."); return; } if(data && data.project){ const base=defaultProject(); const pr=data.project; const imported={...base, ...pr, id:uid(), name:(pr.name||"Projeto importado")+" (Importado)"}; store.projects.unshift(imported); store.activeProjectId=imported.id; saveStore(); syncUIFromProject(); render(); toast("Importado","Projeto importado."); return; } toast("Falhou","JSON inválido (não reconhecido)."); }catch{ toast("Falhou","Não consegui ler esse JSON."); } }; reader.readAsText(file); } function resetApp(){ if(!confirm("Resetar vai apagar projetos, biblioteca, presets e config. Continuar?")) return; store=defaultStore(); saveStore(); syncUIFromProject(); render(); toast("Reset","Aplicação reiniciada."); } /* ---------- Items ---------- */ function clearItemFields(){ $("itemName").value=""; $("itemDesc").value=""; $("itemQty").value=1; $("itemComplexity").value="2"; $("itemExtras").value="0"; $("itemLayer").value="Full"; } function addItem(){ const p=getActiveProject(); const name=($("itemName").value||"").trim(); if(!name){ toast("Atenção","Informe o nome do item."); return; } const category=$("itemCategory").value; const complexity=Number($("itemComplexity").value||1); const qty=Math.max(1, Number($("itemQty").value||1)); const extras=Number($("itemExtras").value||0); const layer=$("itemLayer").value; const desc=($("itemDesc").value||"").trim(); const points=(complexity+extras)*qty; p.items.push({ id:uid(), name, category, layer, desc, qty, complexity, extras, points, phaseId:p.activePhaseId }); clearItemFields(); saveStore(); render(); toast("Adicionado","Item incluído."); } /* ---------- Drag & drop ---------- */ let draggedItemId=null; function onDragStart(itemId){ draggedItemId=itemId; } function onDropToPhase(phaseId){ const p=getActiveProject(); if(!draggedItemId) return; const it=p.items.find(x=>x.id===draggedItemId); if(it){ it.phaseId=phaseId; saveStore(); render(); toast("Movido","Item movido de fase."); } draggedItemId=null; } /* ---------- Phases modal ---------- */ function openPhaseModal(){ $("phaseBackdrop").style.display="block"; $("phaseModal").style.display="block"; renderPhaseModal(); } function closePhaseModal(){ $("phaseBackdrop").style.display="none"; $("phaseModal").style.display="none"; } function renderPhaseModal(){ const p=getActiveProject(); const tbody=$("phaseTbody"); tbody.innerHTML=""; const phases=p.phases.slice().sort((a,b)=>a.order-b.order); phases.forEach(ph=>{ const tr=document.createElement("tr"); tr.innerHTML=` `; tbody.appendChild(tr); }); tbody.querySelectorAll("button[data-ph-del]").forEach(btn=>{ btn.addEventListener("click", ()=>{ const id=btn.getAttribute("data-ph-del"); const p=getActiveProject(); if(p.phases.length<=1){ toast("Bloqueado","Precisa ter ao menos 1 fase."); return; } const toDelete=p.phases.find(x=>x.id===id); if(!toDelete) return; if(!confirm(`Excluir fase "${toDelete.name}"? Itens irão para a fase padrão.`)) return; const remaining=p.phases.filter(x=>x.id!==id); const def=remaining.find(x=>x.isDefault) || remaining[0]; p.items.forEach(it=>{ if(it.phaseId===id) it.phaseId=def.id; }); if(p.activePhaseId===id) p.activePhaseId=def.id; p.phases=remaining; saveStore(); syncPhaseSelect(p); renderPhaseModal(); render(); }); }); } function addPhase(){ const p=getActiveProject(); const nextOrder=(p.phases.reduce((m,x)=>Math.max(m, Number(x.order||0)), 0)||0)+1; p.phases.push({ id:uid(), name:`Fase ${nextOrder}`, order:nextOrder, isDefault:false, payPct:0, fixedDays:null }); saveStore(); renderPhaseModal(); syncPhaseSelect(p); render(); } function savePhaseModal(){ const p=getActiveProject(); document.querySelectorAll("input[data-ph-name]").forEach(inp=>{ const id=inp.getAttribute("data-ph-name"); const ph=p.phases.find(x=>x.id===id); if(ph) ph.name=(inp.value||"").trim()||ph.name; }); document.querySelectorAll("input[data-ph-order]").forEach(inp=>{ const id=inp.getAttribute("data-ph-order"); const ph=p.phases.find(x=>x.id===id); if(ph) ph.order=Math.max(1, Number(inp.value||ph.order||1)); }); document.querySelectorAll("input[data-ph-pay]").forEach(inp=>{ const id=inp.getAttribute("data-ph-pay"); const ph=p.phases.find(x=>x.id===id); if(ph) ph.payPct=clamp(Number(inp.value||0),0,100); }); document.querySelectorAll("input[data-ph-fixed]").forEach(inp=>{ const id=inp.getAttribute("data-ph-fixed"); const ph=p.phases.find(x=>x.id===id); const v=(inp.value||"").trim(); if(ph) ph.fixedDays = v ? Math.max(1, Number(v)) : null; }); const defRadio=document.querySelector("input[type=radio][name=defaultPhase]:checked"); if(defRadio){ const id=defRadio.getAttribute("data-ph-default"); p.phases.forEach(x=>x.isDefault=(x.id===id)); if(!p.phases.some(x=>x.id===p.activePhaseId)) p.activePhaseId=id; } p.phases.sort((a,b)=>a.order-b.order); if(!p.phases.some(x=>x.isDefault)) p.phases[0].isDefault=true; if(!p.phases.some(x=>x.id===p.activePhaseId)) p.activePhaseId=p.phases.find(x=>x.isDefault)?.id || p.phases[0].id; const sumPay=p.phases.reduce((a,b)=>a+Number(b.payPct||0),0); if(sumPay!==100) toast("Atenção",`Soma % pagamento por fase = ${sumPay}%. (normalmente 100%)`); else toast("Salvo","Fases atualizadas."); saveStore(); syncPhaseSelect(p); render(); closePhaseModal(); } /* ---------- Squads modal ---------- */ function openSquadModal(){ $("squadBackdrop").style.display="block"; $("squadModal").style.display="block"; renderSquadModal(); } function closeSquadModal(){ $("squadBackdrop").style.display="none"; $("squadModal").style.display="none"; } function renderSquadModal(){ const p=getActiveProject(); if(!Array.isArray(p.squads) || !p.squads.length) p.squads=defaultSquads(); const tbody=$("squadTbody"); tbody.innerHTML=""; p.squads.forEach(s=>{ const tr=document.createElement("tr"); tr.innerHTML=` `; tbody.appendChild(tr); }); tbody.querySelectorAll("button[data-s-del]").forEach(btn=>{ btn.addEventListener("click", ()=>{ const id=btn.getAttribute("data-s-del"); const p=getActiveProject(); if((p.squads||[]).length<=1){ toast("Bloqueado","Precisa ter ao menos 1 squad."); return; } if(!confirm("Excluir squad?")) return; p.squads=p.squads.filter(x=>x.id!==id); saveStore(); renderSquadModal(); render(); }); }); } function addSquad(){ const p=getActiveProject(); p.squads.push({ id: uid(), name: `Squad ${p.squads.length+1}`, usagePct: 0, capacityPerMonth: 80, rates: { jr:120, pl:180, sr:260 }, mix: { jr:20, pl:60, sr:20 } }); saveStore(); renderSquadModal(); render(); } function saveSquadModal(){ const p=getActiveProject(); p.squads.forEach(s=>{ const gid = s.id; const get = (attr)=>document.querySelector(`[${attr}="${gid}"]`); const name = get(`data-s-name`)?.value; const usage = get(`data-s-usage`)?.value; const cap = get(`data-s-cap`)?.value; const rjr = get(`data-s-rjr`)?.value; const mjr = get(`data-s-mjr`)?.value; const rpl = get(`data-s-rpl`)?.value; const mpl = get(`data-s-mpl`)?.value; const rsr = get(`data-s-rsr`)?.value; const msr = get(`data-s-msr`)?.value; s.name = (name||s.name).trim() || s.name; s.usagePct = clamp(Number(usage ?? s.usagePct), 0, 1000); s.capacityPerMonth = Math.max(1, Number(cap ?? s.capacityPerMonth)); s.rates = { jr:Number(rjr??s.rates.jr), pl:Number(rpl??s.rates.pl), sr:Number(rsr??s.rates.sr) }; s.mix = { jr:Number(mjr??s.mix.jr), pl:Number(mpl??s.mix.pl), sr:Number(msr??s.mix.sr) }; }); const usageSum = p.squads.reduce((a,b)=>a+Number(b.usagePct||0),0); if(usageSum !== 100) toast("Atenção",`Soma de uso dos squads = ${usageSum}%. (recomendado 100%)`); else toast("Salvo","Squads atualizados."); saveStore(); render(); closeSquadModal(); } /* ---------- Library modal ---------- */ function openLibModal(){ $("libBackdrop").style.display="block"; $("libModal").style.display="block"; renderLibrary(); } function closeLibModal(){ $("libBackdrop").style.display="none"; $("libModal").style.display="none"; } function renderLibrary(){ const search=(store.ui.libSearch||"").trim().toLowerCase(); const type=store.ui.libFilterType||"all"; const cat=store.ui.libFilterCat||""; const rows=[]; if(type==="all"||type==="items"){ for(const it of store.library.items){ const text=`${it.name} ${it.desc||""} ${it.category||""}`.toLowerCase(); if(search && !text.includes(search)) continue; if(cat && it.category!==cat) continue; rows.push({kind:"item", id:it.id, label:it.name, category:it.category, points:it.points, layer:it.layer, desc:it.desc}); } } if(type==="all"||type==="templates"){ for(const t of store.templates){ const text=`${t.name} ${t.desc||""}`.toLowerCase(); if(search && !text.includes(search)) continue; const tplCat=t.items[0]?.category||"—"; if(cat && tplCat!==cat) continue; const totalPoints=t.items.reduce((a,b)=>a+Number(b.points||0),0); rows.push({kind:"template", id:t.id, label:t.name, category:tplCat, points:totalPoints, layer:"—", desc:t.desc}); } } const tbody=$("libTbody"); tbody.innerHTML=""; rows.forEach(r=>{ const key=`${r.kind}:${r.id}`; const checked=!!store.ui.libSelected[key]; const tr=document.createElement("tr"); tr.innerHTML=`
${escapeHtml(r.desc||"")}
${r.kind==="item"?"Item":"Template"} ${escapeHtml(r.category||"—")} ${Number(r.points||0).toFixed(1)} ${escapeHtml(r.layer||"—")} ${r.kind==="item" ? `
` : `
` } `; tbody.appendChild(tr); }); tbody.querySelectorAll("input[type=checkbox][data-lib]").forEach(cb=>{ cb.addEventListener("change", ()=>{ const key=cb.getAttribute("data-lib"); store.ui.libSelected[key]=cb.checked; saveStore(); }); }); tbody.querySelectorAll("button[data-del-item]").forEach(btn=>{ btn.addEventListener("click", ()=>{ const id=btn.getAttribute("data-del-item"); if(!confirm("Excluir item da biblioteca?")) return; store.library.items = store.library.items.filter(x=>x.id!==id); delete store.ui.libSelected[`item:${id}`]; saveStore(); renderLibrary(); toast("Excluído","Item removido."); }); }); tbody.querySelectorAll("button[data-edit-item]").forEach(btn=>{ btn.addEventListener("click", ()=>{ const id=btn.getAttribute("data-edit-item"); const it=store.library.items.find(x=>x.id===id); if(!it) return; const name=prompt("Nome:", it.name); if(name===null) return; const desc=prompt("Descrição:", it.desc||""); if(desc===null) return; it.name=(name||it.name).trim()||it.name; it.desc=(desc||"").trim(); saveStore(); renderLibrary(); toast("Salvo","Item atualizado."); }); }); tbody.querySelectorAll("button[data-open-template]").forEach(btn=>{ btn.addEventListener("click", ()=>{ const id=btn.getAttribute("data-open-template"); const tpl=store.templates.find(t=>t.id===id); if(!tpl) return; alert(`Template: ${tpl.name}\n\nItens:\n- `+tpl.items.map(i=>i.name).join("\n- ")); }); }); } function libNewItem(){ const name=prompt("Nome do item:", "Item novo"); if(!name) return; const category=prompt("Categoria:", "CRUD") || "Outros"; const complexity=Number(prompt("Complexidade (1,2,3,5):","2")||2); const extras=Number(prompt("Extras (0..6):","0")||0); const layer=prompt("Camada (Full/FE/BE/DevOps/QA/UX):","Full") || "Full"; const desc=prompt("Descrição:","") || ""; store.library.items.unshift({ id:uid(), name:name.trim(), category, complexity, extras, layer, desc, qty:1, points:(complexity+extras) }); saveStore(); renderLibrary(); toast("Criado","Item adicionado à biblioteca."); } function libNewTpl(){ const name=prompt("Nome do template:", "Template novo"); if(!name) return; store.templates.unshift({ id:uid(), name:name.trim(), desc:"", items:[] }); saveStore(); renderLibrary(); toast("Criado","Template criado (adicione itens via import JSON por enquanto)."); } function exportLibrary(){ const data={ version:APP_VERSION, library:store.library, templates:store.templates }; const blob=new Blob([JSON.stringify(data,null,2)],{type:"application/json"}); const url=URL.createObjectURL(blob); const a=document.createElement("a"); a.href=url; a.download=`orcador_v51_biblioteca_${new Date().toISOString().slice(0,10)}.json`; a.click(); URL.revokeObjectURL(url); toast("Exportado","Biblioteca baixada."); } function importLibrary(file){ const reader=new FileReader(); reader.onload=()=>{ try{ const data=JSON.parse(reader.result); if(!data || (!data.library && !data.templates)){ toast("Falhou","JSON inválido."); return; } if(data.library && Array.isArray(data.library.items)){ store.library.items=data.library.items.map(it=>({ ...it, id:uid() })); } if(data.templates && Array.isArray(data.templates)){ store.templates=data.templates.map(t=>({ ...t, id:uid(), items:(t.items||[]).map(it=>({ ...it, id:uid() })) })); } store.ui.libSelected={}; saveStore(); renderLibrary(); toast("Importado","Biblioteca importada."); }catch{ toast("Falhou","Não consegui ler esse JSON."); } }; reader.readAsText(file); } function applyLibrarySelection(){ const p=getActiveProject(); const phaseId=$("libApplyPhaseSelect").value || p.activePhaseId; const bulkQty=Math.max(1, Number($("bulkQty").value||1)); const selectedKeys=Object.keys(store.ui.libSelected).filter(k=>store.ui.libSelected[k]); if(selectedKeys.length===0){ toast("Atenção","Selecione itens/templates na biblioteca."); return; } let added=0; for(const key of selectedKeys){ const [kind,id]=key.split(":"); if(kind==="item"){ const it=store.library.items.find(x=>x.id===id); if(!it) continue; const qty=bulkQty; const points=(Number(it.complexity||0)+Number(it.extras||0))*qty; p.items.push({ id:uid(), name:it.name, category:it.category, layer:it.layer, desc:it.desc||"", qty, complexity:Number(it.complexity||0), extras:Number(it.extras||0), points, phaseId }); added++; }else if(kind==="template"){ const tpl=store.templates.find(t=>t.id===id); if(!tpl) continue; for(const it of tpl.items){ p.items.push({ ...it, id:uid(), phaseId }); added++; } } } saveStore(); render(); toast("Aplicado",`${added} item(ns) aplicado(s) na fase selecionada.`); } /* ---------- Presets apply ---------- */ function applyPreset(){ const p=getActiveProject(); const presetId=$("presetSelect").value; const preset=(store.presets||[]).find(x=>x.id===presetId); if(!preset){ toast("Falhou","Preset não encontrado."); return; } const mode = prompt( "Aplicar preset:\n1) Limpar itens e aplicar\n2) Adicionar sem apagar\n\nDigite 1 ou 2:", "2" ); if(mode === null) return; const doClear = String(mode).trim()==="1"; if(doClear) p.items = []; const toggle = new Set(preset.toggleRisks||[]); (p.riskRules||[]).forEach(r=>{ if(toggle.has(r.key)) r.checked = true; }); const defPhaseId = p.phases.find(x=>x.isDefault)?.id || p.activePhaseId; const names = preset.addTemplates || []; let added=0; names.forEach(tname=>{ const tpl = store.templates.find(t=>t.name===tname); if(!tpl) return; tpl.items.forEach(it=>{ p.items.push({ ...it, id:uid(), phaseId:defPhaseId }); added++; }); }); saveStore(); render(); toast("Preset aplicado", `${added} itens adicionados.`); } /* ---------- Proposal export ---------- */ async function copyProposal(){ try{ await navigator.clipboard.writeText($("proposalText").value); toast("Copiado","Texto copiado."); } catch{ toast("Falhou","Navegador bloqueou copiar. Copie manualmente."); } } function saveProposalText(){ const p=getActiveProject(); p.proposalText=$("proposalText").value||""; saveStore(); toast("Salvo","Texto salvo."); } function resetProposalText(){ const p=getActiveProject(); p.proposalText=""; saveStore(); render(); toast("OK","Voltou para o padrão."); } function exportTxt(){ const p=getActiveProject(); const blob=new Blob([$("proposalText").value],{type:"text/plain;charset=utf-8"}); const url=URL.createObjectURL(blob); const a=document.createElement("a"); a.href=url; a.download=`${(p.name||"proposta").replace(/[^\w\-]+/g,"_").toLowerCase()}_proposta.txt`; a.click(); URL.revokeObjectURL(url); toast("Exportado",".txt baixado."); } function exportWordDoc(){ const p=getActiveProject(); const content = $("proposalText").value .replaceAll("&","&").replaceAll("<","<").replaceAll(">",">") .replaceAll("\n","
"); const html = ` ${escapeHtml(p.name)} ${content} `; const blob=new Blob([html],{type:"application/msword"}); const url=URL.createObjectURL(blob); const a=document.createElement("a"); a.href=url; a.download=`${(p.name||"proposta").replace(/[^\w\-]+/g,"_").toLowerCase()}_proposta.doc`; a.click(); URL.revokeObjectURL(url); toast("Exportado","Arquivo .doc gerado."); } /* ========================= DOCX REAL (OpenXML + ZIP) ========================= */ function xmlEscape(s){ return String(s||"") .replaceAll("&","&") .replaceAll("<","<") .replaceAll(">",">") .replaceAll('"',""") .replaceAll("'","'"); } function buildDocumentXmlFromPlainText(text){ // Cada linha vira um parágrafo. Linhas vazias viram parágrafo vazio. const lines = String(text||"").replaceAll("\r\n","\n").replaceAll("\r","\n").split("\n"); const paragraphs = lines.map(line=>{ // Word XML: evitar vazio sem xml:space quando tiver espaços const hasLeadingOrDoubleSpace = /^\s|\s{2,}|\s$/.test(line); const spaceAttr = hasLeadingOrDoubleSpace ? ' xml:space="preserve"' : ""; return ` ${xmlEscape(line)} `; }).join(""); return ` ${paragraphs} `; } function buildStylesXml(){ return ` `; } function buildContentTypesXml(){ return ` `; } function buildRelsRootXml(){ return ` `; } function buildDocumentRelsXml(){ return ` `; } function buildCorePropsXml(title){ const now = new Date().toISOString(); // minimal core properties return ` ${xmlEscape(title||"Proposta")} Orçador SaaS v5.1 Orçador SaaS v5.1 ${now} ${now} `; } function buildAppPropsXml(){ return ` Orçador SaaS v5.1 0 false false false false 16.0000 `; } async function ensureJSZip(){ if(window.JSZip) return true; // tenta carregar via CDN try{ await new Promise((resolve, reject)=>{ const s=document.createElement("script"); s.src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"; s.onload=resolve; s.onerror=reject; document.head.appendChild(s); }); return !!window.JSZip; }catch{ return false; } } async function exportWordDocx(){ const ok = await ensureJSZip(); if(!ok){ $("docxNotice").style.display=""; toast("Sem DOCX","JSZip não foi carregado."); return; } $("docxNotice").style.display="none"; const p=getActiveProject(); const filename = `${(p.name||"proposta").replace(/[^\w\-]+/g,"_").toLowerCase()}_proposta.docx`; const text = $("proposalText").value || ""; const zip = new JSZip(); // arquivos obrigatórios zip.file("[Content_Types].xml", buildContentTypesXml()); zip.folder("_rels").file(".rels", buildRelsRootXml()); const word = zip.folder("word"); word.file("document.xml", buildDocumentXmlFromPlainText(text)); word.file("styles.xml", buildStylesXml()); word.folder("_rels").file("document.xml.rels", buildDocumentRelsXml()); const docProps = zip.folder("docProps"); docProps.file("core.xml", buildCorePropsXml(p.name || "Proposta")); docProps.file("app.xml", buildAppPropsXml()); const blob = await zip.generateAsync({ type:"blob", compression:"DEFLATE" }); const url=URL.createObjectURL(blob); const a=document.createElement("a"); a.href=url; a.download=filename; a.click(); URL.revokeObjectURL(url); toast("Exportado","Arquivo .docx gerado."); } /* ---------- Tabs ---------- */ function setTab(tab){ store.ui.tab=tab; document.querySelectorAll(".tab").forEach(t=>t.classList.toggle("active", t.getAttribute("data-tab")===tab)); $("view-tech").style.display = (tab==="tech") ? "" : "none"; $("view-sales").style.display = (tab==="sales") ? "" : "none"; $("view-biz").style.display = (tab==="biz") ? "" : "none"; saveStore(); render(); } /* ---------- Render ---------- */ function render(){ const p=getActiveProject(); const c=computeProject(p); $("weightedRateLbl").textContent = fmtBRL(c.hourRate); $("capEffLbl").textContent = `${Math.round(c.capEff)}`; $("weightedRateDetail").textContent = c.mixWarns.length ? `(atenção: ${c.mixWarns.join(" | ")})` : `(ok)`; $("riskEffectiveLbl").textContent = `x${c.risk.eff.toFixed(2)}`; $("riskEffectiveDetail").textContent = `(base ${c.risk.base.toFixed(2)} + deltas ${c.risk.sum.toFixed(2)})`; const sch=scheduleByPhase(p,c); $("scheduleBox").textContent=sch.text||"—"; const end=sch.schedule.length?sch.schedule[sch.schedule.length-1].end:null; $("schedulePill").textContent=end?`Fim estimado: ${formatDate(end)}`:"—"; $("kpis").innerHTML = [ { t:"Pontos totais", v:`${c.totals.points.toFixed(1)}`, s:`${p.hoursPerPoint}h por ponto` }, { t:"Horas estimadas", v:`${Math.round(c.totals.hours)}h`, s:`Hora média: ${fmtBRL(c.hourRate)}` }, { t:"Preço final", v: fmtBRL(c.totals.finalPrice), s:`Risco: x${c.risk.eff.toFixed(2)} • Margem: ${p.marginPct}%` }, { t:"Prazo total", v:`${c.months.toFixed(1)} meses`, s:`Capacidade efetiva: ${Math.round(c.capEff)}h/mês` } ].map(k=>`
${k.t}
${k.v}
${k.s}
`).join(""); $("scopeMeta").textContent = p.phases.slice().sort((a,b)=>a.order-b.order).map(ph=>{ const x=c.perPhase[ph.id]; const pay=c.totals.finalPrice*(Number(ph.payPct||0)/100); return `${ph.name}: ${Math.round(x.hours)}h • ${fmtBRL(x.finalPrice)} • ${Number(ph.payPct||0)}% (${fmtBRL(pay)})`; }).join(" | ") || "—"; const search=(store.ui.itemSearch||"").trim().toLowerCase(); const filterPhase=store.ui.filterPhase||""; const catFilter=store.ui.filterCategory||""; const layerFilter=store.ui.filterLayer||""; const tbody=$("itemsTbody"); tbody.innerHTML=""; const rows=[]; for(const it of p.items){ const text=`${it.name} ${it.desc||""} ${it.category||""}`.toLowerCase(); if(search && !text.includes(search)) continue; if(filterPhase && it.phaseId!==filterPhase) continue; if(catFilter && it.category!==catFilter) continue; if(layerFilter && it.layer!==layerFilter) continue; const hrs=Number(it.points||0)*Number(p.hoursPerPoint||0); rows.push({it, hrs}); } rows.forEach(({it,hrs})=>{ const tr=document.createElement("tr"); tr.setAttribute("draggable","true"); tr.addEventListener("dragstart", ()=>onDragStart(it.id)); tr.innerHTML=`
${escapeHtml(it.name)}
${escapeHtml(it.desc||"")}
qtd: ${Number(it.qty||1)} • comp: ${Number(it.complexity||0)} • extras: ${Number(it.extras||0)}
arraste a linha → mudar fase
${escapeHtml(it.category)} ${Number(it.points||0).toFixed(1)} ${Math.round(hrs)}h ${escapeHtml(it.layer)}
`; tbody.appendChild(tr); }); tbody.querySelectorAll("select[data-item-phase]").forEach(sel=>{ sel.addEventListener("change", ()=>{ const id=sel.getAttribute("data-item-phase"); const it=p.items.find(x=>x.id===id); if(it){ it.phaseId=sel.value; saveStore(); render(); } }); sel.addEventListener("dragover",(e)=>e.preventDefault()); sel.addEventListener("drop",(e)=>{ e.preventDefault(); onDropToPhase(sel.value); }); }); tbody.querySelectorAll("button[data-del]").forEach(btn=>{ btn.addEventListener("click", ()=>{ const id=btn.getAttribute("data-del"); if(!confirm("Excluir item?")) return; p.items=p.items.filter(x=>x.id!==id); saveStore(); render(); toast("Removido","Item excluído."); }); }); tbody.querySelectorAll("button[data-edit]").forEach(btn=>{ btn.addEventListener("click", ()=>{ const id=btn.getAttribute("data-edit"); const it=p.items.find(x=>x.id===id); if(!it) return; $("itemName").value=it.name; $("itemCategory").value=it.category; $("itemComplexity").value=String(it.complexity); $("itemQty").value=String(it.qty); $("itemExtras").value=String(it.extras); $("itemLayer").value=it.layer; $("itemDesc").value=it.desc||""; setActivePhase(p,it.phaseId); p.items=p.items.filter(x=>x.id!==id); saveStore(); render(); toast("Edição","Edite e clique em “Adicionar item” para salvar."); }); }); $("distBox").textContent = [ `Distribuição aproximada (horas)`, `- Backend: ${Math.round(c.dist.BE)}h`, `- Frontend: ${Math.round(c.dist.FE)}h`, `- DevOps: ${Math.round(c.dist.DevOps)}h`, `- QA: ${Math.round(c.dist.QA)}h`, `- UX/UI: ${Math.round(c.dist.UX)}h`, ``, `Por fase:`, ...p.phases.slice().sort((a,b)=>a.order-b.order).map(ph=>{ const x=c.perPhase[ph.id]; return `- ${ph.name}: ${Math.round(x.hours)}h • ${fmtBRL(x.finalPrice)}`; }), ``, `Squads:`, ...(p.squads||[]).map(s=>{ const wr=weightedRateForSquad(s).value; return `- ${s.name}: uso ${Number(s.usagePct||0)}% • cap ${Math.round(s.capacityPerMonth)}h/mês • hora média ${fmtBRL(wr)}`; }) ].join("\n"); $("notesBox").textContent = (p.notes||"").trim() || "—"; const defaultProposal = buildProposalText(p,c,sch.text); $("proposalText").value = (p.proposalText && p.proposalText.trim()) ? p.proposalText : defaultProposal; // biz const discount=clamp(Number(store.ui.discountPct||0),0,95); const buffer=clamp(Number(store.ui.bufferPct||0),0,200); const bufferedHours=c.totals.hours*(1+buffer/100); const baseWithBuffer=bufferedHours*c.hourRate; const riskWithBuffer=baseWithBuffer*c.risk.eff; const marginWithBuffer=riskWithBuffer*(Number(p.marginPct||0)/100); const finalWithBuffer=riskWithBuffer+marginWithBuffer; const discountedFinal=finalWithBuffer*(1-discount/100); const impliedMargin=discountedFinal>0?((discountedFinal-riskWithBuffer)/discountedFinal)*100:0; const monthsWithBuffer=bufferedHours/Math.max(1,c.capEff); $("bizBox").textContent = [ `Projeto: ${p.name} (${p.type})`, `Risco efetivo: x${c.risk.eff.toFixed(2)}`, ``, `Horas (com buffer): ${Math.round(bufferedHours)}h (buffer ${buffer}%)`, `Prazo (com buffer): ${monthsWithBuffer.toFixed(2)} meses`, ``, `Hora média (multi-squad): ${fmtBRL(c.hourRate)}`, `Base (com buffer): ${fmtBRL(baseWithBuffer)}`, `Subtotal com risco: ${fmtBRL(riskWithBuffer)}`, `+ Margem: ${fmtBRL(marginWithBuffer)}`, `= Final (com buffer): ${fmtBRL(finalWithBuffer)}`, ``, `Desconto: ${discount}% → ${fmtBRL(discountedFinal)}`, `Margem implícita após desconto: ${impliedMargin.toFixed(1)}%`, ].join("\n"); const hints=[]; if(p.items.length===0) hints.push("Sem itens no escopo: aplique um preset ou adicione módulos/telas."); if(impliedMargin < 20 && p.items.length>0) hints.push("Margem implícita < 20%: perigoso (retrabalho/escopo)."); const paySum=p.phases.reduce((a,b)=>a+Number(b.payPct||0),0); if(paySum!==100) hints.push(`Soma % pagamento por fase = ${paySum}%. (normalmente 100%.)`); const usageSum=(p.squads||[]).reduce((a,b)=>a+Number(b.usagePct||0),0); if(usageSum!==100) hints.push(`Soma uso dos squads = ${usageSum}%. (recomendado 100%.)`); if(c.mixWarns.length) hints.push(`Alguns mixes de squad não somam 100%: ${c.mixWarns.join(" | ")}`); $("riskHint").innerHTML = hints.length ? `` : `Sem alertas críticos com as premissas atuais.`; $("phasePill").textContent = `Fase: ${getActivePhase(p)?.name || "—"}`; saveStore(); } /* ---------- Events init ---------- */ function init(){ syncProjectSelect(); syncPresets(); $("projectSelect").addEventListener("change", e=>setActiveProject(e.target.value)); $("newProjectBtn").addEventListener("click", newProject); $("dupProjectBtn").addEventListener("click", duplicateProject); $("delProjectBtn").addEventListener("click", deleteProject); $("exportAllBtn").addEventListener("click", exportAll); $("exportProjectBtn").addEventListener("click", exportProject); $("importAllBtn").addEventListener("click", ()=>$("importFile").click()); $("importFile").addEventListener("change", e=>{ const file=e.target.files?.[0]; if(file) importAll(file); e.target.value=""; }); $("resetAppBtn").addEventListener("click", resetApp); document.querySelectorAll(".tab").forEach(t=>t.addEventListener("click", ()=>setTab(t.getAttribute("data-tab")))); $("phaseSelect").addEventListener("change", ()=>{ const p=getActiveProject(); setActivePhase(p, $("phaseSelect").value); }); $("phaseManageBtn").addEventListener("click", openPhaseModal); $("phaseCloseBtn").addEventListener("click", closePhaseModal); $("phaseBackdrop").addEventListener("click", closePhaseModal); $("phaseAddBtn").addEventListener("click", addPhase); $("phaseSaveBtn").addEventListener("click", savePhaseModal); $("squadManageBtn").addEventListener("click", openSquadModal); $("squadCloseBtn").addEventListener("click", closeSquadModal); $("squadBackdrop").addEventListener("click", closeSquadModal); $("squadAddBtn").addEventListener("click", addSquad); $("squadSaveBtn").addEventListener("click", saveSquadModal); $("libOpenBtn").addEventListener("click", openLibModal); $("libCloseBtn").addEventListener("click", closeLibModal); $("libBackdrop").addEventListener("click", closeLibModal); $("libSearch").addEventListener("input", ()=>{ store.ui.libSearch=$("libSearch").value||""; saveStore(); renderLibrary(); }); $("libFilterType").addEventListener("change", ()=>{ store.ui.libFilterType=$("libFilterType").value; saveStore(); renderLibrary(); }); $("libFilterCat").addEventListener("change", ()=>{ store.ui.libFilterCat=$("libFilterCat").value||""; saveStore(); renderLibrary(); }); $("libNewItemBtn").addEventListener("click", libNewItem); $("libNewTplBtn").addEventListener("click", libNewTpl); $("applySelectionBtn").addEventListener("click", ()=>{ pullUIToProject(); applyLibrarySelection(); }); $("libExportBtn").addEventListener("click", exportLibrary); $("libImportBtn").addEventListener("click", ()=>$("libImportFile").click()); $("libImportFile").addEventListener("change", e=>{ const file=e.target.files?.[0]; if(file) importLibrary(file); e.target.value=""; }); $("applyPresetBtn").addEventListener("click", ()=>{ pullUIToProject(); applyPreset(); }); $("presetSelect").addEventListener("change", ()=>{ store.ui.presetId=$("presetSelect").value; saveStore(); }); $("addItemBtn").addEventListener("click", ()=>{ pullUIToProject(); addItem(); }); $("clearItemBtn").addEventListener("click", clearItemFields); $("itemSearch").addEventListener("input", ()=>{ store.ui.itemSearch=$("itemSearch").value||""; saveStore(); render(); }); $("filterPhase").addEventListener("change", ()=>{ store.ui.filterPhase=$("filterPhase").value||""; saveStore(); render(); }); $("filterCategory").addEventListener("change", ()=>{ store.ui.filterCategory=$("filterCategory").value||""; saveStore(); render(); }); $("filterLayer").addEventListener("change", ()=>{ store.ui.filterLayer=$("filterLayer").value||""; saveStore(); render(); }); $("discountPct").addEventListener("input", ()=>{ store.ui.discountPct=Number($("discountPct").value||0); saveStore(); render(); }); $("bufferPct").addEventListener("input", ()=>{ store.ui.bufferPct=Number($("bufferPct").value||0); saveStore(); render(); }); $("copyProposalBtn").addEventListener("click", copyProposal); $("saveProposalBtn").addEventListener("click", saveProposalText); $("resetProposalBtn").addEventListener("click", resetProposalText); $("exportDocxBtn").addEventListener("click", exportWordDocx); $("exportWordBtn").addEventListener("click", exportWordDoc); $("exportTxtBtn").addEventListener("click", exportTxt); $("printPdfBtn").addEventListener("click", ()=>{ pullUIToProject(); saveStore(); window.print(); }); const liveIds=["projectName","projectType","hoursPerPoint","marginPct","riskBase","startDate","billingModel","projectNotes","libApplyPhaseSelect","bulkQty"]; liveIds.forEach(id=>{ $(id).addEventListener("input", ()=>{ pullUIToProject(); saveStore(); if(id==="projectName"||id==="projectType") syncProjectSelect(); render(); }); $(id).addEventListener("change", ()=>{ pullUIToProject(); saveStore(); if(id==="projectName"||id==="projectType") syncProjectSelect(); render(); }); }); syncUIFromProject(); setTab(store.ui.tab||"tech"); render(); } init(); /* ---------- Lazy check JSZip availability for UI notice ---------- */ (async ()=>{ const ok = await ensureJSZip(); $("docxNotice").style.display = ok ? "none" : ""; })();