380 lines
17 KiB
JavaScript
380 lines
17 KiB
JavaScript
(() => {
|
||
'use strict';
|
||
|
||
const ROOT_ID = 'inventory';
|
||
const SORT_KEY = 'vendoo_inventory_sort';
|
||
|
||
const createNode = (tag, className = '', text = '') => {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text) node.textContent = text;
|
||
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;
|
||
};
|
||
|
||
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?.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) {
|
||
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) {
|
||
const header = root.querySelector('.inventory-header');
|
||
const stats = root.querySelector('#inv-stats');
|
||
if (!header || !stats || root.querySelector('.ux-inventory-overview')) return;
|
||
|
||
const overview = createNode('section', 'ux-inventory-overview');
|
||
overview.setAttribute('aria-label', 'Bestandsübersicht');
|
||
const head = 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';
|
||
head.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(head, metrics);
|
||
header.after(overview);
|
||
stats.classList.add('ux-inventory-stats-secondary');
|
||
|
||
next.addEventListener('click', () => {
|
||
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 });
|
||
}
|
||
});
|
||
}
|
||
|
||
function createLocationToolbar(root) {
|
||
const locations = root.querySelector('#inv-locations');
|
||
if (!locations || root.querySelector('.ux-inventory-toolbar')) 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 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');
|
||
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(SORT_KEY) || 'attention'; } catch {}
|
||
sortLabel.append(sort);
|
||
controls.append(filters, sortLabel);
|
||
toolbar.append(primary, controls);
|
||
locations.before(toolbar);
|
||
}
|
||
|
||
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') 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');
|
||
if (!container) return;
|
||
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 (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');
|
||
});
|
||
if (!sorted.some((card, index) => current[index] !== card)) return;
|
||
sorted.forEach(card => container.append(card));
|
||
}
|
||
|
||
function applyLocationFilters(root) {
|
||
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 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'}`;
|
||
}
|
||
|
||
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) node.textContent = value;
|
||
});
|
||
const next = root.querySelector('[data-ux-inventory-next]');
|
||
if (next) {
|
||
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);
|
||
}
|
||
|
||
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 moreBody = createNode('div', 'ux-inventory-more-actions-body');
|
||
const rename = detail.querySelector('#inv-rename-btn');
|
||
if (rename) moreBody.append(rename);
|
||
more.append(createNode('summary', '', 'Lagerort-Aktionen'), 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');
|
||
selection.append(selectedCount);
|
||
if (move) selection.append(move);
|
||
|
||
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 && row.dataset.uxInventoryStatusReady !== 'true') {
|
||
const raw = normalize(status.textContent);
|
||
status.textContent = raw === 'sold' ? 'Verkauft' : raw === 'reserved' ? 'Reserviert' : 'Aktiv';
|
||
row.dataset.uxInventoryStatus = raw || 'active';
|
||
row.dataset.uxInventoryStatusReady = '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);
|
||
}
|
||
|
||
function applyDetailFilter(root) {
|
||
const query = normalize(root.querySelector('[data-ux-inventory-detail-search]')?.value);
|
||
root.querySelectorAll('#inv-detail-list .history-item').forEach(row => {
|
||
row.classList.toggle('ux-inventory-detail-filtered', Boolean(query) && !normalize(row.textContent).includes(query));
|
||
});
|
||
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 count = root.querySelector('.ux-inventory-selected-count');
|
||
if (count) count.textContent = `${selected.length} ausgewählt`;
|
||
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 selectedVisible = visible.filter(item => item.checked).length;
|
||
selectAll.checked = visible.length > 0 && selectedVisible === visible.length;
|
||
selectAll.indeterminate = selectedVisible > 0 && selectedVisible < 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(SORT_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')]
|
||
.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();
|
||
})(); |