(() => { const grid = document.getElementById('inventory-grid'); const note = document.getElementById('inventory-note'); const selected = document.getElementById('selected-item'); const createForm = document.getElementById('create-form'); const editForm = document.getElementById('edit-form'); const sellButton = document.getElementById('sell-one'); const restockButton = document.getElementById('restock-five'); const deleteButton = document.getElementById('delete-item'); const commandJSON = document.getElementById('command-json'); const rawJSON = document.getElementById('raw-json'); const eventSummary = document.getElementById('event-summary'); const auditList = document.getElementById('audit-list'); const slotNote = document.getElementById('slot-note'); const selectedSummary = document.getElementById('selected-summary'); const selectedName = document.getElementById('selected-name'); const selectedMeta = document.getElementById('selected-meta'); const moreActions = document.querySelector('.more-actions'); const detailTabs = Array.from(document.querySelectorAll('[data-detail-tab]')); const detailViews = Array.from(document.querySelectorAll('[data-detail-view]')); const maxInventoryItems = 4; let inventory = []; let lastChangedID = null; let preferredSelectedID = ''; let mode = 'product'; function pretty(value) { return JSON.stringify(value, null, 2); } function setCommand(payload) { commandJSON.textContent = pretty(payload); } function stockClass(quantity) { if (quantity <= 0) return 'empty'; if (quantity <= 5) return 'low'; return 'ok'; } function stockLabel(quantity) { if (quantity <= 0) return 'empty'; if (quantity <= 5) return 'low stock'; return 'in stock'; } function renderInventory(items) { const previousSelectedID = selected.value; inventory = Array.isArray(items) ? items : []; grid.innerHTML = ''; selected.innerHTML = ''; if (!inventory.length) { selected.innerHTML = ''; for (let i = 0; i < maxInventoryItems; i += 1) renderEmptySlot(); mode = 'create'; updateActionPane(); note.textContent = `0 / ${maxInventoryItems} products`; return; } for (const item of inventory) { const row = document.createElement('button'); row.type = 'button'; row.className = `inventory-slot ${stockClass(item.quantity)}`; row.dataset.id = item.id; row.innerHTML = ` ${escapeHTML(item.sku)}${stockLabel(item.quantity)} ${escapeHTML(item.name)} ${item.quantity} / ${escapeHTML(item.location)} `; row.addEventListener('click', () => { mode = 'product'; selected.value = String(item.id); if (moreActions) moreActions.open = false; updateActionPane(); }); grid.appendChild(row); const option = document.createElement('option'); option.value = item.id; option.textContent = `${item.sku} / ${item.name}`; selected.appendChild(option); } for (let i = inventory.length; i < maxInventoryItems; i += 1) { renderEmptySlot(); } if (preferredSelectedID && inventory.some((item) => String(item.id) === preferredSelectedID)) { selected.value = preferredSelectedID; preferredSelectedID = ''; mode = 'product'; } else if (previousSelectedID && inventory.some((item) => String(item.id) === previousSelectedID)) { selected.value = previousSelectedID; mode = 'product'; } else if (mode !== 'create') { selected.value = String(inventory[0].id); mode = 'product'; } updateActionPane(); note.textContent = `${inventory.length} / ${maxInventoryItems} products`; } function renderEmptySlot() { const empty = document.createElement('button'); empty.type = 'button'; empty.className = 'inventory-slot create-slot'; empty.innerHTML = '+ Create productempty slot'; empty.addEventListener('click', () => { mode = 'create'; selected.value = ''; if (moreActions) moreActions.open = false; updateActionPane(); }); grid.appendChild(empty); } function updateActionPane() { const item = inventory.find((row) => String(row.id) === selected.value); const hasSpace = inventory.length < maxInventoryItems; const createMode = mode === 'create'; document.querySelectorAll('.inventory-slot[data-id]').forEach((row) => { row.classList.toggle('selected', !createMode && row.dataset.id === selected.value); row.classList.toggle('changed', row.dataset.id === String(lastChangedID || '')); }); document.querySelectorAll('.create-slot').forEach((row) => { row.classList.toggle('selected', createMode); }); selectedSummary.classList.toggle('create-mode', createMode); editForm.hidden = createMode || !item; createForm.hidden = !createMode; if (createMode) { slotNote.textContent = hasSpace ? `${inventory.length} / ${maxInventoryItems} products. Create fills the empty slot.` : `${maxInventoryItems} / ${maxInventoryItems} products. Delete one before creating.`; selectedName.textContent = hasSpace ? 'Empty slot' : 'Slots full'; selectedMeta.textContent = hasSpace ? 'Create a product in the available slot.' : 'No empty inventory slots are available.'; if (moreActions) moreActions.open = false; createForm.querySelector('button[type="submit"]').disabled = !hasSpace; return; } createForm.querySelector('button[type="submit"]').disabled = !hasSpace; slotNote.textContent = `${inventory.length} / ${maxInventoryItems} products.`; if (!item) { selectedName.textContent = 'No product selected'; selectedMeta.textContent = 'Select a product slot.'; return; } selectedName.textContent = `${item.sku} / ${item.name}`; selectedMeta.textContent = `quantity ${item.quantity} ยท ${item.location}`; } function selectCreatedOrChanged(event) { const id = changedInventoryID(event); if (!id) return; preferredSelectedID = String(id); mode = event.op === 'd' ? 'product' : 'product'; } async function loadInitialInventory() { const response = await fetch('/api/inventory', { headers: { Accept: 'application/json' } }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const rows = await response.json(); renderInventory(rows); } async function sendCommand(method, url, body, label) { setCommand({ command: label, method, url, body: body || null, note: 'UI waits for CDC event before changing inventory rows.' }); const response = await fetch(url, { method, headers: body ? { 'Content-Type': 'application/json', Accept: 'application/json' } : { Accept: 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); const payload = await response.json().catch(() => ({})); setCommand({ ...payload, method, url, request: body || null, waiting_for: 'Debezium -> NATS JetStream -> SSE' }); return response.ok; } function selectedID() { return selected.value; } createForm.addEventListener('submit', async (event) => { event.preventDefault(); const data = new FormData(createForm); const ok = await sendCommand('POST', '/api/inventory', { sku: String(data.get('sku')).trim(), name: String(data.get('name')).trim(), quantity: Number(data.get('quantity')), location: String(data.get('location')).trim(), }, 'CREATE inventory item'); if (ok) createForm.reset(); }); editForm.addEventListener('submit', async (event) => { event.preventDefault(); if (!selectedID()) return; const data = new FormData(editForm); const body = {}; const name = String(data.get('name')).trim(); const location = String(data.get('location')).trim(); if (name) body.name = name; if (location) body.location = location; await sendCommand('PATCH', `/api/inventory/${selectedID()}`, body, 'UPDATE inventory name/location'); editForm.elements.name.value = ''; editForm.elements.location.value = ''; if (moreActions) moreActions.open = false; }); sellButton.addEventListener('click', async () => { if (!selectedID()) return; await sendCommand('PATCH', `/api/inventory/${selectedID()}/sell`, null, 'UPDATE inventory quantity -1'); }); restockButton.addEventListener('click', async () => { if (!selectedID()) return; await sendCommand('PATCH', `/api/inventory/${selectedID()}/restock`, { amount: 5 }, 'UPDATE inventory quantity +5'); }); deleteButton.addEventListener('click', async () => { if (!selectedID()) return; await sendCommand('DELETE', `/api/inventory/${selectedID()}`, null, 'DELETE inventory item'); if (moreActions) moreActions.open = false; }); function connectEvents() { const events = new EventSource('/events'); events.addEventListener('cdc', (message) => { const event = JSON.parse(message.data); lastChangedID = changedInventoryID(event); selectCreatedOrChanged(event); renderInventory(event.inventory); renderEvent(event); renderAudit(event); rawJSON.textContent = pretty(event.raw || {}); }); } function changedInventoryID(event) { if (event.after && event.after.id) return event.after.id; if (event.before && event.before.id) return event.before.id; return null; } function renderEvent(event) { const changes = diff(event.before, event.after); eventSummary.innerHTML = `
CDC event received: ${escapeHTML(event.crud)}
source: ${escapeHTML(event.source)}
${escapeHTML(changes || 'No before/after field changes available.')}
`;
}
function renderAudit(event) {
if (auditList.querySelector('.muted')) auditList.innerHTML = '';
const row = document.createElement('article');
row.className = 'audit-row';
row.innerHTML = `
${escapeHTML(event.crud)}
${escapeHTML(event.table)}
${escapeHTML(diff(event.before, event.after) || 'snapshot/delete event')}
`;
auditList.prepend(row);
while (auditList.children.length > 12) auditList.lastElementChild.remove();
}
function diff(before, after) {
if (!before && !after) return '';
if (!before && after) return Object.entries(after).map(([key, value]) => `${key}: ${value}`).join('\n');
if (before && !after) return Object.entries(before).map(([key, value]) => `${key}: ${value} -> deleted`).join('\n');
const lines = [];
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
for (const key of keys) {
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
lines.push(`${key}: ${before[key]} -> ${after[key]}`);
}
}
return lines.join('\n');
}
function escapeHTML(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
}[char]));
}
selected.addEventListener('change', () => {
mode = 'product';
if (moreActions) moreActions.open = false;
updateActionPane();
});
detailTabs.forEach((tab) => {
tab.addEventListener('click', () => {
const name = tab.dataset.detailTab;
detailTabs.forEach((item) => item.classList.toggle('active', item === tab));
detailViews.forEach((view) => view.classList.toggle('active', view.dataset.detailView === name));
});
});
loadInitialInventory().catch((err) => {
note.textContent = `Could not load inventory: ${err.message}`;
});
connectEvents();
})();