Projeto integrador: calculadora de orçamento

Consolidamos lógica, DOM e organização em utilitário real: estimativa de custo de projeto de software por perfil (dev, design, QA). Escopo fechado, código legível, testes manuais documentados.

Requisitos

  • Linhas por perfil: horas × valor hora.
  • Desconto 8% se total de horas > 200.
  • Validação: horas e valores não negativos.
  • UI: formulário + breakdown + total.
  • Persistir último cálculo em localStorage.
Diagrama PlantUML

Módulo de cálculo (puro)

const DESCONTO_LIMITE_HORAS = 200;
const DESCONTO_PERCENTUAL = 0.08;

function calcularOrcamento(linhas) {
  if (!Array.isArray(linhas) || linhas.length === 0) {
    throw new Error('Informe ao menos uma linha');
  }

  const subtotais = linhas.map(({ perfil, horas, valorHora }) => {
    if (!perfil?.trim()) throw new Error('Perfil obrigatório');
    if (horas < 0 || valorHora < 0) throw new Error(`Valores inválidos: ${perfil}`);
    return { perfil, horas, valorHora, total: horas * valorHora };
  });

  const totalHoras = subtotais.reduce((s, l) => s + l.horas, 0);
  let total = subtotais.reduce((s, l) => s + l.total, 0);
  let desconto = 0;

  if (totalHoras > DESCONTO_LIMITE_HORAS) {
    desconto = total * DESCONTO_PERCENTUAL;
    total -= desconto;
  }

  return { subtotais, totalHoras, total, desconto };
}

Integração DOM

function lerLinhasDoFormulario() {
  return [...document.querySelectorAll('[data-linha]')].map(row => ({
    perfil: row.querySelector('[name="perfil"]').value,
    horas: Number(row.querySelector('[name="horas"]').value),
    valorHora: Number(row.querySelector('[name="valorHora"]').value)
  }));
}

function renderResultado(resultado) {
  const alvo = document.querySelector('#resultado');
  alvo.innerHTML = resultado.subtotais.map(l =>
    `<p><strong>${l.perfil}</strong>: ${l.horas}h × R$ ${l.valorHora} = R$ ${l.total.toFixed(2)}</p>`
  ).join('');
  alvo.innerHTML += `<p>Total horas: ${resultado.totalHoras}</p>`;
  if (resultado.desconto) {
    alvo.innerHTML += `<p>Desconto: R$ ${resultado.desconto.toFixed(2)}</p>`;
  }
  alvo.innerHTML += `<p><strong>Total: R$ ${resultado.total.toFixed(2)}</strong></p>`;
}

localStorage

function salvar(resultado) {
  localStorage.setItem('orcamento:v1', JSON.stringify(resultado));
}

function restaurar() {
  const raw = localStorage.getItem('orcamento:v1');
  if (raw) renderResultado(JSON.parse(raw));
}

Checklist de entrega

  1. Funciona com 1 e 5 linhas de perfil.
  2. Erro claro para horas negativas.
  3. 200h exatas: sem desconto; 201h: com desconto.
  4. Recarregar página restaura último resultado.
  5. Código de cálculo isolado em arquivo separado do DOM.

Estrutura de arquivos sugerida

orcamento/
  index.html      ← markup sem lógica de negócio
  styles.css      ← layout e componentes
  calculadora.js  ← calcularOrcamento (puro, testável no Node)
  app.js          ← DOM, eventos, localStorage
  README.md       ← como rodar, casos de teste, decisões

Markup inicial (esqueleto)

<main>
  <h1>Calculadora de orçamento</h1>
  <form id="form-orcamento">
    <div id="linhas">
      <div data-linha>
        <input name="perfil" placeholder="Perfil" required>
        <input name="horas" type="number" min="0" step="1" required>
        <input name="valorHora" type="number" min="0" step="0.01" required>
      </div>
    </div>
    <button type="button" id="btn-add-linha">+ Linha</button>
    <button type="submit">Calcular</button>
  </form>
  <section id="resultado" aria-live="polite"></section>
</main>

Implementação passo a passo

  1. Dia 1: calcularOrcamento no Node com console.log — sem HTML.
  2. Dia 2: formulário estático + submit chama função e exibe resultado em texto.
  3. Dia 3: adicionar/remover linhas dinamicamente; validação visual de erros.
  4. Dia 4: localStorage + polish CSS + README com matriz de testes.

Matriz de testes manuais

CenárioEntradaEsperado
Vaziosubmit sem linhasMensagem de erro
Negativohoras = -1Erro por perfil
Limite desconto200h totaisSem desconto
Acima limite201h totais8% sobre total
Persistênciacalcular → F5Resultado restaurado

Extensões opcionais (se sobrar tempo)

  • Exportar resultado como JSON ou CSV.
  • Modo escuro via prefers-color-scheme.
  • Testes automatizados de calcularOrcamento com Node (sem framework, assert nativo).

Este projeto vira peça de portfolio — publique na semana 4 com GitHub Pages.

Para aprofundar na web

Para entender melhor este tema, pesquise por:

  • "separar lógica apresentação JavaScript" — módulos puros vs DOM
  • "localStorage API MDN" — persistência no navegador
  • "validação formulário HTML5 JavaScript" — camadas cliente e regras de negócio
  • "Node assert testar função pura" — testes mínimos sem framework
  • "README projeto portfolio GitHub exemplo" — documentar para recrutadores

Grave GIF curto ou screenshot do projeto funcionando — evidência visual no portfolio.

Atividades

  1. Por que isolar calcularOrcamento() do DOM?

    • A) HTML proíbe funções
    • B) Testar regras de negócio sem navegador
    • C) localStorage exige
    • D) CSS Grid
    Ver resposta

    Resposta correta: B) Testar regras de negócio sem navegador

    Lógica pura é testável no Node e reutilizável.

  2. Com 250 horas totais, desconto incide sobre:

    • A) Cada perfil isolado
    • B) Total monetário agregado
    • C) Só QA
    • D) Nunca
    Ver resposta

    Resposta correta: B) Total monetário agregado

    Regra aplica percentual sobre soma dos subtotais.

  3. Cite três casos de teste manual obrigatórios antes de considerar o projeto pronto.

    Ver resposta

    Horas negativas; limite 200 vs 201 horas; formulário vazio; persistência após reload; múltiplos perfis.