/* ============================================================ Forja 3D — Data store (localStorage-backed) + helpers Exposes to window: useStore, store, fmt, helpers ============================================================ */ const STORAGE_KEY = 'forja3d_v3'; const uid = () => Math.random().toString(36).slice(2, 9); const today = () => new Date().toISOString().slice(0, 10); /* ---------- seed data ---------- */ function seed() { const fPLA = uid(), fPETG = uid(), fPLAplus = uid(), fTPU = uid(), fSilk = uid(); return { settings: { printerName: 'Ender 3 V3', printerCost: 2500, printerLifeHours: 5000, powerWatts: 150, energyCostKwh: 0.92, laborPerHour: 5, defaultMargin: 120, failRate: 8, defaultPackaging: 2.5, marketplaceFee: 14, }, filaments: [ { id: fPLA, name: 'PLA Preto', type: 'PLA', brand: 'Voolt', color: '#1a1a1a', costPerKg: 90, spoolWeight: 1000, remaining: 640 }, { id: fPLAplus, name: 'PLA+ Laranja', type: 'PLA+', brand: '3D Lab', color: '#FF6A1A', costPerKg: 98, spoolWeight: 1000, remaining: 820 }, { id: fPETG, name: 'PETG Transparente', type: 'PETG', brand: 'GTMax', color: '#cfe8ff', costPerKg: 120, spoolWeight: 1000, remaining: 310 }, { id: fSilk, name: 'PLA Silk Dourado', type: 'Silk', brand: 'Voolt', color: '#d4af37', costPerKg: 135, spoolWeight: 1000, remaining: 180 }, { id: fTPU, name: 'TPU Flexível Preto', type: 'TPU', brand: 'Sunlu', color: '#222222', costPerKg: 160, spoolWeight: 500, remaining: 90 }, ], debts: [ { id: uid(), name: 'Impressora Ender 3 V3', total: 2500, paid: 1600, note: 'Parcelado 10x — Casas Bahia', kind: 'impressora' }, { id: uid(), name: 'Lote de Filamentos', total: 600, paid: 480, note: 'Compra atacado fornecedor', kind: 'insumo' }, { id: uid(), name: 'Mesa + ferramentas', total: 450, paid: 150, note: 'Bancada, alicates, espátulas', kind: 'equipamento' }, ], expenses: [ { id: uid(), date: '2026-06-06', description: 'Filamento PLA+ 1kg', category: 'Filamento', amount: 92 }, { id: uid(), date: '2026-06-04', description: 'Conta de energia (rateio)', category: 'Energia', amount: 48 }, { id: uid(), date: '2026-06-02', description: 'Caixas de papelão (50un)', category: 'Embalagem', amount: 65 }, { id: uid(), date: '2026-05-28', description: 'Bico 0.4mm reposição (x3)', category: 'Manutenção', amount: 35 }, { id: uid(), date: '2026-05-20', description: 'Anúncio impulsionado Shopee', category: 'Marketing', amount: 40 }, ], sales: [ { id: uid(), date: '2026-06-08', product: 'Suporte de fone gamer', channel: 'Shopee', price: 95, cost: 38, qty: 1 }, { id: uid(), date: '2026-06-05', product: 'Vaso geométrico G', channel: 'Encomenda', price: 119, cost: 61, qty: 1 }, { id: uid(), date: '2026-06-03', product: 'Kit organizador mesa', channel: 'Mercado Livre', price: 65, cost: 27, qty: 3 }, { id: uid(), date: '2026-05-30', product: 'Luminária lua 15cm', channel: 'Instagram', price: 149, cost: 84, qty: 1 }, { id: uid(), date: '2026-05-26', product: 'Porta-treco gato', channel: 'Shopee', price: 45, cost: 18, qty: 2 }, ], products: [ { id: uid(), name: 'Suporte de fone gamer', filamentId: fPETG, weightG: 85, printTimeMin: 240, price: 95, color: '#cfe8ff' }, { id: uid(), name: 'Vaso geométrico G', filamentId: fPLAplus, weightG: 145, printTimeMin: 420, price: 119, color: '#FF6A1A' }, { id: uid(), name: 'Kit organizador mesa', filamentId: fPLA, weightG: 60, printTimeMin: 180, price: 65, color: '#1a1a1a' }, { id: uid(), name: 'Luminária lua 15cm', filamentId: fSilk, weightG: 180, printTimeMin: 540, price: 149, color: '#d4af37' }, ], prints: [ { id: uid(), date: '2026-06-08', product: 'Suporte de fone gamer', filamentId: fPETG, grams: 85, timeMin: 240, status: 'sucesso' }, { id: uid(), date: '2026-06-07', product: 'Vaso geométrico G', filamentId: fPLAplus, grams: 145, timeMin: 420, status: 'sucesso' }, { id: uid(), date: '2026-06-06', product: 'Teste calibração', filamentId: fPLA, grams: 22, timeMin: 55, status: 'falha' }, { id: uid(), date: '2026-06-05', product: 'Kit organizador (x3)', filamentId: fPLA, grams: 180, timeMin: 540, status: 'sucesso' }, { id: uid(), date: '2026-06-03', product: 'Luminária lua 15cm', filamentId: fSilk, grams: 180, timeMin: 540, status: 'sucesso' }, ], }; } /* ---------- store core ---------- */ function load() { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) return JSON.parse(raw); } catch (e) {} return seed(); } let state = load(); const listeners = new Set(); let _applyingRemote = false; // true enquanto aplicamos mudança vinda da nuvem let _syncHandler = null; // callback(state) → empurra pra nuvem const EMPTY = { settings: {}, filaments: [], debts: [], expenses: [], sales: [], products: [], prints: [] }; function persist() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {} } function emit() { persist(); if (_syncHandler && !_applyingRemote) _syncHandler(state); listeners.forEach((l) => l()); } const store = { get: () => state, set(patch) { state = { ...state, ...(typeof patch === 'function' ? patch(state) : patch) }; emit(); }, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }, /* settings */ updateSettings(patch) { store.set({ settings: { ...state.settings, ...patch } }); }, /* filaments */ addFilament(f) { store.set({ filaments: [{ id: uid(), ...f }, ...state.filaments] }); }, updateFilament(id, patch) { store.set({ filaments: state.filaments.map((x) => x.id === id ? { ...x, ...patch } : x) }); }, removeFilament(id) { store.set({ filaments: state.filaments.filter((x) => x.id !== id) }); }, /* debts */ addDebt(d) { store.set({ debts: [{ id: uid(), paid: 0, ...d }, ...state.debts] }); }, payDebt(id, amount) { store.set({ debts: state.debts.map((x) => x.id === id ? { ...x, paid: Math.min(x.total, x.paid + amount) } : x) }); }, updateDebt(id, patch) { store.set({ debts: state.debts.map((x) => x.id === id ? { ...x, ...patch } : x) }); }, removeDebt(id) { store.set({ debts: state.debts.filter((x) => x.id !== id) }); }, /* expenses */ addExpense(e) { store.set({ expenses: [{ id: uid(), ...e }, ...state.expenses] }); }, removeExpense(id) { store.set({ expenses: state.expenses.filter((x) => x.id !== id) }); }, /* sales */ addSale(s) { store.set({ sales: [{ id: uid(), ...s }, ...state.sales] }); }, removeSale(id) { store.set({ sales: state.sales.filter((x) => x.id !== id) }); }, /* products */ addProduct(p) { store.set({ products: [{ id: uid(), ...p }, ...state.products] }); }, updateProduct(id, patch) { store.set({ products: state.products.map((x) => x.id === id ? { ...x, ...patch } : x) }); }, removeProduct(id) { store.set({ products: state.products.filter((x) => x.id !== id) }); }, /* prints */ addPrint(p) { store.set({ prints: [{ id: uid(), ...p }, ...state.prints] }); }, removePrint(id) { store.set({ prints: state.prints.filter((x) => x.id !== id) }); }, resetAll() { state = seed(); emit(); }, wipe() { state = { settings: state.settings, filaments: [], debts: [], expenses: [], sales: [], products: [], prints: [] }; emit(); }, /* ---- sincronização com a nuvem ---- */ setSyncHandler(fn) { _syncHandler = fn; }, // aplica estado vindo da nuvem sem reempurrar (evita loop) applyRemote(data) { _applyingRemote = true; state = { ...EMPTY, ...(data || {}) }; persist(); listeners.forEach((l) => l()); _applyingRemote = false; }, }; function useStore() { return React.useSyncExternalStore(store.subscribe, store.get); } /* ---------- formatting + finance helpers ---------- */ const fmt = { brl(n, dec = 2) { if (n == null || isNaN(n)) n = 0; return 'R$ ' + Number(n).toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec }); }, brl0(n) { return fmt.brl(n, 0); }, num(n, dec = 0) { return Number(n || 0).toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec }); }, date(iso) { if (!iso) return ''; const [y, m, d] = iso.split('-'); return `${d}/${m}`; }, dateFull(iso) { if (!iso) return ''; const [y, m, d] = iso.split('-'); return `${d}/${m}/${y}`; }, pct(n) { return Math.round(n) + '%'; }, hm(min) { const h = Math.floor(min / 60), m = Math.round(min % 60); return h > 0 ? `${h}h${m > 0 ? ' ' + m + 'min' : ''}` : `${m}min`; }, }; /* Compute full cost breakdown for a print job */ function computeCost(input, settings) { const { grams = 0, costPerKg = 0, printTimeMin = 0, packaging = 0, margin = 0, failRate = 0, marketplaceFee = 0, extraCost = 0 } = input; const hours = printTimeMin / 60; const filament = (grams / 1000) * costPerKg; const energy = (settings.powerWatts / 1000) * hours * settings.energyCostKwh; const labor = hours * settings.laborPerHour; const depreciation = (settings.printerCost / settings.printerLifeHours) * hours; const baseCost = filament + energy + labor + depreciation + packaging + extraCost; const withFail = baseCost * (1 + failRate / 100); const withMargin = withFail * (1 + margin / 100); // final price grossed up so marketplace fee leaves `withMargin` net const finalPrice = marketplaceFee > 0 ? withMargin / (1 - marketplaceFee / 100) : withMargin; const feeAmount = finalPrice - withMargin; const profit = withMargin - withFail; return { hours, filament, energy, labor, depreciation, packaging, extraCost, baseCost, withFail, failAmount: withFail - baseCost, withMargin, finalPrice, feeAmount, profit, marginPct: margin }; } /* month helpers */ function isThisMonth(iso, ref = new Date()) { if (!iso) return false; const [y, m] = iso.split('-').map(Number); return y === ref.getFullYear() && m === ref.getMonth() + 1; } Object.assign(window, { useStore, store, fmt, computeCost, isThisMonth, uid, today });