570 lines
19 KiB
Plaintext
570 lines
19 KiB
Plaintext
---
|
|
import Layout from "../layouts/Main.astro";
|
|
import Footer from "../components/Footer.astro";
|
|
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
|
|
import { db } from '../db/index';
|
|
import { inventory, skus } from '../db/schema';
|
|
import { sql, sum, eq } from "drizzle-orm";
|
|
|
|
|
|
|
|
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;
|
|
|
|
---
|
|
|
|
<Layout title="Inventory Dashboard">
|
|
|
|
<div class="row g-0" style="min-height: calc(100vh - 120px)" slot="page">
|
|
<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
|
|
class="btn btn-sm btn-success fs-7"
|
|
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
|
|
class="list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center justify-content-between active"
|
|
data-catalog="all"
|
|
role="button"
|
|
style="cursor:pointer"
|
|
>
|
|
<span class="d-flex align-items-center gap-2">
|
|
View all cards
|
|
</span>
|
|
<span class="badge rounded-pill text-bg-secondary small">{totalQty}</span>
|
|
</li>
|
|
|
|
{["Case Cards", "Japanese Singles", "Bulk"].map((name) => (
|
|
<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"
|
|
>
|
|
{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>Profit/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="col-12 col-md-10 p-4">
|
|
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
|
|
<!--div class="d-flex align-items-center gap-1">
|
|
<button id="btnGrid" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn active" title="Images view">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5v-3zm8 0A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5v-3zm-8 8A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3zm8 0A1.5 1.5 0 0 1 10.5 9h3A1.5 1.5 0 0 1 15 10.5v3A1.5 1.5 0 0 1 13.5 15h-3A1.5 1.5 0 0 1 9 13.5v-3z"/>
|
|
</svg>
|
|
<span class="small ms-1">Images</span>
|
|
</button>
|
|
<button id="btnTable" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn" title="List view">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
|
<path fill-rule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z"/>
|
|
</svg>
|
|
<span class="small ms-1">List</span>
|
|
</button>
|
|
</div>-->
|
|
|
|
<div class="vr opacity-25 mx-1"></div>
|
|
|
|
<a href="/pokemon" class="btn btn-sm btn-vendor">+ Add Card</a>
|
|
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm btn-outline-light"
|
|
data-bs-toggle="modal"
|
|
data-bs-target="#bulkImportModal"
|
|
>Bulk Import</button>
|
|
|
|
<div class="ms-auto position-relative">
|
|
<input
|
|
id="inventorySearch"
|
|
class="form-control form-control-sm bg-dark text-light border-secondary"
|
|
placeholder="Search inventory…"
|
|
style="min-width:200px; padding-right:2rem"
|
|
/>
|
|
<button
|
|
id="clearSearch"
|
|
type="button"
|
|
class="btn btn-sm p-0 position-absolute top-50 end-0 translate-middle-y me-2 text-secondary d-none"
|
|
style="line-height:1"
|
|
aria-label="Clear search"
|
|
>✕</button>
|
|
</div>
|
|
</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" hx-post="/partials/inventory-cards" hx-trigger="load">
|
|
</div>
|
|
|
|
<!-- <div id="tableView" style="display:none">
|
|
<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>
|
|
</div> -->
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- <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">
|
|
<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>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
</div> -->
|
|
|
|
<Footer slot="footer" />
|
|
</Layout>
|
|
|
|
<style>
|
|
|
|
.view-toggle-btn {
|
|
text-decoration: none;
|
|
color: var(--bs-secondary-color) !important;
|
|
}
|
|
|
|
.view-toggle-btn.active {
|
|
color: var(--bs-body-color) !important;
|
|
}
|
|
|
|
.view-toggle-btn.active svg,
|
|
.view-toggle-btn:hover svg {
|
|
color: var(--bs-body-color);
|
|
}
|
|
|
|
#catalogList .list-group-item.active {
|
|
background-color: rgba(var(--bs-danger-rgb), .15) !important;
|
|
color: rgba(var(--bs-danger-rgb), 1) !important;
|
|
border-left: 2px solid var(--bs-danger) !important;
|
|
}
|
|
|
|
#gridView {
|
|
row-gap: 1.5rem;
|
|
}
|
|
|
|
.inv-grid-card {
|
|
position: relative;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.inv-grid-media {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.inv-grid-body {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
padding: .85rem .15rem 0;
|
|
}
|
|
|
|
.inv-grid-main {
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.inv-grid-title {
|
|
font-size: 1.9rem;
|
|
line-height: 1.05;
|
|
font-weight: 500;
|
|
margin-bottom: .4rem;
|
|
color: var(--bs-emphasis-color);
|
|
}
|
|
|
|
.inv-grid-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .2rem;
|
|
}
|
|
|
|
.inv-grid-submeta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: .35rem;
|
|
font-size: .9rem;
|
|
color: var(--bs-secondary-color);
|
|
}
|
|
|
|
.inv-grid-price {
|
|
min-width: 112px;
|
|
text-align: right;
|
|
flex-shrink: 0;
|
|
color: #f3f3f3 !important;
|
|
}
|
|
|
|
.inv-grid-trend,
|
|
.inv-list-price-line {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: .35rem;
|
|
font-weight: 700;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.inv-grid-value,
|
|
.inv-list-price {
|
|
font-size: 1.15rem;
|
|
color: #111;
|
|
}
|
|
|
|
.inv-grid-trend.up .inv-grid-arrow,
|
|
.inv-list-price-line.up .inv-grid-arrow {
|
|
color: #2e7d32;
|
|
}
|
|
|
|
.inv-grid-trend.down,
|
|
.inv-list-price-line.down {
|
|
color: #c62828;
|
|
}
|
|
|
|
.inv-grid-delta,
|
|
.inv-list-delta {
|
|
text-align: right;
|
|
}
|
|
|
|
.inv-grid-delta.up,
|
|
.inv-list-delta.up {
|
|
color: #2e7d32;
|
|
}
|
|
|
|
.inv-grid-delta.down,
|
|
.inv-list-delta.down {
|
|
color: #c62828;
|
|
}
|
|
|
|
.inv-grid-qty,
|
|
.inv-list-qty {
|
|
margin-top: .35rem;
|
|
font-size: .9rem;
|
|
color: #1ea7a1;
|
|
}
|
|
|
|
.inv-grid-cart,
|
|
.inv-list-cart {
|
|
position: absolute;
|
|
right: .35rem;
|
|
bottom: .1rem;
|
|
width: 2.15rem;
|
|
height: 2.15rem;
|
|
border-radius: 999px;
|
|
border: 1px solid #4cb7b3;
|
|
background: transparent;
|
|
color: #38a9a5;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: .15s ease;
|
|
}
|
|
|
|
.inv-grid-cart:hover,
|
|
.inv-list-cart:hover {
|
|
background: rgba(76, 183, 179, .08);
|
|
color: #2a9a96;
|
|
}
|
|
|
|
#tableView {
|
|
background: transparent;
|
|
}
|
|
|
|
.inv-list-wrap {
|
|
border-radius: 0;
|
|
overflow: visible;
|
|
}
|
|
|
|
.inv-list-table {
|
|
--bs-table-bg: transparent;
|
|
--bs-table-hover-bg: transparent;
|
|
--bs-table-color: inherit;
|
|
margin: 0;
|
|
}
|
|
|
|
.inv-list-table tbody,
|
|
.inv-list-table tr,
|
|
.inv-list-table td {
|
|
border: 0 !important;
|
|
background: transparent !important;
|
|
}
|
|
|
|
.inv-list-row + .inv-list-row .inv-list-cardcell {
|
|
border-top: 1px solid rgba(0, 0, 0, .08) !important;
|
|
}
|
|
|
|
.inv-list-cardcell {
|
|
padding: 0;
|
|
}
|
|
|
|
.inv-list-card {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
min-height: 116px;
|
|
padding: .8rem 4.5rem .8rem .35rem;
|
|
background: #f3f3f3;
|
|
border: 1px solid rgba(0, 0, 0, .08);
|
|
border-radius: .35rem;
|
|
}
|
|
|
|
.inv-list-thumb {
|
|
width: 70px;
|
|
flex: 0 0 70px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.inv-list-thumb img {
|
|
width: 100%;
|
|
display: block;
|
|
border-radius: .25rem;
|
|
}
|
|
|
|
.inv-list-info {
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.inv-list-name {
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
color: #111827;
|
|
margin-bottom: .35rem;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: none;
|
|
}
|
|
|
|
.inv-list-name:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.inv-list-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .1rem;
|
|
font-size: .95rem;
|
|
color: #4b5563;
|
|
}
|
|
|
|
.inv-list-setlink {
|
|
color: #5b6f8f;
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
|
|
.inv-list-condition {
|
|
margin-top: .35rem;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: .35rem;
|
|
font-size: .95rem;
|
|
color: #2b7a78;
|
|
}
|
|
|
|
.inv-list-right {
|
|
margin-left: auto;
|
|
min-width: 140px;
|
|
text-align: right;
|
|
padding-right: 1rem;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.inv-grid-body {
|
|
flex-direction: column;
|
|
gap: .5rem;
|
|
}
|
|
|
|
.inv-grid-price {
|
|
min-width: 0;
|
|
text-align: left;
|
|
}
|
|
|
|
.inv-grid-trend {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.inv-list-card {
|
|
align-items: flex-start;
|
|
padding-right: 3.75rem;
|
|
}
|
|
|
|
.inv-list-right {
|
|
min-width: 0;
|
|
padding-right: .25rem;
|
|
}
|
|
}
|
|
</style>
|