// ============================================================ // Inventory — warehouse stock (packed / unpacked) per SKU. // Printing a label auto-deducts "packed" stock (see api/print.php); this // screen is for opening balances, manual corrections, bulk CSV updates and // a printable brand-wise reconciliation report. // ============================================================ (function () { const { Icon, Badge, BrandDot, Btn, Field, Modal, fmtNum } = window; const { useState, useMemo, useRef, useEffect } = React; const SHEET_SYNC_INTERVAL_MS = 45000; // Quote-aware CSV parser (product names can contain commas) — same approach // as the Products CSV importer, so a "title" column here is safe to add // for readability without corrupting the sku/packed/unpacked columns. function parseCsv(text) { if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); // strip UTF-8 BOM if present const table = []; let row = [], field = '', inQuotes = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQuotes) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; } else field += c; } else if (c === '"') inQuotes = true; else if (c === ',') { row.push(field); field = ''; } else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; row.push(field); field = ''; if (row.some(f => f !== '')) table.push(row); row = []; } else field += c; } if (field !== '' || row.length) { row.push(field); if (row.some(f => f !== '')) table.push(row); } if (!table.length) return []; const header = table[0].map(h => h.trim().toLowerCase()); const skuIdx = header.indexOf('sku'); const packedIdx = header.findIndex(h => h === 'packed' || h === 'packed_change'); const unpackedIdx = header.findIndex(h => h === 'unpacked' || h === 'unpacked_change'); if (skuIdx === -1) return []; return table.slice(1).map(cols => ({ sku: (cols[skuIdx] || '').trim(), packed: packedIdx > -1 ? (parseInt(cols[packedIdx], 10) || 0) : 0, unpacked: unpackedIdx > -1 ? (parseInt(cols[unpackedIdx], 10) || 0) : 0, })).filter(r => r.sku && (r.packed || r.unpacked)); } function csvField(v) { return '"' + String(v == null ? '' : v).replace(/"/g, '""') + '"'; } function downloadCsv(lines, filename) { const blob = new Blob([String.fromCharCode(0xFEFF) + lines.join('\n')], { type: 'text/csv;charset=utf-8;' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); } function Inventory({ inventory, onSetInventory, onImportInventory, onLoadMoves, sheetUrl, onSaveSheetUrl, onSyncSheet, toast }) { const [q, setQ] = useState(''); const [brand, setBrand] = useState('All'); const [editing, setEditing] = useState(null); const [saving, setSaving] = useState(false); const [movesFor, setMovesFor] = useState(null); const [moves, setMoves] = useState([]); const [movesLoading, setMovesLoading] = useState(false); const [importBusy, setImportBusy] = useState(false); const fileRef = useRef(null); const [sheetUrlDraft, setSheetUrlDraft] = useState(sheetUrl || ''); const [savingUrl, setSavingUrl] = useState(false); const [syncing, setSyncing] = useState(false); const [syncError, setSyncError] = useState(null); const [lastSynced, setLastSynced] = useState(null); const filtered = useMemo(() => inventory.filter(it => (brand === 'All' || it.brand === brand) && (!q || (it.title + it.sku).toLowerCase().includes(q.toLowerCase())) ), [inventory, brand, q]); const totals = filtered.reduce((s, it) => ({ packed: s.packed + (+it.packed_qty || 0), unpacked: s.unpacked + (+it.unpacked_qty || 0) }), { packed: 0, unpacked: 0 }); const brandGroups = useMemo(() => { const g = {}; inventory.forEach(it => { (g[it.brand] = g[it.brand] || []).push(it); }); return g; }, [inventory]); const openEdit = (it) => setEditing({ sku: it.sku, title: it.title, brand: it.brand, packed: String(it.packed_qty || 0), unpacked: String(it.unpacked_qty || 0), note: '' }); const saveEdit = async () => { setSaving(true); try { await onSetInventory(editing.sku, +editing.packed || 0, +editing.unpacked || 0, editing.note); toast('Stock updated · ' + editing.sku); setEditing(null); } catch (e) { toast(e.message || 'Update failed', 'err'); } setSaving(false); }; const openMoves = async (sku) => { setMovesFor(sku); setMoves([]); setMovesLoading(true); try { setMoves(await onLoadMoves(sku)); } catch (e) { toast(e.message || 'Could not load history', 'err'); } setMovesLoading(false); }; const downloadTemplate = () => { // "title" is here purely so you can tell SKUs apart while filling the sheet // in Excel — it's ignored on import, only sku/packed_change/unpacked_change matter. const lines = ['sku,title,packed_change,unpacked_change']; inventory.forEach(it => lines.push([it.sku, csvField((it.title || '').replace(it.brand, '').trim()), 0, 0].join(','))); downloadCsv(lines, 'inventory-template.csv'); toast('Template downloaded — fill in +in / -out and re-upload'); }; const exportCurrent = () => { const lines = ['sku,brand,title,packed,unpacked,total']; inventory.forEach(it => lines.push([it.sku, it.brand, csvField(it.title), it.packed_qty, it.unpacked_qty, (+it.packed_qty || 0) + (+it.unpacked_qty || 0)].join(','))); downloadCsv(lines, 'inventory-stock.csv'); }; const handleFile = async (e) => { const file = e.target.files[0]; e.target.value = ''; if (!file) return; const rows = parseCsv(await file.text()); if (!rows.length) { toast('No valid rows found — expected columns: sku, packed_change, unpacked_change', 'err'); return; } setImportBusy(true); try { const r = await onImportInventory(rows); toast('Applied ' + fmtNum(r.rows || rows.length) + ' stock change' + ((r.rows || rows.length) === 1 ? '' : 's') + ' from ' + rows.length + ' row' + (rows.length === 1 ? '' : 's')); } catch (e) { toast(e.message || 'Import failed', 'err'); } setImportBusy(false); }; const saveSheetUrl = async () => { setSavingUrl(true); try { await onSaveSheetUrl(sheetUrlDraft.trim()); toast('Sheet link saved'); } catch (e) { toast(e.message || 'Could not save the link', 'err'); } setSavingUrl(false); }; // SET semantics, not add/subtract — the sheet's numbers are the true // current stock, so re-syncing unchanged rows is always a safe no-op // (that's what makes the 45s auto-poll below safe to leave running). const syncNow = async (silent) => { setSyncing(true); setSyncError(null); try { const r = await onSyncSheet(); setLastSynced(new Date().toLocaleTimeString('en-IN')); if (!silent) toast(fmtNum(r.changed) + ' SKU' + (r.changed === 1 ? '' : 's') + ' updated · ' + fmtNum(r.unchanged) + ' already matched'); } catch (e) { setSyncError(e.message || 'Sync failed'); if (!silent) toast(e.message || 'Sync failed', 'err'); } setSyncing(false); }; // While this screen is open, keep pulling the sheet automatically — this // is the "near real-time" part; edits stop syncing the moment you leave // the screen (no server-side cron here), so hit "Sync now" for anything urgent. useEffect(() => { if (!sheetUrl) return; const iv = setInterval(() => syncNow(true), SHEET_SYNC_INTERVAL_MS); return () => clearInterval(iv); }, [sheetUrl]); return React.createElement('div', { className: 'screen' }, React.createElement('div', { className: 'screen-head' }, React.createElement('div', null, React.createElement('h1', null, 'Inventory'), React.createElement('p', { className: 'screen-sub' }, fmtNum(filtered.length), ' SKUs · ', React.createElement('b', { style: { color: 'var(--amber)' } }, fmtNum(totals.packed)), ' packed · ', React.createElement('b', null, fmtNum(totals.unpacked)), ' unpacked') ), React.createElement('div', { className: 'btn-row' }, React.createElement('input', { type: 'file', accept: '.csv,text/csv', ref: fileRef, style: { display: 'none' }, onChange: handleFile }), React.createElement(Btn, { variant: 'ghost', icon: 'download', onClick: downloadTemplate }, 'CSV template'), React.createElement(Btn, { variant: 'ghost', icon: 'layers', onClick: () => fileRef.current && fileRef.current.click(), disabled: importBusy }, importBusy ? 'Importing…' : 'Import CSV'), React.createElement(Btn, { variant: 'ghost', icon: 'download', onClick: exportCurrent }, 'Export CSV'), React.createElement(Btn, { variant: 'primary', icon: 'print', onClick: () => window.print() }, 'Print report') ) ), React.createElement('div', { className: 'panel sheet-sync-panel' }, React.createElement('div', { className: 'panel-head' }, React.createElement('h3', null, React.createElement(Icon, { name: 'refresh', size: 14, style: { marginRight: 6, verticalAlign: -2 } }), 'Google Sheet live sync'), React.createElement('span', { className: 'conn-pill ' + (sheetUrl ? 'on' : 'off') }, React.createElement('span', { className: 'pulse-dot ' + (sheetUrl ? 'live' : 'off') }), sheetUrl ? ('Auto-syncing every ' + (SHEET_SYNC_INTERVAL_MS / 1000) + 's') : 'Not linked') ), React.createElement('div', { className: 'sheet-sync-row' }, React.createElement('input', { className: 'input mono sm', value: sheetUrlDraft, onChange: e => setSheetUrlDraft(e.target.value), placeholder: 'Paste Google Sheet share link…' }), React.createElement(Btn, { size: 'sm', icon: 'check', onClick: saveSheetUrl, disabled: savingUrl || sheetUrlDraft.trim() === (sheetUrl || '') }, savingUrl ? 'Saving…' : 'Save link'), React.createElement(Btn, { variant: 'primary', size: 'sm', icon: 'refresh', onClick: () => syncNow(false), disabled: syncing || !sheetUrl }, syncing ? 'Syncing…' : 'Sync now') ), lastSynced && !syncError && React.createElement('div', { className: 'test-result ok' }, React.createElement(Icon, { name: 'check', size: 15, stroke: 2.2 }), React.createElement('span', null, 'Last synced ', lastSynced)), syncError && React.createElement('div', { className: 'test-result bad' }, React.createElement(Icon, { name: 'alert', size: 15, stroke: 2.2 }), React.createElement('span', null, syncError)), React.createElement('div', { className: 'hint-box' }, React.createElement('b', null, 'How: '), 'Make a Google Sheet with columns ', React.createElement('code', { className: 'mono' }, 'sku, packed, unpacked'), ' — one row per SKU, each number is the true current stock count (not a change). Share it as "Anyone with the link — Viewer" (or File → Share → Publish to web), paste the link above and Save. While this screen stays open it re-syncs automatically; "Sync now" pulls it immediately.') ), React.createElement('div', { className: 'toolbar' }, React.createElement('div', { className: 'search' }, React.createElement(Icon, { name: 'search', size: 17, color: 'var(--txt-mut)' }), React.createElement('input', { value: q, onChange: e => setQ(e.target.value), placeholder: 'Search SKU, product…' }) ), React.createElement('div', { className: 'seg' }, ['All', 'LUXURIATE', 'VITMINVEDA'].map(b => React.createElement('button', { key: b, className: 'seg-btn' + (brand === b ? ' on' : ''), onClick: () => setBrand(b) }, b !== 'All' && React.createElement(BrandDot, { brand: b }), b === 'All' ? 'All' : b === 'LUXURIATE' ? 'LUX' : 'VMV')) ) ), React.createElement('div', { className: 'panel table-panel' }, React.createElement('div', { className: 'table inv' }, React.createElement('div', { className: 'tr th' }, React.createElement('span', null, 'Product'), React.createElement('span', { className: 'mono-h' }, 'SKU'), React.createElement('span', null, 'Packed'), React.createElement('span', null, 'Unpacked'), React.createElement('span', null, 'Total'), React.createElement('span', null, 'Updated'), React.createElement('span', null, '') ), filtered.length === 0 && React.createElement('div', { className: 'empty' }, React.createElement(Icon, { name: 'layers', size: 28, color: 'var(--txt-mut)' }), React.createElement('p', null, 'No products match')), filtered.map(it => { const total = (+it.packed_qty || 0) + (+it.unpacked_qty || 0); return React.createElement('div', { key: it.sku, className: 'tr' }, React.createElement('span', { className: 'h-prod' }, React.createElement(BrandDot, { brand: it.brand, size: 9 }), React.createElement('div', null, React.createElement('b', null, (it.title || '').replace(it.brand, '').trim()))), React.createElement('span', { className: 'mono c-sku' }, it.sku), React.createElement('span', { className: 'mono' + ((+it.packed_qty || 0) < 0 ? ' inv-neg' : '') }, fmtNum(+it.packed_qty || 0)), React.createElement('span', { className: 'mono' }, fmtNum(+it.unpacked_qty || 0)), React.createElement('span', null, React.createElement('b', { className: 'qty-pill' }, fmtNum(total))), React.createElement('span', { className: 'h-op' }, it.updated_at ? it.updated_at.slice(0, 10) : '—'), React.createElement('span', { className: 'c-act' }, React.createElement('button', { className: 'icon-btn-sm', title: 'Movement history', onClick: () => openMoves(it.sku) }, React.createElement(Icon, { name: 'history', size: 15 })), React.createElement('button', { className: 'icon-btn-sm', title: 'Edit stock', onClick: () => openEdit(it) }, React.createElement(Icon, { name: 'edit', size: 15 })) ) ); }) ) ), React.createElement(Modal, { open: !!editing, onClose: () => !saving && setEditing(null), title: editing ? 'Edit stock · ' + editing.sku : '', footer: React.createElement(React.Fragment, null, React.createElement(Btn, { variant: 'ghost', onClick: () => setEditing(null), disabled: saving }, 'Cancel'), React.createElement(Btn, { variant: 'primary', icon: 'check', onClick: saveEdit, disabled: saving }, saving ? 'Saving…' : 'Save') ) }, editing && React.createElement('div', { className: 'editor-fields' }, React.createElement('p', { className: 'confirm-txt' }, (editing.title || '').replace(editing.brand, '').trim()), React.createElement('div', { className: 'frow' }, React.createElement(Field, { label: 'Packed qty' }, React.createElement('input', { className: 'input mono', value: editing.packed, onChange: e => setEditing(o => ({ ...o, packed: e.target.value.replace(/[^\d-]/g, '') })) })), React.createElement(Field, { label: 'Unpacked qty' }, React.createElement('input', { className: 'input mono', value: editing.unpacked, onChange: e => setEditing(o => ({ ...o, unpacked: e.target.value.replace(/[^\d-]/g, '') })) })) ), React.createElement(Field, { label: 'Note (optional)', hint: 'Why the count changed — e.g. "physical recount", "opening stock"' }, React.createElement('input', { className: 'input', value: editing.note, onChange: e => setEditing(o => ({ ...o, note: e.target.value })) })) )), React.createElement(Modal, { open: !!movesFor, onClose: () => setMovesFor(null), title: 'Movement history · ' + movesFor, width: 620 }, movesLoading ? React.createElement('div', { className: 'mini-empty' }, 'Loading…') : moves.length === 0 ? React.createElement('div', { className: 'mini-empty' }, 'No stock movements yet') : React.createElement('div', { className: 'moves-list' }, moves.map(m => React.createElement('div', { key: m.id, className: 'move-row' }, React.createElement('span', { className: 'move-when' }, (m.created_at || '').replace('T', ' ').slice(0, 16)), React.createElement('span', { className: 'move-pool' }, m.pool), React.createElement('span', { className: 'mono move-delta' + (+m.delta < 0 ? ' neg' : ' pos') }, (+m.delta > 0 ? '+' : ''), m.delta), React.createElement('span', { className: 'move-reason' }, m.reason, m.channel ? ' · ' + m.channel : ''), React.createElement('span', { className: 'move-note' }, m.note || m.user_name || '') )) ) ), // Print-only brand-wise reconciliation report (see @media print rules) — // hidden on screen, becomes the whole page when "Print report" is used. React.createElement('div', { className: 'inv-report' }, React.createElement('h1', null, 'Inventory Reconciliation Report'), React.createElement('p', { className: 'report-meta' }, 'Generated ', new Date().toLocaleString('en-IN')), Object.keys(brandGroups).sort().map(b => { const rows = brandGroups[b]; const pTot = rows.reduce((s, it) => s + (+it.packed_qty || 0), 0); const uTot = rows.reduce((s, it) => s + (+it.unpacked_qty || 0), 0); return React.createElement('div', { key: b, className: 'report-brand' }, React.createElement('h2', null, b), React.createElement('table', { className: 'report-table' }, React.createElement('thead', null, React.createElement('tr', null, ['SKU', 'Product', 'Packed', 'Unpacked', 'Total'].map(h => React.createElement('th', { key: h }, h)))), React.createElement('tbody', null, rows.map(it => React.createElement('tr', { key: it.sku }, React.createElement('td', null, it.sku), React.createElement('td', null, (it.title || '').replace(it.brand, '').trim()), React.createElement('td', null, it.packed_qty), React.createElement('td', null, it.unpacked_qty), React.createElement('td', null, (+it.packed_qty || 0) + (+it.unpacked_qty || 0)) )), React.createElement('tr', { className: 'report-total-row' }, React.createElement('td', { colSpan: 2 }, 'Brand total'), React.createElement('td', null, pTot), React.createElement('td', null, uTot), React.createElement('td', null, pTot + uTot) ) ) ) ); }) ) ); } Object.assign(window, { Inventory }); })();