Files
vendoo/public/inventory-workspace.js
T

380 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'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 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(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();
})();