// labelCenter.js — Label Center overlay: cross-device label queue, sheet assignment, and batch print. import { buildLabelHTML, renderBarcode, formatPhone } from './labelGen.js'; import { showToast } from './toast.js'; import { searchLocal } from './assetBrowser.js'; import CONFIG from '../config.js'; // ── Sheet type configurations ───────────────────────────────────────────────── const SHEET_CONFIGS = { OL875LP: { label: 'OL875LP \u2014 2.625\u2033 \xd7 1\u2033 (30-up)', labelW: 2.625, labelH: 1.0, cols: 3, rows: 10, pageSize: 30, colOffsets: [0.1875, 2.9375, 5.6875], rowOffsets: [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5], }, OL25LP: { label: 'OL25LP \u2014 1.75\u2033 \xd7 0.5\u2033 (80-up)', labelW: 1.75, labelH: 0.5, cols: 4, rows: 20, pageSize: 80, colOffsets: [0.328125, 2.359375, 4.390625, 6.421875], rowOffsets: Array.from({ length: 20 }, (_, i) => 0.5 + i * 0.5), }, }; let _sheetTypeKey = localStorage.getItem('lc_sheet_type') || 'OL875LP'; function activeSheet() { return SHEET_CONFIGS[_sheetTypeKey] ?? SHEET_CONFIGS.OL875LP; } // ── Module state ────────────────────────────────────────────────────────────── function posStart(page) { return (page - 1) * activeSheet().pageSize + 1; } function posEnd(page) { return page * activeSheet().pageSize; } let _queue = []; // Array — synced from server let _pickerTargetPos = null; // which grid position the picker is open for let _pageCount = 1; function _loadPageCount() { const maxPos = Math.max(0, ..._queue.map(i => i.sheet_position ?? 0), ...[..._usedPositions], ); const pagesNeeded = maxPos > 0 ? Math.ceil(maxPos / activeSheet().pageSize) : 1; const stored = parseInt(localStorage.getItem('lc_page_count') || '1', 10); _pageCount = Math.max(pagesNeeded, stored, 1); } function _savePageCount() { localStorage.setItem('lc_page_count', String(_pageCount)); } let _usedPositions = new Set( JSON.parse(localStorage.getItem('lc_used_positions') || '[]') ); function _saveUsedPositions() { localStorage.setItem('lc_used_positions', JSON.stringify([..._usedPositions])); } // ── Derived helpers ─────────────────────────────────────────────────────────── function assignedItems() { return _queue.filter(i => i.sheet_position != null); } function unassignedItems() { return _queue.filter(i => i.sheet_position == null); } function itemAtPos(pos) { return _queue.find(i => i.sheet_position === pos) ?? null; } // ── Public API ──────────────────────────────────────────────────────────────── export function initLabelCenter() { document.getElementById('btn-label-center')?.addEventListener('click', openLabelCenter); document.getElementById('lc-close-btn')?.addEventListener('click', closeLabelCenter); document.getElementById('lc-clear-all-btn')?.addEventListener('click', handleClearAll); document.getElementById('lc-autofill-btn')?.addEventListener('click', handleAutoFill); document.getElementById('lc-reset-all-btn')?.addEventListener('click', () => handleResetSheet(false)); document.getElementById('lc-print-btn')?.addEventListener('click', handlePrintAll); document.getElementById('lc-picker-cancel')?.addEventListener('click', closePicker); document.getElementById('lc-sheet-type')?.addEventListener('change', async e => { const newKey = e.target.value; if (newKey === _sheetTypeKey) return; const hasAssigned = _queue.some(i => i.sheet_position != null); if (hasAssigned) { const confirmed = await _showSheetChangeConfirm(newKey); if (!confirmed) { e.target.value = _sheetTypeKey; // revert return; } await handleResetSheet(true); } _sheetTypeKey = newKey; localStorage.setItem('lc_sheet_type', newKey); _usedPositions.clear(); _saveUsedPositions(); _pageCount = 1; _savePageCount(); renderAllPages(); }); // Close picker on outside click document.addEventListener('click', e => { const picker = document.getElementById('lc-picker'); if (picker && !picker.hidden && !picker.contains(e.target)) { closePicker(); } }, { capture: true }); // Close overlay on Escape document.addEventListener('keydown', e => { const overlay = document.getElementById('label-center-overlay'); if (e.key === 'Escape' && overlay && !overlay.hidden) { const picker = document.getElementById('lc-picker'); if (picker && !picker.hidden) { closePicker(); } else { closeLabelCenter(); } } }); } export async function addToQueue(asset) { const customerName = asset.customer?.business_name ?? asset.customer?.business_then_name ?? asset.customer?.name ?? '—'; const phone = asset.contact?.phone ?? asset.customer?.phone ?? null; try { const res = await fetch('/api/label-queue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_id: asset.id, asset_name: asset.name, asset_serial: asset.asset_serial ?? asset.serial ?? null, customer_name: customerName, customer_phone: phone, }), }); if (!res.ok) { const { error } = await res.json().catch(() => ({})); showToast(error ?? 'Failed to add to queue', 'error'); return; } const { queue } = await res.json(); _queue = queue; updateBadgeDOM(queue.length); const btn = document.getElementById('action-add-to-queue'); if (btn) { btn.classList.add('added'); btn.innerHTML = `${iconQueuePlus()} Added ✓`; setTimeout(() => { btn.classList.remove('added'); btn.innerHTML = `${iconQueuePlus()} Add to Sheet`; }, 1800); } showToast(`${esc(asset.name)} added to Label Center`, 'success'); } catch (err) { showToast('Failed to add to queue: ' + err.message, 'error'); } } export async function addToQueueAndOpen(asset) { await addToQueue(asset); openLabelCenter(); } export async function refreshBadge() { try { const res = await fetch('/api/label-queue', { credentials: 'same-origin' }); if (!res.ok) return; const { queue } = await res.json(); _queue = queue; updateBadgeDOM(queue.length); } catch { /* ignore — badge stays hidden */ } } // ── Overlay lifecycle ───────────────────────────────────────────────────────── async function openLabelCenter() { const overlay = document.getElementById('label-center-overlay'); if (!overlay) return; overlay.hidden = false; const sheetSelect = document.getElementById('lc-sheet-type'); if (sheetSelect) sheetSelect.value = _sheetTypeKey; try { const res = await fetch('/api/label-queue', { credentials: 'same-origin' }); if (!res.ok) throw new Error('Failed to load queue'); const { queue } = await res.json(); _queue = queue; updateBadgeDOM(queue.length); } catch (err) { showToast('Could not load label queue: ' + err.message, 'error'); _queue = []; } renderQueuePanel(); renderAllPages(); } export function closeLabelCenter() { const overlay = document.getElementById('label-center-overlay'); if (overlay) overlay.hidden = true; closePicker(); } // ── Queue panel ─────────────────────────────────────────────────────────────── function renderQueuePanel() { const list = document.getElementById('lc-queue-list'); const countEl = document.getElementById('lc-queue-count'); const printBtn = document.getElementById('lc-print-btn'); if (countEl) { countEl.textContent = `${_queue.length} item${_queue.length !== 1 ? 's' : ''} queued`; } if (printBtn) { printBtn.disabled = assignedItems().length === 0; } if (!list) return; if (!_queue.length) { list.innerHTML = `
No labels queued. Tap "Add to Sheet" on any asset card to get started.
`; return; } list.innerHTML = _queue.map(item => { const pos = item.sheet_position; return `
${esc(item.asset_name)}
${esc(item.customer_name)}
${pos != null ? `
Position ${pos}
` : ''}
`; }).join(''); list.querySelectorAll('[data-remove-id]').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); handleRemoveItem(parseInt(btn.dataset.removeId, 10)); }); }); } // ── Sheet grid (multi-page) ──────────────────────────────────────────────── function renderAllPages() { const container = document.getElementById('lc-pages-container'); if (!container) return; _loadPageCount(); let html = ''; for (let p = 1; p <= _pageCount; p++) { const start = posStart(p); const end = posEnd(p); const hasItems = _queue.some(i => i.sheet_position != null && i.sheet_position >= start && i.sheet_position <= end); html += `
Page ${p}pos. ${start}–${end}
`; } html += ` `; container.innerHTML = html; for (let p = 1; p <= _pageCount; p++) { renderPageGrid(p); } container.querySelectorAll('.lc-page-autofill-btn').forEach(btn => { btn.addEventListener('click', () => handleAutoFillPage(+btn.dataset.page)); }); container.querySelectorAll('.lc-page-print-btn').forEach(btn => { btn.addEventListener('click', () => handlePrintPage(+btn.dataset.page)); }); container.querySelectorAll('.lc-page-reset-btn').forEach(btn => { btn.addEventListener('click', () => handleResetPage(+btn.dataset.page)); }); container.querySelectorAll('.lc-page-remove-btn').forEach(btn => { btn.addEventListener('click', () => handleRemovePage(+btn.dataset.page)); }); document.getElementById('lc-add-page-btn')?.addEventListener('click', () => { _pageCount++; _savePageCount(); renderAllPages(); }); } function renderPageGrid(page) { const grid = document.getElementById(`lc-page-grid-${page}`); if (!grid) return; grid.style.gridTemplateColumns = `repeat(${activeSheet().cols}, 1fr)`; const start = posStart(page); const end = posEnd(page); let html = ''; const barcodeQueue = []; for (let pos = start; pos <= end; pos++) { const item = itemAtPos(pos); const localPos = pos - start + 1; if (item) { let labelHTML; let svgIdForSn = null, serial = null; if (_sheetTypeKey === 'OL25LP') { const sn = item.asset_serial ?? ''; if (sn) { const bcPreviewId = `lc-cell-bc-${item.asset_id}-${pos}`; svgIdForSn = bcPreviewId; serial = sn; labelHTML = `
${esc(sn)}
`; } else { labelHTML = `
${esc(item.asset_name)}
`; } } else { const assetObj = { id: item.asset_id, name: item.asset_name, asset_serial: item.asset_serial ?? null, custom_line: item.custom_line ?? null, customer: { business_name: item.customer_name, phone: item.customer_phone ?? null, }, }; const prefix = `lc-cell-${item.asset_id}-${pos}`; ({ html: labelHTML, svgIdForSn, serial } = buildLabelHTML(assetObj, prefix)); } if (svgIdForSn && serial) barcodeQueue.push({ svgIdForSn, serial }); html += `
${labelHTML}
${localPos}
`; } else if (_usedPositions.has(pos)) { html += `
${localPos}
Used
`; } else { html += `
${localPos}
`; } } grid.innerHTML = html; barcodeQueue.forEach(({ svgIdForSn, serial }) => renderBarcode(svgIdForSn, serial)); grid.querySelectorAll('.lc-cell').forEach(cell => { cell.addEventListener('click', () => handleCellClick(parseInt(cell.dataset.pos, 10))); cell.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCellClick(parseInt(cell.dataset.pos, 10)); } }); }); grid.querySelectorAll('.lc-cell-action--delete').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const pos = parseInt(btn.closest('.lc-cell').dataset.pos, 10); const item = itemAtPos(pos); if (item) unassignPosition(item.asset_id); }); }); grid.querySelectorAll('.lc-cell-action--edit').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const pos = parseInt(btn.closest('.lc-cell').dataset.pos, 10); const item = itemAtPos(pos); if (item) _showEditLabelForm(pos, item); }); }); } function handleCellClick(pos) { const item = itemAtPos(pos); if (item) { return; // handled by action buttons on hover } else if (_usedPositions.has(pos)) { // Toggle: un-mark as used _usedPositions.delete(pos); _saveUsedPositions(); renderAllPages(); } else { openPicker(pos); } } // ── Assignment picker ───────────────────────────────────────────────────────── function openPicker(pos) { _pickerTargetPos = pos; const picker = document.getElementById('lc-picker'); const posLabel = document.getElementById('lc-picker-pos-label'); const pickerList = document.getElementById('lc-picker-list'); if (!picker || !pickerList) return; if (posLabel) posLabel.textContent = pos; const unassigned = unassignedItems(); const itemsHTML = unassigned.length ? unassigned.map(item => ` `).join('') : `
No unassigned labels
`; pickerList.innerHTML = itemsHTML + ` `; pickerList.querySelectorAll('[data-pick-id]').forEach(btn => { btn.addEventListener('click', () => { assignPosition(parseInt(btn.dataset.pickId, 10), _pickerTargetPos); closePicker(); }); }); document.getElementById('lc-picker-mark-used-btn')?.addEventListener('click', () => { _usedPositions.add(_pickerTargetPos); _saveUsedPositions(); closePicker(); renderAllPages(); }); document.getElementById('lc-picker-custom-label-btn')?.addEventListener('click', () => { const targetPos = _pickerTargetPos; closePicker(); _showCustomLabelForm(targetPos); }); // Position the picker near the clicked cell const cell = document.querySelector(`.lc-cell[data-pos="${pos}"]`); if (cell) { const rect = cell.getBoundingClientRect(); const pickerW = 290; const pickerH = 480; let left = rect.left + rect.width / 2 - pickerW / 2; let top = rect.bottom + 8; left = Math.max(8, Math.min(left, window.innerWidth - pickerW - 8)); top = Math.max(8, Math.min(top, window.innerHeight - pickerH - 8)); picker.style.left = left + 'px'; picker.style.top = top + 'px'; } picker.hidden = false; _setupPickerSearch(pos); } function _setupPickerSearch(pos) { const input = document.getElementById('lc-picker-search'); const results = document.getElementById('lc-picker-search-results'); if (!input || !results) return; input.value = ''; results.hidden = true; results.innerHTML = ''; let _timer = null; let _activeIdx = -1; let _items = []; let _remoteReq = 0; // cancel stale remote results function _render(items) { _items = items; _activeIdx = -1; if (!items.length) { results.hidden = true; return; } results.innerHTML = items.map((item, i) => { const meta = [item.customerName, item.serial].filter(Boolean).join(' · '); return ``; }).join(''); results.hidden = false; results.querySelectorAll('[data-idx]').forEach(btn => { btn.addEventListener('click', () => _pick(_items[+btn.dataset.idx])); }); } function _updateActive() { results.querySelectorAll('.lc-picker-search-result').forEach((btn, i) => { btn.classList.toggle('active', i === _activeIdx); if (i === _activeIdx) btn.scrollIntoView({ block: 'nearest' }); }); } async function _search(q) { const req = ++_remoteReq; // Immediate local results const local = searchLocal(q).map(r => ({ ...r })); _render(local); // Show spinner row while remote is in flight if (local.length === 0) { results.innerHTML = `
Searching…
`; results.hidden = false; } try { const res = await fetch(`/api/asset/search?query=${encodeURIComponent(q)}`); if (req !== _remoteReq) return; // stale if (!res.ok) return; const data = await res.json(); const localIds = new Set(local.map(r => r.asset?.id)); const remote = (data.assets ?? []).map(a => ({ asset: a, serial: a.asset_serial ?? a.serial ?? null, customerName: a.customer?.business_name ?? a.customer?.business_then_name ?? a.customer?.name ?? null, })).filter(r => !localIds.has(r.asset?.id)); _render([...local, ...remote]); } catch { /* leave local results */ } } function _pick(item) { if (!item?.asset) return; input.value = ''; results.hidden = true; addAndAssign(item.asset, item.customerName, pos); closePicker(); } input.addEventListener('input', () => { clearTimeout(_timer); const q = input.value.trim(); if (q.length < 2) { results.hidden = true; _items = []; return; } _timer = setTimeout(() => _search(q), 220); }); input.addEventListener('keydown', e => { if (e.key === 'ArrowDown') { e.preventDefault(); _activeIdx = Math.min(_activeIdx + 1, _items.length - 1); _updateActive(); } else if (e.key === 'ArrowUp') { e.preventDefault(); _activeIdx = Math.max(_activeIdx - 1, 0); _updateActive(); } else if (e.key === 'Enter' && _activeIdx >= 0) { e.preventDefault(); _pick(_items[_activeIdx]); } else if (e.key === 'Escape') { e.stopPropagation(); closePicker(); } }); // Focus search on open setTimeout(() => input.focus(), 40); } function _showCustomLabelForm(pos) { // Remove any existing form document.getElementById('lc-custom-label-backdrop')?.remove(); const backdrop = document.createElement('div'); backdrop.id = 'lc-custom-label-backdrop'; backdrop.className = 'lc-print-dialog-backdrop'; backdrop.innerHTML = ` `; document.body.appendChild(backdrop); const deviceInput = document.getElementById('clf-device'); const ownerInput = document.getElementById('clf-owner'); const phoneInput = document.getElementById('clf-phone'); const serialInput = document.getElementById('clf-serial'); const customInput = document.getElementById('clf-custom'); const deviceErr = document.getElementById('clf-device-err'); const ownerErr = document.getElementById('clf-owner-err'); setTimeout(() => deviceInput?.focus(), 40); backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); }); document.getElementById('clf-cancel-btn')?.addEventListener('click', () => backdrop.remove()); document.getElementById('clf-submit-btn')?.addEventListener('click', async () => { const deviceName = deviceInput.value.trim(); const ownerName = ownerInput.value.trim(); const phone = phoneInput.value.trim() || null; const serial = serialInput.value.trim() || null; const customLine = customInput.value.trim() || null; let valid = true; if (!deviceName) { deviceErr.hidden = false; valid = false; } else { deviceErr.hidden = true; } if (!ownerName) { ownerErr.hidden = false; valid = false; } else { ownerErr.hidden = true; } if (!valid) return; const fakeAsset = { id: -Date.now(), name: deviceName, asset_serial: serial, custom_line: customLine, customer: { business_name: ownerName, phone }, }; backdrop.remove(); await addAndAssign(fakeAsset, ownerName, pos); }); } async function updateLabelFields(assetId, { name, serial, customerName, phone, customLine }) { const res = await fetch(`/api/label-queue/${assetId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_name: name, asset_serial: serial ?? null, customer_name: customerName, customer_phone: phone ?? null, custom_line: customLine ?? null }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? 'Update failed'); _queue = data.queue; renderAllPages(); } function _showEditLabelForm(pos, item) { document.getElementById('lc-edit-label-backdrop')?.remove(); const backdrop = document.createElement('div'); backdrop.id = 'lc-edit-label-backdrop'; backdrop.className = 'lc-print-dialog-backdrop'; backdrop.innerHTML = ` `; document.body.appendChild(backdrop); const deviceInput = document.getElementById('elf-device'); const ownerInput = document.getElementById('elf-owner'); const phoneInput = document.getElementById('elf-phone'); const serialInput = document.getElementById('elf-serial'); const customInput = document.getElementById('elf-custom'); const deviceErr = document.getElementById('elf-device-err'); const ownerErr = document.getElementById('elf-owner-err'); setTimeout(() => deviceInput?.focus(), 40); backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); }); document.getElementById('elf-cancel-btn')?.addEventListener('click', () => backdrop.remove()); document.getElementById('elf-submit-btn')?.addEventListener('click', async () => { const deviceName = deviceInput.value.trim(); const ownerName = ownerInput.value.trim(); const phone = phoneInput.value.trim() || null; const serial = serialInput.value.trim() || null; const customLine = customInput.value.trim() || null; let valid = true; if (!deviceName) { deviceErr.hidden = false; valid = false; } else { deviceErr.hidden = true; } if (!ownerName) { ownerErr.hidden = false; valid = false; } else { ownerErr.hidden = true; } if (!valid) return; backdrop.remove(); await updateLabelFields(item.asset_id, { name: deviceName, serial, customerName: ownerName, phone, customLine }); }); } function closePicker() { _pickerTargetPos = null; const picker = document.getElementById('lc-picker'); if (picker) picker.hidden = true; const input = document.getElementById('lc-picker-search'); const results = document.getElementById('lc-picker-search-results'); if (input) input.value = ''; if (results) { results.hidden = true; results.innerHTML = ''; } } // ── API mutations (all re-render after response) ────────────────────────────── // Add an asset directly to the queue and immediately assign it to a sheet position. // Used by the picker's inline search — bypasses the normal "add to queue" flow. async function addAndAssign(asset, customerName, pos) { const name = customerName ?? asset.customer?.business_name ?? asset.customer?.business_then_name ?? asset.customer?.name ?? '—'; const phone = asset.contact?.phone ?? asset.customer?.phone ?? null; try { // 1. Add (or update) the queue entry const addRes = await fetch('/api/label-queue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_id: asset.id, asset_name: asset.name, asset_serial: asset.asset_serial ?? asset.serial ?? null, customer_name: name, customer_phone: phone, custom_line: asset.custom_line ?? null, }), }); if (!addRes.ok) { const { error } = await addRes.json().catch(() => ({})); showToast(error ?? 'Failed to add asset', 'error'); return; } const { queue: q1 } = await addRes.json(); _queue = q1; // 2. Assign to the target position const patchRes = await fetch(`/api/label-queue/${asset.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheet_position: pos }), }); if (!patchRes.ok) { const { error } = await patchRes.json().catch(() => ({})); showToast(error ?? 'Failed to assign position', 'error'); return; } const { queue: q2 } = await patchRes.json(); _queue = q2; updateBadgeDOM(_queue.length); renderQueuePanel(); renderAllPages(); } catch (err) { showToast('Error: ' + err.message, 'error'); } } async function assignPosition(assetId, pos) { try { const res = await fetch(`/api/label-queue/${assetId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheet_position: pos }), }); if (!res.ok) { const { error } = await res.json().catch(() => ({})); showToast(error ?? 'Failed to assign position', 'error'); return; } const { queue } = await res.json(); _queue = queue; renderQueuePanel(); renderAllPages(); } catch (err) { showToast('Error assigning position: ' + err.message, 'error'); } } async function unassignPosition(assetId) { try { const res = await fetch(`/api/label-queue/${assetId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheet_position: null }), }); if (!res.ok) { showToast('Failed to unassign position', 'error'); return; } const { queue } = await res.json(); _queue = queue; renderQueuePanel(); renderAllPages(); } catch (err) { showToast('Error unassigning position: ' + err.message, 'error'); } } async function handleRemoveItem(assetId) { try { const res = await fetch(`/api/label-queue/${assetId}`, { method: 'DELETE' }); if (!res.ok) { showToast('Failed to remove item', 'error'); return; } const { queue } = await res.json(); _queue = queue; updateBadgeDOM(queue.length); renderQueuePanel(); renderAllPages(); } catch (err) { showToast('Error removing item: ' + err.message, 'error'); } } async function handleClearAll() { if (!_queue.length) return; try { const res = await fetch('/api/label-queue', { method: 'DELETE' }); if (!res.ok) { showToast('Failed to clear queue', 'error'); return; } const { queue } = await res.json(); _queue = queue; updateBadgeDOM(0); closePicker(); renderQueuePanel(); renderAllPages(); } catch (err) { showToast('Error clearing queue: ' + err.message, 'error'); } } // ── Per-page auto-fill ──────────────────────────────────────────────────────── async function handleAutoFillPage(page) { const unassigned = unassignedItems(); if (!unassigned.length) { showToast('No unassigned labels to auto-fill', 'info'); return; } const start = posStart(page); const end = posEnd(page); const emptyPositions = []; for (let pos = start; pos <= end && emptyPositions.length < unassigned.length; pos++) { if (!itemAtPos(pos) && !_usedPositions.has(pos)) emptyPositions.push(pos); } if (!emptyPositions.length) { showToast(`Page ${page} is full`, 'info'); return; } try { const results = await Promise.all(emptyPositions.map((pos, i) => { const item = unassigned[i]; if (!item) return Promise.resolve(null); return fetch(`/api/label-queue/${item.asset_id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheet_position: pos }), }).then(r => r.json()); })); const last = results.filter(Boolean).pop(); if (last?.queue) { _queue = last.queue; } else { const r = await fetch('/api/label-queue'); const { queue } = await r.json(); _queue = queue; } renderQueuePanel(); renderAllPages(); } catch (err) { showToast('Auto-fill error: ' + err.message, 'error'); } } // ── Remove page ─────────────────────────────────────────────────────────────── async function handleRemovePage(page) { if (_pageCount <= 1) { showToast('Cannot remove the only page', 'info'); return; } const start = posStart(page); const end = posEnd(page); const pageItems = _queue.filter(i => i.sheet_position != null && i.sheet_position >= start && i.sheet_position <= end); if (pageItems.length === 0) { await _doRemovePage(page, 'unassign'); } else { _showRemovePageDialog(page, pageItems); } } function _showRemovePageDialog(page, pageItems) { const n = pageItems.length; const backdrop = document.createElement('div'); backdrop.className = 'lc-print-dialog-backdrop'; backdrop.innerHTML = ` `; document.body.appendChild(backdrop); const dismiss = () => backdrop.remove(); backdrop.addEventListener('click', e => { if (e.target === backdrop) dismiss(); }); document.getElementById('lc-rp-move')?.addEventListener('click', async () => { dismiss(); await _doRemovePage(page, 'move'); }); document.getElementById('lc-rp-unassign')?.addEventListener('click', async () => { dismiss(); await _doRemovePage(page, 'unassign'); }); document.getElementById('lc-rp-cancel')?.addEventListener('click', dismiss); } async function _doRemovePage(page, action) { const start = posStart(page); const end = posEnd(page); const pageItems = _queue.filter(i => i.sheet_position != null && i.sheet_position >= start && i.sheet_position <= end); // Record the local positions (1-30) of each displaced item so we can try to preserve them const localPosMap = new Map(pageItems.map(i => [i.asset_id, i.sheet_position - start + 1])); try { // Step 1: unassign all items on this page if (pageItems.length > 0) { await Promise.all(pageItems.map(item => fetch(`/api/label-queue/${item.asset_id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheet_position: null }), }) )); } // Step 2: shift items on pages above this one down by activeSheet().pageSize (sequential, ascending) const r0 = await fetch('/api/label-queue'); const { queue: q0 } = await r0.json(); const aboveItems = q0 .filter(i => i.sheet_position != null && i.sheet_position > end) .sort((a, b) => a.sheet_position - b.sheet_position); for (const item of aboveItems) { await fetch(`/api/label-queue/${item.asset_id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheet_position: item.sheet_position - activeSheet().pageSize }), }); } // Step 3: shift used positions in localStorage const newUsed = new Set(); for (const pos of _usedPositions) { if (pos < start) newUsed.add(pos); else if (pos > end) newUsed.add(pos - activeSheet().pageSize); } _usedPositions = newUsed; _saveUsedPositions(); // Step 4: decrement page count _pageCount--; _savePageCount(); // Step 5: re-fetch current queue state const r1 = await fetch('/api/label-queue'); const { queue: q1 } = await r1.json(); _queue = q1; // Step 6: if 'move', place displaced items preserving local position where possible if (action === 'move' && pageItems.length > 0) { const unassigned = _queue.filter(i => i.sheet_position == null); const displaced = pageItems.map(orig => unassigned.find(u => u.asset_id === orig.asset_id)).filter(Boolean); if (displaced.length > 0) { const maxPos = _pageCount * activeSheet().pageSize; const taken = () => new Set(_queue.map(i => i.sheet_position).filter(Boolean)); for (const item of displaced) { const localPos = localPosMap.get(item.asset_id); // 1-30 within original page let targetPos = null; // Try each remaining page at the same local slot for (let p = 1; p <= _pageCount; p++) { const candidate = posStart(p) + localPos - 1; if (candidate <= posEnd(p) && !taken().has(candidate) && !_usedPositions.has(candidate)) { targetPos = candidate; break; } } // Fallback: first available position anywhere if (!targetPos) { for (let pos = 1; pos <= maxPos; pos++) { if (!taken().has(pos) && !_usedPositions.has(pos)) { targetPos = pos; break; } } } if (targetPos) { const res = await fetch(`/api/label-queue/${item.asset_id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheet_position: targetPos }), }); const data = await res.json(); if (data?.queue) _queue = data.queue; // keep queue fresh for taken() calls } } const r2 = await fetch('/api/label-queue'); const { queue: q2 } = await r2.json(); _queue = q2; } } renderQueuePanel(); renderAllPages(); showToast(`Page ${page} removed`, 'success'); } catch (err) { showToast('Error removing page: ' + err.message, 'error'); } } async function handleAutoFill() { const unassigned = unassignedItems(); if (!unassigned.length) { showToast('No unassigned labels to auto-fill', 'info'); return; } // Find empty positions across all pages in order, skipping used ones const maxPos = _pageCount * activeSheet().pageSize; const emptyPositions = []; for (let pos = 1; pos <= maxPos && emptyPositions.length < unassigned.length; pos++) { if (!itemAtPos(pos) && !_usedPositions.has(pos)) emptyPositions.push(pos); } if (!emptyPositions.length) { showToast('Sheet is full', 'info'); return; } // Send all PATCH requests, then apply the last response state try { const promises = emptyPositions.map((pos, i) => { const item = unassigned[i]; if (!item) return Promise.resolve(null); return fetch(`/api/label-queue/${item.asset_id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheet_position: pos }), }).then(r => r.json()); }); const results = await Promise.all(promises); // Use the last successful queue state const last = results.filter(Boolean).pop(); if (last?.queue) { _queue = last.queue; } else { // Fallback: re-fetch const r = await fetch('/api/label-queue'); const { queue } = await r.json(); _queue = queue; } renderQueuePanel(); renderAllPages(); } catch (err) { showToast('Auto-fill error: ' + err.message, 'error'); } } async function handleResetPage(page) { const start = posStart(page); const end = posEnd(page); const assigned = _queue.filter(i => i.sheet_position != null && i.sheet_position >= start && i.sheet_position <= end); if (!assigned.length) { showToast(`Page ${page} is already empty`, 'info'); return; } try { const results = await Promise.all(assigned.map(item => fetch(`/api/label-queue/${item.asset_id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheet_position: null }), }).then(r => r.json()) )); const last = results.filter(Boolean).pop(); if (last?.queue) { _queue = last.queue; } else { const r = await fetch('/api/label-queue'); const { queue } = await r.json(); _queue = queue; } renderQueuePanel(); renderAllPages(); showToast(`Page ${page} reset`, 'success'); } catch (err) { showToast('Error resetting page: ' + err.message, 'error'); } } async function handleResetSheet(silent = false) { const assigned = assignedItems(); if (!assigned.length) { if (!silent) showToast('Sheet is already empty', 'info'); return; } if (!silent && !confirm('Reset the sheet? All label positions will be cleared. Labels will stay in the queue.')) return; try { const res = await fetch('/api/label-queue', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reset_sheet_only: true }), }); if (!res.ok) { showToast('Failed to reset sheet', 'error'); return; } const { queue } = await res.json(); _queue = queue; _usedPositions.clear(); _saveUsedPositions(); _pageCount = 1; _savePageCount(); closePicker(); renderQueuePanel(); renderAllPages(); showToast('Sheet reset — labels are still queued', 'success'); } catch (err) { showToast('Error resetting sheet: ' + err.message, 'error'); } } // ── Print sheet ─────────────────────────────────────────────────────────────── function _openPrintWindow(name, items) { const win = window.open('', name, 'width=960,height=800,menubar=no,toolbar=no,location=no,status=no,scrollbars=yes'); if (!win) { alert('Popup blocked — please allow popups for this site.'); return null; } win.document.write(items); win.document.close(); return win; } function _watchPrintWindow(win, onClose) { let count = 0; const poll = setInterval(() => { if (win.closed || ++count >= 180) { clearInterval(poll); if (win.closed) onClose(); } }, 1000); } function handlePrintPage(page) { const start = posStart(page); const end = posEnd(page); const assigned = _queue.filter(i => i.sheet_position != null && i.sheet_position >= start && i.sheet_position <= end); if (!assigned.length) { showToast('No labels on this page', 'info'); return; } // Normalize positions to 1–30 within this page for the print layout const normalized = assigned.map(i => ({ ...i, sheet_position: i.sheet_position - start + 1 })); const win = _openPrintWindow(`lc_print_p${page}`, buildSheetHTMLMulti(normalized)); if (win) _watchPrintWindow(win, () => _showPostPrintDialog(page)); } function handlePrintAll() { const assigned = assignedItems(); if (!assigned.length) { showToast('No labels assigned to the sheet yet', 'info'); return; } // Build one HTML document with all pages, each as a separate .page div const pageGroups = []; for (let p = 1; p <= _pageCount; p++) { const start = posStart(p); const end = posEnd(p); const items = assigned .filter(i => i.sheet_position >= start && i.sheet_position <= end) .map(i => ({ ...i, sheet_position: i.sheet_position - start + 1 })); if (items.length) pageGroups.push({ page: p, items }); } const win = _openPrintWindow('lc_print_all', buildAllPagesHTML(pageGroups)); if (win) _watchPrintWindow(win, () => _showPostPrintDialog(null)); } function _showSheetChangeConfirm(newKey) { return new Promise(resolve => { const newLabel = SHEET_CONFIGS[newKey]?.label ?? newKey; const backdrop = document.createElement('div'); backdrop.className = 'lc-print-dialog-backdrop'; backdrop.innerHTML = ` `; document.body.appendChild(backdrop); const dismiss = ok => { backdrop.remove(); resolve(ok); }; backdrop.querySelector('#lc-sc-confirm').addEventListener('click', () => dismiss(true)); backdrop.querySelector('#lc-sc-cancel').addEventListener('click', () => dismiss(false)); backdrop.addEventListener('click', e => { if (e.target === backdrop) dismiss(false); }); }); } function _showPostPrintDialog(page) { const isAll = page === null; const title = isAll ? 'All pages printed?' : `Page ${page} printed?`; const sheetLabel = isAll ? 'Clear all sheet layouts' : `Clear page ${page} layout`; const sheetDesc = isAll ? 'Remove all position assignments — labels stay in the queue' : `Remove position assignments for page ${page} — labels stay in the queue`; const backdrop = document.createElement('div'); backdrop.className = 'lc-print-dialog-backdrop'; backdrop.innerHTML = ` `; document.body.appendChild(backdrop); const dismiss = () => backdrop.remove(); backdrop.addEventListener('click', e => { if (e.target === backdrop) dismiss(); }); document.getElementById('lc-pd-nothing')?.addEventListener('click', dismiss); document.getElementById('lc-pd-clear-sheet')?.addEventListener('click', async () => { dismiss(); if (isAll) { await handleResetSheet(true); } else { await handleResetPage(page); } }); document.getElementById('lc-pd-clear-queue')?.addEventListener('click', async () => { dismiss(); await handleClearAll(); }); } function buildAllPagesHTML(pageGroups) { // Build the inner label HTML for each page group, then wrap in a single print document // Each group's items are already normalized to positions 1–30 const allBarcodes = []; const pageDivs = []; for (const { page, items } of pageGroups) { const labels = _buildLabelData(items, page); allBarcodes.push(...labels.filter(l => l.hasSn).map(l => [l.bcId, l.serial])); const isFirst = pageDivs.length === 0; pageDivs.push(`
\n${labels.map(l => l.slDiv).join('\n')}\n
`); } return _buildPrintDocument(pageDivs.join('\n'), JSON.stringify(allBarcodes), `${pageGroups.reduce((n, g) => n + g.items.length, 0)} label(s) across ${pageGroups.length} page(s)`); } function buildSheetHTMLMulti(items) { // items: normalized to positions 1–30 const labels = _buildLabelData(items, 1); const barcodeArray = JSON.stringify(labels.filter(l => l.hasSn).map(l => [l.bcId, l.serial])); const pageDiv = `
\n${labels.map(l => l.slDiv).join('\n')}\n
`; return _buildPrintDocument(pageDiv, barcodeArray, `${items.length} label${items.length !== 1 ? 's' : ''}`); } function _buildLabelData(items, pageNum) { const sheet = activeSheet(); return items.map(item => { const colIdx = (item.sheet_position - 1) % sheet.cols; const rowIdx = Math.floor((item.sheet_position - 1) / sheet.cols); const leftIn = sheet.colOffsets[colIdx]; const topIn = sheet.rowOffsets[rowIdx]; const bcId = `sbc-${item.asset_id}-${pageNum}-${item.sheet_position}`; if (_sheetTypeKey === 'OL25LP') { return _buildOL25LPLabelData(item, leftIn, topIn, bcId); } const name = item.asset_name ?? 'Unknown Asset'; const serial = item.asset_serial ?? ''; const hasSn = serial.length > 0; const customerName = item.customer_name ?? ''; const customerPhone = formatPhone(item.customer_phone ?? ''); const customLine = item.custom_line ?? ''; const snBarcode = hasSn ? `
barcode
SN: ${esc(serial)}
` : ''; const slDiv = `
deRenzy Business Technologies
(413) 739-4706
${esc(name)}
${customerName ? `
${esc(customerName)}
` : ''} ${customerPhone ? `
${esc(customerPhone)}
` : ''} ${customLine ? `
${esc(customLine)}
` : ''}
${snBarcode}
`; return { slDiv, hasSn, serial, bcId }; }); } function _buildOL25LPLabelData(item, leftIn, topIn, bcId) { const serial = item.asset_serial ?? ''; const hasSn = serial.length > 0; const slDiv = `
${hasSn ? `` : ''} ${hasSn ? `
${esc(serial)}
` : ''}
`; return { slDiv, hasSn, serial, bcId }; } function _buildPrintDocument(pagesHTML, barcodeArrayJSON, titleSuffix) { return ` Label Sheet \u2014 ${titleSuffix}