// ============================================================ // Stock Control Module β€” Full Rework // ============================================================ let stockState = { page: 1, search: '', cat: '', low_stock: false, zero_cost: false, no_image: false }; // Settings cache β€” loaded once per stock page render let _stockSettings = null; async function getStockSettings(force = false) { if (_stockSettings && !force) return _stockSettings; const res = await API.post('settings/get', { group: 'stock' }); const map = res.data?.map || {}; _stockSettings = { slipCapture: (map.stock_slip_capture ?? '1') !== '0', // default ON slipRequired: (map.stock_slip_required ?? '0') === '1', // default OFF barcodeEnabled: (map.stock_barcode_enabled ?? '0') === '1', // default OFF }; return _stockSettings; } async function renderStock(params = {}) { const isAdmin = Auth.can('stock', 'edit'); const canStock = Auth.can('stock', 'create') || Auth.can('stock', 'edit'); // techs can also book in/out and add items const content = document.getElementById('page-content'); content.innerHTML = `

Stock Control

Inventory, book in & out, reports

${canStock ? `` : ''} ${canStock ? `` : ''} ${canStock ? `` : ''} ${canStock ? `` : ''} ${Auth.can('stock', 'reports') ? `` : ''} ${canStock ? `` : ''}
`; const stockSearchDb = debounce(v => { stockState.search = v; loadStock(1); }, 350); window.stockSearchDb = stockSearchDb; // Load categories for filter API.post('stock/categories', { action: 'list' }).then(r => { const sel = document.getElementById('stock-cat-filter'); if (sel) sel.innerHTML = '' + (r.data?.categories || []).map(c => ``).join(''); }); await loadStock(1); getStockSettings(true); // warm the cache } async function loadStock(page = 1) { stockState.page = page; const wrap = document.getElementById('stock-table-wrap'); if (!wrap) return; const res = await API.post('stock/items', { action: 'list', page, limit: 25, search: stockState.search, category_id: stockState.cat, low_stock: stockState.low_stock ? 1 : 0, zero_cost: stockState.zero_cost ? 1 : 0, no_image: stockState.no_image ? 1 : 0 }); if (!res.success) { wrap.innerHTML = `
${res.message}
`; return; } const items = res.data.items; if (!items.length) { wrap.innerHTML = `
πŸ“¦
No items found
`; document.getElementById('stock-pagination').innerHTML = ''; return; } const isAdmin = Auth.isAdmin() || Auth.isDev(); if (window.innerWidth < 768) { wrap.innerHTML = `
${items.map(i => { const low = parseFloat(i.qty_on_hand) <= parseFloat(i.min_qty); return `
${i.image_path ? `` : `
πŸ“¦
`}
${i.name}
${i.sku} Β· ${i.category_name || 'β€”'} Β· ${i.unit || 'each'}
On hand: ${parseFloat(i.qty_on_hand).toFixed(2)}${low ? ' ⚠ LOW' : ''} R${i.unit_cost ? parseFloat(i.unit_cost).toFixed(2) : '0'} · Val: R${parseFloat(i.stock_value || 0).toFixed(2)}
${Auth.can('stock', 'edit') ? `` : ''}
`; }).join('')}
`; } else { wrap.innerHTML = `
${isAdmin ? '' : ''}${items.map(i => { const low = parseFloat(i.qty_on_hand) <= parseFloat(i.min_qty); return ` ${isAdmin ? `` : ''} `; }).join('')}
ImageNameSKUCategoryUnitOn HandMinUnit CostValueStatus
${i.image_path ? `` : `πŸ“¦`} ${i.name} ${i.sku} ${i.category_name || 'β€”'} ${i.unit || 'each'} ${parseFloat(i.qty_on_hand).toFixed(2)} ${parseFloat(i.min_qty).toFixed(2)} R ${i.unit_cost ? parseFloat(i.unit_cost).toFixed(2) : 'β€”'} R ${parseFloat(i.stock_value || 0).toFixed(2)} ${low ? 'Low Stock' : 'OK'}
`; } renderPagination('stock-pagination', res.data.pagination, `p => loadStock(p)`); } // ── Add/Edit Item Modal ──────────────────────────────────────── async function openAddItemModal(id = 0) { let item = null; if (id) { const res = await API.post('stock/items', { action: 'get', id }); if (res.success) item = res.data.item; } const catsRes = await API.post('stock/categories', { action: 'list' }); const cats = catsRes.data?.categories || []; Modal.open({ id: 'add-item', title: id ? 'Edit Stock Item' : 'New Stock Item', size: 'modal-lg', body: `
${id ? `` : ''}
${id ? `
${item?.image_path ? `` : `πŸ“¦`}
${item?.image_path ? `` : ''}

JPG, PNG or WebP Β· max 5MB

` : '

πŸ’‘ Save item first, then re-open to upload an image.

'}
`, footer: ` ` }); // Render QR code if feature enabled and item exists if (id && item) { getStockSettings().then(cfg => { if (!cfg.barcodeEnabled) return; const wrap = document.getElementById('item-qr-wrap'); if (!wrap) return; wrap.innerHTML = `

${item.sku}

${item.name}

`; // Generate QR code setTimeout(() => { const el = document.getElementById('item-qr-canvas'); if (el && window.QRCode) { el.innerHTML = ''; new QRCode(el, { text: item.sku, width: 96, height: 96, correctLevel: QRCode.CorrectLevel.M }); } }, 100); }); } } async function submitItem(id = 0) { const btn = document.querySelector('#modal-add-item .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('item-form'); const res = await API.post('stock/items', { action: 'save', ...data }); if (btn) btn.classList.remove('loading'); if (res.success) { // Upload image if one was selected const fileInput = document.getElementById('stock-img-file'); if (fileInput && fileInput.files[0]) { const fd = new FormData(); fd.append('image', fileInput.files[0]); fd.append('item_id', res.data.id || id); await API.postForm('stock/image_upload', fd); } Toast.show(id ? 'Item updated.' : 'Item created.', 'success'); Modal.close(); loadStock(stockState.page); } else Toast.show(res.message, 'error'); } function previewStockImage(input) { const file = input.files[0]; if (!file) return; const preview = document.getElementById('stock-img-preview'); if (!preview) return; const reader = new FileReader(); reader.onload = e => { preview.innerHTML = ``; }; reader.readAsDataURL(file); } async function removeStockImage(itemId) { if (!await Modal.confirm('Remove image from this item?', 'Remove Image', true)) return; const res = await API.post('stock/items', { action: 'remove_image', id: itemId }); if (res.success) { Toast.show('Image removed.', 'success'); openAddItemModal(itemId); } else Toast.show(res.message || 'Failed to remove image.', 'error'); } // ── Book In ──────────────────────────────────────────────────── async function openStockBookin() { const cfg = await getStockSettings(); const slipsRes = cfg.slipCapture ? await API.post('slips/list', { page: 1, limit: 50, status: 'unlinked', my_only: 0 }) : { data: { slips: [] } }; const slips = slipsRes.data?.slips || []; const slipField = cfg.slipCapture ? `
${cfg.slipRequired ? '

⚠ A slip must be attached before receiving stock.

' : ''}
` : ''; const scanBtn = cfg.barcodeEnabled ? `` : ''; Modal.open({ id: 'stock-bookin', title: 'πŸ“¦ Book Stock In', size: 'modal-xl', body: `
${slipField}
`, footer: ` ` }); setTimeout(() => document.getElementById('bookin-search')?.focus(), 150); } let _bookinSearchTimer; const _bookinItemCache = {}; function searchBookinItem(val) { clearTimeout(_bookinSearchTimer); const results = document.getElementById('bookin-search-results'); if (!results) return; if (!val || val.length < 2) { results.innerHTML = ''; return; } _bookinSearchTimer = setTimeout(async () => { const res = await API.post('stock/items', { action: 'search', q: val }); const items = res.data?.items || []; if (!items.length) { results.innerHTML = `
No items found β€” Create new item
`; return; } items.forEach(i => { _bookinItemCache[i.id] = i; }); results.innerHTML = items.map(i => `
${i.name} ${i.sku} Β· ${parseFloat(i.qty_on_hand).toFixed(1)} ${i.unit} on hand${i.supplier ? ' Β· ' + i.supplier : ''}
`).join(''); }, 250); } function addBookinLine(id) { const i = _bookinItemCache[id]; if (!i) return; const wrap = document.getElementById('bookin-lines'); if (!wrap) return; // If already added, just focus its qty const existing = wrap.querySelector(`.bookin-line[data-item-id="${id}"]`); if (existing) { existing.querySelector('.bl-qty')?.focus(); existing.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); document.getElementById('bookin-search').value = ''; document.getElementById('bookin-search-results').innerHTML = ''; return; } const div = document.createElement('div'); div.className = 'bookin-line'; div.dataset.itemId = id; div.style.cssText = 'display:grid;grid-template-columns:1fr 110px 120px 36px;gap:.75rem;align-items:center;padding:.75rem;margin-bottom:.5rem;background:var(--bg-secondary,#f9fafb);border:1px solid var(--border);border-radius:var(--radius-sm)'; div.innerHTML = `
${i.name}
${i.unit || 'each'}${i.supplier ? ' Β· ' + i.supplier : ''}
`; wrap.appendChild(div); document.getElementById('bookin-search').value = ''; document.getElementById('bookin-search-results').innerHTML = ''; document.getElementById('bookin-search').focus(); setTimeout(() => div.querySelector('.bl-qty')?.select(), 80); } // searchStockItem / selectStockItem removed β€” each modal now has its own dedicated search async function submitBookin() { const btn = document.querySelector('#modal-stock-bookin .btn-primary'); if (btn) btn.classList.add('loading'); const cfg = await getStockSettings(); // Client-side slip required check const slipVal = document.getElementById('bookin-slip')?.value || ''; if (cfg.slipRequired && !slipVal) { Toast.show('A linked slip is required to receive stock. Please select a slip.', 'error'); if (btn) btn.classList.remove('loading'); return; } const lines = []; document.querySelectorAll('.bookin-line').forEach(line => { const id = line.querySelector('.bl-item-id')?.value; const qty = line.querySelector('.bl-qty')?.value; const cost = line.querySelector('.bl-cost')?.value; if (id && qty) lines.push({ item_id: id, qty, unit_cost: cost }); }); if (!lines.length) { Toast.show('Add at least one item.', 'error'); if (btn) btn.classList.remove('loading'); return; } const res = await API.post('stock/bookin', { lines, reference_no: document.getElementById('bookin-ref')?.value, slip_id: document.getElementById('bookin-slip')?.value || '', notes: document.getElementById('bookin-notes')?.value || '', }); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show(`Stock received! Ref: ${res.data.reference}`, 'success'); Modal.close(); loadStock(stockState.page); } else Toast.show(res.message, 'error'); } // ── Book Out ─────────────────────────────────────────────────── async function openStockBookout() { const cfg = await getStockSettings(); const scanBtn = cfg.barcodeEnabled ? `` : ''; Modal.open({ id: 'stock-bookout', title: 'πŸ“€ Book Stock Out', size: 'modal-xl', body: `
`, footer: ` ` }); setTimeout(() => document.getElementById('bookout-search')?.focus(), 150); } let _bookoutSearchTimer; const _bookoutItemCache = {}; function searchBookoutItem(val) { clearTimeout(_bookoutSearchTimer); const results = document.getElementById('bookout-search-results'); if (!results) return; if (!val || val.length < 2) { results.innerHTML = ''; return; } _bookoutSearchTimer = setTimeout(async () => { const res = await API.post('stock/items', { action: 'search', q: val }); const items = res.data?.items || []; if (!items.length) { results.innerHTML = `
No items found
`; return; } items.forEach(i => { _bookoutItemCache[i.id] = i; }); results.innerHTML = items.map(i => `
${i.name} ${i.sku} Β· ${parseFloat(i.qty_on_hand).toFixed(1)} ${i.unit} on hand
`).join(''); }, 250); } let _jcBOTimer, _projBOTimer; function searchJcForBookout(val) { clearTimeout(_jcBOTimer); const results = document.getElementById('bookout-jc-results'); const hidden = document.getElementById('bookout-jc-id'); const chosen = document.getElementById('bookout-jc-chosen'); if (hidden) hidden.value = ''; if (chosen) chosen.textContent = ''; if (!val || val.length < 2) { if (results) results.innerHTML = ''; return; } _jcBOTimer = setTimeout(async () => { const res = await API.post('jobcards/list', { search: val, limit: 10, page: 1, context: 'bookout' }); const jcs = res.data?.job_cards || []; if (!results) return; results.innerHTML = jcs.length ? jcs.map(j => `
${j.job_number} β€” ${j.title}
`).join('') : '
No job cards found
'; }, 300); } function selectJcBO(id, num, title) { document.getElementById('bookout-jc-id').value = id; document.getElementById('bookout-jc-search').value = num; document.getElementById('bookout-jc-chosen').textContent = `βœ“ ${num} β€” ${title}`; document.getElementById('bookout-jc-results').innerHTML = ''; } function searchProjForBookout(val) { clearTimeout(_projBOTimer); const results = document.getElementById('bookout-proj-results'); const hidden = document.getElementById('bookout-proj-id'); const chosen = document.getElementById('bookout-proj-chosen'); if (hidden) hidden.value = ''; if (chosen) chosen.textContent = ''; if (!val || val.length < 2) { if (results) results.innerHTML = ''; return; } _projBOTimer = setTimeout(async () => { const res = await API.post('projects/list', { search: val, limit: 8, page: 1 }); const projs = res.data?.projects || []; if (!results) return; results.innerHTML = projs.length ? projs.map(p => `
${p.name}
`).join('') : '
No projects found
'; }, 300); } function selectProjBO(id, name) { document.getElementById('bookout-proj-id').value = id; document.getElementById('bookout-proj-search').value = name; document.getElementById('bookout-proj-chosen').textContent = `βœ“ ${name}`; document.getElementById('bookout-proj-results').innerHTML = ''; } function addBookoutLine(id) { const i = _bookoutItemCache[id]; if (!i) return; const wrap = document.getElementById('bookout-lines'); if (!wrap) return; const existing = wrap.querySelector(`.bookout-line[data-item-id="${id}"]`); if (existing) { existing.querySelector('.bl-qty')?.focus(); existing.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); document.getElementById('bookout-search').value = ''; document.getElementById('bookout-search-results').innerHTML = ''; return; } const div = document.createElement('div'); div.className = 'bookout-line'; div.dataset.itemId = id; div.style.cssText = 'display:grid;grid-template-columns:1fr 110px 36px;gap:.75rem;align-items:center;padding:.75rem;margin-bottom:.5rem;background:var(--bg-secondary,#f9fafb);border:1px solid var(--border);border-radius:var(--radius-sm)'; div.innerHTML = `
${i.name}
${parseFloat(i.qty_on_hand).toFixed(1)} ${i.unit} on hand
`; wrap.appendChild(div); document.getElementById('bookout-search').value = ''; document.getElementById('bookout-search-results').innerHTML = ''; document.getElementById('bookout-search').focus(); setTimeout(() => div.querySelector('.bl-qty')?.select(), 80); } async function submitBookout() { const btn = document.querySelector('#modal-stock-bookout .btn-primary'); if (btn) btn.classList.add('loading'); const jcId = document.getElementById('bookout-jc-id')?.value; const jcSearch = document.getElementById('bookout-jc-search')?.value?.trim(); if (jcSearch && !jcId) { Toast.show('Select a valid job card.', 'error'); if (btn) btn.classList.remove('loading'); return; } const projId = document.getElementById('bookout-proj-id')?.value; const projSearch = document.getElementById('bookout-proj-search')?.value?.trim(); if (projSearch && !projId) { Toast.show('Select a valid project.', 'error'); if (btn) btn.classList.remove('loading'); return; } const lines = []; document.querySelectorAll('.bookout-line').forEach(line => { const id = line.querySelector('.bl-item-id')?.value; const qty = line.querySelector('.bl-qty')?.value; if (id && qty) lines.push({ item_id: id, qty }); }); if (!lines.length) { Toast.show('Add at least one item.', 'error'); if (btn) btn.classList.remove('loading'); return; } const res = await API.post('stock/bookout', { lines, job_card_id: jcId || '', project_id: projId || '', notes: document.getElementById('bookout-notes')?.value || '', }); if (btn) btn.classList.remove('loading'); if (res.success) { let msg = `Stock issued! Ref: ${res.data.reference}`; const alerts = res.data.low_stock_alerts || []; if (alerts.length) msg += ` ⚠ Low stock: ${alerts.map(a => a.name).join(', ')}`; Toast.show(msg, alerts.length ? 'warning' : 'success'); Modal.close(); loadStock(stockState.page); } else Toast.show(res.message, 'error'); } // ── Return from Job Card ────────────────────────────────────── async function openStockReturnJc() { const cfg = await getStockSettings(); const scanBtn = cfg.barcodeEnabled ? `` : ''; Modal.open({ id: 'stock-return-jc', title: '↩️ Return Items to Stock', size: 'modal-lg', body: `
`, footer: ` ` }); } async function searchJcForReturn(val) { const results = document.getElementById('rtn-jc-results'); const hidden = document.getElementById('rtn-jc-id'); const chosen = document.getElementById('rtn-jc-chosen'); if (!results) return; if (!val || val.length < 2) { results.innerHTML = ''; return; } const res = await API.post('jobcards/list', { search: val, limit: 10, page: 1, context: 'bookout' }); if (!res.success || !res.data.job_cards.length) { results.innerHTML = '
No job cards found
'; return; } results.innerHTML = res.data.job_cards.map(j => `
${j.job_number} ${j.title || ''} ${j.status}
` ).join(''); } async function selectJcForReturn(id, num, title) { document.getElementById('rtn-jc-id').value = id; document.getElementById('rtn-jc-search').value = num; document.getElementById('rtn-jc-chosen').textContent = `βœ“ ${num} β€” ${title}`; document.getElementById('rtn-jc-results').innerHTML = ''; // Load items booked out to this job card const res = await API.post('stock/jobcard_items', { job_card_id: id }); const linesWrap = document.getElementById('rtn-lines'); const itemsWrap = document.getElementById('rtn-items-wrap'); const emptyMsg = document.getElementById('rtn-empty'); const submitBtn = document.getElementById('rtn-submit-btn'); if (!res.success || !res.data.items.length) { itemsWrap.style.display = 'none'; emptyMsg.style.display = 'block'; submitBtn.style.display = 'none'; return; } emptyMsg.style.display = 'none'; itemsWrap.style.display = 'block'; submitBtn.style.display = ''; linesWrap.innerHTML = res.data.items.map(item => `
${item.item_name}
${item.sku} Β· ${parseFloat(item.net_issued).toFixed(2)} ${item.unit} on job
of ${parseFloat(item.net_issued).toFixed(2)} ${item.unit}
`).join(''); } async function submitStockReturn() { const btn = document.getElementById('rtn-submit-btn'); if (btn) btn.classList.add('loading'); const jcId = document.getElementById('rtn-jc-id')?.value; if (!jcId) { Toast.show('Select a job card first.', 'error'); if (btn) btn.classList.remove('loading'); return; } const lines = []; document.querySelectorAll('.rtn-line').forEach(line => { const itemId = line.dataset.itemId; const qty = parseFloat(line.querySelector('.rtn-qty')?.value || 0); if (itemId && qty > 0) lines.push({ item_id: itemId, qty }); }); if (!lines.length) { Toast.show('No items to return β€” enter quantities above 0.', 'error'); if (btn) btn.classList.remove('loading'); return; } const res = await API.post('stock/return_jobcard', { job_card_id: jcId, lines, notes: document.getElementById('rtn-notes')?.value || '', }); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show(`Items returned to stock. Ref: ${res.data.reference}`, 'success'); Modal.close(); loadStock(stockState.page); } else { Toast.show(res.message, 'error'); } } window.openStockReturnJc = openStockReturnJc; window.searchJcForReturn = searchJcForReturn; window.selectJcForReturn = selectJcForReturn; window.submitStockReturn = submitStockReturn; // ── Stock Movements ─────────────────────────────────────────── let _mvState = { page: 1, search: '', type: '', item_id: '', date_from: '', date_to: '' }; async function openStockMovements() { Modal.open({ id: 'stock-movements', title: 'πŸ“‹ Stock Movements', size: 'modal-xl', body: `
`, footer: `` }); _mvState = { page: 1, search: '', type: '', item_id: '', date_from: '', date_to: '' }; await loadMovements(1); } function clearMvFilters() { _mvState = { page: 1, search: '', type: '', item_id: '', date_from: '', date_to: '' }; const f = document.getElementById('mv-search'); if (f) f.value = ''; const t = document.getElementById('mv-type'); if (t) t.value = ''; const d1 = document.getElementById('mv-from'); if (d1) d1.value = ''; const d2 = document.getElementById('mv-to'); if (d2) d2.value = ''; loadMovements(1); } const mvSearchDebounced = debounce(v => { _mvState.search = v; loadMovements(1); }, 350); window.mvSearchDebounced = mvSearchDebounced; const TX_LABELS = { receive: 'Received', issue_jobcard: 'Job Card', issue_project: 'Project', issue_person: 'Person', return: 'Return', adjust: 'Adjust', transfer: 'Transfer' }; const TX_COLORS = { receive: 'badge-success', issue_jobcard: 'badge-info', issue_project: 'badge-info', issue_person: 'badge-warning', return: 'badge-muted', adjust: 'badge-warning', transfer: 'badge-muted' }; async function loadMovements(page = 1) { _mvState.page = page; const wrap = document.getElementById('mv-table-wrap'); if (!wrap) return; wrap.innerHTML = '
'; const res = await API.post('stock/transactions', { ..._mvState, page }); if (!res.success) { wrap.innerHTML = `
${res.message}
`; return; } const rows = res.data.transactions; if (!rows.length) { wrap.innerHTML = '
πŸ“‹
No transactions found
'; document.getElementById('mv-pagination').innerHTML = ''; return; } wrap.innerHTML = `
${rows.map(r => { const isOut = ['issue_jobcard', 'issue_project', 'issue_person'].includes(r.transaction_type); const qtyStr = (isOut ? 'βˆ’' : '+') + Math.abs(parseFloat(r.quantity)).toFixed(2); const qtyColor = isOut ? 'color:var(--danger)' : 'color:var(--success)'; const linked = r.job_number ? `${r.job_number}` : (r.project_name ? `${r.project_name}` : 'β€”'); return ``; }).join('')}
DateTypeItemSKU QtyUnitUnit Cost ReferenceSupplierJob / ProjectRecorded By
${Fmt.datetime(r.transaction_date)} ${TX_LABELS[r.transaction_type] || r.transaction_type} ${r.item_name} ${r.sku} ${qtyStr} ${r.unit || ''} ${r.unit_cost ? 'R ' + parseFloat(r.unit_cost).toFixed(2) : 'β€”'} ${r.reference_no || 'β€”'} ${r.supplier || 'β€”'} ${linked} ${r.recorded_by}
`; renderPagination('mv-pagination', res.data.pagination, `p => loadMovements(p)`); } // ── Report Modal ─────────────────────────────────────────────── async function openStockReport() { Modal.open({ id: 'stock-report', title: 'πŸ“Š Stock Report', size: 'modal-xl', body: `
`, footer: `` }); const res = await API.post('stock/report', { type: 'summary' }); const body = document.getElementById('stock-report-body'); if (!body || !res.success) return; const items = res.data.items; const totalVal = res.data.total_value; const lowCount = res.data.low_stock_count; // Group by category const cats = {}; items.forEach(i => { const cat = i.category_name || 'Uncategorised'; if (!cats[cat]) cats[cat] = []; cats[cat].push(i); }); body.innerHTML = `
${res.data.total_items}
Total Items
R ${parseFloat(totalVal).toFixed(2)}
Total Stock Value
${lowCount}
Low Stock Items
${Object.entries(cats).map(([cat, catItems]) => `
${cat} (${catItems.length} items, R ${catItems.reduce((s, i) => s + parseFloat(i.stock_value || 0), 0).toFixed(2)})
${catItems.map(i => ` `).join('')}
NameSKUUnitOn HandMinUnit CostValueStatus
${i.name} ${i.sku} ${i.unit || 'each'} ${parseFloat(i.qty_on_hand).toFixed(2)} ${parseFloat(i.min_qty).toFixed(2)} R ${i.unit_cost ? parseFloat(i.unit_cost).toFixed(2) : 'β€”'} R ${parseFloat(i.stock_value || 0).toFixed(2)} ${parseFloat(i.qty_on_hand) <= parseFloat(i.min_qty) ? 'Low' : 'OK'}
`).join('')}`; } // ── Barcode / QR Scanner ────────────────────────────────────── // targetModal: 'bookin' | 'bookout' | 'return' let _scanStream = null; function openBarcodeScanner(targetModal) { // Build the scanner overlay const overlay = document.createElement('div'); overlay.id = 'barcode-scanner-overlay'; overlay.style.cssText = ` position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:99999; display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem`; overlay.innerHTML = `
πŸ“· Point camera at QR code or barcode
Initialising camera…
`; document.body.appendChild(overlay); const video = document.getElementById('bc-video'); const canvas = document.getElementById('bc-canvas'); const status = document.getElementById('bc-status'); let scanning = true; let rafId; navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false }) .then(stream => { _scanStream = stream; video.srcObject = stream; video.onloadedmetadata = () => { video.play(); status.textContent = 'Ready β€” align the code within the frame'; scanFrame(); }; }) .catch(err => { status.textContent = '⚠ Camera access denied or unavailable: ' + err.message; }); function scanFrame() { if (!scanning) return; if (video.readyState === video.HAVE_ENOUGH_DATA) { canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); let code = null; // Try native BarcodeDetector first (Chrome Android, Safari 17+) if (window.BarcodeDetector) { window._bcDetector = window._bcDetector || new BarcodeDetector({ formats: ['qr_code', 'code_128', 'ean_13', 'ean_8', 'code_39', 'upc_a', 'upc_e', 'data_matrix'] }); window._bcDetector.detect(canvas) .then(barcodes => { if (barcodes.length && scanning) { const val = barcodes[0].rawValue; scanning = false; handleScan(val, targetModal); } }) .catch(() => { }); } else if (window.jsQR) { // Fallback to jsQR (QR only) code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' }); if (code && scanning) { scanning = false; handleScan(code.data, targetModal); } } } rafId = requestAnimationFrame(scanFrame); } window._bcScanRafId = rafId; } async function handleScan(rawValue, targetModal) { closeBarcodeScanner(); // rawValue is expected to be the SKU const sku = rawValue.trim(); if (!sku) return; const res = await API.post('stock/items', { action: 'search', q: sku }); const items = res.data?.items || []; // Try exact SKU match first, then fallback to first result const match = items.find(i => i.sku.toUpperCase() === sku.toUpperCase()) || items[0]; if (!match) { Toast.show(`No stock item found for code: ${sku}`, 'error'); return; } // Add to the correct modal's cache and call the right add function if (targetModal === 'bookin') { _bookinItemCache[match.id] = match; addBookinLine(match.id); Toast.show(`βœ“ Scanned: ${match.name}`, 'success'); } else if (targetModal === 'bookout') { _bookoutItemCache[match.id] = match; addBookoutLine(match.id); Toast.show(`βœ“ Scanned: ${match.name}`, 'success'); } else if (targetModal === 'return') { // Highlight matching row in return list or show toast const rtnLines = document.querySelectorAll('.rtn-line'); let found = false; rtnLines.forEach(row => { if (String(row.dataset.itemId) === String(match.id)) { row.style.outline = '2px solid var(--primary)'; row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); row.querySelector('.rtn-qty')?.focus(); found = true; } }); Toast.show(found ? `βœ“ Scanned: ${match.name}` : `Item "${match.name}" is not on this job card.`, found ? 'success' : 'warning'); } } function closeBarcodeScanner() { if (_scanStream) { _scanStream.getTracks().forEach(t => t.stop()); _scanStream = null; } if (window._bcScanRafId) { cancelAnimationFrame(window._bcScanRafId); } const el = document.getElementById('barcode-scanner-overlay'); if (el) el.remove(); } // ── Print QR Label ──────────────────────────────────────────── function printItemQR(sku, name) { const win = window.open('', '_blank', 'width=400,height=300'); win.document.write(`Label β€” ${sku}