Files
CSG-Picker-Shipper-Helper/app/assets/setup_labels.html
T
2026-07-06 00:24:30 -05:00

678 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSG First-Time Setup: Batch Label Generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #0d0f12;
--panel-bg: rgba(22, 26, 33, 0.85);
--border-color: rgba(255, 255, 255, 0.08);
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--success-color: #10b981;
--error-color: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: rgba(13, 15, 18, 0.7);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-color);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-dot {
width: 12px;
height: 12px;
background-color: var(--accent-color);
border-radius: 50%;
box-shadow: 0 0 12px var(--accent-color);
}
h1 {
font-size: 1.15rem;
font-weight: 600;
letter-spacing: -0.025em;
}
header p {
font-size: 0.75rem;
color: var(--text-secondary);
}
.back-btn {
font-size: 0.8rem;
background-color: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
border: 1px solid var(--border-color);
padding: 0.4rem 0.8rem;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
transition: all 0.15s;
}
.back-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: var(--text-secondary);
}
main {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 320px;
background: var(--panel-bg);
border-right: 1px solid var(--border-color);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
overflow-y: auto;
}
.sidebar h2 {
font-size: 0.95rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-group label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input, .form-group select {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.form-group input:focus, .form-group select:focus {
border-color: var(--accent-color);
}
button.primary-btn {
background: var(--accent-color);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.6rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
button.primary-btn:hover {
background: var(--accent-hover);
}
button.secondary-btn {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.6rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
button.secondary-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.content-area {
flex: 1;
padding: 2rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.status-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.status-title {
font-size: 0.9rem;
font-weight: 600;
}
.status-desc {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Labels View grid default screen preview */
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* Label style in preview */
.label-card {
background: #fff;
color: #000;
border-radius: 8px;
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid #ddd;
page-break-inside: avoid;
}
.label-qr-wrap {
width: 90px;
height: 90px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.label-text-wrap {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0; /* allows text truncation/wrap */
}
.label-sku {
font-family: monospace;
font-size: 13px;
font-weight: 700;
word-break: break-all;
line-height: 1.2;
}
.label-meta {
font-size: 11px;
color: #555;
line-height: 1.3;
}
.progress-bar-wrap {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.05);
border-radius: 9999px;
overflow: hidden;
display: none;
}
.progress-bar-fill {
height: 100%;
width: 0%;
background: var(--accent-color);
transition: width 0.1s linear;
}
/* Printing Specific Styles */
@media print {
.no-print {
display: none !important;
}
body, html {
background: #fff !important;
color: #000 !important;
height: auto !important;
min-height: auto !important;
overflow: visible !important;
}
main {
display: block !important;
overflow: visible !important;
}
.content-area {
padding: 0 !important;
margin: 0 !important;
display: block !important;
overflow: visible !important;
}
/* Printing layout based on selection */
.preview-grid.print-cols-1 {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
}
.preview-grid.print-cols-2 {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 12px !important;
}
.preview-grid.print-cols-3 {
display: grid !important;
grid-template-columns: 1fr 1fr 1fr !important;
gap: 8px !important;
}
.label-card {
box-shadow: none !important;
border: 1px solid #aaa !important;
border-radius: 4px !important;
background: #fff !important;
color: #000 !important;
margin: 0 !important;
page-break-inside: avoid !important;
}
}
</style>
</head>
<body>
<!-- Header (Hidden during print) -->
<header class="no-print">
<div class="header-brand">
<div class="logo-dot"></div>
<div>
<h1>CSG Warehouse Setup</h1>
<p>Batch Label Generation & Printing Utility</p>
</div>
</div>
<a href="/picker" class="back-btn">⬅ Back to Picker Home</a>
</header>
<main>
<!-- Sidebar Controls (Hidden during print) -->
<div class="sidebar no-print">
<h2>🔍 Filter Parts</h2>
<div class="form-group">
<label for="filter-sku">SKU Search / Prefix</label>
<input type="text" id="filter-sku" placeholder="e.g. 84126" oninput="applyFilters()">
</div>
<h2>📏 Label Layout</h2>
<div class="form-group">
<label for="print-cols">Layout Columns (for print)</label>
<select id="print-cols" onchange="updatePrintCols()">
<option value="print-cols-1">Roll Printer (1 Column)</option>
<option value="print-cols-2" selected>Sheet Paper (2 Columns)</option>
<option value="print-cols-3">Sheet Paper (3 Columns)</option>
</select>
</div>
<div class="form-group">
<label for="qr-mode">QR Code Mode</label>
<select id="qr-mode" onchange="applyFilters()">
<option value="sku">SKU only (highly recommended)</option>
<option value="sku_bin">SKU + Bin Location</option>
</select>
</div>
<div class="form-group">
<label for="batch-limit">Batch Render Limit</label>
<select id="batch-limit" onchange="applyFilters()">
<option value="50">First 50 matches</option>
<option value="100" selected>First 100 matches</option>
<option value="250">First 250 matches</option>
<option value="500">First 500 matches</option>
<option value="all">⚠️ Render All (2,000+ parts)</option>
</select>
</div>
<h2>💾 Import / Export</h2>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem;">
<button class="secondary-btn" style="flex: 1; padding: 0.5rem;" onclick="triggerUpload()">📤 Upload</button>
<button class="secondary-btn" style="flex: 1; padding: 0.5rem;" onclick="exportLabels()">⬇️ Export</button>
</div>
<input type="file" id="upload-file-input" accept=".json,.csv" style="display:none;" onchange="handleUpload(event)">
<button class="primary-btn" onclick="triggerPrint()">🖨️ Print Labels</button>
<button class="secondary-btn" onclick="resetFilters()">🔄 Reset Filters</button>
</div>
<!-- Printable content / preview area -->
<div class="content-area">
<!-- Setup Progress / Status Banner (Hidden during print) -->
<div class="status-card no-print">
<div class="status-info">
<span class="status-title" id="status-title">Loading parts inventory...</span>
<span class="status-desc" id="status-desc">Fetching latest warehouse spreadsheet database.</span>
<div class="progress-bar-wrap" id="progress-wrap">
<div class="progress-bar-fill" id="progress-fill"></div>
</div>
</div>
</div>
<!-- Preview Label Grid -->
<div class="preview-grid print-cols-2" id="labels-grid">
<!-- Dynamically loaded label cards -->
</div>
</div>
</main>
<!-- Dependencies -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
let allParts = [];
let filteredParts = [];
let renderQueue = [];
let isRendering = false;
// Fetch all parts on load
async function fetchParts() {
try {
const response = await fetch('/api/parts');
if (!response.ok) throw new Error('Failed to load parts api');
const data = await response.json();
allParts = data.parts || [];
document.getElementById('status-title').textContent = `Loaded ${allParts.length} parts database.`;
document.getElementById('status-desc').textContent = 'Filter below to print barcodes in batches.';
applyFilters();
} catch (err) {
document.getElementById('status-title').textContent = '⚠️ Inventory Database Error';
document.getElementById('status-desc').textContent = 'Failed to fetch parts. Ensure picker server is running.';
console.error(err);
}
}
function triggerUpload() {
document.getElementById('upload-file-input').click();
}
function handleUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
const text = e.target.result;
let parsedList = [];
try {
if (file.name.endsWith('.json')) {
// Parse JSON
const data = JSON.parse(text);
const items = Array.isArray(data) ? data : (data.parts || data.items || []);
parsedList = items.map(item => ({
sku: (item.sku || item.SKU || item.stock_number || item['STOCK NUMBER'] || '').trim().toUpperCase(),
description: (item.description || item.DESCRIPTION || item.desc || '')
})).filter(x => x.sku);
} else {
// Parse CSV
const lines = text.split(/\r?\n/);
if (lines.length > 0) {
const headers = lines[0].split(',').map(h => h.trim().toUpperCase());
// Find column indices
let skuIdx = headers.findIndex(h => h === 'SKU' || h === 'STOCK NUMBER' || h === 'STOCK_NUMBER');
let descIdx = headers.findIndex(h => h === 'DESCRIPTION' || h === 'DESC');
// Fallback if no header match
if (skuIdx === -1) skuIdx = 0;
if (descIdx === -1) descIdx = 1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Split comma-separated, respecting quoted fields
const cols = line.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/).map(c => c.replace(/^"|"$/g, '').trim());
const sku = (cols[skuIdx] || '').toUpperCase();
const description = cols[descIdx] || '';
if (sku) {
parsedList.push({ sku, description });
}
}
}
}
if (parsedList.length === 0) {
alert("No valid part labels found in the uploaded file. Ensure columns contain SKU/STOCK NUMBER and DESCRIPTION.");
return;
}
// Update parts list
allParts = parsedList;
document.getElementById('status-title').textContent = `Uploaded Custom List: ${allParts.length} parts loaded.`;
document.getElementById('status-desc').textContent = 'Successfully imported custom label batch.';
// Reset search input
document.getElementById('filter-sku').value = '';
applyFilters();
alert(`Successfully imported ${allParts.length} custom labels!`);
} catch (err) {
alert("Error parsing file: " + err.message);
}
};
reader.readAsText(file);
event.target.value = '';
}
function exportLabels() {
if (filteredParts.length === 0) {
alert("No labels to export.");
return;
}
const exportData = filteredParts.map(part => ({
sku: part.sku,
description: part.description
}));
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData, null, 2));
const dlAnchor = document.createElement('a');
dlAnchor.setAttribute("href", dataStr);
dlAnchor.setAttribute("download", `CSG_Labels_Export_${Date.now()}.json`);
document.body.appendChild(dlAnchor);
dlAnchor.click();
dlAnchor.remove();
}
function applyFilters() {
const skuVal = document.getElementById('filter-sku').value.toUpperCase().trim();
const limitVal = document.getElementById('batch-limit').value;
// Apply filters
filteredParts = allParts.filter(part => {
if (skuVal && !part.sku.includes(skuVal)) return false;
return true;
});
// Handle limit
const limit = limitVal === 'all' ? filteredParts.length : parseInt(limitVal, 10);
renderQueue = filteredParts.slice(0, limit);
// Re-render
startBatchRender();
}
function resetFilters() {
document.getElementById('filter-sku').value = '';
document.getElementById('batch-limit').value = '100';
applyFilters();
}
function updatePrintCols() {
const grid = document.getElementById('labels-grid');
grid.className = 'preview-grid ' + document.getElementById('print-cols').value;
}
// Start progressive asynchronous rendering of QRs to keep page responsive
function startBatchRender() {
isRendering = false; // stops active loops
const grid = document.getElementById('labels-grid');
grid.innerHTML = '';
if (renderQueue.length === 0) {
grid.innerHTML = '<div style="color:var(--text-secondary); grid-column: 1 / -1; text-align: center; padding: 2rem;">No matching parts found.</div>';
document.getElementById('status-title').textContent = 'Loaded 0 labels';
document.getElementById('progress-wrap').style.display = 'none';
return;
}
document.getElementById('status-title').textContent = `Generating ${renderQueue.length} labels...`;
document.getElementById('progress-wrap').style.display = 'block';
document.getElementById('progress-fill').style.width = '0%';
isRendering = true;
let index = 0;
const qrMode = document.getElementById('qr-mode').value;
function renderNextChunk() {
if (!isRendering) return;
const chunk = 10; // Render 10 at a time
const end = Math.min(index + chunk, renderQueue.length);
for (let i = index; i < end; i++) {
const part = renderQueue[i];
createLabelCard(grid, part, i, qrMode);
}
index = end;
const percentage = (index / renderQueue.length) * 100;
document.getElementById('progress-fill').style.width = percentage + '%';
if (index < renderQueue.length) {
requestAnimationFrame(renderNextChunk);
} else {
document.getElementById('status-title').textContent = `Ready: ${renderQueue.length} labels generated.`;
document.getElementById('status-desc').textContent = 'Press "Print Labels" to open printer queue dialog.';
document.getElementById('progress-wrap').style.display = 'none';
isRendering = false;
}
}
requestAnimationFrame(renderNextChunk);
}
function createLabelCard(container, part, index, qrMode) {
const card = document.createElement('div');
card.className = 'label-card';
const qrWrap = document.createElement('div');
qrWrap.className = 'label-qr-wrap';
qrWrap.id = `setup-qr-wrap-${index}`;
const textWrap = document.createElement('div');
textWrap.className = 'label-text-wrap';
const skuDiv = document.createElement('div');
skuDiv.className = 'label-sku';
skuDiv.textContent = part.sku;
const metaDiv = document.createElement('div');
metaDiv.className = 'label-meta';
metaDiv.innerHTML = part.description || 'No description available';
textWrap.appendChild(skuDiv);
textWrap.appendChild(metaDiv);
card.appendChild(qrWrap);
card.appendChild(textWrap);
container.appendChild(card);
// Determine QR code text
let qrText = part.sku;
if (qrMode === 'sku_bin' && part.bin_location) {
qrText = `${part.sku}\n${part.bin_location}`;
}
// Create QR code
new QRCode(qrWrap, {
text: qrText,
width: 80,
height: 80,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M
});
}
function triggerPrint() {
if (isRendering) {
alert("Please wait for all labels to finish rendering before printing!");
return;
}
window.print();
}
// Initialize on load
window.addEventListener('DOMContentLoaded', fetchParts);
</script>
</body>
</html>