From c9b532abfb25ae2c47cc6ac5f55d2ea91654c6f9 Mon Sep 17 00:00:00 2001 From: Masterluke77 <163446896+Masterluke77@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:51:12 +0200 Subject: [PATCH] fix(ux): harden inventory workspace observers --- public/inventory-workspace.js | 230 ++++++++++++++-------------------- 1 file changed, 94 insertions(+), 136 deletions(-) diff --git a/public/inventory-workspace.js b/public/inventory-workspace.js index c18945d..7d9f992 100644 --- a/public/inventory-workspace.js +++ b/public/inventory-workspace.js @@ -2,7 +2,7 @@ 'use strict'; const ROOT_ID = 'inventory'; - const VIEW_KEY = 'vendoo_inventory_view'; + const SORT_KEY = 'vendoo_inventory_sort'; const createNode = (tag, className = '', text = '') => { const node = document.createElement(tag); @@ -11,17 +11,25 @@ return node; }; + const normalize = value => String(value || '').trim().toLocaleLowerCase('de-DE'); const parseNumber = value => { const match = String(value ?? '').replace(/\./g, '').match(/-?[\d,]+/); return match ? Number(match[0].replace(',', '.')) || 0 : 0; }; - const normalize = value => String(value || '').trim().toLocaleLowerCase('de-DE'); - function readStat(root, label) { const card = [...root.querySelectorAll('#inv-stats .inv-stat-card')] .find(item => normalize(item.querySelector('.inv-stat-label')?.textContent) === normalize(label)); - return card ? card.querySelector('.inv-stat-value')?.textContent?.trim() || '0' : '0'; + return card?.querySelector('.inv-stat-value')?.textContent?.trim() || '0'; + } + + function locationMeta(card) { + return { + unassigned: card.classList.contains('inv-unassigned'), + count: parseNumber(card.querySelector('.inv-loc-count')?.textContent), + value: parseNumber(card.querySelector('.inv-loc-value')?.textContent), + name: card.querySelector('.inv-loc-name')?.textContent?.trim() || '', + }; } function createMetric(key, label, copy) { @@ -35,15 +43,13 @@ } function createOverview(root) { - if (root.querySelector('.ux-inventory-overview')) return; const header = root.querySelector('.inventory-header'); const stats = root.querySelector('#inv-stats'); - if (!header || !stats) return; + if (!header || !stats || root.querySelector('.ux-inventory-overview')) return; const overview = createNode('section', 'ux-inventory-overview'); overview.setAttribute('aria-label', 'Bestandsübersicht'); - - const heading = createNode('div', 'ux-inventory-overview-head'); + const head = createNode('div', 'ux-inventory-overview-head'); const copy = createNode('div'); copy.append( createNode('span', 'ux-inventory-kicker', 'Bestand im Blick'), @@ -53,7 +59,7 @@ const next = createNode('button', 'vd-button primary ux-inventory-next', 'Bestand öffnen'); next.type = 'button'; next.dataset.uxInventoryNext = 'overview'; - heading.append(copy, next); + head.append(copy, next); const metrics = createNode('div', 'ux-inventory-metrics'); metrics.append( @@ -62,27 +68,25 @@ createMetric('active', 'Aktiv', 'Aktuell im Verkaufsbestand'), createMetric('value', 'Lagerwert', 'Summe der aktiven Artikel') ); - - overview.append(heading, metrics); + overview.append(head, metrics); header.after(overview); stats.classList.add('ux-inventory-stats-secondary'); next.addEventListener('click', () => { - const unassigned = root.querySelector('#inv-locations .inv-unassigned:not(.ux-inventory-filtered-out)'); - if (unassigned) { - unassigned.click(); - return; + const target = root.querySelector('#inv-locations .inv-unassigned:not(.ux-inventory-filtered-out)') + || root.querySelector('#inv-locations .inv-location-card:not(.ux-inventory-filtered-out)'); + if (!target) return; + if (target.classList.contains('inv-unassigned')) target.click(); + else { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + target.focus({ preventScroll: true }); } - const first = root.querySelector('#inv-locations .inv-location-card:not(.ux-inventory-filtered-out)'); - first?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - first?.focus({ preventScroll: true }); }); } function createLocationToolbar(root) { - if (root.querySelector('.ux-inventory-toolbar')) return; const locations = root.querySelector('#inv-locations'); - if (!locations) return; + if (!locations || root.querySelector('.ux-inventory-toolbar')) return; const toolbar = createNode('section', 'ux-inventory-toolbar card'); const primary = createNode('div', 'ux-inventory-toolbar-primary'); @@ -93,57 +97,39 @@ search.placeholder = 'Regal, Karton oder Schrank …'; search.dataset.uxInventorySearch = 'true'; searchLabel.append(search); - const count = createNode('span', 'ux-inventory-visible-count', '0 Lagerorte'); count.setAttribute('aria-live', 'polite'); primary.append(searchLabel, count); const controls = createNode('div', 'ux-inventory-controls'); - const filterGroup = createNode('div', 'ux-inventory-filter-group'); - filterGroup.setAttribute('role', 'group'); - filterGroup.setAttribute('aria-label', 'Lagerorte filtern'); - [ - ['all', 'Alle'], - ['assigned', 'Zugewiesen'], - ['unassigned', 'Ohne Lagerort'], - ].forEach(([value, label]) => { + const filters = createNode('div', 'ux-inventory-filter-group'); + filters.setAttribute('role', 'group'); + filters.setAttribute('aria-label', 'Lagerorte filtern'); + [['all', 'Alle'], ['assigned', 'Zugewiesen'], ['unassigned', 'Ohne Lagerort']].forEach(([value, label]) => { const button = createNode('button', 'ux-inventory-filter', label); button.type = 'button'; button.dataset.uxInventoryFilter = value; button.setAttribute('aria-pressed', value === 'all' ? 'true' : 'false'); - filterGroup.append(button); + filters.append(button); }); const sortLabel = createNode('label', 'ux-inventory-sort'); sortLabel.append(createNode('span', '', 'Sortierung')); const sort = createNode('select'); sort.dataset.uxInventorySort = 'true'; - [ - ['attention', 'Aufmerksamkeit zuerst'], - ['name', 'Name A–Z'], - ['items', 'Meiste Artikel'], - ['value', 'Höchster Wert'], - ].forEach(([value, label]) => { - const option = createNode('option', '', label); - option.value = value; - sort.append(option); - }); - try { sort.value = localStorage.getItem(VIEW_KEY) || 'attention'; } catch {} + [['attention', 'Aufmerksamkeit zuerst'], ['name', 'Name A–Z'], ['items', 'Meiste Artikel'], ['value', 'Höchster Wert']] + .forEach(([value, label]) => { + const option = createNode('option', '', label); + option.value = value; + sort.append(option); + }); + try { sort.value = localStorage.getItem(SORT_KEY) || 'attention'; } catch {} sortLabel.append(sort); - - controls.append(filterGroup, sortLabel); + controls.append(filters, sortLabel); toolbar.append(primary, controls); locations.before(toolbar); } - function locationMeta(card) { - const unassigned = card.classList.contains('inv-unassigned'); - const count = parseNumber(card.querySelector('.inv-loc-count')?.textContent); - const value = parseNumber(card.querySelector('.inv-loc-value')?.textContent); - const name = card.querySelector('.inv-loc-name')?.textContent?.trim() || ''; - return { unassigned, count, value, name }; - } - function enhanceLocationCards(root) { root.querySelectorAll('#inv-locations .inv-location-card').forEach(card => { const meta = locationMeta(card); @@ -155,51 +141,45 @@ card.setAttribute('role', 'button'); card.tabIndex = 0; card.setAttribute('aria-label', `${meta.name || 'Lagerort'} öffnen, ${meta.count} Artikel`); - - if (card.dataset.uxInventoryKeyboard !== 'true') { - card.dataset.uxInventoryKeyboard = 'true'; - card.addEventListener('keydown', event => { - if (!['Enter', ' '].includes(event.key)) return; - event.preventDefault(); - card.click(); - }); - } + if (card.dataset.uxInventoryKeyboard === 'true') return; + card.dataset.uxInventoryKeyboard = 'true'; + card.addEventListener('keydown', event => { + if (!['Enter', ' '].includes(event.key)) return; + event.preventDefault(); + card.click(); + }); }); } function sortLocationCards(root) { const container = root.querySelector('#inv-locations'); - const sort = root.querySelector('[data-ux-inventory-sort]')?.value || 'attention'; if (!container) return; - const cards = [...container.querySelectorAll('.inv-location-card')]; - const compare = (a, b) => { + const mode = root.querySelector('[data-ux-inventory-sort]')?.value || 'attention'; + const current = [...container.querySelectorAll('.inv-location-card')]; + const sorted = [...current].sort((a, b) => { const left = locationMeta(a); const right = locationMeta(b); - if (sort === 'name') return left.name.localeCompare(right.name, 'de'); - if (sort === 'items') return right.count - left.count || left.name.localeCompare(right.name, 'de'); - if (sort === 'value') return right.value - left.value || left.name.localeCompare(right.name, 'de'); + if (mode === 'name') return left.name.localeCompare(right.name, 'de'); + if (mode === 'items') return right.count - left.count || left.name.localeCompare(right.name, 'de'); + if (mode === 'value') return right.value - left.value || left.name.localeCompare(right.name, 'de'); if (left.unassigned !== right.unassigned) return left.unassigned ? -1 : 1; return right.count - left.count || left.name.localeCompare(right.name, 'de'); - }; - cards.sort(compare).forEach(card => container.append(card)); + }); + if (!sorted.some((card, index) => current[index] !== card)) return; + sorted.forEach(card => container.append(card)); } function applyLocationFilters(root) { - const search = normalize(root.querySelector('[data-ux-inventory-search]')?.value); - const activeFilter = root.querySelector('[data-ux-inventory-filter][aria-pressed="true"]')?.dataset.uxInventoryFilter || 'all'; + const query = normalize(root.querySelector('[data-ux-inventory-search]')?.value); + const filter = root.querySelector('[data-ux-inventory-filter][aria-pressed="true"]')?.dataset.uxInventoryFilter || 'all'; let visible = 0; - root.querySelectorAll('#inv-locations .inv-location-card').forEach(card => { const meta = locationMeta(card); - const matchesSearch = !search || normalize(meta.name).includes(search); - const matchesFilter = activeFilter === 'all' - || (activeFilter === 'unassigned' && meta.unassigned) - || (activeFilter === 'assigned' && !meta.unassigned); - const show = matchesSearch && matchesFilter; + const show = (!query || normalize(meta.name).includes(query)) + && (filter === 'all' || (filter === 'assigned' && !meta.unassigned) || (filter === 'unassigned' && meta.unassigned)); card.classList.toggle('ux-inventory-filtered-out', !show); if (show) visible += 1; }); - const count = root.querySelector('.ux-inventory-visible-count'); if (count) count.textContent = `${visible} ${visible === 1 ? 'Lagerort' : 'Lagerorte'}`; } @@ -215,14 +195,12 @@ }; Object.entries(values).forEach(([key, value]) => { const node = root.querySelector(`[data-ux-inventory-metric="${key}"]`); - if (node) node.textContent = value; + if (node && node.textContent !== value) node.textContent = value; }); - const next = root.querySelector('[data-ux-inventory-next]'); if (next) { - next.textContent = unassigned > 0 - ? `${unassigned} ${unassigned === 1 ? 'Artikel' : 'Artikel'} ohne Lagerort zuweisen` - : 'Lagerorte durchsuchen'; + const label = unassigned > 0 ? `${unassigned} Artikel ohne Lagerort zuweisen` : 'Lagerorte durchsuchen'; + if (next.textContent !== label) next.textContent = label; next.dataset.uxInventoryNext = unassigned > 0 ? 'unassigned' : 'overview'; } root.classList.toggle('ux-inventory-has-unassigned', unassigned > 0); @@ -242,11 +220,10 @@ if (title) titleWrap.append(title); const more = createNode('details', 'ux-inventory-more-actions'); - const moreSummary = createNode('summary', '', 'Lagerort-Aktionen'); const moreBody = createNode('div', 'ux-inventory-more-actions-body'); const rename = detail.querySelector('#inv-rename-btn'); if (rename) moreBody.append(rename); - more.append(moreSummary, moreBody); + more.append(createNode('summary', '', 'Lagerort-Aktionen'), moreBody); head.append(titleWrap, more); const toolbar = createNode('div', 'ux-inventory-detail-toolbar'); @@ -257,7 +234,6 @@ search.placeholder = 'Titel, SKU, Plattform oder Status …'; search.dataset.uxInventoryDetailSearch = 'true'; searchLabel.append(search); - const selectLabel = createNode('label', 'ux-inventory-select-all'); const selectAll = createNode('input'); selectAll.type = 'checkbox'; @@ -269,8 +245,8 @@ selection.setAttribute('aria-live', 'polite'); const selectedCount = createNode('strong', 'ux-inventory-selected-count', '0 ausgewählt'); const move = detail.querySelector('#inv-move-btn'); - if (move) selection.append(selectedCount, move); - else selection.append(selectedCount); + selection.append(selectedCount); + if (move) selection.append(move); actions.remove(); detail.prepend(head); @@ -279,11 +255,10 @@ search.addEventListener('input', () => applyDetailFilter(root)); selectAll.addEventListener('change', () => { - root.querySelectorAll('#inv-detail-list .history-item:not(.ux-inventory-detail-filtered) .inv-item-check') - .forEach(checkbox => { - checkbox.checked = selectAll.checked; - checkbox.closest('.history-item')?.classList.toggle('ux-inventory-selected', checkbox.checked); - }); + root.querySelectorAll('#inv-detail-list .history-item:not(.ux-inventory-detail-filtered) .inv-item-check').forEach(checkbox => { + checkbox.checked = selectAll.checked; + checkbox.closest('.history-item')?.classList.toggle('ux-inventory-selected', checkbox.checked); + }); updateDetailSelection(root); }); } @@ -292,26 +267,23 @@ root.querySelectorAll('#inv-detail-list .history-item').forEach(row => { const checkbox = row.querySelector('.inv-item-check'); const status = row.querySelector('.status-dot'); - if (status) { + if (status && row.dataset.uxInventoryStatusReady !== 'true') { const raw = normalize(status.textContent); - const label = raw === 'sold' ? 'Verkauft' : raw === 'reserved' ? 'Reserviert' : 'Aktiv'; - status.textContent = label; + status.textContent = raw === 'sold' ? 'Verkauft' : raw === 'reserved' ? 'Reserviert' : 'Aktiv'; row.dataset.uxInventoryStatus = raw || 'active'; + row.dataset.uxInventoryStatusReady = 'true'; } - - if (row.dataset.uxInventoryBound !== 'true') { - row.dataset.uxInventoryBound = 'true'; - checkbox?.addEventListener('change', () => { - row.classList.toggle('ux-inventory-selected', checkbox.checked); - updateDetailSelection(root); - }); - row.addEventListener('click', event => { - if (event.target.closest('input,button,a,select')) return; - if (!checkbox) return; - checkbox.checked = !checkbox.checked; - checkbox.dispatchEvent(new Event('change', { bubbles: true })); - }); - } + if (row.dataset.uxInventoryBound === 'true') return; + row.dataset.uxInventoryBound = 'true'; + checkbox?.addEventListener('change', () => { + row.classList.toggle('ux-inventory-selected', checkbox.checked); + updateDetailSelection(root); + }); + row.addEventListener('click', event => { + if (event.target.closest('input,button,a,select') || !checkbox) return; + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + }); }); applyDetailFilter(root); updateDetailSelection(root); @@ -320,8 +292,7 @@ function applyDetailFilter(root) { const query = normalize(root.querySelector('[data-ux-inventory-detail-search]')?.value); root.querySelectorAll('#inv-detail-list .history-item').forEach(row => { - const show = !query || normalize(row.textContent).includes(query); - row.classList.toggle('ux-inventory-detail-filtered', !show); + row.classList.toggle('ux-inventory-detail-filtered', Boolean(query) && !normalize(row.textContent).includes(query)); }); updateDetailSelection(root); } @@ -329,18 +300,16 @@ function updateDetailSelection(root) { const visible = [...root.querySelectorAll('#inv-detail-list .history-item:not(.ux-inventory-detail-filtered) .inv-item-check')]; const selected = [...root.querySelectorAll('#inv-detail-list .inv-item-check:checked')]; - const bar = root.querySelector('.ux-inventory-selection-bar'); const count = root.querySelector('.ux-inventory-selected-count'); - const move = root.querySelector('#inv-move-btn'); - const selectAll = root.querySelector('[data-ux-inventory-select-all]'); - if (count) count.textContent = `${selected.length} ausgewählt`; - bar?.classList.toggle('has-selection', selected.length > 0); + root.querySelector('.ux-inventory-selection-bar')?.classList.toggle('has-selection', selected.length > 0); + const move = root.querySelector('#inv-move-btn'); if (move) move.disabled = selected.length === 0; + const selectAll = root.querySelector('[data-ux-inventory-select-all]'); if (selectAll) { - const visibleSelected = visible.filter(item => item.checked).length; - selectAll.checked = visible.length > 0 && visibleSelected === visible.length; - selectAll.indeterminate = visibleSelected > 0 && visibleSelected < visible.length; + const selectedVisible = visible.filter(item => item.checked).length; + selectAll.checked = visible.length > 0 && selectedVisible === visible.length; + selectAll.indeterminate = selectedVisible > 0 && selectedVisible < visible.length; } } @@ -356,15 +325,13 @@ event.preventDefault(); const buttons = [...root.querySelectorAll('[data-ux-inventory-filter]')]; const index = buttons.indexOf(button); - const nextIndex = event.key === 'Home' ? 0 - : event.key === 'End' ? buttons.length - 1 - : event.key === 'ArrowRight' ? (index + 1) % buttons.length - : (index - 1 + buttons.length) % buttons.length; + const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 + : event.key === 'ArrowRight' ? (index + 1) % buttons.length : (index - 1 + buttons.length) % buttons.length; buttons[nextIndex]?.focus(); }); }); root.querySelector('[data-ux-inventory-sort]')?.addEventListener('change', event => { - try { localStorage.setItem(VIEW_KEY, event.target.value); } catch {} + try { localStorage.setItem(SORT_KEY, event.target.value); } catch {} sortLocationCards(root); applyLocationFilters(root); }); @@ -388,19 +355,10 @@ refresh(root); }); }; - const observer = new MutationObserver(schedule); - [ - root.querySelector('#inv-stats'), - root.querySelector('#inv-locations'), - root.querySelector('#inv-detail-list'), - root.querySelector('#inv-detail-title'), - ].filter(Boolean).forEach(node => observer.observe(node, { - subtree: true, - childList: true, - characterData: true, - })); - + [root.querySelector('#inv-stats'), root.querySelector('#inv-locations'), root.querySelector('#inv-detail-list')] + .filter(Boolean) + .forEach(node => observer.observe(node, { subtree: true, childList: true, characterData: true })); window.addEventListener('beforeunload', () => observer.disconnect(), { once: true }); }