WIP: Vendoo 1.44.0 – UX Foundation & Guided Workspaces #29
+94
-136
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user