[feat] inventory modal initiated

This commit is contained in:
Thad Miller
2026-07-09 18:33:16 -04:00
parent 4990c3aa04
commit 5abd6e4b92
5 changed files with 496 additions and 6 deletions

View File

@@ -253,6 +253,13 @@ const totalGain = summary.totalGain || 0;
</div>
</div>
</div>
<!-- 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>
<!--
<div class="modal fade" id="inventoryEditModal" tabindex="-1" aria-labelledby="inventoryEditLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-dialog-centered">
@@ -290,6 +297,237 @@ const totalGain = summary.totalGain || 0;
</div>
</div>
</div> -->
<script is:inline>
(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>
</div>
<Footer slot="footer" />
</Layout>