/* 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 (
| Picolezeiro |
Saídas |
Faturado |
Comissão |
Comissão % |
Performance |
{data.map((d, i) => (
{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))}} />
| Praia |
Município |
Idas |
Enviadas |
Vendidas |
Faturado |
{rows.map(d => {
const b = ctx.state.beaches.find(x => x.id === d.id);
return (
| {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 (
| Distribuidor |
Cidade |
Compras |
Volume |
Ticket médio |
Participação |
{list.map(d => (
| {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 (
| Produto |
Linha |
Unidades |
Faturado |
Participação |
{data.map(d => (
| {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.
) : (
| Produto |
Saídas |
Sobras |
Taxa |
Performance |
{rows.map(r => (
| {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.
) : (
| Insumo |
Consumido |
Custo total |
Estoque restante |
Cobertura estimada |
{rows.map(r => {
const days = r.used > 0 ? Math.floor(r.inp.stock / Math.max(r.used / 30, 0.1)) : 999;
return (
| {r.inp.name} |
{r.used} {r.inp.unit} |
{fmtBRL(r.cost)} |
{r.inp.stock} {r.inp.unit} |
~{days > 99 ? "—" : days+"d"}
|
);
})}
)}
);
}
window.Relatorios = Relatorios;