1618 lines
63 KiB
JavaScript
Executable file
1618 lines
63 KiB
JavaScript
Executable file
// 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<QueueItem> — 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 = `<div class="lc-queue-empty">No labels queued. Tap "Add to Sheet" on any asset card to get started.</div>`;
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = _queue.map(item => {
|
||
const pos = item.sheet_position;
|
||
return `
|
||
<div class="lc-queue-item${pos != null ? ' assigned' : ''}" data-asset-id="${item.asset_id}">
|
||
<div class="lc-queue-item-info">
|
||
<div class="lc-queue-item-name">${esc(item.asset_name)}</div>
|
||
<div class="lc-queue-item-customer">${esc(item.customer_name)}</div>
|
||
${pos != null ? `<div class="lc-queue-item-pos">Position ${pos}</div>` : ''}
|
||
</div>
|
||
<button class="lc-queue-item-remove" data-remove-id="${item.asset_id}" title="Remove from queue" aria-label="Remove ${esc(item.asset_name)} from queue">✕</button>
|
||
</div>`;
|
||
}).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 += `
|
||
<div class="lc-page-card" data-page="${p}">
|
||
<div class="lc-page-card-header">
|
||
<span class="lc-page-card-title">Page ${p}<span class="lc-page-pos-range">pos. ${start}–${end}</span></span>
|
||
<div class="lc-page-card-btns">
|
||
<button class="btn btn-ghost lc-page-autofill-btn" data-page="${p}" style="font-size:.78rem;padding:4px 10px">Auto-fill</button>
|
||
<button class="btn btn-ghost lc-page-print-btn" data-page="${p}" style="font-size:.78rem;padding:4px 10px"${!hasItems ? ' disabled' : ''}>Print</button>
|
||
<button class="btn btn-ghost btn-danger lc-page-reset-btn" data-page="${p}" style="font-size:.78rem;padding:4px 10px"${!hasItems ? ' disabled' : ''}>Reset</button>
|
||
<button class="btn btn-ghost lc-page-remove-btn" data-page="${p}" style="font-size:.78rem;padding:4px 8px;border-color:var(--red-200,#fecaca);color:var(--red,#dc2626);" title="Remove page ${p}">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="lc-sheet-grid" id="lc-page-grid-${p}"></div>
|
||
</div>`;
|
||
}
|
||
|
||
html += `
|
||
<button class="lc-add-page-btn" id="lc-add-page-btn">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
Add page
|
||
</button>`;
|
||
|
||
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 = `<div class="lc-ol25lp-preview">
|
||
<img id="${bcPreviewId}" class="lc-ol25lp-bc" alt="">
|
||
<div class="lc-ol25lp-sn"><span>${esc(sn)}</span></div>
|
||
</div>`;
|
||
} else {
|
||
labelHTML = `<div class="lc-ol25lp-preview"><div class="lc-ol25lp-sn lc-ol25lp-name">${esc(item.asset_name)}</div></div>`;
|
||
}
|
||
} 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 += `
|
||
<div class="lc-cell filled" data-pos="${pos}" data-asset-id="${item.asset_id}"
|
||
aria-label="Position ${pos}: ${esc(item.asset_name)}">
|
||
<div class="lc-cell-content">${labelHTML}</div>
|
||
<div class="lc-cell-pos-number">${localPos}</div>
|
||
<div class="lc-cell-actions">
|
||
<button class="lc-cell-action lc-cell-action--delete" title="Remove label" aria-label="Remove label">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/>
|
||
</svg>
|
||
<span class="lc-cell-action-label">Remove</span>
|
||
</button>
|
||
<button class="lc-cell-action lc-cell-action--edit" title="Edit label" aria-label="Edit label">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
|
||
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||
</svg>
|
||
<span class="lc-cell-action-label">Edit</span>
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
} else if (_usedPositions.has(pos)) {
|
||
html += `
|
||
<div class="lc-cell used" data-pos="${pos}" role="button" tabindex="0"
|
||
aria-label="Position ${pos}: marked as used — click to clear">
|
||
<div class="lc-cell-pos-number">${localPos}</div>
|
||
<span style="font-size:.6rem;position:absolute;bottom:3px;right:4px;opacity:.6">Used</span>
|
||
</div>`;
|
||
} else {
|
||
html += `
|
||
<div class="lc-cell empty" data-pos="${pos}" role="button" tabindex="0"
|
||
aria-label="Position ${pos}: empty — click to assign">
|
||
<div class="lc-cell-pos-number">${localPos}</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
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 => `
|
||
<button class="lc-picker-item" data-pick-id="${item.asset_id}">
|
||
<div class="lc-picker-item-name">${esc(item.asset_name)}</div>
|
||
<div class="lc-picker-item-customer">${esc(item.customer_name)}</div>
|
||
</button>`).join('')
|
||
: `<div class="lc-picker-empty">No unassigned labels</div>`;
|
||
|
||
pickerList.innerHTML = itemsHTML + `
|
||
<div class="lc-picker-footer-row">
|
||
<button class="lc-picker-mark-used" id="lc-picker-mark-used-btn">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||
</svg>
|
||
Mark as used
|
||
</button>
|
||
<button class="lc-picker-custom-label" id="lc-picker-custom-label-btn">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||
<rect x="3" y="5" width="18" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="13" y2="14"/>
|
||
</svg>
|
||
Custom label
|
||
</button>
|
||
</div>`;
|
||
|
||
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 `<button class="lc-picker-search-result" data-idx="${i}">
|
||
<div class="lc-picker-search-result-name">${esc(item.asset?.name ?? `Asset ${item.asset?.id}`)}</div>
|
||
${meta ? `<div class="lc-picker-search-result-meta">${esc(meta)}</div>` : ''}
|
||
</button>`;
|
||
}).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 = `<div class="lc-picker-search-spinner">Searching…</div>`;
|
||
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 = `
|
||
<div class="lc-custom-label-form" role="dialog" aria-modal="true" aria-label="Custom label">
|
||
<div class="lc-custom-label-form-header">Custom Label — Position ${pos}</div>
|
||
<div class="lc-clf-field">
|
||
<label class="lc-clf-label">Device name <span class="lc-clf-required">*</span></label>
|
||
<input class="lc-clf-input" id="clf-device" type="text" placeholder="e.g. Dell Latitude 5520" autocomplete="off">
|
||
<div class="lc-clf-error" id="clf-device-err" hidden>Required</div>
|
||
</div>
|
||
<div class="lc-clf-field">
|
||
<label class="lc-clf-label">Owner name <span class="lc-clf-required">*</span></label>
|
||
<input class="lc-clf-input" id="clf-owner" type="text" placeholder="e.g. Jane Smith" autocomplete="off">
|
||
<div class="lc-clf-error" id="clf-owner-err" hidden>Required</div>
|
||
</div>
|
||
<div class="lc-clf-field">
|
||
<label class="lc-clf-label">Phone <span style="color:var(--text-muted);font-size:11px">(optional)</span></label>
|
||
<input class="lc-clf-input" id="clf-phone" type="text" placeholder="e.g. 555-867-5309" autocomplete="off">
|
||
</div>
|
||
<div class="lc-clf-field">
|
||
<label class="lc-clf-label">Serial number <span style="color:var(--text-muted);font-size:11px">(optional)</span></label>
|
||
<input class="lc-clf-input" id="clf-serial" type="text" placeholder="e.g. ABC123XYZ" autocomplete="off">
|
||
</div>
|
||
<div class="lc-clf-field">
|
||
<label class="lc-clf-label">Custom line <span style="color:var(--text-muted);font-size:11px">(optional)</span></label>
|
||
<input class="lc-clf-input" id="clf-custom" type="text" placeholder="e.g. Room 204 / Dept. 12" autocomplete="off">
|
||
</div>
|
||
<div class="lc-print-dialog-btns">
|
||
<button class="lc-print-dialog-btn lc-print-dialog-btn--primary" id="clf-submit-btn">Add to position ${pos}</button>
|
||
<button class="lc-print-dialog-btn" id="clf-cancel-btn">Cancel</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
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 = `
|
||
<div class="lc-custom-label-form" role="dialog" aria-modal="true" aria-label="Edit label">
|
||
<div class="lc-custom-label-form-header">Edit Label — Position ${pos}</div>
|
||
<div class="lc-clf-field">
|
||
<label class="lc-clf-label">Device name <span class="lc-clf-required">*</span></label>
|
||
<input class="lc-clf-input" id="elf-device" type="text" value="${esc(item.asset_name)}" autocomplete="off">
|
||
<div class="lc-clf-error" id="elf-device-err" hidden>Required</div>
|
||
</div>
|
||
<div class="lc-clf-field">
|
||
<label class="lc-clf-label">Owner name <span class="lc-clf-required">*</span></label>
|
||
<input class="lc-clf-input" id="elf-owner" type="text" value="${esc(item.customer_name)}" autocomplete="off">
|
||
<div class="lc-clf-error" id="elf-owner-err" hidden>Required</div>
|
||
</div>
|
||
<div class="lc-clf-field">
|
||
<label class="lc-clf-label">Phone <span style="color:var(--text-muted);font-size:11px">(optional)</span></label>
|
||
<input class="lc-clf-input" id="elf-phone" type="text" value="${esc(item.customer_phone ?? '')}" autocomplete="off">
|
||
</div>
|
||
<div class="lc-clf-field">
|
||
<label class="lc-clf-label">Serial number <span style="color:var(--text-muted);font-size:11px">(optional)</span></label>
|
||
<input class="lc-clf-input" id="elf-serial" type="text" value="${esc(item.asset_serial ?? '')}" autocomplete="off">
|
||
</div>
|
||
<div class="lc-clf-field">
|
||
<label class="lc-clf-label">Custom line <span style="color:var(--text-muted);font-size:11px">(optional)</span></label>
|
||
<input class="lc-clf-input" id="elf-custom" type="text" value="${esc(item.custom_line ?? '')}" placeholder="e.g. Room 204 / Dept. 12" autocomplete="off">
|
||
</div>
|
||
<div class="lc-print-dialog-btns">
|
||
<button class="lc-print-dialog-btn lc-print-dialog-btn--primary" id="elf-submit-btn">Save changes</button>
|
||
<button class="lc-print-dialog-btn" id="elf-cancel-btn">Cancel</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
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 = `
|
||
<div class="lc-print-dialog" role="dialog" aria-modal="true">
|
||
<div class="lc-print-dialog-title">Page ${page} has ${n} assigned label${n !== 1 ? 's' : ''}</div>
|
||
<div class="lc-print-dialog-body">What would you like to do with the assigned labels before removing this page?</div>
|
||
<div class="lc-print-dialog-actions">
|
||
<button class="lc-print-dialog-btn" id="lc-rp-move">
|
||
<div class="lc-print-dialog-btn-label">
|
||
Yes, keep their positions
|
||
<div class="lc-print-dialog-btn-desc">Move labels to the same spot on another page if available, otherwise place in next open position</div>
|
||
</div>
|
||
</button>
|
||
<button class="lc-print-dialog-btn" id="lc-rp-unassign">
|
||
<div class="lc-print-dialog-btn-label">
|
||
No, put back in queue
|
||
<div class="lc-print-dialog-btn-desc">Unassign them — they'll remain in the label queue for later</div>
|
||
</div>
|
||
</button>
|
||
<button class="lc-print-dialog-btn" id="lc-rp-cancel">
|
||
<div class="lc-print-dialog-btn-label">Cancel</div>
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
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 = `
|
||
<div class="lc-print-dialog" role="dialog" aria-modal="true" aria-labelledby="lc-sc-title">
|
||
<div class="lc-print-dialog-title" id="lc-sc-title">Change sheet type?</div>
|
||
<div class="lc-print-dialog-body">Switching to <strong>${esc(newLabel)}</strong> will clear all position assignments. Labels will remain in the queue.</div>
|
||
<div class="lc-print-dialog-actions">
|
||
<button class="lc-print-dialog-btn" id="lc-sc-confirm">
|
||
<div class="lc-print-dialog-btn-label">
|
||
Switch sheet type
|
||
<div class="lc-print-dialog-btn-desc">Clear position assignments and switch</div>
|
||
</div>
|
||
</button>
|
||
<button class="lc-print-dialog-btn" id="lc-sc-cancel">
|
||
<div class="lc-print-dialog-btn-label">
|
||
Cancel
|
||
<div class="lc-print-dialog-btn-desc">Keep current sheet type</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
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 = `
|
||
<div class="lc-print-dialog" role="dialog" aria-modal="true" aria-labelledby="lc-pd-title">
|
||
<div class="lc-print-dialog-title" id="lc-pd-title">${esc(title)}</div>
|
||
<div class="lc-print-dialog-body">What would you like to do with the queued labels and sheet layout?</div>
|
||
<div class="lc-print-dialog-actions">
|
||
<button class="lc-print-dialog-btn" id="lc-pd-nothing">
|
||
<div class="lc-print-dialog-btn-label">
|
||
Keep everything
|
||
<div class="lc-print-dialog-btn-desc">Leave the queue and sheet as-is</div>
|
||
</div>
|
||
</button>
|
||
<button class="lc-print-dialog-btn" id="lc-pd-clear-sheet">
|
||
<div class="lc-print-dialog-btn-label">
|
||
${esc(sheetLabel)}
|
||
<div class="lc-print-dialog-btn-desc">${esc(sheetDesc)}</div>
|
||
</div>
|
||
</button>
|
||
<button class="lc-print-dialog-btn danger" id="lc-pd-clear-queue">
|
||
<div class="lc-print-dialog-btn-label">
|
||
Clear everything
|
||
<div class="lc-print-dialog-btn-desc">Remove all labels from the queue and reset the sheet</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
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(`<div class="page${isFirst ? '' : ' page-break'}">\n${labels.map(l => l.slDiv).join('\n')}\n</div>`);
|
||
}
|
||
|
||
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 = `<div class="page">\n${labels.map(l => l.slDiv).join('\n')}\n</div>`;
|
||
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
|
||
? `<div class="lbc"><img id="${bcId}" class="sbc-bc" alt="barcode"><div class="sbc-cap"><span>SN: ${esc(serial)}</span></div></div>`
|
||
: '';
|
||
|
||
const slDiv = ` <div class="sl" style="left:${leftIn}in;top:${topIn}in">
|
||
<div class="sl-hdr">
|
||
<div class="sl-hdr-left">
|
||
<img src="/assets/logo-swirl.png" class="sl-logo" alt="">
|
||
<span class="sl-co">deRenzy Business Technologies</span>
|
||
</div>
|
||
<span class="sl-phone">(413) 739-4706</span>
|
||
</div>
|
||
<div class="sl-body">
|
||
<div class="sl-text">
|
||
<div class="sl-name">${esc(name)}</div>
|
||
${customerName ? `<div class="sl-info">${esc(customerName)}</div>` : ''}
|
||
${customerPhone ? `<div class="sl-info">${esc(customerPhone)}</div>` : ''}
|
||
${customLine ? `<div class="sl-info">${esc(customLine)}</div>` : ''}
|
||
</div>
|
||
${snBarcode}
|
||
</div>
|
||
</div>`;
|
||
|
||
return { slDiv, hasSn, serial, bcId };
|
||
});
|
||
}
|
||
|
||
function _buildOL25LPLabelData(item, leftIn, topIn, bcId) {
|
||
const serial = item.asset_serial ?? '';
|
||
const hasSn = serial.length > 0;
|
||
|
||
const slDiv = ` <div class="sl sl-ol25lp" style="left:${leftIn}in;top:${topIn}in">
|
||
<div class="sl25-wrap">
|
||
${hasSn ? `<img id="${bcId}" class="sl25-bc" alt="">` : ''}
|
||
${hasSn ? `<div class="sl25-sn"><span>${esc(serial)}</span></div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
|
||
return { slDiv, hasSn, serial, bcId };
|
||
}
|
||
|
||
function _buildPrintDocument(pagesHTML, barcodeArrayJSON, titleSuffix) {
|
||
return `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Label Sheet \u2014 ${titleSuffix}</title>
|
||
<script src="/vendor/JsBarcode.all.min.js"><\/script>
|
||
<style>
|
||
@page { size: letter; margin: 0; }
|
||
html, body { margin: 0; padding: 0; }
|
||
|
||
@media screen {
|
||
body { background: #9ca3af; font-family: 'Segoe UI', Arial, sans-serif; }
|
||
.toolbar { background: #1a3565; color: #fff; padding: 10px 20px;
|
||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||
font-size: .85rem; }
|
||
.toolbar-info { opacity: .8; }
|
||
.toolbar-btns { display: flex; gap: 8px; }
|
||
.toolbar-btn { background: none; border: 1px solid rgba(255,255,255,.4); color: #fff;
|
||
border-radius: 4px; padding: 5px 14px; cursor: pointer; font-size: .82rem; }
|
||
.toolbar-btn:hover { background: rgba(255,255,255,.15); }
|
||
.toolbar-btn.primary { background: #C4622A; border-color: #C4622A; }
|
||
.toolbar-btn.primary:hover { background: #a3501f; }
|
||
.page { background: white; width: 8.5in; min-height: 11in; margin: 16px auto;
|
||
position: relative; box-shadow: 0 4px 20px rgba(0,0,0,.2); }
|
||
.page-break { margin-top: 32px; }
|
||
.sl { outline: 2px dashed rgba(196,98,42,.4); outline-offset: -1px; }
|
||
}
|
||
|
||
@media print {
|
||
body { background: white; }
|
||
.toolbar { display: none; }
|
||
.page { width: 8.5in; min-height: 11in; margin: 0; box-shadow: none; }
|
||
.page-break { page-break-before: always; }
|
||
.sl { outline: none; }
|
||
}
|
||
|
||
.page { position: relative; }
|
||
.sl {
|
||
position: absolute;
|
||
width: 2.625in;
|
||
height: 1in;
|
||
overflow: hidden;
|
||
box-sizing: border-box;
|
||
padding: 0.05in 0.07in;
|
||
display: flex;
|
||
flex-direction: column;
|
||
font-family: 'Segoe UI', Arial, sans-serif;
|
||
}
|
||
.sl-hdr {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
border-bottom: 0.5pt solid #ddd;
|
||
padding-bottom: 1.5pt; margin-bottom: 1.5pt; flex-shrink: 0;
|
||
}
|
||
.sl-hdr-left { display: flex; align-items: center; gap: 2.5pt; min-width: 0; }
|
||
.sl-logo { height: 0.14in; width: auto; }
|
||
.sl-co { font-size: 5.5pt; font-weight: 700; color: #1a3565;
|
||
text-transform: uppercase; letter-spacing: .04em; white-space: nowrap; }
|
||
.sl-phone { font-size: 5.5pt; font-weight: 700; color: #1a3565; white-space: nowrap; }
|
||
.sl-body { display: flex; align-items: center; gap: 3pt;
|
||
flex: 1; min-height: 0; overflow: hidden; }
|
||
.sl-text { flex: 1; display: flex; flex-direction: column;
|
||
justify-content: center; min-width: 0; overflow: hidden; }
|
||
.sl-name { font-size: 10pt; font-weight: 700; color: #111;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.sl-info { font-size: 7pt; color: #333; overflow: hidden; margin-top: 1pt;
|
||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||
.lbc { position: relative; flex-shrink: 0; width: 45%; align-self: stretch;
|
||
overflow: hidden; }
|
||
.sbc-bc { width: 100%; height: 100%; object-fit: fill; display: block;
|
||
image-rendering: pixelated; border-bottom-right-radius: 7px; }
|
||
.sbc-cap { position: absolute; bottom: 1.5pt; left: 0; right: 0; text-align: center; }
|
||
.sbc-cap span { display: inline-block; font-size: 5pt; color: #444; font-weight: 600;
|
||
font-family: monospace; background: #fff; padding: 0.5pt 2pt; }
|
||
/* OL25LP \u2014 1.75\u2033 \xd7 0.5\u2033 minimal barcode label */
|
||
.sl-ol25lp { width: 1.75in; height: 0.5in; padding: 0; overflow: hidden; }
|
||
.sl25-wrap { position: relative; width: 100%; height: 100%; }
|
||
.sl25-bc { width: 100%; height: 100%; object-fit: fill; display: block;
|
||
image-rendering: pixelated; }
|
||
.sl25-sn { position: absolute; bottom: 1.5pt; left: 0; right: 0;
|
||
text-align: center; pointer-events: none; }
|
||
.sl25-sn span { display: inline-block; font-size: 4.5pt; font-weight: 700;
|
||
font-family: monospace; color: #000; background: #fff;
|
||
padding: 0.5pt 2.5pt; line-height: 1.1; white-space: nowrap;
|
||
border: 0.5pt solid #fff; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="toolbar">
|
||
<span class="toolbar-info">Label Sheet \u2014 ${titleSuffix}</span>
|
||
<div class="toolbar-btns">
|
||
<button class="toolbar-btn primary" onclick="window.print()">\uD83D\uDDA8 Print</button>
|
||
<button class="toolbar-btn" onclick="window.close()">Close</button>
|
||
</div>
|
||
</div>
|
||
${pagesHTML}
|
||
<script>
|
||
var barcodes = ${barcodeArrayJSON};
|
||
var printed = false;
|
||
function doPrint() { if (!printed) { printed = true; window.print(); } }
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
if (!barcodes.length) { setTimeout(doPrint, 300); return; }
|
||
|
||
if (typeof JsBarcode !== 'undefined') {
|
||
barcodes.forEach(function(b) {
|
||
var c = document.createElement('canvas');
|
||
JsBarcode(c, b[1], { format:'CODE128', width:4, height:400, displayValue:false, margin:0 });
|
||
var img = document.getElementById(b[0]);
|
||
if (img) img.src = c.toDataURL('image/png');
|
||
});
|
||
}
|
||
|
||
var imgs = document.querySelectorAll('.sbc-bc, .sl25-bc');
|
||
var pending = imgs.length;
|
||
if (!pending) { setTimeout(doPrint, 200); return; }
|
||
|
||
function onLoad() { if (--pending <= 0) setTimeout(doPrint, 100); }
|
||
imgs.forEach(function(img) {
|
||
if (img.complete && img.naturalWidth > 0) { pending--; }
|
||
else {
|
||
img.addEventListener('load', onLoad, { once: true });
|
||
img.addEventListener('error', onLoad, { once: true });
|
||
}
|
||
});
|
||
if (pending <= 0) setTimeout(doPrint, 100);
|
||
else setTimeout(doPrint, 800);
|
||
});
|
||
<\/script>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
// ── Badge ─────────────────────────────────────────────────────────────────────
|
||
|
||
function updateBadgeDOM(count) {
|
||
const badge = document.getElementById('lc-badge');
|
||
if (!badge) return;
|
||
badge.textContent = count;
|
||
badge.hidden = count === 0;
|
||
}
|
||
|
||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||
|
||
function iconQueuePlus() {
|
||
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||
<line x1="12" y1="8" x2="12" y2="16"/>
|
||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||
</svg>`;
|
||
}
|
||
|
||
// Make iconQueuePlus available for assetCard.js to import
|
||
export { iconQueuePlus };
|
||
|
||
// ── Utils ─────────────────────────────────────────────────────────────────────
|
||
|
||
function esc(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|