[feat] dashboard shows inventory

This commit is contained in:
2026-04-07 22:34:31 -04:00
parent cb829e1922
commit 71c167308d
8 changed files with 219 additions and 506 deletions

View File

@@ -5,304 +5,14 @@ import NavItems from "../components/NavItems.astro";
import Footer from "../components/Footer.astro";
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
// Mock inventory using the same schema as the Typesense cards collection.
// skus mirror the real shape: marketPrice is in cents (÷100 = dollars).
const inventory = [
{
productId: "42382",
productName: "Charizard",
setName: "Base Set",
setCode: "BS",
number: "4/102",
rarityName: "Rare Holo",
energyType: "Fire",
variant: "1st Edition",
qty: 2,
purchasePrice: 32000,
skus: [
{ condition: "Near Mint", marketPrice: 40000 },
{ condition: "Lightly Played", marketPrice: 31000 },
{ condition: "Moderately Played", marketPrice: 22000 },
{ condition: "Heavily Played", marketPrice: 14000 },
{ condition: "Damaged", marketPrice: 8500 },
],
},
{
productId: "146682",
productName: "Pikachu",
setName: "Shining Legends",
setCode: "SLG",
number: "SM70",
rarityName: "Promo",
energyType: "Lightning",
variant: "Normal",
qty: 5,
purchasePrice: 1500,
skus: [
{ condition: "Near Mint", marketPrice: 2000 },
{ condition: "Lightly Played", marketPrice: 1500 },
{ condition: "Moderately Played", marketPrice: 1100 },
{ condition: "Heavily Played", marketPrice: 700 },
{ condition: "Damaged", marketPrice: 400 },
],
},
{
productId: "246723",
productName: "Umbreon VMAX",
setName: "Evolving Skies",
setCode: "EVS",
number: "215/203",
rarityName: "Secret Rare",
energyType: "Darkness",
variant: "Alternate Art",
qty: 1,
purchasePrice: 8500,
skus: [
{ condition: "Near Mint", marketPrice: 11500 },
{ condition: "Lightly Played", marketPrice: 9000 },
{ condition: "Moderately Played", marketPrice: 6500 },
{ condition: "Heavily Played", marketPrice: 4000 },
{ condition: "Damaged", marketPrice: 2000 },
],
},
{
productId: "197660",
productName: "Gyarados",
setName: "Hidden Fates",
setCode: "HIF",
number: "SV19/SV94",
rarityName: "Shiny Holo Rare",
energyType: "Water",
variant: "Shiny",
qty: 3,
purchasePrice: 2500,
skus: [
{ condition: "Near Mint", marketPrice: 3000 },
{ condition: "Lightly Played", marketPrice: 2300 },
{ condition: "Moderately Played", marketPrice: 1600 },
{ condition: "Heavily Played", marketPrice: 900 },
{ condition: "Damaged", marketPrice: 500 },
],
},
{
productId: "246733",
productName: "Rayquaza VMAX",
setName: "Evolving Skies",
setCode: "EVS",
number: "218/203",
rarityName: "Secret Rare",
energyType: "Dragon",
variant: "Alternate Art",
qty: 2,
purchasePrice: 6500,
skus: [
{ condition: "Near Mint", marketPrice: 8800 },
{ condition: "Lightly Played", marketPrice: 7000 },
{ condition: "Moderately Played", marketPrice: 5000 },
{ condition: "Heavily Played", marketPrice: 3200 },
{ condition: "Damaged", marketPrice: 1800 },
],
},
{
productId: "264218",
productName: "Eevee",
setName: "Sword & Shield",
setCode: "SSH",
number: "TG07/TG30",
rarityName: "Trainer Gallery",
energyType: "Colorless",
variant: "Normal",
qty: 10,
purchasePrice: 800,
skus: [
{ condition: "Near Mint", marketPrice: 900 },
{ condition: "Lightly Played", marketPrice: 700 },
{ condition: "Moderately Played", marketPrice: 500 },
{ condition: "Heavily Played", marketPrice: 300 },
{ condition: "Damaged", marketPrice: 150 },
],
},
{
productId: "451834",
productName: "Lugia V",
setName: "Silver Tempest",
setCode: "SIT",
number: "186/195",
rarityName: "Ultra Rare",
energyType: "Colorless",
variant: "Alternate Art",
qty: 1,
purchasePrice: 4500,
skus: [
{ condition: "Near Mint", marketPrice: 5800 },
{ condition: "Lightly Played", marketPrice: 4600 },
{ condition: "Moderately Played", marketPrice: 3200 },
{ condition: "Heavily Played", marketPrice: 2000 },
{ condition: "Damaged", marketPrice: 1000 },
],
},
{
productId: "106997",
productName: "Blastoise",
setName: "Base Set",
setCode: "BS",
number: "2/102",
rarityName: "Rare Holo",
energyType: "Water",
variant: "Shadowless",
qty: 1,
purchasePrice: 18000,
skus: [
{ condition: "Near Mint", marketPrice: 24000 },
{ condition: "Lightly Played", marketPrice: 18500 },
{ condition: "Moderately Played", marketPrice: 13000 },
{ condition: "Heavily Played", marketPrice: 8000 },
{ condition: "Damaged", marketPrice: 4500 },
],
},
{
productId: "253265",
productName: "Espeon VMAX",
setName: "Evolving Skies",
setCode: "EVS",
number: "205/203",
rarityName: "Secret Rare",
energyType: "Psychic",
variant: "Alternate Art",
qty: 2,
purchasePrice: 7000,
skus: [
{ condition: "Near Mint", marketPrice: 9200 },
{ condition: "Lightly Played", marketPrice: 7300 },
{ condition: "Moderately Played", marketPrice: 5200 },
{ condition: "Heavily Played", marketPrice: 3300 },
{ condition: "Damaged", marketPrice: 1600 },
],
},
{
productId: "253266",
productName: "Gengar VMAX",
setName: "Fusion Strike",
setCode: "FST",
number: "271/264",
rarityName: "Secret Rare",
energyType: "Psychic",
variant: "Alternate Art",
qty: 1,
purchasePrice: 5500,
skus: [
{ condition: "Near Mint", marketPrice: 4800 },
{ condition: "Lightly Played", marketPrice: 3800 },
{ condition: "Moderately Played", marketPrice: 2700 },
{ condition: "Heavily Played", marketPrice: 1700 },
{ condition: "Damaged", marketPrice: 900 },
],
},
{
productId: "226432",
productName: "Pikachu VMAX",
setName: "Vivid Voltage",
setCode: "VIV",
number: "188/185",
rarityName: "Secret Rare",
energyType: "Lightning",
variant: "Rainbow Rare",
qty: 3,
purchasePrice: 3200,
skus: [
{ condition: "Near Mint", marketPrice: 4100 },
{ condition: "Lightly Played", marketPrice: 3200 },
{ condition: "Moderately Played", marketPrice: 2300 },
{ condition: "Heavily Played", marketPrice: 1400 },
{ condition: "Damaged", marketPrice: 750 },
],
},
{
productId: "253275",
productName: "Mew VMAX",
setName: "Fusion Strike",
setCode: "FST",
number: "269/264",
rarityName: "Secret Rare",
energyType: "Psychic",
variant: "Alternate Art",
qty: 2,
purchasePrice: 4200,
skus: [
{ condition: "Near Mint", marketPrice: 5600 },
{ condition: "Lightly Played", marketPrice: 4400 },
{ condition: "Moderately Played", marketPrice: 3100 },
{ condition: "Heavily Played", marketPrice: 2000 },
{ condition: "Damaged", marketPrice: 1000 },
],
},
{
productId: "478077",
productName: "Darkrai VSTAR",
setName: "Astral Radiance",
setCode: "ASR",
number: "189/189",
rarityName: "Secret Rare",
energyType: "Darkness",
variant: "Gold",
qty: 1,
purchasePrice: 3800,
skus: [
{ condition: "Near Mint", marketPrice: 3200 },
{ condition: "Lightly Played", marketPrice: 2500 },
{ condition: "Moderately Played", marketPrice: 1800 },
{ condition: "Heavily Played", marketPrice: 1100 },
{ condition: "Damaged", marketPrice: 600 },
],
},
{
productId: "477060",
productName: "Leafeon VSTAR",
setName: "Pokémon GO",
setCode: "PGO",
number: "076/078",
rarityName: "Ultra Rare",
energyType: "Grass",
variant: "Normal",
qty: 4,
purchasePrice: 1200,
skus: [
{ condition: "Near Mint", marketPrice: 1800 },
{ condition: "Lightly Played", marketPrice: 1400 },
{ condition: "Moderately Played", marketPrice: 1000 },
{ condition: "Heavily Played", marketPrice: 600 },
{ condition: "Damaged", marketPrice: 300 },
],
},
{
productId: "478100",
productName: "Giratina VSTAR",
setName: "Lost Origin",
setCode: "LOR",
number: "196/196",
rarityName: "Secret Rare",
energyType: "Dragon",
variant: "Alternate Art",
qty: 1,
purchasePrice: 5200,
skus: [
{ condition: "Near Mint", marketPrice: 7800 },
{ condition: "Lightly Played", marketPrice: 6100 },
{ condition: "Moderately Played", marketPrice: 4400 },
{ condition: "Heavily Played", marketPrice: 2800 },
{ condition: "Damaged", marketPrice: 1400 },
],
},
];
// Helpers
const nmPrice = (card: typeof inventory[0]) => (card.skus[0]?.marketPrice ?? 0) / 100;
const nmPurchase = (card: typeof inventory[0]) => card.purchasePrice / 100;
const gain = (card: typeof inventory[0]) => nmPrice(card) - nmPurchase(card);
const totalQty = inventory.reduce((s, c) => s + c.qty, 0);
const totalValue = inventory.reduce((s, c) => s + nmPrice(c) * c.qty, 0);
const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
// const totalQty = inventory.reduce((s, c) => s + c.qty, 0);
// const totalValue = inventory.reduce((s, c) => s + nmPrice(c) * c.qty, 0);
// const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
const totalQty = 1234;
const totalValue = 5678.90;
const totalGain = 1234.56;
---
<Layout title="Inventory Dashboard">
@@ -406,72 +116,10 @@ const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
</div>
<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">
{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 (
<div class="col">
<article class="inv-grid-card">
<div
class="card-trigger position-relative inv-grid-media"
data-card-id={card.productId}
data-bs-toggle="modal"
data-bs-target="#cardModal"
>
<div
class="rounded-4 card-image"
data-energy={card.energyType}
data-rarity={card.rarityName}
data-variant={card.variant}
data-name={card.productName}
>
<img
src={`static/cards/${card.productId}.jpg`}
alt={card.productName}
loading="lazy"
decoding="async"
class="img-fluid rounded-4 w-100"
onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')"
/>
<span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4">
<FirstEditionIcon edition={card.variant} />
</span>
</div>
</div>
<div class="d-flex flex-row justify-content-between my-1 align-items-center">
<input type="number" class="form-control text-center" style="max-width: 50%;" value="1" min="1" max="999" aria-label="Quantity input" aria-describedby="button-minus button-plus">
<div class="" aria-label="Edit controls">
<button type="button" class="btn btn-outline-warning btn-sm"><svg class="edit-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M100.4 417.2C104.5 402.6 112.2 389.3 123 378.5L304.2 197.3L338.1 163.4C354.7 180 389.4 214.7 442.1 267.4L476 301.3L442.1 335.2L260.9 516.4C250.2 527.1 236.8 534.9 222.2 539L94.4 574.6C86.1 576.9 77.1 574.6 71 568.4C64.9 562.2 62.6 553.3 64.9 545L100.4 417.2zM156 413.5C151.6 418.2 148.4 423.9 146.7 430.1L122.6 517L209.5 492.9C215.9 491.1 221.7 487.8 226.5 483.2L155.9 413.5zM510 267.4C493.4 250.8 458.7 216.1 406 163.4L372 129.5C398.5 103 413.4 88.1 416.9 84.6C430.4 71 448.8 63.4 468 63.4C487.2 63.4 505.6 71 519.1 84.6L554.8 120.3C568.4 133.9 576 152.3 576 171.4C576 190.5 568.4 209 554.8 222.5C551.3 226 536.4 240.9 509.9 267.4z"/></svg></button>
<button type="button" class="btn btn-outline-danger btn-sm"><svg class="delete-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M232.7 69.9L224 96L128 96C110.3 96 96 110.3 96 128C96 145.7 110.3 160 128 160L512 160C529.7 160 544 145.7 544 128C544 110.3 529.7 96 512 96L416 96L407.3 69.9C402.9 56.8 390.7 48 376.9 48L263.1 48C249.3 48 237.1 56.8 232.7 69.9zM512 208L128 208L149.1 531.1C150.7 556.4 171.7 576 197 576L443 576C468.3 576 489.3 556.4 490.9 531.1L512 208z"/></svg></button>
</div>
</div>
<div class="d-flex flex-row mt-1">
<div class="p small text-secondary">{card.setName}</div>
</div>
<div class="d-flex flex-row mt-1">
<div class="h5">{card.productName}</div>
</div>
<div class="d-flex flex-row mt-1 justify-content-between align-items-baseline">
<div class={`inv-grid-trend small ${isGain ? "up" : "down"}`}>
<span class="inv-grid-arrow">{isGain ? "▲" : "▼"}</span>
<span class="h6 my-0">${market.toFixed(2)}</span>
</div>
<div class={`inv-grid-delta small ${isGain ? "up" : "down"}`}>
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)}</br>{isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%
</div>
</div>
</article>
</div>
);
})}
<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>
<div id="tableView" style="display:none">
<!-- <div id="tableView" style="display:none">
<div class="inv-list-wrap">
<table class="table align-middle mb-0 inv-list-table">
<tbody id="inventoryRows">
@@ -544,12 +192,12 @@ const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
</table>
</div>
<div class="text-secondary small mt-2 ps-1" id="rowCount"></div>
</div>
</div> -->
</div>
</main>
</div>
<div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" aria-modal="true" role="dialog">
<!-- <div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" 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">
@@ -629,7 +277,7 @@ const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
</div>
</div>
</div>
</div>
</div> -->
<Footer slot="footer" />
</Layout>
@@ -915,134 +563,3 @@ const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
}
}
</style>
<script>
import * as bootstrap from "bootstrap";
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".modal").forEach((el) => {
bootstrap.Modal.getOrCreateInstance(el);
});
document.querySelectorAll("#catalogList [data-catalog]").forEach((item) => {
item.addEventListener("click", () => {
document.querySelectorAll("#catalogList [data-catalog]").forEach((i) => i.classList.remove("active"));
item.classList.add("active");
const catalog = item.dataset.catalog ?? "all";
document.querySelectorAll("#gridView .col").forEach((col) => {
col.style.display = catalog === "all" ? "" : "none";
});
document.querySelectorAll("#inventoryRows tr").forEach((row) => {
row.style.display = catalog === "all" ? "" : "none";
});
updateRowCount();
});
});
const gridView = document.getElementById("gridView");
const tableView = document.getElementById("tableView");
const btnGrid = document.getElementById("btnGrid");
const btnTable = document.getElementById("btnTable");
const rowCount = document.getElementById("rowCount");
const tbody = document.getElementById("inventoryRows");
function showGrid() {
gridView.style.display = "";
tableView.style.display = "none";
btnGrid.classList.add("active");
btnTable.classList.remove("active");
}
function showTable() {
gridView.style.display = "none";
tableView.style.display = "";
btnGrid.classList.remove("active");
btnTable.classList.add("active");
updateRowCount();
}
btnGrid?.addEventListener("click", showGrid);
btnTable?.addEventListener("click", showTable);
const searchInput = document.getElementById("inventorySearch");
const clearBtn = document.getElementById("clearSearch");
let searchTimer;
searchInput?.addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
const term = searchInput.value.toLowerCase();
clearBtn.classList.toggle("d-none", !term);
tbody?.querySelectorAll("tr").forEach((row) => {
row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none";
});
updateRowCount();
}, 120);
});
clearBtn?.addEventListener("click", () => {
searchInput.value = "";
clearBtn.classList.add("d-none");
tbody?.querySelectorAll("tr").forEach((r) => {
r.style.display = "";
});
updateRowCount();
});
function updateRowCount() {
if (!rowCount || !tbody) return;
const visible = [...tbody.querySelectorAll("tr")].filter((r) => r.style.display !== "none").length;
rowCount.textContent = `Showing ${visible} card${visible !== 1 ? "s" : ""}`;
}
updateRowCount();
document.getElementById("createCatalogBtn")?.addEventListener("click", () => {
const input = document.getElementById("catalogNameInput");
const name = input.value.trim();
if (!name) {
input.focus();
return;
}
const li = document.createElement("li");
li.className =
"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";
li.setAttribute("data-catalog", name);
li.setAttribute("role", "button");
li.style.cursor = "pointer";
li.innerHTML = `<span class="text-secondary" style="font-size:.7rem">▸</span>${name}`;
li.addEventListener("click", () => {
document.querySelectorAll("#catalogList [data-catalog]").forEach((i) => i.classList.remove("active"));
li.classList.add("active");
});
document.getElementById("catalogList")?.appendChild(li);
input.value = "";
//bootstrap.Modal.getInstance(document.getElementById("newCatalogModal"))?.hide();
});
document.getElementById("csvFileInput")?.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const lines = ev.target.result.split("\n").slice(0, 4);
const preview = document.getElementById("csvPreviewContent");
preview.textContent = lines.join("\n");
document.getElementById("csvPreview")?.classList.remove("d-none");
};
reader.readAsText(file);
});
});
</script>