/* Relatórios — múltiplas visões */ function Relatorios({ ctx }) { const [report, setReport] = useState("geral"); const [period, setPeriod] = useState({ kind: "month" }); const [allInOne, setAllInOne] = useState(false); const refDate = DEFAULT_REF_DATE; const r = periodRange(period, refDate); const printReport = () => { // captura o conteúdo do bloco de relatórios e abre em janela de impressão const block = document.getElementById("reports-print-area"); if (!block) return; const styles = Array.from(document.querySelectorAll("link[rel='stylesheet'], style")).map(el => el.outerHTML).join("\n"); const logo = ctx.state.company.logo; const logoIsImage = logo && !/^data:application\/pdf/i.test(logo); const header = `
${logoIsImage ? `` : `
P
`}
Relatórios — ${ctx.state.company.name}
Período: ${r.label} · Gerado em ${new Date().toLocaleString("pt-BR")}
`; const win = window.open("", "_blank", "width=1100,height=900"); win.document.open(); win.document.write(`Relatórios — ${r.label} ${styles}

Relatórios — ${r.label}

${header}${block.innerHTML}
`); win.document.close(); }; return ( <>

Relatórios

Análises detalhadas e exportáveis de toda a operação

{allInOne ? (
) : (
{report === "geral" && } {report === "picolezeiros" && } {report === "praias" && } {report === "distribuidores" && } {report === "produtos" && } {report === "sobras" && } {report === "insumos" && }
)} ); } function RGeral({ ctx, period, refDate }) { const inP = (dateStr) => period ? inPeriod(dateStr, period, refDate) : true; const fin = ctx.state.finance.filter(f => inP(f.date)); const ins = fin.filter(f => f.kind === "in").reduce((s, f) => s + f.value, 0); const outs = fin.filter(f => f.kind === "out").reduce((s, f) => s + f.value, 0); const inputCosts = fin.filter(f => f.cat === "Compra de Insumo" || f.cat === "Produção").reduce((s, f) => s + f.value, 0); const margin = ins > 0 ? ((ins - inputCosts) / ins) * 100 : 0; const commissions = fin.filter(f => f.cat === "Comissão Picolezeiro").reduce((s, f) => s + f.value, 0); return ( <>
} label="Faturamento" value={fmtBRL(ins)} sub="Entradas confirmadas" /> } label="Margem bruta" value={margin.toFixed(1) + "%"} sub="Faturamento − insumos" /> } label="Comissões pagas" value={fmtBRL(commissions)} sub={`${ctx.state.picolezeiros.length} picolezeiros`} /> } label="Saídas totais" value={fmtBRL(outs)} sub="Custos + despesas" />
{ins === 0 ? (

Sem dados financeiros no período.

) : (
{["Venda Distribuidor", "Retorno Picolezeiro", "Outras Entradas"].map(cat => { const v = fin.filter(f => f.cat === cat && f.kind === "in").reduce((s, f) => s + f.value, 0); if (v === 0) return null; const pct = (v / ins) * 100; return (
{cat} {fmtBRL(v)} · {pct.toFixed(0)}%
); })}
)}
); } function RPicolezeiros({ ctx }) { const data = ctx.state.picolezeiros.map(p => ({ p, saidas: p.totals.saidas, vendido: p.totals.vendido, comissao: p.totals.comissao, })).sort((a,b)=>b.vendido-a.vendido); if (data.length === 0) { return

Sem picolezeiros cadastrados

Cadastre em Clientes para gerar este relatório.

; } const max = Math.max(1, ...data.map(d => d.vendido)); return ( {data.map((d, i) => ( ))}
Picolezeiro Saídas Faturado Comissão Comissão % Performance
{d.p.photo}
{d.p.name}
{d.p.commission}% padrão
{d.saidas} {fmtBRL(d.vendido)} {fmtBRL(d.comissao)} {d.p.commission}%
); } function RPraias({ ctx }) { // Aggregate from closed trips const byBeach = {}; ctx.state.trips.filter(t => t.status === "fechado").forEach(t => { const beaches = t.beachesActual || []; if (beaches.length === 0) return; const perBeach = (t.faturado || 0) / beaches.length; const unitsSold = (t.sold || []).reduce((s, x) => s + x.qty, 0); const unitsTotal = t.items.reduce((s, x) => s + x.qty, 0); beaches.forEach(bid => { if (!byBeach[bid]) byBeach[bid] = { trips:0, units:0, sold:0, faturado:0 }; byBeach[bid].trips++; byBeach[bid].units += unitsTotal / beaches.length; byBeach[bid].sold += unitsSold / beaches.length; byBeach[bid].faturado += perBeach; }); }); const rows = Object.entries(byBeach).map(([id, v]) => ({ id, ...v })).sort((a,b)=>b.faturado-a.faturado); if (rows.length === 0) { return

Sem dados de praias

Os dados aparecem após fechar retornos de picolezeiros.

; } const max = rows[0].faturado; return ( <>
{rows.slice(0,3).map((p, i) => { const b = ctx.state.beaches.find(x => x.id === p.id); return (
{i+1}
{b?.name}
{b?.city} · {Math.round(p.trips)} idas
{fmtBRL(p.faturado)}
); })}
{Math.round(rows.reduce((s,x)=>s+x.trips,0))}} /> {fmtInt(Math.round(rows.reduce((s,x)=>s+x.units,0)))}} /> {fmtInt(Math.round(rows.reduce((s,x)=>s+x.sold,0)))}} /> {fmtBRL(rows.reduce((s,x)=>s+x.faturado,0))}} />
{rows.map(d => { const b = ctx.state.beaches.find(x => x.id === d.id); return ( ); })}
Praia Município Idas Enviadas Vendidas Faturado
{b?.name} {b?.city} {Math.round(d.trips)} {Math.round(d.units)} {Math.round(d.sold)} {fmtBRL(d.faturado)}
); } function Row({ label, value }) { return
{label}{value}
; } function RDistribuidores({ ctx }) { const list = ctx.state.distributors.slice().sort((a,b)=>b.totals.valor - a.totals.valor); if (list.length === 0) return

Sem distribuidores

; const max = list[0]?.totals.valor || 1; return ( {list.map(d => ( ))}
Distribuidor Cidade Compras Volume Ticket médio Participação
{d.name} {d.city} {d.totals.compras} {fmtBRL(d.totals.valor)} {fmtBRL(d.totals.compras > 0 ? d.totals.valor / d.totals.compras : 0)}
); } function RProdutos({ ctx }) { // Per-product sold units from closed trips const sold = {}; ctx.state.trips.filter(t => t.status === "fechado").forEach(t => { (t.sold || []).forEach(s => { sold[s.productId] = (sold[s.productId] || 0) + s.qty; }); }); const data = ctx.state.products .map(p => ({ p, units: sold[p.id] || 0 })) .filter(x => x.units > 0) .sort((a,b)=>b.units-a.units); if (data.length === 0) { return

Sem vendas registradas

Os dados aparecem após fechar retornos.

; } return ( {data.map(d => ( ))}
Produto Linha Unidades Faturado Participação
{d.p.flavor} {d.p.line} {d.units} {fmtBRL(d.units * d.p.price)}
); } function RSobras({ ctx }) { // From closed trips const sobrasByProduct = {}; const saidasByProduct = {}; ctx.state.trips.filter(t => t.status === "fechado").forEach(t => { t.items.forEach(it => { saidasByProduct[it.productId] = (saidasByProduct[it.productId] || 0) + it.qty; }); (t.returned || []).forEach(r => { sobrasByProduct[r.productId] = (sobrasByProduct[r.productId] || 0) + r.qty; }); }); const totalSaidas = Object.values(saidasByProduct).reduce((s,x)=>s+x, 0); const totalSobras = Object.values(sobrasByProduct).reduce((s,x)=>s+x, 0); const taxaGeral = totalSaidas > 0 ? (totalSobras / totalSaidas * 100) : 0; const rows = Object.keys(saidasByProduct).map(pid => { const p = ctx.state.products.find(x => x.id === pid); const saidas = saidasByProduct[pid] || 0; const sobras = sobrasByProduct[pid] || 0; return { p, saidas, sobras, taxa: saidas > 0 ? (sobras/saidas*100) : 0 }; }).filter(r => r.p).sort((a,b)=>b.sobras-a.sobras); return ( <>
} label="Total de sobras" value={Math.round(totalSobras) + " un"} sub="Voltadas ao estoque" /> } label="Taxa de sobra" value={taxaGeral.toFixed(1) + "%"} sub={`${Math.round(totalSaidas)} un total em saídas`} /> } label="Sabores com sobra" value={rows.filter(r => r.sobras > 0).length + ""} sub={`de ${rows.length} sabores`} />
{rows.length === 0 ? (

Sem dados de sobras

Os dados aparecem após fechar retornos.

) : ( {rows.map(r => ( ))}
Produto Saídas Sobras Taxa Performance
{r.p.flavor} {r.saidas} {r.sobras} 10 ? "var(--warning)" : r.taxa > 5 ? "var(--ink-700)" : "var(--success)" }}>{r.taxa.toFixed(1)}% 15 ? "danger" : r.taxa > 8 ? "warn" : "ok"} />
)} ); } function RInsumos({ ctx }) { // From recipes, sum required usage across all production const used = {}; const prodLogs = ctx.state.finance.filter(f => f.cat === "Produção"); // Match by recipe name in ref — heuristic ctx.state.recipes.forEach(r => { const matches = prodLogs.filter(p => p.ref.startsWith(r.name)); // each ref ends with "(Nx)" — extract batches matches.forEach(m => { const m2 = m.ref.match(/\((\d+)x\)/); const batches = m2 ? parseInt(m2[1], 10) : 1; r.items.forEach(it => { used[it.inputId] = (used[it.inputId] || 0) + it.qty * batches; }); }); }); const rows = ctx.state.inputs.map(inp => ({ inp, used: used[inp.id] || 0, cost: (used[inp.id] || 0) * inp.unitCost, })).sort((a,b)=>b.cost-a.cost); return ( {rows.length === 0 || rows.every(r => r.used === 0) ? (

Sem consumo registrado

Registre produções em Fabricação para gerar este relatório.

) : ( {rows.map(r => { const days = r.used > 0 ? Math.floor(r.inp.stock / Math.max(r.used / 30, 0.1)) : 999; return ( ); })}
Insumo Consumido Custo total Estoque restante Cobertura estimada
{r.inp.name} {r.used} {r.inp.unit} {fmtBRL(r.cost)} {r.inp.stock} {r.inp.unit}
~{days > 99 ? "—" : days+"d"}
)}
); } window.Relatorios = Relatorios;