Compare commits
3 Commits
a265aea424
...
4992890503
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4992890503 | ||
|
|
0858d3eaec | ||
|
|
b3799a5318 |
@@ -1,4 +1,5 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
|
import { parse } from 'csv';
|
||||||
import { db } from '../../db/index';
|
import { db } from '../../db/index';
|
||||||
import { inventory, priceHistory } from '../../db/schema';
|
import { inventory, priceHistory } from '../../db/schema';
|
||||||
import { client } from '../../db/typesense';
|
import { client } from '../../db/typesense';
|
||||||
@@ -89,25 +90,8 @@ const getInventory = async (userId: string, cardId: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
|
// Build the Typesense document for an inventory row + its card details.
|
||||||
// First add to database
|
const inventoryTsDoc = (i: typeof inventory.$inferSelect, card: any) => ({
|
||||||
const inv = await db.insert(inventory).values({
|
|
||||||
userId: userId,
|
|
||||||
skuId: skuId,
|
|
||||||
catalogName: catalogName,
|
|
||||||
purchasePrice: purchasePrice.toFixed(2),
|
|
||||||
quantity: quantity,
|
|
||||||
note: note,
|
|
||||||
}).returning();
|
|
||||||
// Get card details from the database to add to Typesense
|
|
||||||
const card = await db.query.cards.findFirst({
|
|
||||||
where: { cardId: cardId },
|
|
||||||
with: { set: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// And then add to Typesense for searching
|
|
||||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
|
||||||
id: i.inventoryId,
|
id: i.inventoryId,
|
||||||
userId: i.userId,
|
userId: i.userId,
|
||||||
catalogName: i.catalogName,
|
catalogName: i.catalogName,
|
||||||
@@ -127,7 +111,30 @@ const addToInventory = async (userId: string, cardId: number, skuId: number, pur
|
|||||||
card?.rarityName,
|
card?.rarityName,
|
||||||
card?.artist || ""
|
card?.artist || ""
|
||||||
].join(' '),
|
].join(' '),
|
||||||
})));
|
});
|
||||||
|
|
||||||
|
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
|
||||||
|
// Insert one row per copy: a quantity of 5 becomes 5 separate entries of qty 1.
|
||||||
|
const count = Math.max(1, Math.floor(quantity));
|
||||||
|
const inv = await db.insert(inventory).values(
|
||||||
|
Array.from({ length: count }, () => ({
|
||||||
|
userId: userId,
|
||||||
|
skuId: skuId,
|
||||||
|
catalogName: catalogName,
|
||||||
|
purchasePrice: purchasePrice.toFixed(2),
|
||||||
|
quantity: 1,
|
||||||
|
note: note,
|
||||||
|
}))
|
||||||
|
).returning();
|
||||||
|
// Get card details from the database to add to Typesense
|
||||||
|
const card = await db.query.cards.findFirst({
|
||||||
|
where: { cardId: cardId },
|
||||||
|
with: { set: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// And then add to Typesense for searching
|
||||||
|
await client.collections('inventories').documents().import(inv.map(i => inventoryTsDoc(i, card)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding inventory to Typesense:', error);
|
console.error('Error adding inventory to Typesense:', error);
|
||||||
}
|
}
|
||||||
@@ -154,6 +161,123 @@ const updateInventory = async (inventoryId: string, quantity: number, purchasePr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bulk CSV import ─────────────────────────────────────────────────────────
|
||||||
|
// Expected columns (header row, case-insensitive): name, set, condition, qty,
|
||||||
|
// price, market. An optional `date` column is used as the inventory createdAt;
|
||||||
|
// rows without a (valid) date default to the current time.
|
||||||
|
const CONDITION_MAP: Record<string, string> = {
|
||||||
|
'nm': 'Near Mint', 'near mint': 'Near Mint', 'mint': 'Near Mint',
|
||||||
|
'lp': 'Lightly Played', 'lightly played': 'Lightly Played',
|
||||||
|
'mp': 'Moderately Played', 'moderately played': 'Moderately Played',
|
||||||
|
'hp': 'Heavily Played', 'heavily played': 'Heavily Played',
|
||||||
|
'dmg': 'Damaged', 'dm': 'Damaged', 'damaged': 'Damaged',
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCondition = (value?: string) => {
|
||||||
|
const key = (value || '').trim().toLowerCase();
|
||||||
|
return CONDITION_MAP[key] || 'Near Mint';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse an optional date cell into a Date, or null when empty/invalid.
|
||||||
|
const parseImportDate = (value?: string): Date | null => {
|
||||||
|
const raw = (value || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const date = new Date(raw);
|
||||||
|
return isNaN(date.getTime()) ? null : date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseCsv = (text: string): Promise<Record<string, string>[]> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
parse(text, { columns: true, trim: true, skip_empty_lines: true, bom: true },
|
||||||
|
(err, records) => (err ? reject(err) : resolve(records)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Case-insensitive column lookup for a parsed CSV row.
|
||||||
|
const lowerKeys = (row: Record<string, string>) => {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(row)) out[k.trim().toLowerCase()] = v;
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findCardId = async (name: string, set: string): Promise<number | undefined> => {
|
||||||
|
const escapedSet = set.replace(/`/g, '');
|
||||||
|
let result = await client.collections('cards').documents().search({
|
||||||
|
q: name, query_by: 'productName', per_page: 1,
|
||||||
|
...(escapedSet ? { filter_by: `setName:\`${escapedSet}\`` } : {}),
|
||||||
|
});
|
||||||
|
// Fall back to a name-only match if the set filter found nothing.
|
||||||
|
if (!result.hits?.length && escapedSet) {
|
||||||
|
result = await client.collections('cards').documents().search({
|
||||||
|
q: name, query_by: 'productName', per_page: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.hits?.[0]?.document?.cardId as number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importInventory = async (userId: string, rows: Record<string, string>[], catalogName: string) => {
|
||||||
|
const skipped: { row: number; name: string; reason: string }[] = [];
|
||||||
|
const tsDocs: ReturnType<typeof inventoryTsDoc>[] = [];
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const rowNumber = i + 2; // account for header row (1-indexed for humans)
|
||||||
|
const row = lowerKeys(rows[i]);
|
||||||
|
const name = (row.name || '').trim();
|
||||||
|
const set = (row.set || '').trim();
|
||||||
|
|
||||||
|
if (!name) { skipped.push({ row: rowNumber, name, reason: 'Missing card name' }); continue; }
|
||||||
|
|
||||||
|
const createdAt = parseImportDate(row.date);
|
||||||
|
if ((row.date || '').trim() && !createdAt) {
|
||||||
|
skipped.push({ row: rowNumber, name, reason: `Invalid date "${row.date.trim()}"` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const condition = normalizeCondition(row.condition);
|
||||||
|
const count = Math.max(1, parseInt(row.qty, 10) || 1);
|
||||||
|
const purchasePrice = parseFloat(String(row.price ?? '').replace(/[^0-9.]/g, '')) || 0;
|
||||||
|
|
||||||
|
const cardId = await findCardId(name, set);
|
||||||
|
if (!cardId) { skipped.push({ row: rowNumber, name, reason: 'Card not found' }); continue; }
|
||||||
|
|
||||||
|
const sku = await db.query.skus.findFirst({
|
||||||
|
where: { cardId: cardId, condition: condition },
|
||||||
|
columns: { skuId: true },
|
||||||
|
});
|
||||||
|
if (!sku?.skuId) { skipped.push({ row: rowNumber, name, reason: `No "${condition}" SKU for card` }); continue; }
|
||||||
|
|
||||||
|
// One row per copy: a qty of 5 becomes 5 separate entries of qty 1.
|
||||||
|
const inserted = await db.insert(inventory).values(
|
||||||
|
Array.from({ length: count }, () => ({
|
||||||
|
userId,
|
||||||
|
skuId: sku.skuId,
|
||||||
|
catalogName,
|
||||||
|
purchasePrice: purchasePrice.toFixed(2),
|
||||||
|
quantity: 1,
|
||||||
|
note: '',
|
||||||
|
...(createdAt ? { createdAt } : {}),
|
||||||
|
}))
|
||||||
|
).returning();
|
||||||
|
|
||||||
|
const card = await db.query.cards.findFirst({
|
||||||
|
where: { cardId: cardId },
|
||||||
|
with: { set: true },
|
||||||
|
});
|
||||||
|
for (const entry of inserted) tsDocs.push(inventoryTsDoc(entry, card));
|
||||||
|
imported += inserted.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tsDocs.length) {
|
||||||
|
try {
|
||||||
|
await client.collections('inventories').documents().import(tsDocs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing inventory to Typesense:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imported, skipped };
|
||||||
|
};
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
// Access form data from the request body
|
// Access form data from the request body
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
@@ -179,6 +303,33 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
|
await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'import': {
|
||||||
|
if (!userId) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Not authenticated' }), {
|
||||||
|
status: 401, headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
if (!file) {
|
||||||
|
return new Response(JSON.stringify({ error: 'No file uploaded' }), {
|
||||||
|
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const importCatalog = formData.get('catalogName')?.toString()?.trim() || 'Default';
|
||||||
|
try {
|
||||||
|
const rows = await parseCsv(await file.text());
|
||||||
|
const summary = await importInventory(userId, rows, importCatalog);
|
||||||
|
return new Response(JSON.stringify(summary), {
|
||||||
|
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing inventory CSV:', error);
|
||||||
|
return new Response(JSON.stringify({ error: 'Could not parse CSV file' }), {
|
||||||
|
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case 'remove':
|
case 'remove':
|
||||||
const inventoryId = formData.get('inventoryId')?.toString() || '';
|
const inventoryId = formData.get('inventoryId')?.toString() || '';
|
||||||
await removeFromInventory(inventoryId);
|
await removeFromInventory(inventoryId);
|
||||||
|
|||||||
@@ -103,24 +103,28 @@ const catalogs = catalogRows
|
|||||||
|
|
||||||
<div class="ms-auto position-relative">
|
<div class="ms-auto position-relative">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="hidden" name="start" id="start" value="0" />
|
<input
|
||||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
type="search"
|
||||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
name="q"
|
||||||
<input type="search" name="i" id="searchInput" class="form-control search-input" placeholder="Search your inventory" />
|
id="inventorySearchInput"
|
||||||
|
class="form-control search-input"
|
||||||
|
placeholder="Search your inventory"
|
||||||
|
hx-post="/partials/inventory-cards"
|
||||||
|
hx-trigger="keyup[key=='Enter']"
|
||||||
|
hx-target="#gridView"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
|
id="inventorySearchBtn"
|
||||||
class="btn btn-purple border-start-0"
|
class="btn btn-purple border-start-0"
|
||||||
aria-label="search"
|
aria-label="search"
|
||||||
onclick="
|
hx-post="/partials/inventory-cards"
|
||||||
const i = this.closest('form').querySelector('[name=i]').value;
|
hx-include="#inventorySearchInput"
|
||||||
dataLayer.push({ event: 'view_inventory_results', search_term: i });
|
hx-target="#gridView"
|
||||||
if (window.location.pathname !== '/dashboard') {
|
hx-swap="innerHTML"
|
||||||
event.preventDefault();
|
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
|
||||||
event.stopPropagation();
|
|
||||||
sessionStorage.setItem('pendingSearch', 1);
|
|
||||||
window.location.href = '/dashboard';
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
|
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -144,15 +148,19 @@ const catalogs = catalogRows
|
|||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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>.
|
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
|
||||||
</p>
|
</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 — it’s used as the entry’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>
|
<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" />
|
<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">
|
<div id="csvPreview" class="mt-3 d-none">
|
||||||
<p class="small text-secondary fw-semibold mb-1">Preview</p>
|
<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>
|
||||||
|
<div id="csvImportResult" class="mt-3 d-none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-secondary">
|
<div class="modal-footer border-secondary">
|
||||||
<button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button>
|
||||||
@@ -172,14 +180,107 @@ const catalogs = catalogRows
|
|||||||
<script is:inline>
|
<script is:inline>
|
||||||
(function () {
|
(function () {
|
||||||
const catalogList = document.getElementById('catalogList');
|
const catalogList = document.getElementById('catalogList');
|
||||||
|
const searchInput = document.getElementById('inventorySearchInput');
|
||||||
|
const searchBtn = document.getElementById('inventorySearchBtn');
|
||||||
|
|
||||||
if (catalogList) {
|
if (catalogList) {
|
||||||
catalogList.addEventListener('click', (e) => {
|
catalogList.addEventListener('click', (e) => {
|
||||||
const li = e.target.closest('li[data-catalog]');
|
const li = e.target.closest('li[data-catalog]');
|
||||||
if (!li) return;
|
if (!li) return;
|
||||||
catalogList.querySelectorAll('li[data-catalog]').forEach(el => el.classList.remove('active'));
|
catalogList.querySelectorAll('li[data-catalog]').forEach(el => el.classList.remove('active'));
|
||||||
li.classList.add('active');
|
li.classList.add('active');
|
||||||
|
// switching catalogs clears the search box so its contents don't misrepresent the grid
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// analytics only — the search request itself is fired declaratively via htmx attributes
|
||||||
|
function pushSearchEvent() {
|
||||||
|
if (window.dataLayer) {
|
||||||
|
window.dataLayer.push({ event: 'view_inventory_results', search_term: searchInput?.value.trim() || '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchBtn?.addEventListener('click', pushSearchEvent);
|
||||||
|
searchInput?.addEventListener('keyup', (e) => { if (e.key === 'Enter') pushSearchEvent(); });
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── 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) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 () {
|
(function () {
|
||||||
|
|||||||
@@ -137,4 +137,16 @@ console.log(`totalHits: ${totalHits}`);
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
})
|
})}
|
||||||
|
{start + 20 < totalHits &&
|
||||||
|
<div
|
||||||
|
hx-post="/partials/inventory-cards"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-vals={JSON.stringify({ start: start + 20, catalog, q: query })}
|
||||||
|
hx-target="#gridView"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-on--after-request="this.remove()"
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user