[feat] inventory modal initiated
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user