2026-03-25 08:42:17 -04:00
|
|
|
|
---
|
|
|
|
|
|
import Layout from "../layouts/Main.astro";
|
|
|
|
|
|
import Footer from "../components/Footer.astro";
|
2026-04-10 09:05:22 -04:00
|
|
|
|
import BackToTop from "../components/BackToTop.astro";
|
2026-03-25 08:42:17 -04:00
|
|
|
|
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
|
2026-04-08 07:57:03 -04:00
|
|
|
|
import { db } from '../db/index';
|
|
|
|
|
|
import { inventory, skus } from '../db/schema';
|
|
|
|
|
|
import { sql, sum, eq } from "drizzle-orm";
|
2026-03-25 08:42:17 -04:00
|
|
|
|
|
2026-04-07 22:34:31 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-08 07:57:03 -04:00
|
|
|
|
const { userId } = Astro.locals.auth();
|
|
|
|
|
|
|
|
|
|
|
|
const summary = await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
totalQty: sum(inventory.quantity).mapWith(Number),
|
|
|
|
|
|
totalValue: sum(sql`(${inventory.quantity} * ${skus.marketPrice})`).mapWith(Number),
|
|
|
|
|
|
totalGain: sum(sql`(${inventory.quantity} * (${skus.marketPrice} - ${inventory.purchasePrice}))`).mapWith(Number),
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(inventory)
|
|
|
|
|
|
.innerJoin(skus, eq(inventory.skuId, skus.skuId))
|
|
|
|
|
|
.where(eq(inventory.userId, userId!))
|
|
|
|
|
|
.execute()
|
|
|
|
|
|
.then(res => res[0]);
|
|
|
|
|
|
const totalQty = summary.totalQty || 0;
|
|
|
|
|
|
const totalValue = summary.totalValue || 0;
|
|
|
|
|
|
const totalGain = summary.totalGain || 0;
|
|
|
|
|
|
|
2026-07-10 11:30:41 -04:00
|
|
|
|
// distinct catalog names for this user, for the sidebar catalog list
|
|
|
|
|
|
const catalogRows = userId
|
|
|
|
|
|
? await db
|
|
|
|
|
|
.selectDistinct({ name: inventory.catalogName })
|
|
|
|
|
|
.from(inventory)
|
|
|
|
|
|
.where(eq(inventory.userId, userId))
|
|
|
|
|
|
: [];
|
|
|
|
|
|
const catalogs = catalogRows
|
|
|
|
|
|
.map(r => r.name)
|
|
|
|
|
|
.filter((n): n is string => !!n)
|
|
|
|
|
|
.sort((a, b) => a.localeCompare(b));
|
|
|
|
|
|
|
2026-03-25 08:42:17 -04:00
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
<Layout title="Inventory Dashboard">
|
2026-04-10 09:05:22 -04:00
|
|
|
|
<div class="container-fluid container-sm mt-3" slot="page">
|
|
|
|
|
|
<BackToTop />
|
|
|
|
|
|
<div class="row mb-4">
|
2026-03-25 08:42:17 -04:00
|
|
|
|
<aside class="col-12 col-md-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3">
|
|
|
|
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
|
|
|
|
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
|
|
|
|
|
|
<button
|
2026-04-10 09:05:22 -04:00
|
|
|
|
class="btn btn-purple-secondary fs-7"
|
2026-03-25 08:42:17 -04:00
|
|
|
|
title="New catalog"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
data-bs-toggle="modal"
|
|
|
|
|
|
data-bs-target="#newCatalogModal"
|
|
|
|
|
|
>+ New</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<ul id="catalogList" class="list-group list-group-flush">
|
|
|
|
|
|
<li
|
2026-04-10 09:05:22 -04:00
|
|
|
|
class="list-group-item list-group-item-action fw-semibold border-0 rounded p-2 d-flex align-items-center active"
|
2026-03-25 08:42:17 -04:00
|
|
|
|
data-catalog="all"
|
|
|
|
|
|
role="button"
|
|
|
|
|
|
style="cursor:pointer"
|
2026-07-10 11:30:41 -04:00
|
|
|
|
hx-post="/partials/inventory-cards"
|
|
|
|
|
|
hx-vals={JSON.stringify({ catalog: "all" })}
|
|
|
|
|
|
hx-target="#gridView"
|
|
|
|
|
|
hx-swap="innerHTML"
|
2026-03-25 08:42:17 -04:00
|
|
|
|
>
|
|
|
|
|
|
<span class="d-flex align-items-center gap-2">
|
|
|
|
|
|
View all cards
|
|
|
|
|
|
</span>
|
2026-04-10 09:05:22 -04:00
|
|
|
|
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
|
2026-03-25 08:42:17 -04:00
|
|
|
|
</li>
|
|
|
|
|
|
|
2026-07-10 11:30:41 -04:00
|
|
|
|
{catalogs.map((name) => (
|
2026-03-25 08:42:17 -04:00
|
|
|
|
<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"
|
|
|
|
|
|
data-catalog={name}
|
|
|
|
|
|
role="button"
|
|
|
|
|
|
style="cursor:pointer"
|
2026-07-10 11:30:41 -04:00
|
|
|
|
hx-post="/partials/inventory-cards"
|
|
|
|
|
|
hx-vals={JSON.stringify({ catalog: name })}
|
|
|
|
|
|
hx-target="#gridView"
|
|
|
|
|
|
hx-swap="innerHTML"
|
2026-03-25 08:42:17 -04:00
|
|
|
|
>
|
|
|
|
|
|
{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>
|
2026-04-10 09:05:22 -04:00
|
|
|
|
<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>
|
2026-03-25 08:42:17 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
<main class="col-12 col-md-10 p-4">
|
|
|
|
|
|
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
|
2026-04-10 09:05:22 -04:00
|
|
|
|
<div class="d-flex align-items-center gap-1">
|
2026-03-25 08:42:17 -04:00
|
|
|
|
<button id="btnGrid" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn active" title="Images view">
|
2026-04-10 09:05:22 -04:00
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5rem" height="1.5rem" fill="currentColor"><path d="M351.6 220.4C349.7 258.3 381.1 288.3 418 286.5L527.6 281.1C593 275.5 589.7 214.9 589 162.5C593.9 90.4 588.4 39.6 502.4 44.6C391.5 42.2 352.5 36.7 354.5 164.3C353.4 183.3 352.4 202.4 351.5 220.3zM418.4 168.6L418.4 168.6C419.6 147.4 420.8 125.9 421.6 109.4C421.6 109.2 421.6 109.1 421.7 109C421.7 108.8 422 108.6 422.1 108.6C456.2 108.5 491.4 108.6 525.7 108.7C525.8 108.7 526 109 526.1 109.1L526.1 109.3C525.5 142.4 524.9 184.6 524.2 217.3L415.6 222.6C416.3 207.3 417.4 188.1 418.5 168.7zM301.4 112.5C303.3 74.7 272 44.6 235 46.4L125.4 51.8C40.8 58.8 69.2 164.2 63.1 222.4C62.4 258.3 91.2 288.3 127.5 288.3C159.4 288.3 198.3 288.3 231 288.3C298.6 286.3 297.2 220.3 298.5 168.6C299.6 149.6 300.6 130.5 301.5 112.6zM234.6 164.3C233.4 185.6 232.2 207 231.4 223.5C231.4 223.7 231.4 223.8 231.3 223.9C231.3 224 231.1 224.2 231.1 224.2L231 224.3C198.3 224.2 159.3 224.3 127.3 224.3C127.3 224.3 127.2 224.3 127.1 224.2C127 224.1 126.9 224 126.9 224L126.9 223.8C127.5 192.1 128.1 149.9 128.8 115.8L237.4 110.5C236.7 125.8 235.6 145 234.5 164.4zM63.6 404C64.5 421.9 65.5 441 66.6 460C68.2 512.2 65.9 577.1 134.1 579.7L237.6 579.7C273.9 579.7 302.7 549.7 302 513.9C301.7 498.8 301.4 480.4 301.1 461.9C301.8 409.5 305 348.9 239.7 343.3L130 338C93 336.2 61.7 366.2 63.6 404.1zM130.4 455.8C129.3 436.4 128.3 417.1 127.5 401.8L236.1 407.1C236.8 441.2 237.3 483.4 238 515C238 515.1 238 515.1 238 515.2C238 515.3 237.9 515.4 237.8 515.5C237.7 515.6 237.6 515.6 237.6 515.6C205.8 515.6 166.4 515.5 134 515.6C133.9 515.6 133.7 515.3 133.7 515.2C133.6 515.1 133.6 515 133.6 514.8C132.8 498.3 131.6 476.9 130.4 455.6L130.4 455.6zM523 578.1C560 579.9 591.3 549.8 589.4 512C588.5 494.1 587.5 475 586.4 456C585.9 381.4 580.2 328.2 490 336.3C457.9 336.4 439 336.3 415.4 336.3C379.1 336.3 350.3 366.3 351 402.1C351.3 417.2 351.6 435.6 351.9 454.1C351.2 506.5 348 567.1 413.3 572.7L523 578.1zM525.5 514.1L416.9 508.8C416.2 474.7 415.7 432.5 415 400.9L415 400.7C415 400.6 415.3 400.3 415.3 400.3C443.6 400.3 484.7 400.3 518.9 400.3C518.9 400.3 519 400.3 519.1 400.4C519.2 400.5 519.3 400.6 519.3 400.7C519.4 400.8 519.4 400.9 519.4 401.1C521.1 435.4 524 484.9 525.4 514.3z"/></svg>
|
|
|
|
|
|
<span class="ms-1">Grid</span>
|
2026-03-25 08:42:17 -04:00
|
|
|
|
</button>
|
|
|
|
|
|
<button id="btnTable" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn" title="List view">
|
2026-04-10 09:05:22 -04:00
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1.5rem" height="1.5rem" fill="currentColor" style="d-inline"><path d="M102.3 211.8C110.7 214 118.9 213.7 126.2 210C132.6 208 138.3 204 142.4 198.4C184.2 169.6 148.5 98.8 98.3 108.3C39.6 119.9 49.3 205.8 102.2 211.8zM114.4 375.6C114.4 375.6 114.8 375.5 116.2 375.3L116.5 375.2C169.2 368.9 178.6 283.3 120.1 271.7C69.9 262.3 34.2 332.9 76 361.8C80.1 367.4 85.9 371.4 92.3 373.4C99 376.6 106.8 377.6 114.5 375.5zM116.8 423.5C63.5 413.9 30 495.4 84.4 522.3C130.5 544.4 183.2 485.4 150.4 446.7C147.9 440.2 143.4 434.9 137.7 431.2C132.1 426.4 124.8 423.5 116.8 423.5zM352.8 508.4C423.1 503.6 491.2 499 561.5 501.8C579.2 502.5 594.1 488.8 594.8 471.1C595.5 453.4 581.7 438.6 564.1 437.9C490.4 435 416.4 439.9 344.2 444.8C310.8 447 277.8 449.3 245.4 450.7C227.7 451.4 214 466.4 214.8 484C215.5 501.7 230.5 515.4 248.1 514.6C283.8 513 318.6 510.7 352.8 508.4zM344 344.8L344.3 344.8C412.9 343.4 479.9 342.9 548.3 346.2C566 347 580.9 333.3 581.7 315.6C582.5 298 568.8 283 551.1 282.2C482.2 278.8 412.2 279.3 343.1 280.7C310.3 281.3 277.9 281.9 245.8 281.9C228.1 281.9 213.8 296.2 213.8 313.9C213.8 331.6 228.1 345.9 245.8 345.9C278.5 345.9 311.4 345.3 343.9 344.7zM444.8 187.4C480.8 186.5 519 181.7 551.8 187.6C569.2 190.7 585.8 179.1 588.9 161.7C592 144.3 580.4 127.7 563 124.6C484.4 115.2 408.3 126.5 331 129.9C301.8 131.9 273.7 133 246 131.4C228.4 130.3 213.2 143.8 212.2 161.4C213.8 217.2 300.2 190.8 335.5 193.7C372.2 191.2 408.7 188.4 444.8 187.4z"/></svg>
|
|
|
|
|
|
<span class="ms-1">List</span>
|
2026-03-25 08:42:17 -04:00
|
|
|
|
</button>
|
2026-04-10 09:05:22 -04:00
|
|
|
|
</div>
|
2026-03-25 08:42:17 -04:00
|
|
|
|
|
|
|
|
|
|
<div class="vr opacity-25 mx-1"></div>
|
|
|
|
|
|
|
2026-04-10 09:05:22 -04:00
|
|
|
|
<a href="/pokemon" class="btn btn-vendor">+ Add Card</a>
|
2026-03-25 08:42:17 -04:00
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-04-10 09:05:22 -04:00
|
|
|
|
class="btn btn-secondary"
|
2026-03-25 08:42:17 -04:00
|
|
|
|
data-bs-toggle="modal"
|
|
|
|
|
|
data-bs-target="#bulkImportModal"
|
2026-03-25 10:23:17 -04:00
|
|
|
|
>Bulk Import</button>
|
2026-03-25 08:42:17 -04:00
|
|
|
|
|
|
|
|
|
|
<div class="ms-auto position-relative">
|
2026-04-10 09:05:22 -04:00
|
|
|
|
<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>
|
2026-03-25 08:42:17 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="inventoryView">
|
2026-04-07 22:34:31 -04:00
|
|
|
|
<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">
|
2026-03-25 08:42:17 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-07 22:34:31 -04:00
|
|
|
|
<!-- <div id="tableView" style="display:none">
|
2026-03-25 08:42:17 -04:00
|
|
|
|
<div class="inv-list-wrap">
|
|
|
|
|
|
<table class="table align-middle mb-0 inv-list-table">
|
|
|
|
|
|
<tbody id="inventoryRows">
|
|
|
|
|
|
{inventory.map(card => {
|
|
|
|
|
|
const market = nmPrice(card);
|
|
|
|
|
|
const purchase = nmPurchase(card);
|
|
|
|
|
|
const diff = market - purchase;
|
|
|
|
|
|
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
|
|
|
|
|
|
const isGain = diff >= 0;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<tr class="inv-list-row">
|
|
|
|
|
|
<td class="inv-list-cardcell">
|
|
|
|
|
|
<div class="inv-list-card">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="inv-list-thumb card-trigger"
|
|
|
|
|
|
data-card-id={card.productId}
|
|
|
|
|
|
data-bs-toggle="modal"
|
|
|
|
|
|
data-bs-target="#cardModal"
|
|
|
|
|
|
>
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={`/cards/${card.productId}.jpg`}
|
|
|
|
|
|
alt={card.productName}
|
|
|
|
|
|
loading="lazy"
|
|
|
|
|
|
decoding="async"
|
|
|
|
|
|
onerror="this.onerror=null;this.src='/cards/default.jpg';"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="inv-list-info">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="inv-list-name"
|
|
|
|
|
|
data-card-id={card.productId}
|
|
|
|
|
|
data-bs-toggle="modal"
|
|
|
|
|
|
data-bs-target="#cardModal"
|
|
|
|
|
|
style="cursor:pointer"
|
|
|
|
|
|
>
|
|
|
|
|
|
{card.productName}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="inv-list-meta">
|
|
|
|
|
|
<div class="inv-list-setlink">{card.setName}</div>
|
|
|
|
|
|
<div>{card.rarityName}</div>
|
|
|
|
|
|
<div>{card.number}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="inv-list-condition">
|
|
|
|
|
|
<span>Near Mint</span>
|
|
|
|
|
|
<span>•</span>
|
|
|
|
|
|
<span>{card.variant !== "Normal" ? card.variant : "Holofoil"}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="inv-list-right">
|
|
|
|
|
|
<div class={`inv-list-price-line ${isGain ? "up" : "down"}`}>
|
|
|
|
|
|
<span class="inv-grid-arrow small">{isGain ? "▲" : "▼"}</span>
|
|
|
|
|
|
<span class="inv-list-price">${market.toFixed(2)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class={`inv-list-delta ${isGain ? "up" : "down"}`}>
|
|
|
|
|
|
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)} ({isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="inv-list-qty">Qty: {card.qty}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="text-secondary small mt-2 ps-1" id="rowCount"></div>
|
2026-04-07 22:34:31 -04:00
|
|
|
|
</div> -->
|
2026-03-25 08:42:17 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</main>
|
2026-04-10 09:05:22 -04:00
|
|
|
|
</div>
|
2026-05-22 06:28:40 -04:00
|
|
|
|
|
2026-04-07 22:34:31 -04:00
|
|
|
|
<!-- <div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" aria-modal="true" role="dialog">
|
2026-03-25 08:42:17 -04:00
|
|
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
|
|
|
|
<div class="modal-content bg-dark text-light border border-secondary">
|
|
|
|
|
|
<div class="modal-header border-secondary">
|
|
|
|
|
|
<h5 class="modal-title" id="newCatalogLabel">Create Catalog</h5>
|
|
|
|
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
|
<label class="form-label small text-secondary text-uppercase fw-semibold" for="catalogNameInput">Catalog Name</label>
|
|
|
|
|
|
<input id="catalogNameInput" type="text" class="form-control bg-dark-subtle text-light border-secondary" placeholder="e.g. Japanese Holos" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer border-secondary">
|
|
|
|
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
|
|
|
|
<button type="button" class="btn btn-success" id="createCatalogBtn">Create</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-22 06:28:40 -04:00
|
|
|
|
</div>-->
|
2026-03-25 08:42:17 -04:00
|
|
|
|
|
|
|
|
|
|
<div class="modal fade" id="bulkImportModal" tabindex="-1" aria-labelledby="bulkImportLabel" aria-modal="true" role="dialog">
|
|
|
|
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
|
|
|
|
<div class="modal-content bg-dark text-light border border-secondary">
|
|
|
|
|
|
<div class="modal-header border-secondary">
|
|
|
|
|
|
<h5 class="modal-title" id="bulkImportLabel">Bulk CSV Import</h5>
|
|
|
|
|
|
<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">
|
|
|
|
|
|
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
|
|
|
|
|
|
</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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer border-secondary">
|
|
|
|
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
|
|
|
|
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload & Preview</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-07-09 18:33:16 -04:00
|
|
|
|
|
|
|
|
|
|
<!-- Inventory detail modal (loaded from /partials/inventory-modal on open) -->
|
|
|
|
|
|
<div class="modal card-modal" id="inventoryModal" tabindex="-1" aria-labelledby="inventoryModalLabel" aria-hidden="true">
|
|
|
|
|
|
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
|
|
|
|
|
<div class="modal-content p-2">Loading...</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-22 06:28:40 -04:00
|
|
|
|
<!--
|
2026-03-25 08:42:17 -04:00
|
|
|
|
<div class="modal fade" id="inventoryEditModal" tabindex="-1" aria-labelledby="inventoryEditLabel" aria-modal="true" role="dialog">
|
|
|
|
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
|
|
|
|
<div class="modal-content bg-dark text-light border border-secondary">
|
|
|
|
|
|
<div class="modal-header border-secondary">
|
|
|
|
|
|
<h5 class="modal-title" id="inventoryEditLabel">Edit Inventory</h5>
|
|
|
|
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-body" id="inventoryEditBody">
|
|
|
|
|
|
<p class="text-secondary small">Select a card to edit its quantity and purchase price.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer border-secondary">
|
|
|
|
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
|
|
|
|
<button type="button" class="btn btn-success">Save</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="modal fade" id="addCardModal" tabindex="-1" aria-labelledby="addCardLabel" aria-modal="true" role="dialog">
|
|
|
|
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
|
|
|
|
<div class="modal-content bg-dark text-light border border-secondary">
|
|
|
|
|
|
<div class="modal-header border-secondary">
|
|
|
|
|
|
<h5 class="modal-title" id="addCardLabel">Add Card</h5>
|
|
|
|
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
|
<input type="text" class="form-control bg-dark-subtle text-light border-secondary mb-3" placeholder="Search card name…" id="addCardSearch" />
|
|
|
|
|
|
<p class="text-secondary small">Search results will appear here. Connect to your card database API to enable live search.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer border-secondary">
|
|
|
|
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
|
|
|
|
<button type="button" class="btn btn-primary" disabled>Add Selected</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-07 22:34:31 -04:00
|
|
|
|
</div> -->
|
2026-07-09 18:33:16 -04:00
|
|
|
|
|
|
|
|
|
|
<script is:inline>
|
2026-07-10 11:30:41 -04:00
|
|
|
|
(function () {
|
|
|
|
|
|
const catalogList = document.getElementById('catalogList');
|
|
|
|
|
|
if (catalogList) {
|
|
|
|
|
|
catalogList.addEventListener('click', (e) => {
|
|
|
|
|
|
const li = e.target.closest('li[data-catalog]');
|
|
|
|
|
|
if (!li) return;
|
|
|
|
|
|
catalogList.querySelectorAll('li[data-catalog]').forEach(el => el.classList.remove('active'));
|
|
|
|
|
|
li.classList.add('active');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
2026-07-09 18:33:16 -04:00
|
|
|
|
(function () {
|
|
|
|
|
|
const modal = document.getElementById('inventoryModal');
|
|
|
|
|
|
if (!modal) return;
|
|
|
|
|
|
|
|
|
|
|
|
const CONDITION_COLORS = {
|
|
|
|
|
|
"Near Mint": { active: 'hsla(88, 50%, 67%, 1)', muted: 'hsla(88, 50%, 67%, 0.67)' },
|
|
|
|
|
|
"Lightly Played": { active: 'hsla(66, 70%, 68%, 1)', muted: 'hsla(66, 70%, 68%, 0.67)' },
|
|
|
|
|
|
"Moderately Played": { active: 'hsla(54, 100%, 73%, 1)', muted: 'hsla(54, 100%, 73%, 0.67)' },
|
|
|
|
|
|
"Heavily Played": { active: 'hsla(46, 100%, 65%, 1)', muted: 'hsla(46, 100%, 65%, 0.67)' },
|
|
|
|
|
|
"Damaged": { active: 'hsla(36, 100%, 65%, 1)', muted: 'hsla(36, 100%, 65%, 0.67)' },
|
|
|
|
|
|
};
|
|
|
|
|
|
const PURCHASE_COLOR = 'hsla(258, 90%, 76%, 1)';
|
|
|
|
|
|
const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity };
|
|
|
|
|
|
|
|
|
|
|
|
let chart = null;
|
|
|
|
|
|
let history = [];
|
|
|
|
|
|
let meta = { purchasePrice: null, purchaseDate: null, condition: 'Near Mint' };
|
|
|
|
|
|
let range = 'all';
|
|
|
|
|
|
|
|
|
|
|
|
function formatDate(dateStr) {
|
|
|
|
|
|
const [y, m, d] = dateStr.split('-');
|
|
|
|
|
|
return new Date(Number(y), Number(m) - 1, Number(d))
|
|
|
|
|
|
.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildData(rangeKey) {
|
|
|
|
|
|
const cutoff = RANGE_DAYS[rangeKey] === Infinity
|
|
|
|
|
|
? new Date(0)
|
|
|
|
|
|
: new Date(Date.now() - RANGE_DAYS[rangeKey] * 86400000);
|
|
|
|
|
|
|
|
|
|
|
|
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
|
|
|
|
|
|
const showPurchase = meta.purchaseDate
|
|
|
|
|
|
&& meta.purchasePrice != null
|
|
|
|
|
|
&& new Date(meta.purchaseDate) >= cutoff;
|
|
|
|
|
|
|
|
|
|
|
|
const dateSet = new Set(filtered.map(r => r.calculatedAt));
|
|
|
|
|
|
if (showPurchase) dateSet.add(meta.purchaseDate);
|
|
|
|
|
|
const dates = [...dateSet].sort();
|
|
|
|
|
|
|
|
|
|
|
|
const priceByDate = {};
|
|
|
|
|
|
filtered.forEach(r => { priceByDate[r.calculatedAt] = Number(r.marketPrice); });
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
labels: dates.map(formatDate),
|
|
|
|
|
|
lineData: dates.map(d => priceByDate[d] ?? null),
|
|
|
|
|
|
purchaseData: dates.map(d => (showPurchase && d === meta.purchaseDate) ? meta.purchasePrice : null),
|
|
|
|
|
|
hasData: filtered.length > 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function render() {
|
|
|
|
|
|
if (!chart) return;
|
|
|
|
|
|
const { labels, lineData, purchaseData } = buildData(range);
|
|
|
|
|
|
chart.data.labels = labels;
|
|
|
|
|
|
chart.data.datasets[0].data = lineData;
|
|
|
|
|
|
chart.data.datasets[1].data = purchaseData;
|
|
|
|
|
|
chart.update('none');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function initChart(root) {
|
|
|
|
|
|
const canvas = root.querySelector('#inventoryPriceChart');
|
|
|
|
|
|
if (!canvas || typeof Chart === 'undefined') return;
|
|
|
|
|
|
if (chart) { chart.destroy(); chart = null; }
|
|
|
|
|
|
|
|
|
|
|
|
try { history = JSON.parse(canvas.dataset.history || '[]'); } catch { history = []; }
|
|
|
|
|
|
meta = {
|
|
|
|
|
|
purchasePrice: canvas.dataset.purchasePrice !== '' ? Number(canvas.dataset.purchasePrice) : null,
|
|
|
|
|
|
purchaseDate: canvas.dataset.purchaseDate || null,
|
|
|
|
|
|
condition: canvas.dataset.condition || 'Near Mint',
|
|
|
|
|
|
};
|
|
|
|
|
|
range = 'all';
|
|
|
|
|
|
root.querySelectorAll('.inv-price-range-btn').forEach(b =>
|
|
|
|
|
|
b.classList.toggle('active', b.dataset.range === 'all'));
|
|
|
|
|
|
|
|
|
|
|
|
const emptyEl = root.querySelector('#inventoryPriceEmpty');
|
|
|
|
|
|
const wrap = canvas.closest('.chart-canvas-wrap');
|
|
|
|
|
|
if (!history.length) {
|
|
|
|
|
|
emptyEl?.classList.remove('d-none');
|
|
|
|
|
|
wrap?.classList.add('d-none');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
emptyEl?.classList.add('d-none');
|
|
|
|
|
|
wrap?.classList.remove('d-none');
|
|
|
|
|
|
|
|
|
|
|
|
const colors = CONDITION_COLORS[meta.condition] || CONDITION_COLORS['Near Mint'];
|
|
|
|
|
|
const { labels, lineData, purchaseData } = buildData(range);
|
|
|
|
|
|
|
|
|
|
|
|
chart = new Chart(canvas.getContext('2d'), {
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
labels,
|
|
|
|
|
|
datasets: [
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'Market Price',
|
|
|
|
|
|
data: lineData,
|
|
|
|
|
|
borderColor: colors.active,
|
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
|
pointRadius: 2,
|
|
|
|
|
|
pointHoverRadius: 5,
|
|
|
|
|
|
pointBackgroundColor: colors.active,
|
|
|
|
|
|
tension: 0.3,
|
|
|
|
|
|
fill: false,
|
|
|
|
|
|
spanGaps: true,
|
|
|
|
|
|
order: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'Purchase',
|
|
|
|
|
|
data: purchaseData,
|
|
|
|
|
|
showLine: false,
|
|
|
|
|
|
pointRadius: 6,
|
|
|
|
|
|
pointHoverRadius: 8,
|
|
|
|
|
|
pointStyle: 'rectRot',
|
|
|
|
|
|
pointBackgroundColor: PURCHASE_COLOR,
|
|
|
|
|
|
pointBorderColor: '#fff',
|
|
|
|
|
|
pointBorderWidth: 1,
|
|
|
|
|
|
order: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
options: {
|
|
|
|
|
|
responsive: true,
|
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
|
interaction: { mode: 'index', intersect: false },
|
|
|
|
|
|
plugins: {
|
|
|
|
|
|
legend: { display: false },
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
|
|
|
|
|
titleColor: 'rgba(255, 255, 255, 0.9)',
|
|
|
|
|
|
bodyColor: 'rgba(255, 255, 255, 0.75)',
|
|
|
|
|
|
borderColor: 'rgba(255, 255, 255, 0.1)',
|
|
|
|
|
|
borderWidth: 1,
|
|
|
|
|
|
padding: 10,
|
|
|
|
|
|
filter: (item) => item.parsed.y != null,
|
|
|
|
|
|
callbacks: {
|
|
|
|
|
|
labelColor: (ctx) => {
|
|
|
|
|
|
const c = ctx.dataset.label === 'Purchase' ? PURCHASE_COLOR : colors.active;
|
|
|
|
|
|
return { borderColor: c, backgroundColor: c };
|
|
|
|
|
|
},
|
|
|
|
|
|
label: (ctx) => {
|
|
|
|
|
|
if (ctx.dataset.label === 'Purchase') {
|
|
|
|
|
|
return ` Purchase: $${Number(ctx.parsed.y).toFixed(2)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return ` Market: $${Number(ctx.parsed.y).toFixed(2)}`;
|
|
|
|
|
|
},
|
|
|
|
|
|
afterLabel: (ctx) => {
|
|
|
|
|
|
if (ctx.dataset.label === 'Purchase' || meta.purchasePrice == null) return;
|
|
|
|
|
|
const diff = ctx.parsed.y - meta.purchasePrice;
|
|
|
|
|
|
const sign = diff >= 0 ? '+' : '−';
|
|
|
|
|
|
const pct = meta.purchasePrice > 0 ? (diff / meta.purchasePrice) * 100 : 0;
|
|
|
|
|
|
return `vs purchase: ${sign}$${Math.abs(diff).toFixed(2)} (${sign}${Math.abs(pct).toFixed(1)}%)`;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
x: {
|
|
|
|
|
|
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
|
|
|
|
|
ticks: { color: 'rgba(255, 255, 255, 0.4)', maxTicksLimit: 6, maxRotation: 0 },
|
|
|
|
|
|
border: { color: 'rgba(255, 255, 255, 0.1)' },
|
|
|
|
|
|
},
|
|
|
|
|
|
y: {
|
|
|
|
|
|
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
|
|
|
|
|
ticks: { color: 'rgba(255, 255, 255, 0.4)', callback: (v) => `$${Number(v).toFixed(2)}` },
|
|
|
|
|
|
border: { color: 'rgba(255, 255, 255, 0.1)' },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Load modal content on open ──────────────────────────────────────────
|
|
|
|
|
|
modal.addEventListener('show.bs.modal', async (e) => {
|
|
|
|
|
|
const trigger = e.relatedTarget;
|
|
|
|
|
|
const inventoryId = trigger?.dataset.inventoryId;
|
|
|
|
|
|
const content = modal.querySelector('.modal-content');
|
|
|
|
|
|
if (!content) return;
|
|
|
|
|
|
if (!inventoryId) { content.innerHTML = '<div class="p-4 text-danger">Missing inventory id.</div>'; return; }
|
|
|
|
|
|
|
|
|
|
|
|
content.innerHTML = '<div class="p-5 text-center text-secondary">Loading…</div>';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/partials/inventory-modal?inventoryId=${encodeURIComponent(inventoryId)}`);
|
|
|
|
|
|
content.innerHTML = await res.text();
|
|
|
|
|
|
requestAnimationFrame(() => initChart(content));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
content.innerHTML = '<div class="p-4 text-danger">Failed to load inventory entry.</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
modal.addEventListener('hidden.bs.modal', () => {
|
|
|
|
|
|
if (chart) { chart.destroy(); chart = null; }
|
|
|
|
|
|
history = [];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── Time-range buttons ──────────────────────────────────────────────────
|
|
|
|
|
|
modal.addEventListener('click', (e) => {
|
|
|
|
|
|
const btn = e.target.closest('.inv-price-range-btn');
|
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
|
modal.querySelectorAll('.inv-price-range-btn').forEach(b => b.classList.remove('active'));
|
|
|
|
|
|
btn.classList.add('active');
|
|
|
|
|
|
range = btn.dataset.range || 'all';
|
|
|
|
|
|
render();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── Save catalog + note ─────────────────────────────────────────────────
|
|
|
|
|
|
modal.addEventListener('submit', async (e) => {
|
|
|
|
|
|
const form = e.target.closest('[data-inventory-edit-form]');
|
|
|
|
|
|
if (!form) return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
const btn = form.querySelector('button[type="submit"]');
|
|
|
|
|
|
const original = btn ? btn.textContent : '';
|
|
|
|
|
|
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/api/inventory', { method: 'POST', body: new FormData(form) });
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
if (btn) { btn.textContent = 'Saved ✓'; btn.classList.remove('btn-success'); btn.classList.add('btn-outline-success'); }
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (btn) { btn.textContent = original; btn.disabled = false; btn.classList.add('btn-success'); btn.classList.remove('btn-outline-success'); }
|
|
|
|
|
|
}, 1500);
|
|
|
|
|
|
} else if (btn) {
|
|
|
|
|
|
btn.textContent = original; btn.disabled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
if (btn) { btn.textContent = original; btn.disabled = false; }
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
})();
|
|
|
|
|
|
</script>
|
2026-05-22 06:28:40 -04:00
|
|
|
|
</div>
|
2026-03-25 08:42:17 -04:00
|
|
|
|
<Footer slot="footer" />
|
2026-05-22 06:28:40 -04:00
|
|
|
|
</Layout>
|