import { useEffect, useMemo, useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; import jsPDF from "jspdf"; import autoTable from "jspdf-autotable"; import { DEFAULT_CATALOG, EMPRESA, type Catalog } from "@/lib/catalog"; import logoUrl from "@/assets/logo.png"; type TipoDesconto = "valor" | "percent"; export const Route = createFileRoute("/")({ head: () => ({ meta: [ { title: "Orçamentos — Viveiro Valdemar Depiné" }, { name: "description", content: "Sistema de orçamentos do Viveiro Valdemar Depiné. Mudas frutíferas, nativas e ornamentais. Geração de PDF, envio por WhatsApp.", }, ], }), component: OrcamentoApp, }); interface ItemOrcamento { id: string; categoria: string; nome: string; preco: number; quantidade: number; } interface Cliente { nome: string; telefone: string; cidade: string; observacoes: string; } const LS_PRECOS = "vvd:precos"; const LS_TEMA = "vvd:tema"; const LS_NUMERO = "vvd:numero"; const LS_CLIENTE = "vvd:cliente"; const LS_ITENS = "vvd:itens"; const LS_FIN = "vvd:fin"; const LS_VALIDADE = "vvd:validade"; const LS_FRETE_GRATIS = "vvd:fretegratis"; const LS_TIPO_DESC = "vvd:tipodesc"; function brl(n: number) { return n.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }); } function maskTel(v: string) { const d = v.replace(/\D/g, "").slice(0, 11); if (d.length <= 10) return d.replace(/^(\d{0,2})(\d{0,4})(\d{0,4}).*/, (_, a, b, c) => [a && `(${a}`, a && a.length === 2 ? ") " : "", b, c && `-${c}`].filter(Boolean).join(""), ); return d.replace(/^(\d{2})(\d{5})(\d{0,4}).*/, "($1) $2-$3"); } function loadJSON(key: string, fallback: T): T { if (typeof window === "undefined") return fallback; try { const raw = localStorage.getItem(key); return raw ? (JSON.parse(raw) as T) : fallback; } catch { return fallback; } } function OrcamentoApp() { const [tema, setTema] = useState<"light" | "dark">("light"); const [catalogo, setCatalogo] = useState(DEFAULT_CATALOG); const [categoriaSel, setCategoriaSel] = useState("Todas"); const [busca, setBusca] = useState(""); const [cliente, setCliente] = useState({ nome: "", telefone: "", cidade: "", observacoes: "", }); const [itens, setItens] = useState([]); const [desconto, setDesconto] = useState(0); const [tipoDesconto, setTipoDesconto] = useState("valor"); const [frete, setFrete] = useState(0); const [freteGratis, setFreteGratis] = useState(false); const [numero, setNumero] = useState(1); const [validadeDias, setValidadeDias] = useState(7); const [toast, setToast] = useState(null); const [logoData, setLogoData] = useState(null); const [mounted, setMounted] = useState(false); // Load persisted state useEffect(() => { setMounted(true); const t = (localStorage.getItem(LS_TEMA) as "light" | "dark") || "light"; setTema(t); const precos = loadJSON(LS_PRECOS, null); if (precos) setCatalogo(precos); setNumero(loadJSON(LS_NUMERO, 1)); setCliente(loadJSON(LS_CLIENTE, cliente)); setItens(loadJSON(LS_ITENS, [])); const fin = loadJSON<{ desconto: number; frete: number }>(LS_FIN, { desconto: 0, frete: 0 }); setDesconto(fin.desconto); setFrete(fin.frete); setFreteGratis(loadJSON(LS_FRETE_GRATIS, false)); setTipoDesconto(loadJSON(LS_TIPO_DESC, "valor")); setValidadeDias(loadJSON(LS_VALIDADE, 7)); // Carrega o logo como dataURL para uso no PDF fetch(logoUrl) .then((r) => r.blob()) .then( (b) => new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result as string); fr.onerror = rej; fr.readAsDataURL(b); }), ) .then(setLogoData) .catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { document.documentElement.classList.toggle("dark", tema === "dark"); localStorage.setItem(LS_TEMA, tema); }, [tema]); useEffect(() => localStorage.setItem(LS_PRECOS, JSON.stringify(catalogo)), [catalogo]); useEffect(() => localStorage.setItem(LS_CLIENTE, JSON.stringify(cliente)), [cliente]); useEffect(() => localStorage.setItem(LS_ITENS, JSON.stringify(itens)), [itens]); useEffect( () => localStorage.setItem(LS_FIN, JSON.stringify({ desconto, frete })), [desconto, frete], ); useEffect( () => localStorage.setItem(LS_FRETE_GRATIS, JSON.stringify(freteGratis)), [freteGratis], ); useEffect(() => localStorage.setItem(LS_TIPO_DESC, JSON.stringify(tipoDesconto)), [tipoDesconto]); useEffect(() => localStorage.setItem(LS_NUMERO, JSON.stringify(numero)), [numero]); useEffect(() => localStorage.setItem(LS_VALIDADE, JSON.stringify(validadeDias)), [validadeDias]); const categorias = useMemo(() => ["Todas", ...Object.keys(catalogo)], [catalogo]); const produtosFiltrados = useMemo(() => { const q = busca.trim().toLowerCase(); const cats = categoriaSel === "Todas" ? Object.keys(catalogo) : [categoriaSel]; const out: { categoria: string; nome: string; preco: number }[] = []; for (const c of cats) { for (const p of catalogo[c] || []) { if (!q || p.nome.toLowerCase().includes(q) || c.toLowerCase().includes(q)) out.push({ categoria: c, ...p }); } } return out.slice(0, 80); }, [catalogo, categoriaSel, busca]); const subtotal = itens.reduce((s, i) => s + i.preco * i.quantidade, 0); // Desconto incide somente sobre o subtotal das plantas (sem frete) const descontoValor = tipoDesconto === "percent" ? subtotal * (Math.min(100, Math.max(0, desconto || 0)) / 100) : Math.max(0, desconto || 0); const freteValor = Math.max(0, frete || 0); // Quando frete grátis, gera um desconto equivalente ao valor do frete const descontoFrete = freteGratis ? freteValor : 0; const totalFinal = Math.max(0, subtotal - descontoValor + freteValor - descontoFrete); const dataAtual = useMemo(() => { if (!mounted) return ""; const d = new Date(); return d.toLocaleDateString("pt-BR"); }, [mounted]); const numeroFmt = String(numero).padStart(4, "0"); function showToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 2400); } function addProduto(categoria: string, nome: string, preco: number) { const id = `${categoria}::${nome}::${Date.now()}`; setItens((arr) => [...arr, { id, categoria, nome, preco, quantidade: 1 }]); } function updateItem(id: string, patch: Partial) { setItens((arr) => arr.map((i) => i.id === id ? { ...i, ...patch, quantidade: Math.max(1, Number(patch.quantidade ?? i.quantidade) || 1), preco: Math.max(0, Number(patch.preco ?? i.preco) || 0), } : i, ), ); } function removeItem(id: string) { setItens((arr) => arr.filter((i) => i.id !== id)); } function updateCatalogPreco(categoria: string, nome: string, preco: number) { setCatalogo((c) => ({ ...c, [categoria]: c[categoria].map((p) => (p.nome === nome ? { ...p, preco } : p)), })); } function novoOrcamento() { if (!confirm("Iniciar novo orçamento? Os dados atuais serão limpos.")) return; setCliente({ nome: "", telefone: "", cidade: "", observacoes: "" }); setItens([]); setDesconto(0); setFrete(0); setFreteGratis(false); setNumero((n) => n + 1); showToast("Novo orçamento iniciado"); } function validar(): string | null { if (!cliente.nome.trim()) return "Preencha o nome do cliente"; if (itens.length === 0) return "Adicione pelo menos um produto"; return null; } function gerarPDF(action: "download" | "share" = "download") { const err = validar(); if (err) { showToast(err); return; } const doc = new jsPDF({ unit: "mm", format: "a4" }); const W = doc.internal.pageSize.getWidth(); const H = doc.internal.pageSize.getHeight(); // Header band doc.setFillColor(34, 89, 50); doc.rect(0, 0, W, 32, "F"); // Logo no cabeçalho if (logoData) { try { doc.addImage(logoData, "PNG", W - 14 - 28, 3, 28, 26); } catch { /* ignore */ } } doc.setTextColor(255, 255, 255); doc.setFont("helvetica", "bold"); doc.setFontSize(16); doc.text(EMPRESA.nome.toUpperCase(), 14, 14); doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.text(EMPRESA.slogan, 14, 20); doc.text(EMPRESA.endereco, 14, 25); doc.text(`WhatsApp: ${EMPRESA.whatsapp1} | ${EMPRESA.whatsapp2}`, 14, 30); doc.setFontSize(10); doc.setFont("helvetica", "bold"); doc.text(`ORÇAMENTO Nº ${numeroFmt}`, 14, 38); doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(60, 60, 60); doc.text(`Data: ${dataAtual}`, W - 14, 38, { align: "right" }); const validadeData = new Date(); validadeData.setDate(validadeData.getDate() + validadeDias); doc.text(`Validade: ${validadeData.toLocaleDateString("pt-BR")}`, W - 14, 43, { align: "right", }); // Marca d'água (logo grande e translúcida no centro) if (logoData) { try { const gState = (doc as unknown as { GState: new (o: { opacity: number }) => unknown; setGState: (g: unknown) => void; }); gState.setGState(new gState.GState({ opacity: 0.08 })); const wmW = 140; const wmH = 130; doc.addImage(logoData, "PNG", (W - wmW) / 2, (H - wmH) / 2, wmW, wmH); gState.setGState(new gState.GState({ opacity: 1 })); } catch { /* ignore */ } } // Cliente let y = 52; doc.setTextColor(40, 40, 40); doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.text("DADOS DO CLIENTE", 14, y); doc.setDrawColor(34, 89, 50); doc.line(14, y + 1.5, W - 14, y + 1.5); y += 7; doc.setFont("helvetica", "normal"); doc.setFontSize(10); doc.text(`Cliente: ${cliente.nome}`, 14, y); doc.text(`Telefone: ${cliente.telefone || "-"}`, W / 2, y); y += 5; doc.text(`Cidade: ${cliente.cidade || "-"}`, 14, y); y += 6; // Produtos autoTable(doc, { startY: y, head: [["#", "Produto", "Categoria", "Qtd.", "Preço Un.", "Subtotal"]], body: itens.map((it, idx) => [ String(idx + 1), it.nome, it.categoria, String(it.quantidade), brl(it.preco), brl(it.preco * it.quantidade), ]), styles: { fontSize: 9, cellPadding: 2 }, headStyles: { fillColor: [34, 89, 50], textColor: 255 }, alternateRowStyles: { fillColor: [240, 246, 240] }, columnStyles: { 0: { cellWidth: 10, halign: "center" }, 3: { halign: "center", cellWidth: 16 }, 4: { halign: "right", cellWidth: 28 }, 5: { halign: "right", cellWidth: 30 }, }, margin: { left: 14, right: 14 }, }); // @ts-expect-error lastAutoTable injected const afterY = (doc.lastAutoTable?.finalY || y) + 6; // Totais const boxX = W - 14 - 90; const boxH = freteGratis ? 44 : 38; doc.setDrawColor(34, 89, 50); doc.setLineWidth(0.3); doc.roundedRect(boxX, afterY, 90, boxH, 2, 2); doc.setFontSize(10); doc.setFont("helvetica", "normal"); let ly = afterY + 7; doc.text("Subtotal:", boxX + 4, ly); doc.text(brl(subtotal), boxX + 86, ly, { align: "right" }); ly += 6; const descLabel = tipoDesconto === "percent" ? `Desconto (${desconto || 0}%):` : "Desconto:"; doc.text(descLabel, boxX + 4, ly); doc.text(`- ${brl(descontoValor)}`, boxX + 86, ly, { align: "right" }); ly += 6; doc.text("Frete:", boxX + 4, ly); doc.text(`+ ${brl(freteValor)}`, boxX + 86, ly, { align: "right" }); ly += 6; if (freteGratis) { doc.setTextColor(180, 60, 60); doc.text("Frete Grátis (cortesia):", boxX + 4, ly); doc.text(`- ${brl(freteValor)}`, boxX + 86, ly, { align: "right" }); doc.setTextColor(40, 40, 40); ly += 6; } doc.setFont("helvetica", "bold"); doc.setFontSize(12); doc.setTextColor(34, 89, 50); doc.text("TOTAL:", boxX + 4, ly + 2); doc.text(brl(totalFinal), boxX + 86, ly + 2, { align: "right" }); doc.setTextColor(40, 40, 40); if (cliente.observacoes) { const obsY = afterY + 38; doc.setFont("helvetica", "bold"); doc.setFontSize(10); doc.text("Observações:", 14, obsY); doc.setFont("helvetica", "normal"); const lines = doc.splitTextToSize(cliente.observacoes, W - 28); doc.text(lines, 14, obsY + 5); } // Footer doc.setDrawColor(34, 89, 50); doc.line(14, H - 24, W - 14, H - 24); doc.setFontSize(8); doc.setTextColor(70, 70, 70); doc.text(EMPRESA.site, 14, H - 18); doc.text(EMPRESA.instagram, 14, H - 14); doc.text(`CNPJ: ${EMPRESA.cnpj}`, 14, H - 10); doc.text(`PIX: ${EMPRESA.pix}`, W - 14, H - 10, { align: "right" }); doc.text("Assinatura: ______________________________", W - 14, H - 18, { align: "right" }); const filename = `Orcamento_${numeroFmt}_${cliente.nome.replace(/\s+/g, "_")}.pdf`; if (action === "download") { doc.save(filename); showToast("PDF gerado com sucesso"); } else { const blob = doc.output("blob"); const url = URL.createObjectURL(blob); window.open(url, "_blank"); } } function imprimir() { const err = validar(); if (err) { showToast(err); return; } window.print(); } function enviarWhatsApp() { const err = validar(); if (err) { showToast(err); return; } const msg = `Olá ${cliente.nome}, segue seu orçamento Nº ${numeroFmt} do Viveiro Valdemar Depiné no valor total de ${brl(totalFinal)}. Validade ${validadeDias} dias.`; const tel = (cliente.telefone || "").replace(/\D/g, ""); const numeroWa = tel.length >= 10 ? `55${tel}` : EMPRESA.whatsappLink; window.open(`https://wa.me/${numeroWa}?text=${encodeURIComponent(msg)}`, "_blank"); } return (
{/* Header */}
Viveiro Valdemar Depiné
{EMPRESA.nome}
Sistema de Orçamentos
Orçamento Nº {numeroFmt}
{/* Coluna esquerda: cliente + catálogo */}
{/* Cliente */}

Dados do cliente

setCliente({ ...cliente, nome: e.target.value })} className={inputCls} placeholder="Nome completo" /> setCliente({ ...cliente, telefone: maskTel(e.target.value) })} className={inputCls} placeholder="(47) 99999-9999" /> setCliente({ ...cliente, cidade: e.target.value })} className={inputCls} placeholder="Cidade / UF" /> setValidadeDias(Math.max(1, Number(e.target.value) || 7)) } className={inputCls} />