[feat] dashboard shows inventory
This commit is contained in:
33
scripts/diagnose-join.ts
Normal file
33
scripts/diagnose-join.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'dotenv/config';
|
||||
import chalk from 'chalk';
|
||||
import util from 'node:util';
|
||||
import { client } from '../src/db/typesense.ts';
|
||||
|
||||
const variants = [
|
||||
'$skus(*, $cards(*))',
|
||||
'$skus(*,$cards(*))',
|
||||
'$skus(*, card_id, $cards(*))',
|
||||
'$skus(*, $cards(*, strategy:nest))',
|
||||
'$skus(*, $cards(*, strategy:merge))',
|
||||
];
|
||||
|
||||
const debug = await client.debug.retrieve();
|
||||
console.log(chalk.cyan(`Typesense server version: ${debug.version}`));
|
||||
console.log();
|
||||
|
||||
for (const include of variants) {
|
||||
console.log(chalk.yellow(`include_fields: ${include}`));
|
||||
try {
|
||||
const res: any = await client.collections('inventories').documents().search({
|
||||
q: '*',
|
||||
query_by: 'content',
|
||||
per_page: 1,
|
||||
include_fields: include,
|
||||
});
|
||||
const doc = res.hits?.[0]?.document;
|
||||
console.log(util.inspect(doc, { depth: null, colors: false }));
|
||||
} catch (err: any) {
|
||||
console.log(chalk.red(` ERROR: ${err.message ?? err}`));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { DBInstance } from '../src/db/index.ts';
|
||||
import fs from "node:fs/promises";
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
import * as util from 'util';
|
||||
|
||||
|
||||
const DollarToInt = (dollar: any) => {
|
||||
if (dollar === null) return null;
|
||||
@@ -83,7 +85,7 @@ export const createSkuCollection = async () => {
|
||||
{ name: 'highestPrice', type: 'int32', optional: true },
|
||||
{ name: 'lowestPrice', type: 'int32', optional: true },
|
||||
{ name: 'marketPrice', type: 'int32', optional: true },
|
||||
{ name: 'card_id', type: 'string', reference: 'cards.id' },
|
||||
{ name: 'card_id', type: 'string', reference: 'cards.id', async_reference: true },
|
||||
]
|
||||
});
|
||||
console.log(chalk.green('Collection "skus" created successfully.'));
|
||||
@@ -102,7 +104,15 @@ export const createInventoryCollection = async () => {
|
||||
{ name: 'id', type: 'string' },
|
||||
{ name: 'userId', type: 'string' },
|
||||
{ name: 'catalogName', type: 'string' },
|
||||
{ name: 'sku_id', type: 'string', reference: 'skus.id' },
|
||||
{ name: 'card_id', type: 'string', reference: 'cards.id', async_reference: true },
|
||||
{ name: 'sku_id', type: 'string', reference: 'skus.id', async_reference: true },
|
||||
// content,setName,productLineName,rarityName,energyType,cardType from cards for searching
|
||||
{ name: 'content', type: 'string', token_separators: ['/'] },
|
||||
{ name: 'setName', type: 'string' },
|
||||
{ name: 'productLineName', type: 'string' },
|
||||
{ name: 'rarityName', type: 'string' },
|
||||
{ name: 'energyType', type: 'string' },
|
||||
{ name: 'cardType', type: 'string' },
|
||||
]
|
||||
});
|
||||
console.log(chalk.green('Collection "inventories" created successfully.'));
|
||||
@@ -153,12 +163,28 @@ export const upsertSkuCollection = async (db:DBInstance) => {
|
||||
}
|
||||
|
||||
export const upsertInventoryCollection = async (db:DBInstance) => {
|
||||
const inv = await db.query.inventory.findMany();
|
||||
const inv = await db.query.inventory.findMany({
|
||||
with: { sku: { with: { card: { with: { set: true } } } } }
|
||||
});
|
||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
||||
id: i.inventoryId,
|
||||
userId: i.userId,
|
||||
catalogName: i.catalogName,
|
||||
card_id: i.sku?.cardId.toString(),
|
||||
sku_id: i.skuId.toString(),
|
||||
productLineName: i.sku?.card?.productLineName,
|
||||
rarityName: i.sku?.card?.rarityName,
|
||||
setName: i.sku?.card?.set?.setName || "",
|
||||
cardType: i.sku?.card?.cardType || "",
|
||||
energyType: i.sku?.card?.energyType || "",
|
||||
content: [
|
||||
i.sku?.card?.productName,
|
||||
i.sku?.card?.productLineName,
|
||||
i.sku?.card?.set?.setName || "",
|
||||
i.sku?.card?.number,
|
||||
i.sku?.card?.rarityName,
|
||||
i.sku?.card?.artist || ""
|
||||
].join(' '),
|
||||
})), { action: 'upsert' });
|
||||
console.log(chalk.green('Collection "inventories" indexed successfully.'));
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { db, ClosePool } from '../src/db/index.ts';
|
||||
import * as Indexing from './pokemon-helper.ts';
|
||||
|
||||
|
||||
//await Indexing.createCardCollection();
|
||||
//await Indexing.createSkuCollection();
|
||||
// await Indexing.createCardCollection();
|
||||
await Indexing.createSkuCollection();
|
||||
await Indexing.createInventoryCollection();
|
||||
|
||||
//await Indexing.upsertCardCollection(db);
|
||||
//await Indexing.upsertSkuCollection(db);
|
||||
// await Indexing.upsertCardCollection(db);
|
||||
await Indexing.upsertSkuCollection(db);
|
||||
await Indexing.upsertInventoryCollection(db);
|
||||
await ClosePool();
|
||||
console.log(chalk.green('Pokémon reindex complete.'));
|
||||
|
||||
@@ -24,7 +24,7 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
inventories: r.many.inventory(),
|
||||
},
|
||||
inventory: {
|
||||
card: r.one.skus({
|
||||
sku: r.one.skus({
|
||||
from: r.inventory.skuId,
|
||||
to: r.skus.skuId,
|
||||
}),
|
||||
|
||||
@@ -85,7 +85,7 @@ const getInventory = async (userId: string, cardId: number) => {
|
||||
}
|
||||
|
||||
|
||||
const addToInventory = async (userId: string, 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
|
||||
const inv = await db.insert(inventory).values({
|
||||
userId: userId,
|
||||
@@ -95,13 +95,37 @@ const addToInventory = async (userId: string, skuId: number, purchasePrice: numb
|
||||
quantity: quantity,
|
||||
note: note,
|
||||
}).returning();
|
||||
// Get card details from the database to add to Typesense
|
||||
const card = await db.query.cards.findFirst({
|
||||
where: { cardId: cardId },
|
||||
with: { set: true },
|
||||
});
|
||||
|
||||
try {
|
||||
// And then add to Typesense for searching
|
||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
||||
id: i.inventoryId,
|
||||
userId: i.userId,
|
||||
catalogName: i.catalogName,
|
||||
sku_id: i.skuId.toString(),
|
||||
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) {
|
||||
console.error('Error adding inventory to Typesense:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromInventory = async (inventoryId: string) => {
|
||||
@@ -141,7 +165,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!skuId) {
|
||||
return new Response('SKU not found for card', { status: 404 });
|
||||
}
|
||||
await addToInventory(userId!, skuId, purchasePrice, quantity, note, catalogName);
|
||||
await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
|
||||
@@ -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>
|
||||
@@ -102,7 +102,7 @@ const facetFilter = (facet:string) => {
|
||||
// primary search values (for cards)
|
||||
let searchArray = [{
|
||||
collection: 'cards',
|
||||
filter_by: `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
|
||||
filter_by: `$skus(id:*) && sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
|
||||
per_page: 20,
|
||||
facet_by: '',
|
||||
max_facet_values: 0,
|
||||
@@ -143,6 +143,7 @@ const totalHits = cardResults?.found;
|
||||
|
||||
// format price to 2 decimal places (or 0 if price >=100) and adds a $ sign, if the price is null it returns "–"
|
||||
const formatPrice = (condition:string, skus: any) => {
|
||||
if (typeof skus === 'undefined' || skus.length === 0) return '—';
|
||||
const sku:any = skus.find((price:any) => price.condition === condition);
|
||||
if (typeof sku === 'undefined' || typeof sku.marketPrice === 'undefined') return '—';
|
||||
|
||||
|
||||
112
src/pages/partials/inventory-cards.astro
Normal file
112
src/pages/partials/inventory-cards.astro
Normal file
@@ -0,0 +1,112 @@
|
||||
---
|
||||
import { client } from '../../db/typesense';
|
||||
import RarityIcon from '../../components/RarityIcon.astro';
|
||||
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
||||
export const prerender = false;
|
||||
|
||||
import * as util from 'util';
|
||||
|
||||
// get the query from post request using form data
|
||||
const formData = await Astro.request.formData();
|
||||
const query = formData.get('q')?.toString() || '';
|
||||
const start = Number(formData.get('start')?.toString() || '0');
|
||||
|
||||
const { userId } = Astro.locals.auth();
|
||||
|
||||
|
||||
// primary search values (for cards)
|
||||
let searchArray = [{
|
||||
collection: 'inventories',
|
||||
filter_by: `userId:=${userId} && $skus($cards(id:*))`,
|
||||
per_page: 20,
|
||||
facet_by: '',
|
||||
max_facet_values: 0,
|
||||
page: Math.floor(start / 20) + 1,
|
||||
include_fields: '$skus(*),$cards(*)',
|
||||
}];
|
||||
|
||||
// on first load (start === 0) we want to get the facets for the filters
|
||||
// if (start === 0) {
|
||||
// for (const facet of Object.keys(facetFields)) {
|
||||
// searchArray.push({
|
||||
// collection: 'cards',
|
||||
// filter_by: facetFilter(facet),
|
||||
// per_page: 0,
|
||||
// facet_by: facet,
|
||||
// max_facet_values: 500,
|
||||
// page: 1,
|
||||
// sort_by: '',
|
||||
// include_fields: '',
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
|
||||
|
||||
const searchRequests = { searches: searchArray };
|
||||
const commonSearchParams = {
|
||||
q: query,
|
||||
query_by: 'content,setName,productLineName,rarityName,energyType,cardType'
|
||||
// query_by: 'userId',
|
||||
};
|
||||
|
||||
// use typesense to search for cards matching the query and return the productIds of the results
|
||||
const searchResults = await client.multiSearch.perform(searchRequests, commonSearchParams);
|
||||
const inventoryResults = searchResults.results[0] as any;
|
||||
console.log('inventoryResults', util.inspect(inventoryResults, { depth: null }));
|
||||
|
||||
|
||||
const pokemon = inventoryResults.hits?.map((hit: any) => hit.document) ?? [];
|
||||
const totalHits = inventoryResults?.found;
|
||||
|
||||
console.log(`totalHits: ${totalHits}`);
|
||||
---
|
||||
|
||||
{pokemon.map((inventory:any) => {
|
||||
const sku = inventory.skus;
|
||||
const card = inventory.cards;
|
||||
const market = sku.marketPrice/100 || 0;
|
||||
const purchase = inventory.purchasePrice/100 || 0;
|
||||
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>
|
||||
);
|
||||
|
||||
})
|
||||
Reference in New Issue
Block a user