neōb

Enter your 4-digit PIN

Reset app data
`; const frame = el('print-frame'); frame.srcdoc = html; addLog('print', `Printed ${items.length} labels`); saveState(); notify(`Printing ${items.length} labels`, 'success'); } // ==================== SYNC ==================== async function syncToSheet() { el('sync-status').className = 'sync-status pending'; el('sync-status').textContent = '⏳ Syncing...'; try { const data = state.products.map(p => ({ sku: p.sku, name: p.name, upc: p.upc, category: p.category, created: p.created })); const response = await fetch(GOOGLE_SCRIPT_URL, { method: 'POST', mode: 'no-cors', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'sync', products: data }) }); el('sync-status').className = 'sync-status synced'; el('sync-status').textContent = 'βœ“ Synced'; } catch (e) { console.error('Sync error:', e); el('sync-status').className = 'sync-status error'; el('sync-status').textContent = 'βœ— Sync failed'; } } async function loadFromSheet() { el('sync-status').className = 'sync-status pending'; el('sync-status').textContent = '⏳ Loading...'; try { const response = await fetch(GOOGLE_SCRIPT_URL + '?action=load'); const data = await response.json(); if (data.products && Array.isArray(data.products)) { // Merge with existing, avoid duplicates by UPC const existingUPCs = new Set(state.products.map(p => p.upc)); data.products.forEach(p => { if (!existingUPCs.has(p.upc)) { state.products.push({ id: Date.now() + Math.random(), sku: p.sku || '', name: p.name, upc: p.upc, category: p.category || 'OT', created: p.created || new Date().toISOString() }); } }); saveState(); addLog('settings', `Loaded ${data.products.length} products from sheet`); notify('Loaded from sheet', 'success'); renderDashboard(); } el('sync-status').className = 'sync-status synced'; el('sync-status').textContent = 'βœ“ Loaded'; } catch (e) { console.error('Load error:', e); el('sync-status').className = 'sync-status error'; el('sync-status').textContent = 'βœ— Load failed'; notify('Load failed', 'error'); } } // ==================== USER SYNC (Google Sheet) ==================== async function loadUsersFromSheet() { try { const response = await fetch(GOOGLE_SCRIPT_URL + '?action=getUsers'); const data = await response.json(); if (data.success && data.users && data.users.length > 0) { // Map sheet users to app format (add failed counter) state.users = data.users.map(u => ({ id: u.id, name: u.name, pin: String(u.pin), role: u.role, active: u.active !== false, failed: 0 })); // Ensure admin always exists if (!state.users.find(u => u.role === 'admin')) { state.users.unshift({ id: 1, name: 'Admin', pin: '1055', role: 'admin', active: true, failed: 0 }); } saveState(); console.log('Users loaded from Google Sheet:', state.users.length); } } catch (e) { console.warn('Could not load users from sheet, using local data:', e.message); // Falls back to localStorage data already loaded in loadState() } } async function syncUsersToSheet() { try { const usersData = state.users.map(u => ({ id: u.id, name: u.name, pin: u.pin, role: u.role, active: u.active })); const url = GOOGLE_SCRIPT_URL + '?action=setUsers&users=' + encodeURIComponent(JSON.stringify(usersData)); await fetch(url, { method: 'GET', mode: 'cors' }); console.log('Users synced to Google Sheet'); } catch (e) { console.warn('Could not sync users to sheet:', e.message); } } // ==================== CSV IMPORT/EXPORT ==================== function exportCSV() { const headers = ['SKU', 'Product Name', 'UPC', 'Category', 'Created']; const rows = state.products.map(p => [ p.sku, `"${p.name.replace(/"/g, '""')}"`, p.upc, p.category, p.created ]); const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n'); downloadFile(csv, 'products.csv', 'text/csv'); addLog('settings', 'Exported products CSV'); notify('CSV exported', 'success'); } function importCSV(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const lines = ev.target.result.split('\n').filter(l => l.trim()); const header = lines[0].toLowerCase(); let imported = 0; for (let i = 1; i < lines.length; i++) { const parts = parseCSVLine(lines[i]); if (parts.length < 3) continue; const [sku, name, upc, category] = parts; if (!name || !upc) continue; if (state.products.some(p => p.upc === upc)) continue; state.products.push({ id: Date.now() + i, sku: sku || getNextSKU(category || 'OT'), name: name, upc: upc, category: category || 'OT', created: new Date().toISOString() }); imported++; } saveState(); addLog('settings', `Imported ${imported} products from CSV`); notify(`Imported ${imported} products`, 'success'); renderDashboard(); } catch (err) { notify('Import failed: ' + err.message, 'error'); } }; reader.readAsText(file); e.target.value = ''; } function parseCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { if (inQuotes && line[i + 1] === '"') { current += '"'; i++; } else inQuotes = !inQuotes; } else if (char === ',' && !inQuotes) { result.push(current.trim()); current = ''; } else { current += char; } } result.push(current.trim()); return result; } // ==================== BACKUP ==================== function exportBackup() { const backup = { version: '1.4', exported: new Date().toISOString(), products: state.products, deletedProducts: state.deletedProducts, categories: state.categories, roles: state.roles, users: state.users.map(u => ({...u, pin: '****'})), // Hide PINs settings: state.settings, log: state.log }; downloadFile(JSON.stringify(backup, null, 2), 'neob_backup.json', 'application/json'); addLog('settings', 'Exported full backup'); notify('Backup exported', 'success'); } function importBackup(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const data = JSON.parse(ev.target.result); if (data.products) state.products = data.products; if (data.deletedProducts) state.deletedProducts = data.deletedProducts; if (data.categories) state.categories = data.categories; if (data.roles) state.roles = data.roles; if (data.settings) state.settings = {...DEFAULT_SETTINGS, ...data.settings}; // Don't import users/log for security saveState(); addLog('settings', 'Imported backup'); notify('Backup imported', 'success'); renderDashboard(); } catch (err) { notify('Import failed: ' + err.message, 'error'); } }; reader.readAsText(file); e.target.value = ''; } // ==================== UTILITIES ==================== function el(id) { return document.getElementById(id); } function escapeHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function formatDate(iso) { if (!iso) return '--'; const d = new Date(iso); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } function formatDateTime(iso) { if (!iso) return '--'; const d = new Date(iso); return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } function downloadFile(content, filename, type) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function notify(msg, type = 'success') { const notif = el('notification'); notif.textContent = msg; notif.className = `notification ${type} show`; setTimeout(() => notif.classList.remove('show'), 3000); } // Close modal on overlay click el('modal').addEventListener('click', e => { if (e.target === el('modal')) el('modal').classList.add('hidden'); }); // Initialize init();