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