Files
pokemon/src/pages/dashboard.astro

534 lines
22 KiB
Plaintext
Raw Normal View History

2026-03-25 08:42:17 -04:00
---
import Layout from "../layouts/Main.astro";
import Footer from "../components/Footer.astro";
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;
// 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">
<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>
</div>
<ul id="catalogList" class="list-group list-group-flush">
<li
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"
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>
<span class="badge rounded-pill text-bg-secondary small ms-auto">{totalQty}</span>
2026-03-25 08:42:17 -04:00
</li>
{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"
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>
<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 mt-4">
<div class="d-flex flex-wrap gap-2 mb-4">
2026-03-25 08:42:17 -04:00
<a href="/pokemon" class="btn btn-vendor">Add cards</a>
2026-03-25 08:42:17 -04:00
<button
type="button"
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">
<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>
</div>
</main>
</div>
2026-05-22 06:28:40 -04:00
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-2">
2026-03-25 08:42:17 -04:00
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
</p>
<p class="small text-secondary mb-3">
Optionally add a <code>date</code> column (e.g. <code>2026-07-16</code>) to record when each card was acquired &mdash; it&rsquo;s used as the entry&rsquo;s added/purchase date. Rows without a date default to today.
</p>
2026-03-25 08:42:17 -04:00
<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 overflow-auto" id="csvPreviewContent" style="max-height: 12rem;">—</div>
2026-03-25 08:42:17 -04:00
</div>
<div id="csvImportResult" class="mt-3 d-none"></div>
2026-03-25 08:42:17 -04:00
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-danger" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload</button>
2026-03-25 08:42:17 -04:00
</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>
<script is:inline>
(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');
});
}
})();
// ── Bulk CSV import ──────────────────────────────────────────────────────
(function () {
const importModal = document.getElementById('bulkImportModal');
const fileInput = document.getElementById('csvFileInput');
const uploadBtn = document.getElementById('csvUploadBtn');
const preview = document.getElementById('csvPreview');
const previewContent = document.getElementById('csvPreviewContent');
const result = document.getElementById('csvImportResult');
if (!importModal || !fileInput || !uploadBtn) return;
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
let selectedFile = null;
let didImport = false;
fileInput.addEventListener('change', () => {
selectedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
result.classList.add('d-none');
if (!selectedFile) { preview.classList.add('d-none'); return; }
const reader = new FileReader();
reader.onload = () => {
const lines = String(reader.result || '').split(/\r?\n/).filter((l) => l.trim()).slice(0, 6);
previewContent.innerHTML = lines
.map((l, i) => `<div class="${i === 0 ? 'text-light fw-semibold' : ''}">${esc(l)}</div>`)
.join('');
preview.classList.remove('d-none');
};
reader.readAsText(selectedFile);
});
uploadBtn.addEventListener('click', async () => {
if (!selectedFile) { alert('Choose a CSV file first.'); return; }
const original = uploadBtn.textContent;
uploadBtn.disabled = true;
uploadBtn.textContent = 'Importing…';
result.classList.add('d-none');
try {
const body = new FormData();
body.append('action', 'import');
body.append('file', selectedFile);
const res = await fetch('/api/inventory', { method: 'POST', body });
const data = await res.json();
if (!res.ok) {
result.className = 'mt-3 alert alert-danger py-2 small';
result.textContent = data.error || 'Import failed.';
result.classList.remove('d-none');
return;
}
didImport = data.imported > 0;
const skipped = data.skipped || [];
let html = `<div class="fw-semibold ${data.imported > 0 ? 'text-success' : ''}">Imported ${data.imported} card${data.imported === 1 ? '' : 's'}.</div>`;
if (skipped.length) {
html += `<div class="mt-1 text-warning fw-semibold">Skipped ${skipped.length}:</div>`;
html += '<ul class="mb-0 ps-3">' + skipped
.map((s) => `<li>Row ${s.row}${s.name ? ' (' + esc(s.name) + ')' : ''}: ${esc(s.reason)}</li>`)
.join('') + '</ul>';
}
result.className = 'mt-3 alert alert-dark py-2 small overflow-auto';
result.style.maxHeight = '14rem';
result.innerHTML = html;
result.classList.remove('d-none');
uploadBtn.textContent = didImport ? 'Done' : 'Upload';
} catch (e) {
result.className = 'mt-3 alert alert-danger py-2 small';
result.textContent = 'Import failed.';
result.classList.remove('d-none');
} finally {
uploadBtn.disabled = false;
if (uploadBtn.textContent === 'Importing…') uploadBtn.textContent = original;
}
});
// Refresh the dashboard (grid, catalogs, totals) after a successful import.
importModal.addEventListener('hidden.bs.modal', () => {
if (didImport) window.location.reload();
});
})();
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 = '6m';
2026-07-09 18:33:16 -04:00
root.querySelectorAll('.inv-price-range-btn').forEach(b =>
b.classList.toggle('active', b.dataset.range === '6m'));
2026-07-09 18:33:16 -04:00
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();
});
// ── Delete inventory entry ──────────────────────────────────────────────
modal.addEventListener('click', async (e) => {
const delBtn = e.target.closest('[data-inventory-delete]');
if (!delBtn) return;
const form = modal.querySelector('[data-inventory-edit-form]');
const inventoryId = form?.querySelector('[name=inventoryId]')?.value;
const cardId = form?.querySelector('[name=cardId]')?.value;
if (!inventoryId) return;
if (!confirm('Are you sure you want to remove this card from your inventory?')) return;
const original = delBtn.textContent;
delBtn.disabled = true; delBtn.textContent = 'Deleting…';
try {
const body = new FormData();
body.append('action', 'remove');
body.append('inventoryId', inventoryId);
if (cardId) body.append('cardId', cardId);
const res = await fetch('/api/inventory', { method: 'POST', body });
if (res.ok) {
// drop the matching grid tile, then close the modal
document.querySelector(`#gridView [data-inventory-id="${inventoryId}"]`)?.closest('.col')?.remove();
const instance = window.bootstrap?.Modal.getInstance(modal);
if (instance) instance.hide();
else modal.querySelector('[data-bs-dismiss=modal]')?.click();
} else {
delBtn.disabled = false; delBtn.textContent = original;
}
} catch {
delBtn.disabled = false; delBtn.textContent = original;
}
});
2026-07-09 18:33:16 -04:00
// ── Save catalog + note ─────────────────────────────────────────────────
modal.addEventListener('submit', async (e) => {
const form = e.target.closest('[data-inventory-edit-form]');
if (!form) return;
e.preventDefault();
const btn = modal.querySelector('button[type="submit"][form="inventoryEditForm"]');
2026-07-09 18:33:16 -04:00
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) {
const instance = window.bootstrap?.Modal.getInstance(modal);
if (instance) instance.hide();
else modal.querySelector('[data-bs-dismiss=modal]')?.click();
2026-07-09 18:33:16 -04:00
} 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>