/* Estoque de Sorvetes — controle por armazém, com transferências e registro append-only */
const MOVEMENT_LABELS = {
"seed": { label: "Saldo inicial", tone: "neutral" },
"produce": { label: "Produção", tone: "success" },
"sale": { label: "Venda", tone: "info" },
"saida": { label: "Saída picolezeiro", tone: "warning" },
"retorno": { label: "Retorno", tone: "info" },
"cancel-saida": { label: "Saída cancelada", tone: "neutral" },
"adjust": { label: "Ajuste manual", tone: "neutral" },
"transfer-out": { label: "Transferência (saída)", tone: "warning" },
"transfer-in": { label: "Transferência (entrada)", tone: "success" },
};
function Estoque({ ctx }) {
const warehouses = ctx.state.warehouses;
const [sub, setSub] = useState("geral");
const [view, setView] = useState("grid");
const [filterLine, setFilterLine] = useState("all");
const [editing, setEditing] = useState(null);
const [adjusting, setAdjusting] = useState(null);
const [transferring, setTransferring] = useState(null);
const items = [
{ value: "geral", label: "Visão geral" },
...warehouses.map(w => ({ value: w.id, label: w.name })),
{ value: "log", label: "Movimentações" },
];
return (
<>
Estoque de Sorvetes
Controle por armazém, validade, transferências e histórico de movimentações
} onClick={() => setTransferring({})}>Transferir
{sub === "geral" && (
)}
{sub === "log" && }
{warehouses.some(w => w.id === sub) && (
w.id === sub)}
view={view} setView={setView}
filterLine={filterLine} setFilterLine={setFilterLine}
onEdit={setEditing}
onAdjust={(payload) => setAdjusting(payload)}
onTransfer={(payload) => setTransferring(payload)}
/>
)}
{editing && setEditing(null)} />}
{adjusting && setAdjusting(null)} />}
{transferring && setTransferring(null)} />}
>
);
}
/* ============ Visão geral — consolidado (TOTAL de todos os armazéns) ============ */
function GeralView({ ctx, onGoTo }) {
const today = new Date("2026-05-20");
const totalUnits = ctx.state.stock.reduce((s, x) => s + x.units, 0);
const totalValue = ctx.state.stock.reduce((s, x) => {
const p = ctx.state.products.find(p => p.id === x.productId);
return s + x.units * (p?.price || 0);
}, 0);
const critical = ctx.state.stock.filter(s => s.units < s.minUnits).length;
const expiringSoon = ctx.state.stock.filter(s => {
const days = (new Date(s.expiresOn) - today) / (1000*60*60*24);
return days < 60;
}).length;
// Tabela consolidada: 1 linha por produto, somando unidades de TODOS os armazéns
const perProduct = ctx.state.products.map(p => {
const rows = ctx.state.stock.filter(s => s.productId === p.id);
const units = rows.reduce((sm, r) => sm + r.units, 0);
const perWh = ctx.state.warehouses.map(w => {
const r = rows.find(x => x.warehouseId === w.id);
return { wh: w, units: r?.units || 0 };
});
const minTotal = rows.reduce((sm, r) => sm + r.minUnits, 0);
return { product: p, units, perWh, minTotal, value: units * (p.price || 0) };
}).filter(x => x.units > 0).sort((a,b) => b.units - a.units);
return (
<>
} label="Total em estoque" value={fmtInt(totalUnits)} sub="Unidades em todos os armazéns" />
} label="Valor estocado" value={fmtBRL(totalValue)} sub="A preço de varejo" />
} label="Itens abaixo do mínimo" value={critical+""} sub={`Em ${ctx.state.warehouses.length} armazéns`} />
} label="Validade próxima" value={expiringSoon+""} sub="Em até 60 dias" />
{ctx.state.warehouses.map(w => {
const rows = ctx.state.stock.filter(s => s.warehouseId === w.id);
const units = rows.reduce((sm, r) => sm + r.units, 0);
const value = rows.reduce((sm, r) => {
const p = ctx.state.products.find(p => p.id === r.productId);
return sm + r.units * (p?.price || 0);
}, 0);
const low = rows.filter(r => r.units < r.minUnits).length;
return (
} onClick={() => onGoTo(w.id)}>Ver detalhe}
>
Sabores
{rows.filter(r => r.units > 0).length}
);
})}
{perProduct.length === 0 ? (
Sem estoque
Quando houver produção ou ajuste, o consolidado aparece aqui.
) : (
| Sabor |
Linha |
{ctx.state.warehouses.map(w => (
{w.name} |
))}
Total |
Valor |
{perProduct.map(x => (
|
{x.product.line} |
{x.perWh.map(({ wh, units }) => (
{units} |
))}
{x.units} |
{fmtBRL(x.value)} |
))}
)}
>
);
}
/* ============ Visão por armazém ============ */
function WarehouseView({ ctx, warehouse, view, setView, filterLine, setFilterLine, onEdit, onAdjust, onTransfer }) {
const today = new Date("2026-05-20");
const allInWh = ctx.state.stock.filter(s => s.warehouseId === warehouse.id);
const totalUnits = allInWh.reduce((s, x) => s + x.units, 0);
const totalValue = allInWh.reduce((s, x) => {
const p = ctx.state.products.find(p => p.id === x.productId);
return s + x.units * (p?.price || 0);
}, 0);
const critical = allInWh.filter(s => s.units < s.minUnits).length;
const expiringSoon = allInWh.filter(s => {
const days = (new Date(s.expiresOn) - today) / (1000*60*60*24);
return days < 60;
}).length;
return (
<>
} label={`${warehouse.name} · estoque`} value={fmtInt(totalUnits)} sub="Unidades" />
} label="Valor estocado" value={fmtBRL(totalValue)} sub="A preço de varejo" />
} label="Sabores abaixo do mínimo" value={critical+""} sub="Itens críticos" />
} label="Validade próxima" value={expiringSoon+""} sub="Em até 60 dias" />
>
);
}
/* ============ Grade/tabela de produtos de UM armazém — reutilizado em Geral e por armazém ============ */
function StockItemsDisplay({ ctx, warehouse, items, view, filterLine, onEdit, onAdjust, onTransfer, emptyMessage }) {
const today = new Date("2026-05-20");
const filtered = items.filter(s => {
const p = ctx.state.products.find(p => p.id === s.productId);
if (!p) return false;
if (filterLine === "all") return true;
return p.line === filterLine;
});
if (filtered.length === 0) {
return (
Sem itens
{emptyMessage || "Nenhum sabor para os filtros atuais."}
);
}
if (view === "grid") {
return (
{filtered.map(s => {
const p = ctx.state.products.find(p => p.id === s.productId);
if (!p) return null;
const days = Math.floor((new Date(s.expiresOn) - today) / (1000*60*60*24));
const low = s.units < s.minUnits;
return (
{s.units} un
min. {s.minUnits} un
≈ {Math.ceil(s.units / Math.max(1, s.perBox))} caixas
Validade
{fmtDate(s.expiresOn)} · {days}d
);
})}
);
}
return (
| Produto |
Linha |
Unidades |
Caixas |
Nível |
Mínimo |
Fabricado em |
Vence em |
|
|
{filtered.map(s => {
const p = ctx.state.products.find(p => p.id === s.productId);
if (!p) return null;
const days = Math.floor((new Date(s.expiresOn) - today) / (1000*60*60*24));
const low = s.units < s.minUnits;
return (
|
{p.line} |
{s.units} |
{Math.ceil(s.units / Math.max(1, s.perBox))} |
|
{s.minUnits} |
{fmtDate(s.producedOn)} |
{fmtDate(s.expiresOn)} |
{low ? baixo : days < 60 ? vence : ok} |
|
);
})}
);
}
/* ============ Página de Movimentações (geral) ============ */
function MovimentacoesLog({ ctx }) {
const [whFilter, setWhFilter] = useState("all");
const [kindFilter, setKindFilter] = useState("all");
const whOptions = [{ value:"all", label:"Todos os armazéns" }, ...ctx.state.warehouses.map(w => ({ value:w.id, label:w.name }))];
const kindOptions = [{ value:"all", label:"Todos os tipos" },
...Object.entries(MOVEMENT_LABELS).map(([k, v]) => ({ value: k, label: v.label }))];
return (
<>
>
);
}
function MovimentacoesTable({ ctx, warehouseId, kind, limit }) {
let rows = ctx.state.stockMovements;
if (warehouseId) rows = rows.filter(m => m.warehouseId === warehouseId);
if (kind) rows = rows.filter(m => m.kind === kind);
rows = rows.slice().sort((a,b) => (b.ts || "").localeCompare(a.ts || "")).slice(0, limit || 100);
if (rows.length === 0) {
return
Sem movimentações
Quando houver produções, vendas, saídas, retornos ou ajustes, eles aparecerão aqui.
;
}
return (
| Data/Hora |
Armazém |
Produto |
Tipo |
Unidades |
Referência |
{rows.map(m => {
const w = ctx.state.warehouses.find(x => x.id === m.warehouseId);
const p = ctx.state.products.find(x => x.id === m.productId);
const counter = m.counterpartWarehouseId ? ctx.state.warehouses.find(x => x.id === m.counterpartWarehouseId) : null;
const meta = MOVEMENT_LABELS[m.kind] || { label: m.kind, tone: "neutral" };
const positive = m.units > 0;
return (
| {fmtDateTime(m.ts)} |
{w?.name || "—"} |
{p ? : "—"} |
{meta.label} |
{positive ? "+" : ""}{m.units}
|
{counter ? `↔ ${counter.name}` : ""}
{counter && m.note ? " · " : ""}
{m.note || (counter ? "" : "—")}
|
);
})}
);
}
/* ============ Modais ============ */
function StockEditModal({ ctx, item, onClose }) {
const p = ctx.state.products.find(p => p.id === item.productId);
const w = ctx.state.warehouses.find(x => x.id === item.warehouseId);
const [min, setMin] = useState(String(item.minUnits));
const submit = () => {
ctx.actions.setStockMin(item.productId, parseInt(min || "0", 10), item.warehouseId);
onClose();
};
return (
>}
>
setMin(e.target.value.replace(/\D/g,""))} autoFocus />
);
}
function AjusteEstoqueModal({ ctx, initial, onClose }) {
const [productId, setProductId] = useState(initial?.productId || ctx.state.products[0]?.id || "");
const [warehouseId, setWarehouseId] = useState(initial?.warehouseId || ctx.state.warehouses[0]?.id || "");
const [kind, setKind] = useState(initial?.kind || "in");
const [qty, setQty] = useState("");
const [reason, setReason] = useState("");
const [err, setErr] = useState({});
const submit = () => {
const n = parseInt(qty, 10);
const e = {};
if (!productId) e.productId = "Selecione o sabor";
if (!warehouseId) e.warehouseId = "Selecione o armazém";
if (!n || n <= 0) e.qty = "Quantidade inválida";
setErr(e);
if (Object.keys(e).length) return;
ctx.actions.adjustStock(productId, kind === "in" ? n : -n, warehouseId, { reason });
ctx.pushToast(`Ajuste registrado · ${kind === "in" ? "+" : "−"}${n} un`);
onClose();
};
return (
>}
>
setQty(e.target.value.replace(/\D/g,""))} placeholder="0" autoFocus />
);
}
function TransferirModal({ ctx, initial, onClose }) {
const whs = ctx.state.warehouses;
const [fromWh, setFromWh] = useState(initial?.fromWh || whs[0]?.id || "");
const [toWh, setToWh] = useState(initial?.toWh || whs.find(w => w.id !== (initial?.fromWh || whs[0]?.id))?.id || "");
const [productId, setProductId] = useState(initial?.productId || ctx.state.products[0]?.id || "");
const [qty, setQty] = useState("");
const [note, setNote] = useState("");
const [err, setErr] = useState({});
const fromRow = ctx.state.stock.find(x => x.warehouseId === fromWh && x.productId === productId);
const available = fromRow?.units || 0;
const submit = () => {
const n = parseInt(qty, 10);
const e = {};
if (!fromWh) e.fromWh = "Selecione a origem";
if (!toWh) e.toWh = "Selecione o destino";
if (fromWh === toWh) e.toWh = "Origem e destino devem ser diferentes";
if (!productId) e.productId = "Selecione o sabor";
if (!n || n <= 0) e.qty = "Quantidade inválida";
else if (n > available) e.qty = `Disponível: ${available} un`;
setErr(e);
if (Object.keys(e).length) return;
ctx.actions.transferStock({ fromWh, toWh, productId, units: n, note });
onClose();
};
return (
>}
>
setQty(e.target.value.replace(/\D/g,""))} placeholder="0" autoFocus />
setNote(e.target.value)} placeholder="Ex.: abastecimento semanal" />
);
}
function FlavorTagX({ product }) {
return (
{product.flavor}
);
}
window.Estoque = Estoque;