/* O Tal Picolé — App shell with central state & CRUD */ const NAV_ITEMS = [ { id:"dashboard", label:"Dashboard", icon: Icons.Dashboard }, { id:"fabricacao", label:"Fabricação", icon: Icons.Factory }, { id:"pdv-distribuidores", label:"PDV Distribuidores", icon: Icons.Store }, { id:"pdv-picolezeiros", label:"PDV Picolezeiros", icon: Icons.Sun }, { id:"clientes", label:"Clientes", icon: Icons.Users }, { id:"estoque", label:"Estoque", icon: Icons.Box }, { id:"movimentacoes", label:"Movimentações", icon: Icons.Wallet }, { id:"relatorios", label:"Relatórios", icon: Icons.Chart }, { id:"configuracoes", label:"Configurações", icon: Icons.Settings }, ]; /* Helpers */ const uid = (prefix) => prefix + "-" + Math.random().toString(36).slice(2, 8); /* Lista de abas para permissões por usuário */ const TAB_PERMS = [ { id:"dashboard", label:"Dashboard" }, { id:"fabricacao", label:"Fabricação" }, { id:"pdv-distribuidores", label:"PDV Distribuidores" }, { id:"pdv-picolezeiros", label:"PDV Picolezeiros" }, { id:"clientes", label:"Clientes" }, { id:"estoque", label:"Estoque" }, { id:"movimentacoes", label:"Movimentações" }, { id:"relatorios", label:"Relatórios" }, { id:"configuracoes", label:"Configurações" }, ]; const DEFAULT_PERMS_BY_ROLE = { "Admin": ["*"], "Gestor": ["dashboard","fabricacao","pdv-distribuidores","pdv-picolezeiros","clientes","estoque","movimentacoes","relatorios"], "Operador": ["dashboard","pdv-distribuidores","pdv-picolezeiros","estoque","clientes"], "Visualizador": ["dashboard","relatorios"], }; window.TAB_PERMS = TAB_PERMS; window.DEFAULT_PERMS_BY_ROLE = DEFAULT_PERMS_BY_ROLE; function App() { const [tab, setTab] = useState("dashboard"); const [toast, setToast] = useState(null); const [confirm, setConfirm] = useState(null); // { title, message, onConfirm } const [mobileNav, setMobileNav] = useState(false); // drawer aberto no celular const [loading, setLoading] = useState(!!window.supa); // só "carregando" se Supabase estiver configurado const [loadError, setLoadError] = useState(null); const [session, setSession] = useState(undefined); // undefined = ainda checando; null = sem sessão; obj = logado const [authReady, setAuthReady] = useState(!window.supa); // se Supabase off, ignora auth // ====== Central state ====== const [state, setState] = useState(() => { /* Migra a_receber existentes do FINANCE mock para sales (pendentes). A nova regra: finance só armazena valores efetivamente pagos. */ const pendingFromFinance = FINANCE.filter(f => f.status === "a_receber" && f.cat === "Venda Distribuidor"); const confirmedFinance = FINANCE.filter(f => !(f.status === "a_receber" && f.cat === "Venda Distribuidor")); const seedPendingSales = pendingFromFinance.map(f => { const client = DISTRIBUTORS.find(d => d.name === f.ref); return { id: "v-seed-" + f.id, clientId: client?.id || "d1", date: f.date, time: "—", items: [], // detalhes não disponíveis no mock — exibido como resumo payment: client?.payment || "Boleto 14d", subtotal: f.value, discount: 0, total: f.value, perBox: 60, paid: 0, status: "a_receber", payments: [], seedNote: "Pendência histórica importada do mock", }; }); return ({ products: PRODUCTS.map(p => ({ ...p })), warehouses: WAREHOUSES.map(w => ({ ...w })), stock: STOCK.map(s => ({ ...s })), stockMovements: STOCK_MOVEMENTS.map(m => ({ ...m })), beaches: BEACHES.map(b => ({ ...b })), picolezeiros: PICOLEZEIROS.map(p => ({ ...p, beaches: [...p.beaches] })), distributors: DISTRIBUTORS.map(d => ({ ...d, totals: { ...d.totals } })), inputs: INPUTS.map(i => ({ ...i })), recipes: RECIPES.map(r => ({ ...r, items: r.items.map(i => ({ ...i })) })), finance: confirmedFinance.map(f => ({ ...f })), trips: PIC_TRIPS.map(t => ({ ...t, items: t.items.map(i => ({ ...i })), beachesPlanned: [...t.beachesPlanned], beachesActual: t.beachesActual ? [...t.beachesActual] : null, sold: t.sold ? t.sold.map(i => ({...i})) : null, returned:t.returned ? t.returned.map(i => ({...i})) : null, })), /* Vendas detalhadas (distribuidores) — com itens, hora, pagamento, etc. */ sales: seedPendingSales, /* Compras de insumos (com forma de pagamento e status de quitação) */ inputPurchases: [], /* Gastos extras da fábrica (embalagens, limpeza, manutenção, etc.) */ expenses: [], /* Lotes pré-montados para PDV distribuidores */ salePresets: [ { id: "preset-mix-verao", name: "Mix Verão", description: "5 picolés mais vendidos · 5 cx cada", items: [ { productId: "p03", boxes: 5 }, // Morango { productId: "p07", boxes: 5 }, // Chocolate { productId: "p01", boxes: 5 }, // Limão { productId: "p04", boxes: 5 }, // Manga { productId: "p09", boxes: 5 }, // Coco ], }, { id: "preset-linha-silver", name: "Linha Silver completa", description: "7 sabores Silver · 2 cx cada", items: [ { productId: "s01", boxes: 2 }, { productId: "s02", boxes: 2 }, { productId: "s03", boxes: 2 }, { productId: "s04", boxes: 2 }, { productId: "s05", boxes: 2 }, { productId: "s06", boxes: 2 }, { productId: "s07", boxes: 2 }, ], }, { id: "preset-combo-quiosque", name: "Combo Quiosque", description: "Picolés + Silver · pedido médio", items: [ { productId: "p03", boxes: 4 }, { productId: "p07", boxes: 4 }, { productId: "p01", boxes: 3 }, { productId: "s05", boxes: 2 }, { productId: "s02", boxes: 2 }, ], }, { id: "preset-gold-degust", name: "Gold Degustação", description: "Linha Gold premium · 1 cx cada", items: [ { productId: "g01", boxes: 1 }, { productId: "g02", boxes: 1 }, { productId: "g03", boxes: 1 }, { productId: "g04", boxes: 1 }, { productId: "g05", boxes: 1 }, ], }, ], staff: STAFF.map(s => ({ ...s, password: s.password || "123456", permissions: s.permissions || (s.role === "Admin Super" ? ["*"] : DEFAULT_PERMS_BY_ROLE[s.role] || ["dashboard"]) })), company: { name: BRAND.name, cnpj: BRAND.cnpj, address: BRAND.address, phone: BRAND.phone, email: "contato@otalpicole.com.br", ie: "", logo: (typeof localStorage !== "undefined" && localStorage.getItem("otp.logo")) || "", }, settings: { commission: 18, // % methods: ["PIX","Dinheiro","Cartão","Boleto 14d","Prazo 30d"], divergencePolicy: "warn", confirmDelete: true, alertLowStock: true, alertExpiring: true, auditLog: true, }, }); }); /* ============ AUTH: checa sessão + escuta mudanças ============ */ useEffect(() => { if (!window.supa) return; window.supa.auth.getSession().then(({ data }) => { setSession(data.session || null); setAuthReady(true); }); const { data: sub } = window.supa.auth.onAuthStateChange((_event, sess) => { setSession(sess || null); }); return () => sub?.subscription?.unsubscribe?.(); }, []); /* ============ SUPABASE: load on mount + realtime ============ */ useEffect(() => { if (!window.supa || !window.supaLoadAll) return; let cancelled = false; window.supaLoadAll() .then(data => { if (cancelled) return; setState(prev => ({ ...prev, ...data, // o que vem do banco é fonte da verdade; mantemos só o que NÃO está no banco ainda })); setLoading(false); console.info("[supabase] state carregado do banco"); }) .catch(err => { console.error("[supabase] falha carregando — usando mocks:", err); setLoadError(err.message); setLoading(false); }); return () => { cancelled = true; }; }, []); // Realtime: aplica mudanças vindas de outras sessões useEffect(() => { if (!window.supa || !window.supaSubscribeAll) return; const unsub = window.supaSubscribeAll(({ table, stateKey, eventType, new: newRow, old: oldRow }) => { setState(prev => { // tabelas single-row (company, settings) if (table === "company" || table === "settings") { return { ...prev, [stateKey]: { ...prev[stateKey], ...newRow } }; } // tabelas com PK composta (stock) → match por warehouseId+productId if (stateKey === "stock") { const list = prev[stateKey] || []; const matches = (x) => x.warehouseId === (newRow?.warehouseId || oldRow?.warehouseId) && x.productId === (newRow?.productId || oldRow?.productId); if (eventType === "DELETE") return { ...prev, [stateKey]: list.filter(x => !matches(x)) }; const i = list.findIndex(matches); if (i < 0) return { ...prev, [stateKey]: [newRow, ...list] }; return { ...prev, [stateKey]: list.map((x, idx) => idx === i ? newRow : x) }; } // demais: PK simples por id const list = prev[stateKey] || []; const id = newRow?.id || oldRow?.id; if (!id) return prev; if (eventType === "DELETE") return { ...prev, [stateKey]: list.filter(x => x.id !== id) }; const i = list.findIndex(x => x.id === id); if (i < 0) return { ...prev, [stateKey]: [newRow, ...list] }; return { ...prev, [stateKey]: list.map(x => x.id === id ? newRow : x) }; }); }); return unsub; }, []); /* ============ ACTIONS ============ */ const pushToast = (msg) => setToast(msg); const askConfirm = (opts) => setConfirm(opts); /* Helper: stateKey (camel) → nome da tabela (snake) no Supabase. Inverso de SUPA_TABLE_MAP. */ const stateKeyToTable = (key) => { if (!window.SUPA_TABLE_MAP) return null; return Object.entries(window.SUPA_TABLE_MAP).find(([_t, k]) => k === key)?.[0] || null; }; /* Helper de persistência write-through. Não bloqueia a UI — registra erro no console se algo falhar. Cada action chama `persist(...)` depois do setState. */ const persist = { upsert: (stateKey, row) => { const table = stateKeyToTable(stateKey); if (table && window.supaWrite) window.supaWrite.upsert(table, row); }, upsertMany: (stateKey, rows) => { const table = stateKeyToTable(stateKey); if (!table || !window.supaWrite) return; rows.forEach(r => window.supaWrite.upsert(table, r)); }, insert: (stateKey, row) => { const table = stateKeyToTable(stateKey); if (table && window.supaWrite) window.supaWrite.insert(table, row); }, update: (stateKey, match, patch) => { const table = stateKeyToTable(stateKey); if (table && window.supaWrite) window.supaWrite.update(table, match, patch); }, remove: (stateKey, match) => { const table = stateKeyToTable(stateKey); if (table && window.supaWrite) window.supaWrite.remove(table, match); }, updateSingleRow: (singleRowTable, patch) => { if (window.supaWrite) window.supaWrite.update(singleRowTable, { id: 1 }, patch); }, }; // Generic CRUD helpers (agora com write-through) const addTo = (key, item, prefix) => { const withId = { ...item, id: item.id || uid(prefix) }; setState(s => ({ ...s, [key]: [withId, ...s[key]] })); persist.insert(key, withId); }; const updateIn = (key, id, patch) => { setState(s => ({ ...s, [key]: s[key].map(x => x.id === id ? { ...x, ...patch } : x) })); persist.update(key, { id }, patch); }; const removeFrom = (key, id) => { setState(s => ({ ...s, [key]: s[key].filter(x => x.id !== id) })); persist.remove(key, { id }); }; // ===== Stock helpers (multi-armazém) ===== // Aplica delta de unidades em (warehouseId, productId), criando linha se necessário, // e devolve { stock, stockMovements } atualizados com o movimento registrado. const applyStockDelta = (s, warehouseId, productId, deltaUnits, mv) => { let stock = s.stock.slice(); const idx = stock.findIndex(x => x.warehouseId === warehouseId && x.productId === productId); if (idx < 0) { stock = [...stock, { warehouseId, productId, units: Math.max(0, deltaUnits), perBox: 60, minUnits: 50, producedOn: new Date(), expiresOn: new Date(Date.now() + 180*86400000), }]; } else { stock = stock.map((x, i) => i === idx ? { ...x, units: Math.max(0, x.units + deltaUnits), ...(deltaUnits > 0 ? { producedOn: new Date() } : {}) } : x); } const movement = { id: uid("mv"), ts: new Date().toISOString(), warehouseId, productId, units: deltaUnits, kind: mv?.kind || "adjust", refType: mv?.refType, refId: mv?.refId, counterpartWarehouseId: mv?.counterpartWarehouseId, note: mv?.note || "", }; return { stock, stockMovements: [movement, ...s.stockMovements] }; }; const adjustStock = (productId, deltaUnits, warehouseId, opts) => setState(s => { const whId = warehouseId || WH_DISTRIB_ID; const next = applyStockDelta(s, whId, productId, deltaUnits, { kind: "adjust", note: opts?.reason || "", }); // persist: stock row + nova movimentação const updatedRow = next.stock.find(x => x.warehouseId === whId && x.productId === productId); if (updatedRow) persist.upsert("stock", updatedRow); const newMv = next.stockMovements[0]; if (newMv) persist.insert("stockMovements", newMv); return { ...s, ...next }; }); const transferStock = ({ fromWh, toWh, productId, units, note }) => { if (!fromWh || !toWh || fromWh === toWh || !productId || !units || units <= 0) return; const refId = uid("tr"); setState(s => { const fromRow = s.stock.find(x => x.warehouseId === fromWh && x.productId === productId); const available = fromRow?.units || 0; const qty = Math.min(units, available); if (qty <= 0) return s; let next = applyStockDelta(s, fromWh, productId, -qty, { kind: "transfer-out", refType: "transfer", refId, counterpartWarehouseId: toWh, note: note || "", }); next = applyStockDelta({ ...s, ...next }, toWh, productId, qty, { kind: "transfer-in", refType: "transfer", refId, counterpartWarehouseId: fromWh, note: note || "", }); // persist: ambos stock rows + 2 movimentações novas const outRow = next.stock.find(x => x.warehouseId === fromWh && x.productId === productId); const inRow = next.stock.find(x => x.warehouseId === toWh && x.productId === productId); if (outRow) persist.upsert("stock", outRow); if (inRow) persist.upsert("stock", inRow); persist.insert("stockMovements", next.stockMovements[0]); persist.insert("stockMovements", next.stockMovements[1]); return { ...s, ...next }; }); pushToast("Transferência registrada"); }; const createWarehouse = ({ name, note }) => { if (!name || !name.trim()) return; const w = { id: uid("wh"), name: name.trim(), kind: "custom", system: false, note: note || "" }; setState(s => ({ ...s, warehouses: [...s.warehouses, w] })); persist.insert("warehouses", w); pushToast("Armazém criado"); return w.id; }; const renameWarehouse = (id, patch) => { setState(s => ({ ...s, warehouses: s.warehouses.map(w => w.id === id ? { ...w, ...patch } : w) })); persist.update("warehouses", { id }, patch); pushToast("Armazém atualizado"); }; const deleteWarehouse = (id) => { setState(s => { const w = s.warehouses.find(x => x.id === id); if (!w || w.system) return s; const hasStock = s.stock.some(x => x.warehouseId === id && x.units > 0); if (hasStock) { pushToast("Esvazie o armazém antes de excluí-lo"); return s; } persist.remove("warehouses", { id }); return { ...s, warehouses: s.warehouses.filter(x => x.id !== id), stock: s.stock.filter(x => x.warehouseId !== id), }; }); }; // Input consumption const consumeInputs = (recipeId, batches) => setState(s => { const r = s.recipes.find(x => x.id === recipeId); if (!r) return s; const inputs = s.inputs.map(inp => { const used = r.items.find(it => it.inputId === inp.id); if (!used) return inp; return { ...inp, stock: Math.max(0, inp.stock - used.qty * batches) }; }); return { ...s, inputs }; }); // High-level business actions const finalizeDistribSale = ({ clientId, items, payment, total, subtotal, discount, perBox }) => { /* items: [{ productId, boxes, loose? }] — loose = unidades avulsas (além das caixas) */ const now = new Date(); const saleId = uid("v"); const isPrazo = (payment||"").startsWith("Prazo") || (payment||"").startsWith("Boleto"); setState(s => { const getBox = (productId) => { const p = s.products.find(x => x.id === productId); return p?.boxSize || perBox || 60; }; // 1. decrement stock do armazém de Distribuidores (caixas × boxSize + avulsos) let working = { ...s }; items.forEach(({ productId, boxes, loose }) => { const totalUnits = (boxes || 0) * getBox(productId) + (loose || 0); if (totalUnits === 0) return; const next = applyStockDelta(working, WH_DISTRIB_ID, productId, -totalUnits, { kind: "sale", refType: "sale", refId: saleId, }); working = { ...working, ...next }; }); let stock = working.stock; let stockMovements = working.stockMovements; // 2. add finance entry SOMENTE se à vista (pendentes só geram movimentação após o pagamento) const client = s.distributors.find(d => d.id === clientId); let finance = s.finance; const initialPayments = []; if (!isPrazo) { const finId = uid("f"); finance = [{ id: finId, date: now.toISOString().slice(0,10), kind: "in", cat: "Venda Distribuidor", ref: client?.name || "—", value: total, status: "confirmado", saleId, method: payment, }, ...s.finance]; initialPayments.push({ date: now.toISOString().slice(0,10), amount: total, method: payment, financeId: finId }); } // 3. bump client totals const distributors = s.distributors.map(d => d.id === clientId ? { ...d, totals: { compras: d.totals.compras + 1, valor: d.totals.valor + total } } : d ); // 4. registra venda detalhada const saleRecord = { id: saleId, clientId, date: now.toISOString().slice(0,10), time: now.toTimeString().slice(0,5), items: items.map(({productId, boxes, loose}) => { const p = s.products.find(x => x.id === productId); const unitPrice = (p?.price || 0) * 0.55; const productBoxSize = p?.boxSize || 60; const b = boxes || 0; const lo = loose || 0; const units = b * productBoxSize + lo; return { productId, boxes: b, loose: lo, boxSize: productBoxSize, units, unitPrice, subtotal: units * unitPrice, }; }), payment, subtotal: subtotal != null ? subtotal : total, discount: discount != null ? discount : 0, total, paid: isPrazo ? 0 : total, status: isPrazo ? "a_receber" : "confirmado", payments: initialPayments, }; // persist: stock rows + movimentações + finance + distributor + sale working.stock.forEach(row => { if (items.some(it => it.productId === row.productId) && row.warehouseId === WH_DISTRIB_ID) { persist.upsert("stock", row); } }); working.stockMovements.slice(0, items.length).forEach(mv => persist.insert("stockMovements", mv)); if (finance[0] && finance[0].id !== s.finance[0]?.id) persist.insert("finance", finance[0]); const distRow = distributors.find(d => d.id === clientId); if (distRow) persist.update("distributors", { id: clientId }, { totals: distRow.totals }); persist.insert("sales", saleRecord); return { ...s, stock, stockMovements, finance, distributors, sales: [saleRecord, ...s.sales] }; }); pushToast(isPrazo ? "Venda registrada como pendente (a receber)" : "Venda finalizada — estoque e financeiro atualizados"); return saleId; }; /* Recebimento de pagamento (parcial ou total) de uma venda pendente */ const receiveSalePayment = ({ saleId, amount, method, date }) => { setState(s => { const sale = s.sales.find(x => x.id === saleId); if (!sale) return s; const client = s.distributors.find(d => d.id === sale.clientId); const dt = date || new Date().toISOString().slice(0,10); const useAmount = Math.min(amount, sale.total - sale.paid); if (useAmount <= 0) return s; const newPaid = sale.paid + useAmount; const newStatus = newPaid >= sale.total - 0.001 ? "confirmado" : "a_receber"; const finId = uid("f"); const financeEntry = { id: finId, date: dt, kind: "in", cat: "Venda Distribuidor", ref: client?.name || "—", value: useAmount, status: "confirmado", saleId, method: method || sale.payment, partial: newStatus !== "confirmado", }; const sales = s.sales.map(x => x.id === saleId ? { ...x, paid: newPaid, status: newStatus, payments: [ ...(x.payments||[]), { date: dt, amount: useAmount, method: method || sale.payment, financeId: finId } ], } : x); persist.update("sales", { id: saleId }, sales.find(x => x.id === saleId)); persist.insert("finance", financeEntry); return { ...s, sales, finance: [financeEntry, ...s.finance] }; }); pushToast("Pagamento registrado em movimentações"); }; /* Compra de insumos — com forma de pagamento e fiado */ const recordInputPurchase = ({ supplier, items, payment, paid, date, note }) => { const purchaseId = uid("ip"); const now = new Date(); const dt = date || now.toISOString().slice(0,10); const total = items.reduce((s, it) => s + (it.qty * it.unitCost), 0); const initialPaid = (payment === "Fiado") ? (paid != null ? paid : 0) : (paid != null ? paid : total); setState(s => { // 1. incrementa estoque (e atualiza custo unit. com média ponderada simples) let inputs = s.inputs.slice(); items.forEach(it => { inputs = inputs.map(inp => { if (inp.id !== it.inputId) return inp; const newStock = inp.stock + it.qty; // média ponderada do custo const blendedCost = newStock > 0 ? ((inp.stock * inp.unitCost) + (it.qty * it.unitCost)) / newStock : it.unitCost; return { ...inp, stock: newStock, unitCost: Math.round(blendedCost * 100) / 100 }; }); }); // 2. finance entry apenas para a parte paga let finance = s.finance; const initialPayments = []; if (initialPaid > 0) { const finId = uid("f"); finance = [{ id: finId, date: dt, kind:"out", cat:"Compra de Insumo", ref: supplier || "Compra de insumos", value: initialPaid, status:"confirmado", purchaseId, method: payment, partial: initialPaid < total, }, ...s.finance]; initialPayments.push({ date: dt, amount: initialPaid, method: payment || "—", financeId: finId }); } // 3. cria registro da compra const purchase = { id: purchaseId, supplier: supplier || "—", date: dt, time: now.toTimeString().slice(0,5), items: items.map(it => ({ inputId: it.inputId, qty: it.qty, unitCost: it.unitCost, subtotal: it.qty * it.unitCost, })), payment: payment || "PIX", total, paid: initialPaid, status: initialPaid >= total - 0.001 ? "confirmado" : (initialPaid > 0 ? "parcial" : "a_pagar"), payments: initialPayments, note: note || "", }; // persist: insumos atualizados + finance + purchase items.forEach(it => { const inp = inputs.find(x => x.id === it.inputId); if (inp) persist.update("inputs", { id: inp.id }, { stock: inp.stock, unitCost: inp.unitCost }); }); if (initialPaid > 0 && finance[0] && finance[0].id !== s.finance[0]?.id) persist.insert("finance", finance[0]); persist.insert("inputPurchases", purchase); return { ...s, inputs, finance, inputPurchases: [purchase, ...s.inputPurchases] }; }); pushToast(initialPaid >= total - 0.001 ? "Compra registrada — insumos no estoque e pagamento lançado" : initialPaid > 0 ? "Compra registrada parcialmente paga — saldo em pendências" : "Compra fiada registrada — saldo em pendências" ); return purchaseId; }; /* Pagamento (parcial ou total) de uma compra fiada/parcial */ const payInputPurchase = ({ purchaseId, amount, method, date }) => { setState(s => { const p = s.inputPurchases.find(x => x.id === purchaseId); if (!p) return s; const dt = date || new Date().toISOString().slice(0,10); const useAmount = Math.min(amount, p.total - p.paid); if (useAmount <= 0) return s; const newPaid = p.paid + useAmount; const newStatus = newPaid >= p.total - 0.001 ? "confirmado" : "parcial"; const finId = uid("f"); const financeEntry = { id: finId, date: dt, kind:"out", cat:"Compra de Insumo", ref: p.supplier || "Compra de insumos", value: useAmount, status:"confirmado", purchaseId, method: method || p.payment, partial: newStatus !== "confirmado", }; const inputPurchases = s.inputPurchases.map(x => x.id === purchaseId ? { ...x, paid: newPaid, status: newStatus, payments: [ ...(x.payments||[]), { date: dt, amount: useAmount, method: method || p.payment, financeId: finId } ], } : x); persist.update("inputPurchases", { id: purchaseId }, inputPurchases.find(x => x.id === purchaseId)); persist.insert("finance", financeEntry); return { ...s, inputPurchases, finance: [financeEntry, ...s.finance] }; }); pushToast("Pagamento de insumo registrado em movimentações"); }; /* Gastos extras da fábrica (embalagens, limpeza, etc.) */ const recordExpense = ({ name, category, qty, unit, total, payment, paid, date, note }) => { const expenseId = uid("ex"); const now = new Date(); const dt = date || now.toISOString().slice(0,10); const totalNum = parseFloat(total) || 0; const initialPaid = payment === "Fiado" ? (paid != null ? paid : 0) : (paid != null ? paid : totalNum); setState(s => { let finance = s.finance; const payments = []; if (initialPaid > 0) { const finId = uid("f"); finance = [{ id: finId, date: dt, kind:"out", cat:"Despesa Operacional", ref: name + (qty ? ` (${qty} ${unit||"un"})` : ""), value: initialPaid, status:"confirmado", expenseId, method: payment, partial: initialPaid < totalNum, }, ...s.finance]; payments.push({ date: dt, amount: initialPaid, method: payment || "—", financeId: finId }); } const expense = { id: expenseId, name, category: category || "Outros", qty: parseFloat(qty) || 0, unit: unit || "un", total: totalNum, paid: initialPaid, payment: payment || "PIX", date: dt, time: now.toTimeString().slice(0,5), status: initialPaid >= totalNum - 0.001 ? "confirmado" : (initialPaid > 0 ? "parcial" : "a_pagar"), payments, note: note || "", }; if (initialPaid > 0 && finance[0] && finance[0].id !== s.finance[0]?.id) persist.insert("finance", finance[0]); persist.insert("expenses", expense); return { ...s, finance, expenses: [expense, ...s.expenses] }; }); pushToast(initialPaid >= totalNum - 0.001 ? "Gasto registrado em movimentações" : initialPaid > 0 ? "Gasto parcialmente pago — saldo em pendências" : "Gasto fiado — saldo em pendências" ); return expenseId; }; const payExpense = ({ expenseId, amount, method, date }) => { setState(s => { const ex = s.expenses.find(x => x.id === expenseId); if (!ex) return s; const dt = date || new Date().toISOString().slice(0,10); const useAmount = Math.min(amount, ex.total - ex.paid); if (useAmount <= 0) return s; const newPaid = ex.paid + useAmount; const newStatus = newPaid >= ex.total - 0.001 ? "confirmado" : "parcial"; const finId = uid("f"); const financeEntry = { id: finId, date: dt, kind:"out", cat:"Despesa Operacional", ref: ex.name + (ex.qty ? ` (${ex.qty} ${ex.unit||"un"})` : ""), value: useAmount, status:"confirmado", expenseId, method: method || ex.payment, partial: newStatus !== "confirmado", }; const expenses = s.expenses.map(x => x.id === expenseId ? { ...x, paid: newPaid, status: newStatus, payments: [ ...(x.payments||[]), { date: dt, amount: useAmount, method: method || ex.payment, financeId: finId } ], } : x); persist.update("expenses", { id: expenseId }, expenses.find(x => x.id === expenseId)); persist.insert("finance", financeEntry); return { ...s, expenses, finance: [financeEntry, ...s.finance] }; }); pushToast("Pagamento de gasto registrado em movimentações"); }; const recordProduction = ({ recipeId, batches, warehouseId }) => { const prodId = uid("prod"); setState(s => { const r = s.recipes.find(x => x.id === recipeId); if (!r) return s; const whId = warehouseId || s.warehouses[0]?.id || WH_DISTRIB_ID; // consume inputs const inputs = s.inputs.map(inp => { const used = r.items.find(it => it.inputId === inp.id); if (!used) return inp; return { ...inp, stock: Math.max(0, inp.stock - used.qty * batches) }; }); // add stock no armazém escolhido const units = r.yield * batches; const next = applyStockDelta({ ...s, inputs }, whId, r.productId, units, { kind: "produce", refType: "production", refId: prodId, note: `${r.name} (${batches}x)`, }); // finance cost const cost = r.items.reduce((sm, it) => { const inp = s.inputs.find(x => x.id === it.inputId); return sm + (inp?.unitCost || 0) * it.qty; }, 0) * batches; const finance = [{ id: uid("f"), date: new Date().toISOString().slice(0,10), kind:"out", cat:"Produção", ref:`${r.name} (${batches}x)`, value: cost, status:"confirmado" }, ...s.finance]; // persist inputs.forEach(inp => { const before = s.inputs.find(x => x.id === inp.id); if (before && before.stock !== inp.stock) persist.update("inputs", { id: inp.id }, { stock: inp.stock }); }); const updatedStock = next.stock.find(x => x.warehouseId === whId && x.productId === r.productId); if (updatedStock) persist.upsert("stock", updatedStock); persist.insert("stockMovements", next.stockMovements[0]); persist.insert("finance", finance[0]); return { ...s, inputs, stock: next.stock, stockMovements: next.stockMovements, finance }; }); pushToast("Produção registrada — estoque e insumos atualizados"); }; const recordSaida = ({ picolezeiroId, beaches, items }) => { const tripId = uid("t"); setState(s => { // reserve stock (decrement) do armazém de Picolezeiros let working = { ...s }; Object.entries(items).forEach(([pid, qty]) => { if (!qty) return; const next = applyStockDelta(working, WH_PICOLE_ID, pid, -qty, { kind: "saida", refType: "trip", refId: tripId, }); working = { ...working, ...next }; }); const trip = { id: tripId, picolezeiroId, date: new Date().toISOString().slice(0,10), status: "em_rota", beachesPlanned: beaches, beachesActual: null, items: Object.entries(items).map(([productId, qty]) => ({ productId, qty })), sold: null, returned: null, faturado: null, comissao: null, recebido: null, }; // persist Object.keys(items).forEach(pid => { const row = working.stock.find(x => x.warehouseId === WH_PICOLE_ID && x.productId === pid); if (row) persist.upsert("stock", row); }); working.stockMovements.slice(0, Object.keys(items).length).forEach(mv => persist.insert("stockMovements", mv)); persist.insert("trips", trip); return { ...s, stock: working.stock, stockMovements: working.stockMovements, trips: [trip, ...s.trips] }; }); pushToast("Saída registrada — unidades reservadas no estoque"); }; const closeRetorno = ({ tripId, sold, beachesActual, received }) => { setState(s => { const trip = s.trips.find(t => t.id === tripId); if (!trip) return s; const pz = s.picolezeiroos || s.picolezeiros; const pic = (pz || s.picolezeiros).find(p => p.id === trip.picolezeiroId); // Return leftovers ao armazém de Picolezeiros let working = { ...s }; const returned = trip.items.map(it => { const soldQ = sold[it.productId] || 0; const rest = it.qty - soldQ; if (rest > 0) { const next = applyStockDelta(working, WH_PICOLE_ID, it.productId, rest, { kind: "retorno", refType: "trip", refId: tripId, }); working = { ...working, ...next }; } return { productId: it.productId, qty: rest }; }); let stock = working.stock; let stockMovements = working.stockMovements; // Calculate faturado, comissao const soldArr = Object.entries(sold).map(([productId, qty]) => ({ productId, qty })); const faturado = soldArr.reduce((sm, it) => { const p = s.products.find(x => x.id === it.productId); return sm + it.qty * (p?.price || 0); }, 0); const comissao = faturado * (pic.commission / 100); const trips = s.trips.map(t => t.id === tripId ? { ...t, status: "fechado", sold: soldArr, returned, beachesActual, faturado, comissao, recebido: received, } : t); // Two finance entries: in (faturado), out (comissao) const today = new Date().toISOString().slice(0,10); const finance = [ { id: uid("f"), date: today, kind:"in", cat:"Retorno Picolezeiro", ref: pic.name, value: faturado, status:"confirmado" }, { id: uid("f"), date: today, kind:"out", cat:"Comissão Picolezeiro", ref: pic.name, value: comissao, status:"confirmado" }, ...s.finance, ]; // Bump picolezeiro totals const picolezeiros = s.picolezeiros.map(p => p.id === pic.id ? { ...p, totals: { saidas: p.totals.saidas + 1, vendido: p.totals.vendido + faturado, comissao: p.totals.comissao + comissao, } } : p); // persist working.stock.forEach(row => { if (row.warehouseId === WH_PICOLE_ID && trip.items.some(it => it.productId === row.productId)) { persist.upsert("stock", row); } }); working.stockMovements.slice(0, returned.filter(r => r.qty > 0).length).forEach(mv => persist.insert("stockMovements", mv)); persist.update("trips", { id: tripId }, trips.find(t => t.id === tripId)); persist.insert("finance", finance[0]); persist.insert("finance", finance[1]); const pcRow = picolezeiros.find(p => p.id === pic.id); if (pcRow) persist.update("picolezeiros", { id: pic.id }, { totals: pcRow.totals }); return { ...s, stock, stockMovements, trips, finance, picolezeiros }; }); pushToast("Retorno fechado · sobras devolvidas · 2 lançamentos gerados"); }; const actions = { // generic addBeach: (item) => { addTo("beaches", item, "b"); pushToast("Praia cadastrada"); }, updateBeach: (id, patch) => { updateIn("beaches", id, patch); pushToast("Praia atualizada"); }, deleteBeach: (id) => { removeFrom("beaches", id); pushToast("Praia excluída"); }, addPicolezeiro: (item) => { addTo("picolezeiros", { ...item, totals:{saidas:0,vendido:0,comissao:0} }, "pz"); pushToast("Picolezeiro cadastrado"); }, updatePicolezeiro: (id, patch) => { updateIn("picolezeiros", id, patch); pushToast("Picolezeiro atualizado"); }, deletePicolezeiro: (id) => { removeFrom("picolezeiros", id); pushToast("Picolezeiro excluído"); }, addDistributor: (item) => { addTo("distributors", { ...item, totals:{compras:0,valor:0} }, "d"); pushToast("Distribuidor cadastrado"); }, updateDistributor: (id, patch) => { updateIn("distributors", id, patch); pushToast("Distribuidor atualizado"); }, deleteDistributor: (id) => { removeFrom("distributors", id); pushToast("Distribuidor excluído"); }, addInput: (item) => { addTo("inputs", item, "i"); pushToast("Insumo cadastrado"); }, updateInput: (id, patch) => { updateIn("inputs", id, patch); pushToast("Insumo atualizado"); }, deleteInput: (id) => { removeFrom("inputs", id); pushToast("Insumo excluído"); }, addRecipe: (item) => { addTo("recipes", item, "r"); pushToast("Receita criada"); }, updateRecipe: (id, patch) => { updateIn("recipes", id, patch); pushToast("Receita atualizada"); }, deleteRecipe: (id) => { removeFrom("recipes", id); pushToast("Receita excluída"); }, addProduct: (item) => { addTo("products", item, "p"); pushToast("Sabor cadastrado"); }, updateProduct: (id, patch) => { updateIn("products", id, patch); pushToast("Sabor atualizado"); }, deleteProduct: (id) => { removeFrom("products", id); pushToast("Sabor excluído"); }, addStaff: (item) => { addTo("staff", item, "u"); pushToast("Usuário criado"); }, updateStaff: (id, patch) => { updateIn("staff", id, patch); pushToast("Usuário atualizado"); }, deleteStaff: (id) => { removeFrom("staff", id); pushToast("Usuário removido"); }, addFinance: (item) => { addTo("finance", item, "f"); pushToast("Lançamento criado"); }, updateFinance: (id, patch) => { updateIn("finance", id, patch); pushToast("Lançamento atualizado"); }, deleteFinance: (id) => { removeFrom("finance", id); pushToast("Lançamento excluído"); }, addSalePreset: (item) => { addTo("salePresets", item, "preset"); pushToast("Lote pré-montado salvo"); }, deleteSalePreset: (id) => { removeFrom("salePresets", id); pushToast("Lote removido"); }, adjustStock, setStockMin: (productId, min, warehouseId) => setState(s => { const stock = s.stock.map(x => (x.productId === productId && (!warehouseId || x.warehouseId === warehouseId)) ? { ...x, minUnits: min } : x); // persist stock.forEach((row, i) => { if (row.minUnits !== s.stock[i].minUnits) persist.upsert("stock", row); }); return { ...s, stock }; }), cancelTrip: (tripId) => { setState(s => { const trip = s.trips.find(t => t.id === tripId); if (!trip) return s; // Return reserved units ao armazém de Picolezeiros let working = { ...s }; trip.items.forEach(it => { const next = applyStockDelta(working, WH_PICOLE_ID, it.productId, it.qty, { kind: "cancel-saida", refType: "trip", refId: tripId, }); working = { ...working, ...next }; }); // persist working.stock.forEach(row => { if (row.warehouseId === WH_PICOLE_ID && trip.items.some(it => it.productId === row.productId)) { persist.upsert("stock", row); } }); working.stockMovements.slice(0, trip.items.length).forEach(mv => persist.insert("stockMovements", mv)); persist.remove("trips", { id: tripId }); return { ...s, stock: working.stock, stockMovements: working.stockMovements, trips: s.trips.filter(t => t.id !== tripId) }; }); pushToast("Saída cancelada · estoque restaurado"); }, createWarehouse, renameWarehouse, deleteWarehouse, transferStock, updateCompany: (patch) => { setState(s => ({ ...s, company: { ...s.company, ...patch } })); if (Object.prototype.hasOwnProperty.call(patch, "logo")) { try { if (patch.logo) localStorage.setItem("otp.logo", patch.logo); else localStorage.removeItem("otp.logo"); } catch(e){} } persist.updateSingleRow("company", patch); pushToast("Dados da empresa salvos"); }, updateSettings: (patch) => { setState(s => ({ ...s, settings: { ...s.settings, ...patch } })); persist.updateSingleRow("settings", patch); pushToast("Parâmetros salvos"); }, // business finalizeDistribSale, receiveSalePayment, recordInputPurchase, payInputPurchase, recordExpense, payExpense, recordProduction, recordSaida, closeRetorno, }; const ctx = { state, actions, pushToast, askConfirm, goTo: setTab }; const current = NAV_ITEMS.find(n => n.id === tab); const emRotaCount = state.trips.filter(t => t.status === "em_rota").length; const navBadges = { "pdv-picolezeiros": emRotaCount }; if (!authReady) { return (
); } // Se Supabase está ativo e não há sessão → tela de login if (window.supa && session === null) { return { /* setSession() acontece via onAuthStateChange */ }} />; } if (loading) { return (
Carregando dados do banco…
Conectando ao Supabase
); } /* Identificar staff logado pelo email da sessão */ const currentStaff = session ? state.staff.find(s => s.email === session.user?.email) : null; const currentLabel = currentStaff?.name || session?.user?.email || "Renata Cardoso"; const currentRole = currentStaff?.role || (session ? "Usuário" : "Admin Super"); const currentAvatar = currentStaff?.photo || (currentStaff?.name ? currentStaff.name.split(/\s+/).slice(0,2).map(w => w[0]).join("").toUpperCase() : (session?.user?.email ? session.user.email[0].toUpperCase() : "RC")); const handleLogout = async () => { if (!window.supa) return; await window.supa.auth.signOut(); }; const goTab = (id) => { setTab(id); setMobileNav(false); }; return (
setMobileNav(false)} />
{state.company.name} / {current?.label}
⌘K
{tab === "dashboard" && } {tab === "fabricacao" && } {tab === "pdv-distribuidores" && } {tab === "pdv-picolezeiros" && } {tab === "clientes" && } {tab === "estoque" && } {tab === "movimentacoes" && } {tab === "relatorios" && } {tab === "configuracoes" && }
setToast(null)} /> {confirm && ( setConfirm(null)} footer={ <> } >

{confirm.message}

{confirm.detail &&

{confirm.detail}

}
)}
); } ReactDOM.createRoot(document.getElementById("root")).render();