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 a07173eae6 - Show all commits
+422
View File
@@ -0,0 +1,422 @@
(() => {
'use strict';
const ROOT_ID = 'inventory';
const VIEW_KEY = 'vendoo_inventory_view';
const createNode = (tag, className = '', text = '') => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
};
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';
}
function createMetric(key, label, copy) {
const item = createNode('article', 'ux-inventory-metric');
const value = createNode('strong', 'ux-inventory-metric-value', '0');
value.dataset.uxInventoryMetric = key;
const body = createNode('span', 'ux-inventory-metric-copy');
body.append(createNode('b', '', label), createNode('small', '', copy));
item.append(value, body);
return item;
}
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;
const overview = createNode('section', 'ux-inventory-overview');
overview.setAttribute('aria-label', 'Bestandsübersicht');
const heading = createNode('div', 'ux-inventory-overview-head');
const copy = createNode('div');
copy.append(
createNode('span', 'ux-inventory-kicker', 'Bestand im Blick'),
createNode('h3', '', 'Was braucht jetzt Aufmerksamkeit?'),
createNode('p', '', 'Nicht zugewiesene Artikel zuerst einordnen, danach Lagerorte durchsuchen und Bestände gezielt bearbeiten.')
);
const next = createNode('button', 'vd-button primary ux-inventory-next', 'Bestand öffnen');
next.type = 'button';
next.dataset.uxInventoryNext = 'overview';
heading.append(copy, next);
const metrics = createNode('div', 'ux-inventory-metrics');
metrics.append(
createMetric('unassigned', 'Ohne Lagerort', 'Diese Artikel zuerst zuweisen'),
createMetric('locations', 'Lagerorte', 'Strukturierte Ablageplätze'),
createMetric('active', 'Aktiv', 'Aktuell im Verkaufsbestand'),
createMetric('value', 'Lagerwert', 'Summe der aktiven Artikel')
);
overview.append(heading, 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 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;
const toolbar = createNode('section', 'ux-inventory-toolbar card');
const primary = createNode('div', 'ux-inventory-toolbar-primary');
const searchLabel = createNode('label', 'ux-inventory-search');
searchLabel.append(createNode('span', '', 'Lagerort suchen'));
const search = createNode('input');
search.type = 'search';
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 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);
});
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 {}
sortLabel.append(sort);
controls.append(filterGroup, 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);
card.dataset.uxInventoryKind = meta.unassigned ? 'unassigned' : 'assigned';
card.dataset.uxInventoryCount = String(meta.count);
card.dataset.uxInventoryValue = String(meta.value);
card.dataset.uxInventoryName = normalize(meta.name);
card.classList.toggle('ux-inventory-needs-attention', meta.unassigned && meta.count > 0);
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();
});
}
});
}
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 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 (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));
}
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';
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;
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'}`;
}
function updateOverview(root) {
const unassignedCard = root.querySelector('#inv-locations .inv-unassigned');
const unassigned = unassignedCard ? locationMeta(unassignedCard).count : 0;
const values = {
unassigned: String(unassigned),
locations: readStat(root, 'Lagerorte'),
active: readStat(root, 'Aktive Artikel'),
value: readStat(root, 'Lagerwert'),
};
Object.entries(values).forEach(([key, value]) => {
const node = root.querySelector(`[data-ux-inventory-metric="${key}"]`);
if (node) 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';
next.dataset.uxInventoryNext = unassigned > 0 ? 'unassigned' : 'overview';
}
root.classList.toggle('ux-inventory-has-unassigned', unassigned > 0);
}
function createDetailWorkspace(root) {
const detail = root.querySelector('#inv-detail');
const list = root.querySelector('#inv-detail-list');
const actions = detail?.querySelector('.inventory-detail-actions');
if (!detail || !list || !actions || detail.dataset.uxInventoryStructured === 'true') return;
const head = createNode('div', 'ux-inventory-detail-head');
const titleWrap = createNode('div', 'ux-inventory-detail-title-wrap');
const back = detail.querySelector('#inv-back');
const title = detail.querySelector('#inv-detail-title');
if (back) titleWrap.append(back);
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);
head.append(titleWrap, more);
const toolbar = createNode('div', 'ux-inventory-detail-toolbar');
const searchLabel = createNode('label', 'ux-inventory-detail-search');
searchLabel.append(createNode('span', '', 'Artikel suchen'));
const search = createNode('input');
search.type = 'search';
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';
selectAll.dataset.uxInventorySelectAll = 'true';
selectLabel.append(selectAll, createNode('span', '', 'Sichtbare auswählen'));
toolbar.append(searchLabel, selectLabel);
const selection = createNode('div', 'ux-inventory-selection-bar');
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);
actions.remove();
detail.prepend(head);
list.before(toolbar, selection);
detail.dataset.uxInventoryStructured = 'true';
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);
});
updateDetailSelection(root);
});
}
function enhanceDetailRows(root) {
root.querySelectorAll('#inv-detail-list .history-item').forEach(row => {
const checkbox = row.querySelector('.inv-item-check');
const status = row.querySelector('.status-dot');
if (status) {
const raw = normalize(status.textContent);
const label = raw === 'sold' ? 'Verkauft' : raw === 'reserved' ? 'Reserviert' : 'Aktiv';
status.textContent = label;
row.dataset.uxInventoryStatus = raw || 'active';
}
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 }));
});
}
});
applyDetailFilter(root);
updateDetailSelection(root);
}
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);
});
updateDetailSelection(root);
}
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);
if (move) move.disabled = selected.length === 0;
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;
}
}
function bindToolbar(root) {
root.querySelector('[data-ux-inventory-search]')?.addEventListener('input', () => applyLocationFilters(root));
root.querySelectorAll('[data-ux-inventory-filter]').forEach(button => {
button.addEventListener('click', () => {
root.querySelectorAll('[data-ux-inventory-filter]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
applyLocationFilters(root);
});
button.addEventListener('keydown', event => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
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;
buttons[nextIndex]?.focus();
});
});
root.querySelector('[data-ux-inventory-sort]')?.addEventListener('change', event => {
try { localStorage.setItem(VIEW_KEY, event.target.value); } catch {}
sortLocationCards(root);
applyLocationFilters(root);
});
}
function refresh(root) {
enhanceLocationCards(root);
sortLocationCards(root);
applyLocationFilters(root);
updateOverview(root);
enhanceDetailRows(root);
}
function observe(root) {
let scheduled = false;
const schedule = () => {
if (scheduled) return;
scheduled = true;
window.requestAnimationFrame(() => {
scheduled = false;
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,
}));
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
}
function init() {
const root = document.getElementById(ROOT_ID);
if (!root || root.dataset.uxInventoryReady === 'true') return;
root.dataset.uxInventoryReady = 'true';
root.classList.add('ux-inventory-ready');
createOverview(root);
createLocationToolbar(root);
createDetailWorkspace(root);
bindToolbar(root);
refresh(root);
observe(root);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
else init();
})();