setPayExpenseTarget(null)}
onConfirm={(payload) => { ctx.actions.payExpense({ expenseId: payExpenseTarget.id, ...payload }); setPayExpenseTarget(null); }}
/>
)}
>
);
}
/* CTA destacado: "Comprar insumo" — mais visível que um botão neutro,
mostra também pendências de compras a pagar para incentivar o reabastecimento. */
function BuyInputButton({ ctx, onBuy }) {
const inputs = ctx.state.inputs || [];
const critical = inputs.filter(i => i.stock < i.min).length;
const pendingPurchases = (ctx.state.inputPurchases || []).filter(p => p.paid < p.total).length;
const disabled = inputs.length === 0;
return (
{ if (!disabled) e.currentTarget.style.transform = "translateY(-1px)"; }}
onMouseLeave={e => { e.currentTarget.style.transform = "translateY(0)"; }}
>
Comprar insumo
{critical > 0
? `${critical} item(s) abaixo do mínimo`
: pendingPurchases > 0
? `${pendingPurchases} compra(s) a pagar`
: "À vista, parcelado ou fiado"}
);
}
function InsumosTab({ ctx, onEdit, onBuy }) {
const inputs = ctx.state.inputs;
const total = inputs.reduce((s, i) => s + i.stock * i.unitCost, 0);
const critical = inputs.filter(i => i.stock < i.min).length;
const onDelete = (i) => ctx.askConfirm({
title: "Excluir insumo?",
message: `Excluir "${i.name}"?`,
detail: "Receitas que usam este insumo precisarão ser ajustadas.",
danger: true,
onConfirm: () => ctx.actions.deleteInput(i.id),
});
return (
<>
} label="Itens em estoque" value={fmtInt(inputs.length)} sub="Em diferentes unidades" />
} label="Valor estocado" value={fmtBRL(total)} sub="Custo a preços atuais" />
} label="Itens críticos" value={critical + ""} sub="abaixo do mínimo" />
0 ? : null}
>
{inputs.length === 0 ? (
Nenhum insumo cadastrado
Cadastre matérias-primas para criar receitas e registrar produção.
) : (
Insumo
Unidade
Estoque
Margem
Custo unit.
Valor estoque
Última compra
Validade
{inputs.map(i => {
const ratio = i.min > 0 ? i.stock / i.min : 2;
const tone = ratio < 1 ? "danger" : (ratio < 1.5 ? "warning" : "ok");
const lastPurchase = (ctx.state.inputPurchases || [])
.filter(p => p.items.some(it => it.inputId === i.id))
.sort((a,b) => b.date.localeCompare(a.date))[0];
return (
{i.name}
{i.unit}
{fmtInt(i.stock)} {i.unit}
{fmtBRL(i.unitCost)}
{fmtBRL(i.unitCost * i.stock)}
{lastPurchase ? (
{fmtDate(lastPurchase.date)}
{paymentChip(lastPurchase.payment)}
{lastPurchase.paid < lastPurchase.total && pendente }
) : — }
{i.expires ? fmtDate(i.expires) : "—"}
onEdit(i)}>
onDelete(i)}>
);
})}
)}
>
);
}
function ReceitasTab({ ctx, onEdit, onProduce }) {
const onDelete = (r) => ctx.askConfirm({
title: "Excluir receita?",
message: `Excluir "${r.name}"?`,
danger: true,
onConfirm: () => ctx.actions.deleteRecipe(r.id),
});
if (ctx.state.recipes.length === 0) {
return (
Nenhuma receita cadastrada
{ctx.state.inputs.length === 0 ? "Cadastre insumos primeiro, depois crie receitas." : "Crie a primeira receita para começar a produzir."}
);
}
return (
{ctx.state.recipes.map(r => {
const product = ctx.state.products.find(p => p.id === r.productId);
if (!product) return null;
const cost = r.items.reduce((s, it) => {
const inp = ctx.state.inputs.find(x => x.id === it.inputId);
return s + (inp?.unitCost || 0) * it.qty;
}, 0);
const costPerUnit = r.yield > 0 ? cost / r.yield : 0;
const margin = product.price > 0 ? ((product.price - costPerUnit) / product.price) * 100 : 0;
return (
{product.flavor}}
>
{r.items.map(it => {
const inp = ctx.state.inputs.find(x => x.id === it.inputId);
const subtotal = (inp?.unitCost || 0) * it.qty;
return (
{inp?.name || insumo removido }
{it.qty} {inp?.unit || ""}
{fmtBRL(subtotal)}
);
})}
{fmtBRL(cost)} Custo da batelada
{fmtBRL(costPerUnit)} Custo por unidade
50 ? "var(--success)" : "var(--warning)" }}>{margin.toFixed(0)}% Margem bruta
} onClick={() => onProduce(r)}>Produzir
} onClick={() => onEdit(r)}>Editar
} onClick={() => onDelete(r)} />
);
})}
);
}
function ProducaoTab({ ctx }) {
// Build history from finance ("Produção" category)
const history = ctx.state.finance.filter(f => f.cat === "Produção").map((f, i) => ({
id: f.id, date: f.date, ref: f.ref, value: f.value
}));
const today = new Date().toISOString().slice(0,10);
const todayCost = ctx.state.finance.filter(f => f.cat === "Produção" && f.date === today).reduce((s,x) => s+x.value, 0);
// Estoque de produtos fabricados (somado em todos os armazéns)
const stockByProduct = ctx.state.products.map(p => {
const rows = ctx.state.stock.filter(s => s.productId === p.id);
return {
product: p,
units: rows.reduce((sm, r) => sm + r.units, 0),
minUnits: rows.reduce((sm, r) => sm + r.minUnits, 0),
perBox: p.boxSize || 60,
};
});
const totalUnitsStock = stockByProduct.reduce((s, x) => s + x.units, 0);
const criticalProducts = stockByProduct.filter(x => x.units < x.minUnits).length;
return (
<>
} label="Receitas cadastradas" value={ctx.state.recipes.length+""} sub="Disponíveis para produção" />
} label="Unidades em estoque" value={fmtInt(totalUnitsStock)} sub={`${stockByProduct.filter(x => x.units > 0).length} sabor(es) com produção`} />
} label="Produções registradas" value={history.length+""} sub="Histórico completo" />
} label="Custo da produção hoje" value={fmtBRL(todayCost)} sub="Insumos consumidos" />
Produto
Estoque (un)
Caixas equivalentes
Preço un.
Valor estoque
Status
{stockByProduct
.sort((a, b) => b.units - a.units)
.map(x => {
const boxes = Math.floor(x.units / x.perBox);
const looseRest = x.units % x.perBox;
const isCritical = x.units < x.minUnits;
const isEmpty = x.units === 0;
return (
{x.product.flavor}
{x.product.line}
{fmtInt(x.units)}
{boxes} cx{looseRest > 0 ? ` + ${looseRest} un` : ""} ({x.perBox}/cx)
{fmtBRL(x.product.price)}
{fmtBRL(x.units * x.product.price)}
{isEmpty
? esgotado
: isCritical
? crítico
: ok }
);
})}
{history.length === 0 ? (
Nenhuma produção registrada
Use o botão "Produzir" em uma receita para registrar a primeira produção.
) : (
Lote
Data
Receita / Lote
Custo
{history.map(h => (
{h.id}
{fmtDate(h.date)}
{h.ref}
{fmtBRL(h.value)}
concluída
))}
)}
>
);
}
function InsumoModal({ initial, onClose, onSave }) {
const [name, setName] = useState(initial?.name || "");
const [stock, setStock] = useState(initial?.stock != null ? String(initial.stock) : "");
const [unit, setUnit] = useState(initial?.unit || "kg");
const [cost, setCost] = useState(initial?.unitCost != null ? String(initial.unitCost) : "");
const [min, setMin] = useState(initial?.min != null ? String(initial.min) : "");
const [expires, setExpires] = useState(initial?.expires || "");
const [err, setErr] = useState({});
const submit = () => {
const e = {};
if (!name.trim()) e.name = "Informe o nome";
if (!stock || isNaN(parseFloat(stock))) e.stock = "Quantidade inválida";
if (!cost || isNaN(parseFloat(cost))) e.cost = "Custo inválido";
setErr(e);
if (Object.keys(e).length) return;
onSave({
name: name.trim(), stock: parseFloat(stock), unit,
unitCost: parseFloat(cost), min: parseFloat(min || 0), expires
});
};
return (
Cancelar {initial ? "Salvar alterações" : "Salvar insumo"} >}
>
setName(e.target.value)} autoFocus />
setStock(e.target.value.replace(/[^\d.,]/g,"").replace(",","."))} />
setUnit(e.target.value)}>
kg L un g ml
setCost(e.target.value.replace(/[^\d.,]/g,"").replace(",","."))} />
setMin(e.target.value.replace(/[^\d.,]/g,"").replace(",","."))} />
setExpires(e.target.value)} />
);
}
function ReceitaModal({ ctx, initial, onClose, onSave }) {
const [name, setName] = useState(initial?.name || "");
const [productId, setProductId] = useState(initial?.productId || ctx.state.products[0]?.id || "");
const [yld, setYld] = useState(initial?.yield != null ? String(initial.yield) : "60");
const [items, setItems] = useState(initial?.items || []);
const [err, setErr] = useState({});
const addRow = () => setItems([...items, { inputId: ctx.state.inputs[0]?.id || "", qty: 1 }]);
const setRow = (i, patch) => setItems(items.map((x, idx) => idx === i ? { ...x, ...patch } : x));
const delRow = (i) => setItems(items.filter((_, idx) => idx !== i));
const submit = () => {
const e = {};
if (!name.trim()) e.name = "Informe o nome";
if (!productId) e.productId = "Selecione o sabor";
if (!yld || isNaN(parseInt(yld, 10))) e.yld = "Rendimento inválido";
if (items.length === 0) e.items = "Adicione ao menos um insumo";
setErr(e);
if (Object.keys(e).length) return;
onSave({ name: name.trim(), productId, yield: parseInt(yld, 10), items });
};
return (
Cancelar {initial ? "Salvar alterações" : "Criar receita"} >}
>
setName(e.target.value)} placeholder="Ex.: Picolé de Morango — base 60u" autoFocus />
setProductId(e.target.value)}>
{ctx.state.products.map(p => {p.line} — {p.flavor} )}
setYld(e.target.value.replace(/\D/g,""))} />
} onClick={addRow}>Adicionar insumo} />
{items.length === 0 ? (
Nenhum insumo. Clique em "Adicionar insumo".
) : (
{items.map((row, i) => {
const inp = ctx.state.inputs.find(x => x.id === row.inputId);
return (
setRow(i, { inputId: e.target.value })} style={{ flex:1 }}>
{ctx.state.inputs.map(x => {x.name} )}
setRow(i, { qty: parseFloat(e.target.value.replace(",", ".")) || 0 })} style={{ width:100 }} />
{inp?.unit || ""}
{fmtBRL((inp?.unitCost || 0) * row.qty)}
delRow(i)} title="Remover">
);
})}
)}
);
}
function ProduzirModal({ ctx, recipe, onClose, onConfirm }) {
const [batches, setBatches] = useState(1);
const [warehouseId, setWarehouseId] = useState(ctx.state.warehouses[0]?.id || WH_DISTRIB_ID);
const product = ctx.state.products.find(p => p.id === recipe.productId);
if (!product) return null;
const totalCost = recipe.items.reduce((s, it) => {
const inp = ctx.state.inputs.find(x => x.id === it.inputId);
return s + (inp?.unitCost || 0) * it.qty;
}, 0) * batches;
const units = recipe.yield * batches;
const insufficient = recipe.items.some(it => {
const inp = ctx.state.inputs.find(x => x.id === it.inputId);
return !inp || inp.stock < it.qty * batches;
});
return (
Ao confirmar, os insumos serão abatidos do estoque automaticamente.
Cancelar
} onClick={() => onConfirm(batches, warehouseId)} disabled={insufficient || !warehouseId}>Confirmar produção
>
}
>
{product.line}
Bateladas
setWarehouseId(e.target.value)}>
{ctx.state.warehouses.map(w => {w.name} )}
Unidades produzidas {units}
Custo total estimado {fmtBRL(totalCost)}
Custo por unidade {fmtBRL(totalCost / Math.max(1, units))}
Receita estimada {fmtBRL(product.price * units)}
{recipe.items.map(it => {
const inp = ctx.state.inputs.find(x => x.id === it.inputId);
if (!inp) return (
Insumo não encontrado · receita precisa ser ajustada
);
const need = it.qty * batches;
const ok = inp.stock >= need;
return (
{inp.name}
Estoque: {inp.stock} {inp.unit}
−{need} {inp.unit}
{!ok &&
Estoque insuficiente
}
{ok &&
Sobrará {(inp.stock - need).toFixed(2)} {inp.unit}
}
);
})}
{insufficient && (
Estoque insuficiente para um ou mais insumos. Reduza as bateladas ou reabasteça antes de produzir.
)}
);
}
function FlavorTagInline({ product }) {
return (
{product.flavor}
);
}
window.Fabricacao = Fabricacao;
/* ============ Pendências de compras (fiado / parcial) ============ */
function PendenciasInsumos({ ctx, onPay, onPayExpense }) {
const pendingPurchases = (ctx.state.inputPurchases || [])
.filter(p => p.paid < p.total)
.sort((a,b) => a.date.localeCompare(b.date));
const pendingExpenses = (ctx.state.expenses || [])
.filter(e => e.paid < e.total)
.sort((a,b) => a.date.localeCompare(b.date));
const totalPendingPurch = pendingPurchases.reduce((s, p) => s + (p.total - p.paid), 0);
const totalPendingExp = pendingExpenses.reduce((s, e) => s + (e.total - e.paid), 0);
const totalPending = totalPendingPurch + totalPendingExp;
const totalPaid = pendingPurchases.reduce((s, p) => s + p.paid, 0) + pendingExpenses.reduce((s, e) => s + e.paid, 0);
const totalCount = pendingPurchases.length + pendingExpenses.length;
return (
<>
} label="A pagar (total)" value={fmtBRL(totalPending)} sub={`${totalCount} pendência(s)`} />
} label="Já pago (parciais)" value={fmtBRL(totalPaid)} sub="De compras/gastos em aberto" />
} label="Volume com pendência" value={fmtBRL(totalPending + totalPaid)} sub="Valor original" />
{totalCount === 0 ? (
Nenhuma pendência
Todas as compras e gastos estão quitados.
) : (
<>
{pendingPurchases.length > 0 && (
0 ? 14 : 0 }}
>
Fornecedor
Data
Itens
Forma original
Total
Pago
Pendente
Progresso
{pendingPurchases.map(p => {
const pendingVal = p.total - p.paid;
const pct = (p.paid / p.total) * 100;
return (
{p.supplier}
{fmtDate(p.date)}
{p.items.map(it => {
const inp = ctx.state.inputs.find(x => x.id === it.inputId);
return `${inp?.name||"?"} ${it.qty}${inp?.unit||""}`;
}).join(" · ")}
{paymentChip(p.payment)}
{fmtBRL(p.total)}
0 ? "var(--success)" : "var(--ink-400)" }}>{p.paid > 0 ? fmtBRL(p.paid) : "—"}
{fmtBRL(pendingVal)}
= 100 ? "ok" : pct >= 50 ? "warn" : "danger"} />
{Math.round(pct)}%
} onClick={() => onPay(p)}>Pagar
);
})}
)}
{pendingExpenses.length > 0 && (
Gasto
Categoria
Data
Forma original
Total
Pago
Pendente
Progresso
{pendingExpenses.map(e => {
const pendingVal = e.total - e.paid;
const pct = (e.paid / e.total) * 100;
return (
{e.name}
{e.qty ? {e.qty} {e.unit}
: null}
{e.category||"Outros"}
{fmtDate(e.date)}
{paymentChip(e.payment)}
{fmtBRL(e.total)}
0 ? "var(--success)" : "var(--ink-400)" }}>{e.paid > 0 ? fmtBRL(e.paid) : "—"}
{fmtBRL(pendingVal)}
= 100 ? "ok" : pct >= 50 ? "warn" : "danger"} />
{Math.round(pct)}%
} onClick={() => onPayExpense(e)}>Pagar
);
})}
)}
>
)}
>
);
}
/* Visual chip por forma de pagamento (com "Fiado" destacado) */
function paymentChip(payment) {
if (payment === "Fiado") return Fiado ;
if (payment === "PIX") return PIX ;
if (payment === "Dinheiro") return Dinheiro ;
if ((payment||"").startsWith("Cartão")) return {payment} ;
if ((payment||"").startsWith("Boleto") || (payment||"").startsWith("Prazo")) return {payment} ;
return {payment || "—"} ;
}
/* ============ Modal: Nova compra de insumo ============ */
const PURCHASE_METHODS = ["PIX","Dinheiro","Cartão","Transferência","Boleto 7d","Boleto 14d","Prazo 30d","Fiado"];
function ComprarInsumoModal({ ctx, onClose, onConfirm }) {
const [supplier, setSupplier] = useState("");
const [date, setDate] = useState(new Date().toISOString().slice(0,10));
const [items, setItems] = useState([{ inputId: ctx.state.inputs[0]?.id || "", qty: 1, unitCost: ctx.state.inputs[0]?.unitCost || 0 }]);
const [payment, setPayment] = useState("PIX");
const [partialPaid, setPartialPaid] = useState("");
const [note, setNote] = useState("");
const [err, setErr] = useState({});
const total = items.reduce((s, it) => s + (parseFloat(it.qty)||0) * (parseFloat(it.unitCost)||0), 0);
const addRow = () => setItems(prev => [...prev, { inputId: ctx.state.inputs[0]?.id || "", qty: 1, unitCost: ctx.state.inputs[0]?.unitCost || 0 }]);
const setRow = (i, patch) => setItems(prev => prev.map((x, idx) => idx === i ? { ...x, ...patch } : x));
const delRow = (i) => setItems(prev => prev.filter((_, idx) => idx !== i));
/* Pré-preenche custo unit. ao trocar o insumo */
const setInput = (i, inputId) => {
const inp = ctx.state.inputs.find(x => x.id === inputId);
setRow(i, { inputId, unitCost: inp?.unitCost || 0 });
};
const isFiado = payment === "Fiado";
const isPartial = !isFiado && partialPaid !== "" && parseFloat(partialPaid) < total;
const paidValue = isFiado
? (partialPaid ? parseFloat(partialPaid) : 0)
: (partialPaid !== "" ? parseFloat(partialPaid) : total);
const submit = () => {
const e = {};
if (!supplier.trim()) e.supplier = "Informe o fornecedor";
if (items.length === 0) e.items = "Adicione ao menos um item";
if (items.some(it => !it.inputId || !it.qty || it.qty <= 0)) e.items = "Confira quantidades dos itens";
if (total <= 0) e.items = "Total inválido";
if (paidValue < 0 || paidValue > total + 0.001) e.paid = "Valor pago inválido";
setErr(e);
if (Object.keys(e).length) return;
onConfirm({
supplier: supplier.trim(),
date,
items: items.map(it => ({ inputId: it.inputId, qty: parseFloat(it.qty), unitCost: parseFloat(it.unitCost) })),
payment,
paid: paidValue,
note: note.trim(),
});
};
return (
Cancelar
} onClick={submit}>
Registrar compra · {fmtBRL(total)}
>
}
>
setSupplier(e.target.value)} placeholder="Ex.: Distribuidora Frutas S/A" autoFocus />
setDate(e.target.value)} />
} onClick={addRow}>Adicionar item} />
{items.length === 0 ? (
Nenhum item. Clique em "Adicionar item".
) : (
Insumo Qtd. Custo unit. (R$) Subtotal
{items.map((row, i) => {
const inp = ctx.state.inputs.find(x => x.id === row.inputId);
const subtotal = (parseFloat(row.qty)||0) * (parseFloat(row.unitCost)||0);
return (
setInput(i, e.target.value)}>
{ctx.state.inputs.map(x => {x.name} ({x.unit}) )}
setRow(i, { qty: e.target.value.replace(/[^\d.,]/g,"").replace(",", ".") })} style={{ textAlign:"right", padding:"6px 8px" }} />
{inp?.unit||""}
setRow(i, { unitCost: e.target.value.replace(/[^\d.,]/g,"").replace(",", ".") })} style={{ textAlign:"right", padding:"6px 8px" }} />
{fmtBRL(subtotal)}
delRow(i)} title="Remover">
);
})}
Total da compra
{fmtBRL(total)}
)}
{PURCHASE_METHODS.map(m => {
const on = payment === m;
const isFiadoOpt = m === "Fiado";
return (
{ setPayment(m); setPartialPaid(""); }}
style={{
padding:"8px 10px", borderRadius:8, fontSize:12, fontWeight:600,
border: "1px solid " + (on ? (isFiadoOpt ? "var(--warning)" : "var(--brand-600)") : "var(--line-strong)"),
background: on ? (isFiadoOpt ? "var(--warning-bg)" : "var(--brand-50)") : "white",
color: on ? (isFiadoOpt ? "var(--warning)" : "var(--brand-700)") : "var(--ink-700)",
cursor:"pointer", display:"inline-flex", justifyContent:"center", gap:5, alignItems:"center"
}}
>
{isFiadoOpt && }
{m}
);
})}
setPartialPaid(e.target.value.replace(/[^\d.,]/g,"").replace(",", "."))} placeholder={isFiado ? "0,00" : fmtBRL(total)} />
setNote(e.target.value)} placeholder="NF, lote, recebimento..." />
{isFiado
? <>Compra fiada : insumos entram no estoque, mas nada entra em Movimentações até que pagamentos sejam registrados. Saldo: {fmtBRL(total - paidValue)} .>
: isPartial
? <>Pagamento parcial : {fmtBRL(paidValue)} entra em Movimentações agora. Saldo de {fmtBRL(total - paidValue)} fica em Pendências.>
: <>Pagamento integral : o valor total ({fmtBRL(total)}) entra em Movimentações como saída confirmada.>
}
);
}
/* ============ Modal: Pagar compra fiada ============ */
function PayPurchaseModal({ purchase, methods, onClose, onConfirm }) {
const pendingVal = purchase.total - purchase.paid;
const [amount, setAmount] = useState(pendingVal);
const [method, setMethod] = useState(methods?.[0] || "PIX");
const [date, setDate] = useState(new Date().toISOString().slice(0,10));
const [err, setErr] = useState("");
const submit = () => {
const v = parseFloat(String(amount).replace(",", "."));
if (!v || v <= 0) { setErr("Valor inválido"); return; }
if (v > pendingVal + 0.001) { setErr(`Valor maior que o saldo pendente (${fmtBRL(pendingVal)})`); return; }
onConfirm({ amount: v, method, date });
};
const quickFills = [
{ label: "100% (quitar)", value: pendingVal },
{ label: "50%", value: Math.round(pendingVal * 0.5 * 100) / 100 },
{ label: "25%", value: Math.round(pendingVal * 0.25 * 100) / 100 },
];
return (
Cancelar } onClick={submit}>Registrar pagamento >}
>
Total da compra
{fmtBRL(purchase.total)}
Já pago
{fmtBRL(purchase.paid)}
Saldo pendente
{fmtBRL(pendingVal)}
{ setErr(""); setAmount(e.target.value.replace(/[^\d.,]/g,"").replace(",", ".")); }} autoFocus style={{ fontSize:20, fontWeight:700, padding:"10px 14px" }} />
{quickFills.map(q => (
{ setErr(""); setAmount(q.value); }}
style={{ padding:"5px 12px", borderRadius:100, fontSize:11.5, fontWeight:600, border:"1px solid var(--line-strong)", background:"white", color:"var(--ink-700)", cursor:"pointer" }}>{q.label}
))}
setMethod(e.target.value)}>
{(methods || ["PIX","Dinheiro","Cartão","Transferência"]).filter(m => m !== "Fiado").map(m => {m} )}
setDate(e.target.value)} />
);
}
/* ============ Gastos extras ============ */
const EXPENSE_CATEGORIES = ["Embalagem", "Limpeza", "Manutenção", "Combustível", "Energia / Água", "Aluguel", "Marketing", "Equipamentos", "Outros"];
const EXPENSE_UNITS = ["un", "kg", "g", "L", "ml", "m", "pacote", "caixa", "rolo", "fardo", "saco", "hora", "diária", "serviço"];
function GastosTab({ ctx, onNewExpense }) {
const expenses = (ctx.state.expenses || []).slice().sort((a,b) => (b.date+b.time).localeCompare(a.date+a.time));
const totalPaid = expenses.reduce((s, e) => s + e.paid, 0);
const totalAll = expenses.reduce((s, e) => s + e.total, 0);
const totalPending = totalAll - totalPaid;
/* Agrupado por categoria — visual quick-stat */
const byCategory = {};
expenses.forEach(e => {
if (!byCategory[e.category]) byCategory[e.category] = { count: 0, total: 0 };
byCategory[e.category].count++;
byCategory[e.category].total += e.total;
});
return (
<>
} label="Gastos lançados" value={expenses.length+""} sub="Embalagens, manutenção, etc." />
} label="Total pago" value={fmtBRL(totalPaid)} sub={`${fmtBRL(totalAll)} lançado`} />
} label="A pagar" value={fmtBRL(totalPending)} sub="Em pendências" />
} label="Categorias usadas" value={Object.keys(byCategory).length+""} sub={Object.keys(byCategory).slice(0,2).join(", ") || "—"} />
} onClick={onNewExpense}>Novo gasto}
>
{expenses.length === 0 ? (
Nenhum gasto registrado
Use "Novo gasto" para registrar despesas como embalagens, materiais de limpeza, manutenção, etc. — com forma de pagamento e opção de fiado.
) : (
Gasto
Categoria
Qtd.
Data
Pagamento
Total
Pago
Status
{expenses.map(e => (
{e.name}
{e.note && {e.note}
}
{e.category || "Outros"}
{e.qty ? `${e.qty} ${e.unit||"un"}` : "—"}
{fmtDate(e.date)}
{e.time && {e.time}
}
{paymentChip(e.payment)}
{fmtBRL(e.total)}
= e.total ? "var(--success)" : (e.paid > 0 ? "var(--info)" : "var(--ink-400)") }}>{e.paid > 0 ? fmtBRL(e.paid) : "—"}
{e.status === "confirmado" ? quitado
: e.status === "parcial" ? parcial
: a pagar }
))}
)}
>
);
}
function NovoGastoModal({ onClose, onConfirm }) {
const [name, setName] = useState("");
const [category, setCategory] = useState(EXPENSE_CATEGORIES[0]);
const [qty, setQty] = useState("1");
const [unit, setUnit] = useState("un");
const [unitMode, setUnitMode] = useState("preset"); // "preset" | "custom"
const [customUnit, setCustomUnit] = useState("");
const [total, setTotal] = useState("");
const [date, setDate] = useState(new Date().toISOString().slice(0,10));
const [payment, setPayment] = useState("PIX");
const [partialPaid, setPartialPaid] = useState("");
const [note, setNote] = useState("");
const [err, setErr] = useState({});
const finalUnit = unitMode === "custom" ? (customUnit || "un") : unit;
const totalNum = parseFloat(String(total).replace(/[^\d.,]/g, "").replace(",", ".")) || 0;
const isFiado = payment === "Fiado";
const isPartial = !isFiado && partialPaid !== "" && parseFloat(partialPaid) < totalNum;
const paidValue = isFiado
? (partialPaid ? parseFloat(partialPaid) : 0)
: (partialPaid !== "" ? parseFloat(partialPaid) : totalNum);
const submit = () => {
const e = {};
if (!name.trim()) e.name = "Informe o nome do gasto";
if (totalNum <= 0) e.total = "Total inválido";
if (paidValue < 0 || paidValue > totalNum + 0.001) e.paid = "Valor pago inválido";
setErr(e);
if (Object.keys(e).length) return;
onConfirm({
name: name.trim(),
category,
qty: parseFloat(qty) || 0,
unit: finalUnit,
total: totalNum,
date,
payment,
paid: paidValue,
note: note.trim(),
});
};
return (
Cancelar } onClick={submit}>Registrar · {fmtBRL(totalNum)} >}
>
setName(e.target.value)} placeholder="Ex.: Embalagem para picolé, Tinta para impressora, Conta de água..." autoFocus />
setCategory(e.target.value)}>
{EXPENSE_CATEGORIES.map(c => {c} )}
setDate(e.target.value)} />
setQty(e.target.value.replace(/[^\d.,]/g,"").replace(",","."))} placeholder="Ex.: 100" />
{unitMode === "preset" ? (
{EXPENSE_UNITS.map(u => (
setUnit(u)}
style={{
padding:"5px 10px", borderRadius:100, fontSize:11.5, fontWeight:600,
border:"1px solid " + (unit === u ? "var(--brand-600)" : "var(--line-strong)"),
background: unit === u ? "var(--brand-50)" : "white",
color: unit === u ? "var(--brand-700)" : "var(--ink-700)",
cursor:"pointer"
}}>{u}
))}
) : (
setCustomUnit(e.target.value)} placeholder="Ex.: cartela, conjunto, kit..." />
)}
setTotal(e.target.value.replace(/[^\d.,]/g,""))} placeholder="0,00"
style={{ fontSize:20, fontWeight:700, padding:"10px 14px" }} />
{PURCHASE_METHODS.map(m => {
const on = payment === m;
const isFiadoOpt = m === "Fiado";
return (
{ setPayment(m); setPartialPaid(""); }}
style={{
padding:"8px 10px", borderRadius:8, fontSize:12, fontWeight:600,
border: "1px solid " + (on ? (isFiadoOpt ? "var(--warning)" : "var(--brand-600)") : "var(--line-strong)"),
background: on ? (isFiadoOpt ? "var(--warning-bg)" : "var(--brand-50)") : "white",
color: on ? (isFiadoOpt ? "var(--warning)" : "var(--brand-700)") : "var(--ink-700)",
cursor:"pointer", display:"inline-flex", justifyContent:"center", gap:5, alignItems:"center"
}}
>
{isFiadoOpt && }
{m}
);
})}
setPartialPaid(e.target.value.replace(/[^\d.,]/g,"").replace(",","."))} placeholder={isFiado ? "0,00" : fmtBRL(totalNum)} />
setNote(e.target.value)} placeholder="NF, fornecedor, motivo..." />
{isFiado
? <>Gasto fiado : nada entra em Movimentações até registrar pagamentos. Saldo: {fmtBRL(totalNum - paidValue)} .>
: isPartial
? <>Pagamento parcial : {fmtBRL(paidValue)} entra em Movimentações agora. Saldo {fmtBRL(totalNum - paidValue)} em Pendências.>
: <>Pagamento integral : o valor total ({fmtBRL(totalNum)}) entra em Movimentações como saída confirmada.>
}
);
}
function PayExpenseModal({ expense, methods, onClose, onConfirm }) {
const pendingVal = expense.total - expense.paid;
const [amount, setAmount] = useState(pendingVal);
const [method, setMethod] = useState(methods?.[0] || "PIX");
const [date, setDate] = useState(new Date().toISOString().slice(0,10));
const [err, setErr] = useState("");
const submit = () => {
const v = parseFloat(String(amount).replace(",", "."));
if (!v || v <= 0) { setErr("Valor inválido"); return; }
if (v > pendingVal + 0.001) { setErr(`Valor maior que o saldo pendente (${fmtBRL(pendingVal)})`); return; }
onConfirm({ amount: v, method, date });
};
const quickFills = [
{ label: "100% (quitar)", value: pendingVal },
{ label: "50%", value: Math.round(pendingVal * 0.5 * 100) / 100 },
{ label: "25%", value: Math.round(pendingVal * 0.25 * 100) / 100 },
];
return (
Cancelar } onClick={submit}>Registrar pagamento >}
>
Total
{fmtBRL(expense.total)}
Já pago
{fmtBRL(expense.paid)}
Saldo
{fmtBRL(pendingVal)}
{ setErr(""); setAmount(e.target.value.replace(/[^\d.,]/g,"").replace(",","."));}} autoFocus style={{ fontSize:20, fontWeight:700, padding:"10px 14px" }} />
{quickFills.map(q => (
{ setErr(""); setAmount(q.value); }}
style={{ padding:"5px 12px", borderRadius:100, fontSize:11.5, fontWeight:600, border:"1px solid var(--line-strong)", background:"white", color:"var(--ink-700)", cursor:"pointer" }}>{q.label}
))}
setMethod(e.target.value)}>
{(methods || ["PIX","Dinheiro","Cartão","Transferência"]).filter(m => m !== "Fiado").map(m => {m} )}
setDate(e.target.value)} />
);
}