WIP: Vendoo 1.44.0 – UX Foundation & Guided Workspaces #29

Draft
Masterluke77 wants to merge 284 commits from release/1.44.0-ux-foundation-production-update-v1 into main
Showing only changes of commit c9b532abfb - Show all commits
+94 -136
View File
@@ -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 AZ'],
['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 AZ'], ['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 });
}