// ============================================================
// 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 = `
`;
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 = ``; return; }
const items = res.data.items;
if (!items.length) { wrap.innerHTML = ``; 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 = `
| Image | Name | SKU | Category | Unit | On Hand | Min | Unit Cost | Value | Status | ${isAdmin ? ' | ' : ''}
${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'} |
${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'} |
${isAdmin ? ` | ` : ''}
`;
}).join('')}
`;
}
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: `
`,
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 = `
`;
// 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 ? `
` : '';
const scanBtn = cfg.barcodeEnabled
? ``
: '';
Modal.open({
id: 'stock-bookin',
title: 'π¦ Book Stock In',
size: 'modal-xl',
body: `
`,
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 = ``;
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: `
Items booked out to this job card
Select quantities to return. Only items with remaining stock on the job card are shown.
${scanBtn}
No items currently booked out to this job card.
`,
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 = ``; return; }
const rows = res.data.transactions;
if (!rows.length) { wrap.innerHTML = 'π
No transactions found
'; document.getElementById('mv-pagination').innerHTML = ''; return; }
wrap.innerHTML = `
| Date | Type | Item | SKU |
Qty | Unit | Unit Cost |
Reference | Supplier | Job / Project | Recorded By |
${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 `
| ${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} |
`;
}).join('')}
`;
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)})
| Name | SKU | Unit | On Hand | Min | Unit Cost | Value | Status |
${catItems.map(i => `
| ${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('')}
`).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}