[feat] bulk import allows date, and quantity is split into multiple entries

This commit is contained in:
Thad Miller
2026-07-16 15:59:25 -04:00
parent a265aea424
commit b3799a5318
2 changed files with 266 additions and 32 deletions

View File

@@ -144,15 +144,19 @@ const catalogs = catalogRows
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-secondary mb-3">
<p class="small text-secondary mb-2">
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
</p>
<p class="small text-secondary mb-3">
Optionally add a <code>date</code> column (e.g. <code>2026-07-16</code>) to record when each card was acquired &mdash; it&rsquo;s used as the entry&rsquo;s added/purchase date. Rows without a date default to today.
</p>
<label class="form-label small text-secondary text-uppercase fw-semibold" for="csvFileInput">Choose File</label>
<input id="csvFileInput" type="file" accept=".csv" class="form-control bg-dark-subtle text-light border-secondary" />
<div id="csvPreview" class="mt-3 d-none">
<p class="small text-secondary fw-semibold mb-1">Preview</p>
<div class="border border-secondary rounded p-2 small text-secondary" id="csvPreviewContent">—</div>
<div class="border border-secondary rounded p-2 small text-secondary overflow-auto" id="csvPreviewContent" style="max-height: 12rem;">—</div>
</div>
<div id="csvImportResult" class="mt-3 d-none"></div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button>
@@ -182,6 +186,85 @@ const catalogs = catalogRows
}
})();
// ── Bulk CSV import ──────────────────────────────────────────────────────
(function () {
const importModal = document.getElementById('bulkImportModal');
const fileInput = document.getElementById('csvFileInput');
const uploadBtn = document.getElementById('csvUploadBtn');
const preview = document.getElementById('csvPreview');
const previewContent = document.getElementById('csvPreviewContent');
const result = document.getElementById('csvImportResult');
if (!importModal || !fileInput || !uploadBtn) return;
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
let selectedFile = null;
let didImport = false;
fileInput.addEventListener('change', () => {
selectedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
result.classList.add('d-none');
if (!selectedFile) { preview.classList.add('d-none'); return; }
const reader = new FileReader();
reader.onload = () => {
const lines = String(reader.result || '').split(/\r?\n/).filter((l) => l.trim()).slice(0, 6);
previewContent.innerHTML = lines
.map((l, i) => `<div class="${i === 0 ? 'text-light fw-semibold' : ''}">${esc(l)}</div>`)
.join('');
preview.classList.remove('d-none');
};
reader.readAsText(selectedFile);
});
uploadBtn.addEventListener('click', async () => {
if (!selectedFile) { alert('Choose a CSV file first.'); return; }
const original = uploadBtn.textContent;
uploadBtn.disabled = true;
uploadBtn.textContent = 'Importing…';
result.classList.add('d-none');
try {
const body = new FormData();
body.append('action', 'import');
body.append('file', selectedFile);
const res = await fetch('/api/inventory', { method: 'POST', body });
const data = await res.json();
if (!res.ok) {
result.className = 'mt-3 alert alert-danger py-2 small';
result.textContent = data.error || 'Import failed.';
result.classList.remove('d-none');
return;
}
didImport = data.imported > 0;
const skipped = data.skipped || [];
let html = `<div class="fw-semibold ${data.imported > 0 ? 'text-success' : ''}">Imported ${data.imported} card${data.imported === 1 ? '' : 's'}.</div>`;
if (skipped.length) {
html += `<div class="mt-1 text-warning fw-semibold">Skipped ${skipped.length}:</div>`;
html += '<ul class="mb-0 ps-3">' + skipped
.map((s) => `<li>Row ${s.row}${s.name ? ' (' + esc(s.name) + ')' : ''}: ${esc(s.reason)}</li>`)
.join('') + '</ul>';
}
result.className = 'mt-3 alert alert-dark py-2 small overflow-auto';
result.style.maxHeight = '14rem';
result.innerHTML = html;
result.classList.remove('d-none');
uploadBtn.textContent = didImport ? 'Done' : 'Upload';
} catch (e) {
result.className = 'mt-3 alert alert-danger py-2 small';
result.textContent = 'Import failed.';
result.classList.remove('d-none');
} finally {
uploadBtn.disabled = false;
if (uploadBtn.textContent === 'Importing…') uploadBtn.textContent = original;
}
});
// Refresh the dashboard (grid, catalogs, totals) after a successful import.
importModal.addEventListener('hidden.bs.modal', () => {
if (didImport) window.location.reload();
});
})();
(function () {
const modal = document.getElementById('inventoryModal');
if (!modal) return;