asset_browser/public/modules/labelCenter.js
2026-03-27 09:18:39 -04:00

1618 lines
63 KiB
JavaScript
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 130 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 130
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 130
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}