Compare commits
5 Commits
a265aea424
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90bf21111 | ||
|
|
3957a86f9c | ||
|
|
4992890503 | ||
|
|
0858d3eaec | ||
|
|
b3799a5318 |
@@ -886,6 +886,16 @@ footer .logo-svg > svg { width: var(--logo-width, 8rem); }
|
|||||||
-------------------------------------------------- */
|
-------------------------------------------------- */
|
||||||
#gridView { row-gap: 1.5rem; }
|
#gridView { row-gap: 1.5rem; }
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.catalog-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--navbar-height, 4rem);
|
||||||
|
height: calc(100vh - var(--navbar-height, 4rem));
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.inv-grid-card {
|
.inv-grid-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
---
|
---
|
||||||
|
// auth check for inventory management features
|
||||||
|
const { canAddInventory } = Astro.locals;
|
||||||
|
const { isAdmin } = Astro.locals;
|
||||||
|
// const canAddInventory = false;
|
||||||
---
|
---
|
||||||
<button
|
<button
|
||||||
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
|
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
|
||||||
@@ -30,9 +34,16 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
|
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
|
||||||
</li>
|
</li>
|
||||||
<!--<li class="nav-item">
|
{canAddInventory && (
|
||||||
<a class="nav-link py-3" href="/dashboard">Dashboard</a>
|
<li class="nav-item">
|
||||||
</li> -->
|
<a class="nav-link py-3 border-bottom border-secondary" href="/dashboard">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link py-3" href="/admin">Admin Panel</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,47 +25,77 @@ import { Show } from '@clerk/astro/components'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Show when="signed-in">
|
<Show when="signed-in">
|
||||||
<form
|
{Astro.url.pathname === '/dashboard' ? (
|
||||||
class="d-flex align-items-center"
|
|
||||||
role="search"
|
|
||||||
id="searchform"
|
|
||||||
hx-post="/partials/cards"
|
|
||||||
hx-target="#cardGrid"
|
|
||||||
hx-trigger="load, submit"
|
|
||||||
hx-vals='{"start":"0"}'
|
|
||||||
hx-on--after-request="afterUpdate()"
|
|
||||||
hx-on--before-request="beforeSearch()"
|
|
||||||
>
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
{Astro.url.pathname === '/pokemon' && (
|
<input
|
||||||
<a class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
|
type="search"
|
||||||
<span class="d-block d-md-none filter-icon py-2">
|
name="q"
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528.8 96.3C558.6 90.8 571.2 118.9 568.9 142.2C572.3 173.4 570.8 207 553.9 230.8C513.9 283.2 459.3 315.9 414.3 364.3C414.9 418.3 419.8 459.8 423.6 511.2C427.6 552.4 388.7 586.8 346.6 570.1C303.2 550.5 259.4 527.5 230.4 493.3C217 453.1 225.9 407.5 222.2 365.3C222.2 365.3 222.1 365.1 222 365C151.4 319.6 59.3 250.9 61 158.4C59.9 121 91.8 96.1 123.8 96.5C259.3 98.5 394.1 104.4 528.8 96.3zM506.1 161.4C378.3 168.2 252 162.1 125.2 160.5C128.6 227 199 270.8 250 306.8C305.5 335.4 281.6 410.5 288.3 461.7C310.8 478.9 334.6 494.6 358.9 505.8C355.4 458 350.7 415.4 350.2 364.6C349.9 349.2 355.3 333.7 366.5 321.7C384.3 302.6 402.8 287.8 421.5 270.1C446.1 245.2 477.9 225.1 499.7 196.7C509 182.2 504.7 174.5 506 161.5z"/></svg>
|
id="inventorySearchInput"
|
||||||
</span>
|
class="form-control search-input"
|
||||||
<span class="d-none d-md-block fw-medium">Filters</span>
|
placeholder="Search your inventory"
|
||||||
</a>
|
hx-post="/partials/inventory-cards"
|
||||||
)}
|
hx-trigger="keyup[key=='Enter']"
|
||||||
<input type="hidden" name="start" id="start" value="0" />
|
hx-target="#gridView"
|
||||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
hx-swap="innerHTML"
|
||||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
hx-vals="js:{start: 0, catalog: document.querySelector('#catalogList li[data-catalog].active')?.dataset.catalog || 'all'}"
|
||||||
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
|
/>
|
||||||
<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 q = this.closest('form').querySelector('[name=q]').value;
|
hx-include="#inventorySearchInput"
|
||||||
dataLayer.push({ event: 'view_search_results', search_term: q });
|
hx-target="#gridView"
|
||||||
if (window.location.pathname !== '/pokemon') {
|
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', q);
|
|
||||||
window.location.href = '/pokemon';
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
) : (
|
||||||
|
<form
|
||||||
|
class="d-flex align-items-center"
|
||||||
|
role="search"
|
||||||
|
id="searchform"
|
||||||
|
hx-post="/partials/cards"
|
||||||
|
hx-target="#cardGrid"
|
||||||
|
hx-trigger="load, submit"
|
||||||
|
hx-vals='{"start":"0"}'
|
||||||
|
hx-on--after-request="afterUpdate()"
|
||||||
|
hx-on--before-request="beforeSearch()"
|
||||||
|
>
|
||||||
|
<div class="input-group">
|
||||||
|
{Astro.url.pathname === '/pokemon' && (
|
||||||
|
<a class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
|
||||||
|
<span class="d-block d-md-none filter-icon py-2">
|
||||||
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528.8 96.3C558.6 90.8 571.2 118.9 568.9 142.2C572.3 173.4 570.8 207 553.9 230.8C513.9 283.2 459.3 315.9 414.3 364.3C414.9 418.3 419.8 459.8 423.6 511.2C427.6 552.4 388.7 586.8 346.6 570.1C303.2 550.5 259.4 527.5 230.4 493.3C217 453.1 225.9 407.5 222.2 365.3C222.2 365.3 222.1 365.1 222 365C151.4 319.6 59.3 250.9 61 158.4C59.9 121 91.8 96.1 123.8 96.5C259.3 98.5 394.1 104.4 528.8 96.3zM506.1 161.4C378.3 168.2 252 162.1 125.2 160.5C128.6 227 199 270.8 250 306.8C305.5 335.4 281.6 410.5 288.3 461.7C310.8 478.9 334.6 494.6 358.9 505.8C355.4 458 350.7 415.4 350.2 364.6C349.9 349.2 355.3 333.7 366.5 321.7C384.3 302.6 402.8 287.8 421.5 270.1C446.1 245.2 477.9 225.1 499.7 196.7C509 182.2 504.7 174.5 506 161.5z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="d-none d-md-block fw-medium">Filters</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<input type="hidden" name="start" id="start" value="0" />
|
||||||
|
<input type="hidden" name="sort" id="sortInput" value="" />
|
||||||
|
<input type="hidden" name="language" id="languageInput" value="all" />
|
||||||
|
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-purple border-start-0"
|
||||||
|
aria-label="search"
|
||||||
|
onclick="
|
||||||
|
const q = this.closest('form').querySelector('[name=q]').value;
|
||||||
|
dataLayer.push({ event: 'view_search_results', search_term: q });
|
||||||
|
if (window.location.pathname !== '/pokemon') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
sessionStorage.setItem('pendingSearch', q);
|
||||||
|
window.location.href = '/pokemon';
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -5,6 +5,7 @@ declare global {
|
|||||||
namespace App {
|
namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
canAddInventory: boolean;
|
canAddInventory: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,53 +27,31 @@ export const onRequest = clerkMiddleware(async (auth, context, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Inventory visibility check ──────────────────────────────────────────────
|
// ── Inventory visibility check ──────────────────────────────────────────────
|
||||||
// Resolves to true if the user belongs to the target org OR has the feature
|
|
||||||
const canAddInventory = process.env.INVENTORY_ACCESS === 'true' ||
|
const canAddInventory = process.env.INVENTORY_ACCESS === 'true' ||
|
||||||
(
|
(
|
||||||
isAuthenticated &&
|
isAuthenticated &&
|
||||||
userId &&
|
userId &&
|
||||||
(
|
(
|
||||||
!!has({ permission: "org:feature:inventory_add" }) || // Clerk feature flag
|
!!has({ permission: "org:feature:inventory_add" }) ||
|
||||||
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
|
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Expose the flag to your Astro pages via locals
|
|
||||||
context.locals.canAddInventory = Boolean(canAddInventory);
|
context.locals.canAddInventory = Boolean(canAddInventory);
|
||||||
|
|
||||||
|
// ── Admin check ──────────────────────────────────────────────────────────
|
||||||
|
// Computed on every request (not just /admin routes) so pages can use
|
||||||
|
// Astro.locals.isAdmin to conditionally show admin-only UI anywhere.
|
||||||
|
context.locals.isAdmin = isAuthenticated && userId
|
||||||
|
? await checkIsAdmin(context, userId)
|
||||||
|
: false;
|
||||||
|
|
||||||
// ── Admin route guard ───────────────────────────────────────────
|
// ── Admin route guard ───────────────────────────────────────────
|
||||||
if (isAdminRoute(context.request)) {
|
if (isAdminRoute(context.request)) {
|
||||||
if (!isAuthenticated || !userId) {
|
if (!isAuthenticated || !userId) {
|
||||||
return redirectToSignIn();
|
return redirectToSignIn();
|
||||||
}
|
}
|
||||||
|
if (!context.locals.isAdmin) {
|
||||||
try {
|
return new Response(null, { status: 404 });
|
||||||
const client = await clerkClient(context);
|
|
||||||
const userOrgIds = await getUserOrgIds(context, userId);
|
|
||||||
const matchingOrgIds = userOrgIds.filter((id) => ADMIN_ORG_IDS.has(id));
|
|
||||||
|
|
||||||
if (matchingOrgIds.length === 0) {
|
|
||||||
return new Response(null, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const membershipLists = await Promise.all(
|
|
||||||
matchingOrgIds.map((orgId) =>
|
|
||||||
client.organizations.getOrganizationMembershipList({ organizationId: orgId })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const isAdmin = membershipLists.some((list) =>
|
|
||||||
list.data.some(
|
|
||||||
(m) => m.publicUserData?.userId === userId && m.role === "org:admin"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return new Response(null, { status: 404 });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Clerk membership check failed:", e);
|
|
||||||
return context.redirect("/");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,3 +69,28 @@ async function getUserOrgIds(context: any, userId: string): Promise<string[]> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helper: is this user an org:admin in any of ADMIN_ORG_IDS? ─────────────
|
||||||
|
async function checkIsAdmin(context: any, userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const userOrgIds = await getUserOrgIds(context, userId);
|
||||||
|
const matchingOrgIds = userOrgIds.filter((id) => ADMIN_ORG_IDS.has(id));
|
||||||
|
if (matchingOrgIds.length === 0) return false;
|
||||||
|
|
||||||
|
const client = await clerkClient(context);
|
||||||
|
const membershipLists = await Promise.all(
|
||||||
|
matchingOrgIds.map((orgId) =>
|
||||||
|
client.organizations.getOrganizationMembershipList({ organizationId: orgId })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return membershipLists.some((list) =>
|
||||||
|
list.data.some(
|
||||||
|
(m) => m.publicUserData?.userId === userId && m.role === "org:admin"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Clerk membership check failed:", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,16 +90,42 @@ const getInventory = async (userId: string, cardId: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Build the Typesense document for an inventory row + its card details.
|
||||||
|
const inventoryTsDoc = (i: typeof inventory.$inferSelect, card: any) => ({
|
||||||
|
id: i.inventoryId,
|
||||||
|
userId: i.userId,
|
||||||
|
catalogName: i.catalogName,
|
||||||
|
sku_id: i.skuId.toString(),
|
||||||
|
purchasePrice: DollarToInt(i.purchasePrice),
|
||||||
|
productLineName: card?.productLineName,
|
||||||
|
rarityName: card?.rarityName,
|
||||||
|
setName: card?.set?.setName || "",
|
||||||
|
cardType: card?.cardType || "",
|
||||||
|
energyType: card?.energyType || "",
|
||||||
|
card_id: card?.cardId.toString() || "",
|
||||||
|
content: [
|
||||||
|
card?.productName,
|
||||||
|
card?.productLineName,
|
||||||
|
card?.set?.setName || "",
|
||||||
|
card?.number,
|
||||||
|
card?.rarityName,
|
||||||
|
card?.artist || ""
|
||||||
|
].join(' '),
|
||||||
|
});
|
||||||
|
|
||||||
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
|
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
|
||||||
// First add to database
|
// Insert one row per copy: a quantity of 5 becomes 5 separate entries of qty 1.
|
||||||
const inv = await db.insert(inventory).values({
|
const count = Math.max(1, Math.floor(quantity));
|
||||||
userId: userId,
|
const inv = await db.insert(inventory).values(
|
||||||
skuId: skuId,
|
Array.from({ length: count }, () => ({
|
||||||
catalogName: catalogName,
|
userId: userId,
|
||||||
purchasePrice: purchasePrice.toFixed(2),
|
skuId: skuId,
|
||||||
quantity: quantity,
|
catalogName: catalogName,
|
||||||
note: note,
|
purchasePrice: purchasePrice.toFixed(2),
|
||||||
}).returning();
|
quantity: 1,
|
||||||
|
note: note,
|
||||||
|
}))
|
||||||
|
).returning();
|
||||||
// Get card details from the database to add to Typesense
|
// Get card details from the database to add to Typesense
|
||||||
const card = await db.query.cards.findFirst({
|
const card = await db.query.cards.findFirst({
|
||||||
where: { cardId: cardId },
|
where: { cardId: cardId },
|
||||||
@@ -107,27 +134,7 @@ const addToInventory = async (userId: string, cardId: number, skuId: number, pur
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// And then add to Typesense for searching
|
// And then add to Typesense for searching
|
||||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
await client.collections('inventories').documents().import(inv.map(i => inventoryTsDoc(i, card)));
|
||||||
id: i.inventoryId,
|
|
||||||
userId: i.userId,
|
|
||||||
catalogName: i.catalogName,
|
|
||||||
sku_id: i.skuId.toString(),
|
|
||||||
purchasePrice: DollarToInt(i.purchasePrice),
|
|
||||||
productLineName: card?.productLineName,
|
|
||||||
rarityName: card?.rarityName,
|
|
||||||
setName: card?.set?.setName || "",
|
|
||||||
cardType: card?.cardType || "",
|
|
||||||
energyType: card?.energyType || "",
|
|
||||||
card_id: card?.cardId.toString() || "",
|
|
||||||
content: [
|
|
||||||
card?.productName,
|
|
||||||
card?.productLineName,
|
|
||||||
card?.set?.setName || "",
|
|
||||||
card?.number,
|
|
||||||
card?.rarityName,
|
|
||||||
card?.artist || ""
|
|
||||||
].join(' '),
|
|
||||||
})));
|
|
||||||
} 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);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Main.astro";
|
export const prerender = false;
|
||||||
import Footer from "../components/Footer.astro";
|
import Layout from '../layouts/Main.astro';
|
||||||
import BackToTop from "../components/BackToTop.astro";
|
|
||||||
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
|
|
||||||
import { db } from '../db/index';
|
import { db } from '../db/index';
|
||||||
import { inventory, skus } from '../db/schema';
|
import { inventory, skus } from '../db/schema';
|
||||||
import { sql, sum, eq } from "drizzle-orm";
|
import { sql, sum, eq } from "drizzle-orm";
|
||||||
@@ -41,95 +39,89 @@ const catalogs = catalogRows
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Inventory Dashboard">
|
<Layout title="Inventory Dashboard">
|
||||||
<div class="container-fluid container-sm mt-3" slot="page">
|
<div class="container-fluid container-sm" slot="page">
|
||||||
<BackToTop />
|
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<aside class="col-12 col-md-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3">
|
<aside
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
class="offcanvas-xl offcanvas-start col-12 col-xl-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3 catalog-sidebar"
|
||||||
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
|
tabindex="-1"
|
||||||
|
id="catalogSidebar"
|
||||||
|
aria-labelledby="catalogSidebarLabel"
|
||||||
|
>
|
||||||
|
<div class="offcanvas-header d-xl-none">
|
||||||
|
<h6 class="offcanvas-title text-uppercase text-secondary fw-bold ls-wide" id="catalogSidebarLabel" style="letter-spacing:.08em">Catalogs</h6>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" data-bs-target="#catalogSidebar" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul id="catalogList" class="list-group list-group-flush">
|
<div class="offcanvas-body d-xl-flex flex-column gap-3 p-0">
|
||||||
<li
|
<div class="d-none d-xl-flex justify-content-between align-items-center">
|
||||||
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
|
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
|
||||||
data-catalog="all"
|
</div>
|
||||||
role="button"
|
|
||||||
style="cursor:pointer"
|
|
||||||
hx-post="/partials/inventory-cards"
|
|
||||||
hx-vals={JSON.stringify({ catalog: "all" })}
|
|
||||||
hx-target="#gridView"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
>
|
|
||||||
<span class="d-flex align-items-center gap-2">
|
|
||||||
View all cards
|
|
||||||
</span>
|
|
||||||
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
{catalogs.map((name) => (
|
<ul id="catalogList" class="list-group list-group-flush">
|
||||||
<li
|
<li
|
||||||
class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2"
|
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
|
||||||
data-catalog={name}
|
data-catalog="all"
|
||||||
role="button"
|
role="button"
|
||||||
style="cursor:pointer"
|
style="cursor:pointer"
|
||||||
hx-post="/partials/inventory-cards"
|
hx-post="/partials/inventory-cards"
|
||||||
hx-vals={JSON.stringify({ catalog: name })}
|
hx-vals={JSON.stringify({ catalog: "all" })}
|
||||||
hx-target="#gridView"
|
hx-target="#gridView"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
>
|
>
|
||||||
{name}
|
<span class="d-flex align-items-center gap-2">
|
||||||
|
All cards
|
||||||
|
</span>
|
||||||
|
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
|
{catalogs.map((name) => (
|
||||||
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
|
<li
|
||||||
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
|
class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2"
|
||||||
<div class="d-flex justify-content-between"><span>Gain/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
|
data-catalog={name}
|
||||||
|
role="button"
|
||||||
|
style="cursor:pointer"
|
||||||
|
hx-post="/partials/inventory-cards"
|
||||||
|
hx-vals={JSON.stringify({ catalog: name })}
|
||||||
|
hx-target="#gridView"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
|
||||||
|
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
|
||||||
|
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
|
||||||
|
<div class="d-flex justify-content-between"><span>Gain/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="col-12 col-md-10 mt-4">
|
<main class="col-12 col-xl-10 mt-4">
|
||||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
<div class="d-flex flex-wrap flex-row gap-2 mb-4">
|
||||||
|
<button
|
||||||
<a href="/pokemon" class="btn btn-vendor">Add cards</a>
|
type="button"
|
||||||
|
class="btn btn-secondary d-xl-none me-auto"
|
||||||
|
data-bs-toggle="offcanvas"
|
||||||
|
data-bs-target="#catalogSidebar"
|
||||||
|
aria-controls="catalogSidebar"
|
||||||
|
>
|
||||||
|
Catalogs
|
||||||
|
</button>
|
||||||
|
<a href="/pokemon" class="btn btn-vendor ms-auto">Add cards</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary"
|
class="btn btn-outline-secondary"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#bulkImportModal"
|
data-bs-target="#bulkImportModal"
|
||||||
>Bulk Import</button>
|
>Bulk Import</button>
|
||||||
|
|
||||||
<div class="ms-auto position-relative">
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="hidden" name="start" id="start" value="0" />
|
|
||||||
<input type="hidden" name="sort" id="sortInput" value="" />
|
|
||||||
<input type="hidden" name="language" id="languageInput" value="all" />
|
|
||||||
<input type="search" name="i" id="searchInput" class="form-control search-input" placeholder="Search your inventory" />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-purple border-start-0"
|
|
||||||
aria-label="search"
|
|
||||||
onclick="
|
|
||||||
const i = this.closest('form').querySelector('[name=i]').value;
|
|
||||||
dataLayer.push({ event: 'view_inventory_results', search_term: i });
|
|
||||||
if (window.location.pathname !== '/dashboard') {
|
|
||||||
event.preventDefault();
|
|
||||||
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>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="inventoryView">
|
<div id="inventoryView">
|
||||||
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
|
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -144,15 +136,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 +168,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 () {
|
||||||
@@ -446,5 +535,4 @@ const catalogs = catalogRows
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
<Footer slot="footer" />
|
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -197,9 +197,7 @@ const altSearchUrl = (card: any) => {
|
|||||||
</datalist>
|
</datalist>
|
||||||
|
|
||||||
<label for="note" class="form-label mt-2">Note</label>
|
<label for="note" class="form-label mt-2">Note</label>
|
||||||
<textarea class="form-control" id="note" name="note" rows="5" maxlength="255" placeholder="e.g. bought at local shop, gift, graded copy…">
|
<textarea class="form-control" id="note" name="note" rows="5" maxlength="255" placeholder="e.g. bought at local shop, gift, graded copy…">{inv?.note ?? ''}</textarea>
|
||||||
{inv?.note ?? ''}
|
|
||||||
</textarea>
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user