diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index e85b72b..8ea0db9 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.swo *~ .DS_Store +bin/ diff --git a/COPYING b/COPYING old mode 100644 new mode 100755 diff --git a/ChangeLog.md b/ChangeLog.md old mode 100644 new mode 100755 diff --git a/PRODUKT-UMBENENNUNG.md b/PRODUKT-UMBENENNUNG.md old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/REFERENZ-SCHEMA.md b/REFERENZ-SCHEMA.md old mode 100644 new mode 100755 diff --git a/admin/about.php b/admin/about.php old mode 100644 new mode 100755 diff --git a/admin/setup.php b/admin/setup.php old mode 100644 new mode 100755 diff --git a/ajax/product_actions.php b/ajax/product_actions.php old mode 100644 new mode 100755 index f95f7ea..b725430 --- a/ajax/product_actions.php +++ b/ajax/product_actions.php @@ -491,6 +491,40 @@ switch ($action) { } break; + case 'update_margin': + if (!$user->hasRight('produktverwaltung', 'write')) { + $response['error'] = 'Permission denied'; + break; + } + if (empty($productId)) { + $response['error'] = 'Missing product_id'; + break; + } + + $marginValue = GETPOST('value', 'alpha'); + if ($marginValue === '' || $marginValue === null) { + $response['error'] = 'Missing value'; + break; + } + + $marginValue = (float) price2num($marginValue); + + // Update preisbot_margin extrafield directly + $sql = "UPDATE ".MAIN_DB_PREFIX."product_extrafields SET preisbot_margin = ".((float) $marginValue)." WHERE fk_object = ".((int) $productId); + $resql = $db->query($sql); + if ($resql) { + // Check if row existed + if ($db->affected_rows($resql) == 0) { + // Maybe no extrafields row yet - insert + $sql2 = "INSERT INTO ".MAIN_DB_PREFIX."product_extrafields (fk_object, preisbot_margin) VALUES (".((int) $productId).", ".((float) $marginValue).")"; + $db->query($sql2); + } + $response['success'] = true; + } else { + $response['error'] = 'Database error'; + } + break; + case 'update_description': if (!$user->hasRight('produktverwaltung', 'write')) { $response['error'] = 'Permission denied'; diff --git a/build/buildzip.php b/build/buildzip.php old mode 100644 new mode 100755 diff --git a/build/makepack-produktverwaltung.conf b/build/makepack-produktverwaltung.conf old mode 100644 new mode 100755 diff --git a/core/modules/modProduktVerwaltung.class.php b/core/modules/modProduktVerwaltung.class.php old mode 100644 new mode 100755 index 6941063..52a1b00 --- a/core/modules/modProduktVerwaltung.class.php +++ b/core/modules/modProduktVerwaltung.class.php @@ -76,7 +76,7 @@ class modProduktVerwaltung extends DolibarrModules $this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@produktverwaltung' // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z' - $this->version = '1.0'; + $this->version = '1.6'; // Url to the file with your last numberversion of this module //$this->url_last_version = 'http://www.example.com/versionmodule.txt'; diff --git a/css/produktverwaltung.css b/css/produktverwaltung.css old mode 100644 new mode 100755 index 8b811d6..cb1c306 --- a/css/produktverwaltung.css +++ b/css/produktverwaltung.css @@ -75,8 +75,19 @@ } .pv-tree ul { list-style: none; - padding-left: 24px; + padding-left: 20px; margin: 0; + border-left: 2px solid #dde3ea; +} +/* Abstand zwischen Geschwister-Kategorien auf Ebene 1 (Root) */ +.pv-tree > li + li { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #dde3ea; +} +/* Abstand zwischen Geschwister-Kategorien auf tieferen Ebenen */ +.pv-tree ul > li + li { + margin-top: 4px; } .pv-tree li { margin: 0; @@ -93,6 +104,24 @@ font-weight: 600; font-size: 0.95em; } +/* Ebene 1: größer */ +.pv-tree > li > .pv-category-header { + font-size: 1.05em; + padding: 6px 10px; + background: var(--colorbacktitle1, #f0f4f8); + border: 1px solid var(--colorborder, #dde3ea); +} +/* Ebene 2 */ +.pv-tree > li > .pv-category-children > ul > li > .pv-category-header { + font-size: 0.95em; + padding-left: 6px; + border-left: 3px solid #b0bec5; +} +/* Ebene 3+ */ +.pv-tree > li > .pv-category-children > ul > li .pv-category-children ul .pv-category-header { + font-size: 0.9em; + color: #555; +} .pv-category-header:hover { background: var(--colorbacklinepairhover, #f5f5f5); } @@ -141,7 +170,7 @@ opacity: 1; } .pv-category-children { - /* animated via JS */ + padding: 2px 0 4px 0; } .pv-category-children.collapsed { display: none; @@ -153,6 +182,13 @@ border-collapse: collapse; width: calc(100% - 22px); font-size: 0.9em; + table-layout: fixed; +} +.pv-products th, +.pv-products td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .pv-products th { background: var(--colorbacktitle1, #f8f8f8); @@ -162,7 +198,6 @@ font-size: 0.85em; color: #555; border-bottom: 2px solid var(--colorborder, #ddd); - white-space: nowrap; } .pv-products td { padding: 3px 8px; @@ -175,7 +210,6 @@ .pv-products .pv-col-status { width: 40px; text-align: center; - white-space: nowrap; } .pv-products .pv-col-status .fas { font-size: 0.75em; @@ -188,30 +222,25 @@ color: #ccc; } .pv-products .pv-col-ref { - width: 160px; + width: 200px; font-family: monospace; font-weight: 600; } .pv-products .pv-col-label { - /* flex */ + /* nimmt den restlichen Platz */ } .pv-products .pv-col-desc { - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + width: 180px; font-size: 0.85em; color: #666; } .pv-products .pv-col-price { - width: 80px; + width: 110px; text-align: right; - white-space: nowrap; } .pv-products .pv-col-margin { - width: 60px; + width: 70px; text-align: right; - white-space: nowrap; } .pv-supplier-tag { display: inline-block; @@ -236,10 +265,17 @@ .pv-margin-warn { color: #e74c3c; } +.pv-margin-extreme { + color: #e74c3c; + font-weight: 700; +} +.pv-margin-extreme .fas { + font-size: 0.8em; + margin-right: 2px; +} .pv-products .pv-col-actions { - width: 110px; + width: 120px; text-align: center; - white-space: nowrap; } .pv-products .pv-col-actions a, .pv-products .pv-col-actions button { @@ -320,6 +356,42 @@ width: 100%; } +/* === Archiv Sektion === */ +.pv-archive { + margin-top: 15px; + border: 2px solid #bdc3c7; + border-radius: 4px; + overflow: hidden; + opacity: 0.7; +} +.pv-archive:hover { + opacity: 1; +} +.pv-archive-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #ecf0f1; + border-bottom: 1px solid #bdc3c7; + cursor: pointer; + font-weight: 600; + color: #7f8c8d; +} +.pv-archive-header:hover { + background: #dfe6e9; +} +.pv-archive-body { + padding: 0; +} +.pv-archive-body.collapsed { + display: none; +} +.pv-archive .pv-products { + margin: 0; + width: 100%; +} + /* === Kategorie-Zuweisungs-Dialog === */ .pv-dialog-overlay { position: fixed; diff --git a/export.php b/export.php old mode 100644 new mode 100755 diff --git a/img/README.md b/img/README.md old mode 100644 new mode 100755 diff --git a/js/produktverwaltung.js b/js/produktverwaltung.js old mode 100644 new mode 100755 index 5295300..8b432ae --- a/js/produktverwaltung.js +++ b/js/produktverwaltung.js @@ -80,6 +80,15 @@ document.addEventListener('DOMContentLoaded', function() { }); } + // Archiv header toggle + var archiveHeader = document.querySelector('.pv-archive-header'); + if (archiveHeader) { + archiveHeader.addEventListener('click', function() { + var body = document.querySelector('.pv-archive-body'); + if (body) body.classList.toggle('collapsed'); + }); + } + // === Ref-Schema toggle === var schemaBox = document.querySelector('.pv-ref-schema'); if (schemaBox) { @@ -187,10 +196,15 @@ document.addEventListener('DOMContentLoaded', function() { function startEditing(el) { if (el.querySelector('.pv-edit-input')) return; // Already editing - var field = el.dataset.field; // 'ref', 'label', or 'description' + var field = el.dataset.field; // 'ref', 'label', 'description', or 'margin' var productId = el.dataset.productId; var currentValue = el.textContent.trim(); + // Margin-Feld: nur den Zahlenwert extrahieren (ohne %, ohne Warn-Icons) + if (field === 'margin') { + currentValue = currentValue.replace(/[^0-9,.\-]/g, '').trim(); + } + var input = document.createElement('input'); input.type = 'text'; input.className = 'pv-edit-input'; @@ -198,6 +212,11 @@ document.addEventListener('DOMContentLoaded', function() { if (field === 'ref') { input.style.textTransform = 'uppercase'; } + if (field === 'margin') { + input.style.width = '70px'; + input.style.textAlign = 'right'; + input.placeholder = '%'; + } el.textContent = ''; el.appendChild(input); @@ -215,6 +234,30 @@ document.addEventListener('DOMContentLoaded', function() { return; } + // Margin-Feld: preisbot_margin Extrafield speichern + if (field === 'margin') { + var parsedVal = newValue.replace(',', '.'); + el.classList.add('pv-saving'); + ajaxCall('update_margin', { + product_id: productId, + value: parsedVal + }, function(response) { + el.classList.remove('pv-saving'); + if (response.success) { + el.textContent = newValue.replace('.', ',') + '%'; + showToast(pvLang.saveSuccess || 'Gespeichert', 'success'); + } else { + el.textContent = currentValue ? currentValue + '%' : '-'; + showToast(response.error || pvLang.saveError || 'Fehler', 'error'); + } + }, function() { + el.classList.remove('pv-saving'); + el.textContent = currentValue ? currentValue + '%' : '-'; + showToast(pvLang.saveError || 'Fehler beim Speichern', 'error'); + }); + return; + } + el.classList.add('pv-saving'); ajaxCall('update_' + field, { product_id: productId, @@ -236,7 +279,11 @@ document.addEventListener('DOMContentLoaded', function() { } function cancel() { - el.textContent = currentValue; + if (field === 'margin') { + el.textContent = currentValue ? currentValue + '%' : '-'; + } else { + el.textContent = currentValue; + } } input.addEventListener('keydown', function(e) { diff --git a/langs/de_DE/produktverwaltung.lang b/langs/de_DE/produktverwaltung.lang old mode 100644 new mode 100755 index 3df0381..14de344 --- a/langs/de_DE/produktverwaltung.lang +++ b/langs/de_DE/produktverwaltung.lang @@ -65,6 +65,7 @@ DefaultExpandedHelp = Wenn aktiviert, werden alle Kategorien beim Seitenaufruf a # Status OnSale = Verkaufbar OnBuy = Beziehbar +ArchivedProducts = Archiv (nicht verkaufbar / nicht beziehbar) CalcMargin = Aufschlag # Kategorien diff --git a/langs/en_US/produktverwaltung.lang b/langs/en_US/produktverwaltung.lang old mode 100644 new mode 100755 diff --git a/lib/produktverwaltung.lib.php b/lib/produktverwaltung.lib.php old mode 100644 new mode 100755 diff --git a/modulebuilder.txt b/modulebuilder.txt old mode 100644 new mode 100755 diff --git a/produktverwaltungindex.php b/produktverwaltungindex.php old mode 100644 new mode 100755 index d81aea6..566894b --- a/produktverwaltungindex.php +++ b/produktverwaltungindex.php @@ -87,11 +87,13 @@ if (is_array($fullTree)) { // Load all products with their category assignments $productsPerCat = array(); // cat_id => array of products $allProductIds = array(); +$archivedProducts = array(); // tosell=0 AND tobuy=0 $sql = "SELECT DISTINCT p.rowid, p.ref, p.label, p.description, p.price, p.price_ttc,"; $sql .= " p.tobuy, p.tosell, p.fk_product_type"; $sql .= ", (SELECT pfp.unitprice FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp WHERE pfp.fk_product = p.rowid ORDER BY pfp.unitprice ASC LIMIT 1) as best_buy_price"; $sql .= ", (SELECT LEFT(COALESCE(NULLIF(s2.name_alias,''), s2.nom), 3) FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp2 LEFT JOIN ".MAIN_DB_PREFIX."societe as s2 ON s2.rowid = pfp2.fk_soc WHERE pfp2.fk_product = p.rowid ORDER BY pfp2.unitprice ASC LIMIT 1) as best_buy_supplier"; +$sql .= ", (SELECT pfe3.kaufmenge FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp3 LEFT JOIN ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields as pfe3 ON pfe3.fk_object = pfp3.rowid WHERE pfp3.fk_product = p.rowid ORDER BY pfp3.unitprice ASC LIMIT 1) as best_buy_kaufmenge"; if ($hasMarginField) { $sql .= ", pe.preisbot_margin"; } @@ -116,6 +118,7 @@ if ($resql) { 'price_ttc' => $obj->price_ttc, 'best_buy_price' => $obj->best_buy_price, 'best_buy_supplier' => $obj->best_buy_supplier, + 'best_buy_kaufmenge' => $obj->best_buy_kaufmenge, 'tosell' => $obj->tosell, 'tobuy' => $obj->tobuy, 'fk_product_type' => $obj->fk_product_type, @@ -125,6 +128,12 @@ if ($resql) { $allProductIds[$obj->rowid] = true; + // Archiv: weder verkaufbar noch beziehbar → separat sammeln + if (empty($obj->tosell) && empty($obj->tobuy)) { + $archivedProducts[$obj->rowid] = $productData; + continue; + } + if (!empty($obj->fk_categorie)) { $catId = (int) $obj->fk_categorie; if (!isset($productsPerCat[$catId])) { @@ -142,6 +151,7 @@ $productsWithoutCat = array(); $sql2 = "SELECT p.rowid, p.ref, p.label, p.description, p.price, p.price_ttc, p.tobuy, p.tosell, p.fk_product_type"; $sql2 .= ", (SELECT pfp.unitprice FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp WHERE pfp.fk_product = p.rowid ORDER BY pfp.unitprice ASC LIMIT 1) as best_buy_price"; $sql2 .= ", (SELECT LEFT(COALESCE(NULLIF(s2.name_alias,''), s2.nom), 3) FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp2 LEFT JOIN ".MAIN_DB_PREFIX."societe as s2 ON s2.rowid = pfp2.fk_soc WHERE pfp2.fk_product = p.rowid ORDER BY pfp2.unitprice ASC LIMIT 1) as best_buy_supplier"; +$sql2 .= ", (SELECT pfe3.kaufmenge FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp3 LEFT JOIN ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields as pfe3 ON pfe3.fk_object = pfp3.rowid WHERE pfp3.fk_product = p.rowid ORDER BY pfp3.unitprice ASC LIMIT 1) as best_buy_kaufmenge"; if ($hasMarginField) { $sql2 .= ", pe.preisbot_margin"; } @@ -156,7 +166,7 @@ $sql2 .= " ORDER BY p.ref ASC"; $resql2 = $db->query($sql2); if ($resql2) { while ($obj = $db->fetch_object($resql2)) { - $productsWithoutCat[$obj->rowid] = array( + $productData2 = array( 'id' => $obj->rowid, 'ref' => $obj->ref, 'label' => $obj->label, @@ -165,11 +175,18 @@ if ($resql2) { 'price_ttc' => $obj->price_ttc, 'best_buy_price' => $obj->best_buy_price, 'best_buy_supplier' => $obj->best_buy_supplier, + 'best_buy_kaufmenge' => $obj->best_buy_kaufmenge, 'tosell' => $obj->tosell, 'tobuy' => $obj->tobuy, 'fk_product_type' => $obj->fk_product_type, 'margin' => $hasMarginField ? $obj->preisbot_margin : null, ); + // Archiv: weder verkaufbar noch beziehbar + if (empty($obj->tosell) && empty($obj->tobuy)) { + $archivedProducts[$obj->rowid] = $productData2; + } else { + $productsWithoutCat[$obj->rowid] = $productData2; + } } $db->free($resql2); } @@ -312,6 +329,20 @@ if ($countWithout > 0) { print ''; } +// === Archiv (tosell=0 AND tobuy=0) === +$countArchived = count($archivedProducts); +if ($countArchived > 0) { + print '
'; + print '
'; + print ''; + print ' '.$langs->trans("ArchivedProducts").' '.$countArchived.''; + print '
'; + print ''; + print '
'; +} + llxFooter(); $db->close(); @@ -447,14 +478,21 @@ function renderProductTable($products, $hasMarginField, $canEdit, $canDelete, $c $description = dol_escape_htmltag(dol_trunc($prod['description'], 80)); $bestBuyPrice = !empty($prod['best_buy_price']) ? $prod['best_buy_price'] : 0; $sellPrice = $prod['price']; + $kaufmenge = !empty($prod['best_buy_kaufmenge']) ? (float) $prod['best_buy_kaufmenge'] : 0; + + // kaufmenge-Korrektur: Wenn kaufmenge > 1, wird EK * kaufmenge als korrigierter EK pro VK-Einheit genutzt + $correctedBuyPrice = $bestBuyPrice; + if ($kaufmenge > 1 && $bestBuyPrice > 0) { + $correctedBuyPrice = $bestBuyPrice * $kaufmenge; + } // Calculate actual margin: ((VK - EK) / EK * 100) $calcMargin = ''; - if ($bestBuyPrice > 0 && $sellPrice > 0) { - $calcMargin = round(($sellPrice - $bestBuyPrice) / $bestBuyPrice * 100, 1); + if ($correctedBuyPrice > 0 && $sellPrice > 0) { + $calcMargin = round(($sellPrice - $correctedBuyPrice) / $correctedBuyPrice * 100, 1); } - print ''; + print ''; // Status (tosell/tobuy) - nur aktive anzeigen print ''; @@ -493,13 +531,17 @@ function renderProductTable($products, $hasMarginField, $canEdit, $canDelete, $c } print ''; - // Best EK (günstigster Lieferantenpreis) + // Best EK (günstigster Lieferantenpreis, bei kaufmenge > 1 korrigiert) + $displayBuyPrice = $correctedBuyPrice; print ''; if ($bestBuyPrice > 0) { - print price($bestBuyPrice, 0, $langs, 1, -1, 2); + print price($displayBuyPrice, 0, $langs, 1, -1, 2); if (!empty($prod['best_buy_supplier'])) { print ' '.dol_escape_htmltag($prod['best_buy_supplier']).''; } + if ($kaufmenge > 1) { + print ' ×'.(int)$kaufmenge.''; + } } else { print '-'; } @@ -510,22 +552,32 @@ function renderProductTable($products, $hasMarginField, $canEdit, $canDelete, $c print $sellPrice > 0 ? price($sellPrice, 0, $langs, 1, -1, 2) : '-'; print ''; - // Stored Margin % (preisbot_margin) + // Stored Margin % (preisbot_margin) - editierbar per Doppelklick if ($hasMarginField) { print ''; - print !empty($prod['margin']) ? price($prod['margin'], 0, $langs, 1, -1, 1).'%' : '-'; + $marginVal = !empty($prod['margin']) ? price($prod['margin'], 0, $langs, 1, -1, 1).'%' : '-'; + if ($canEdit) { + print ''.$marginVal.''; + } else { + print $marginVal; + } print ''; } - // Calculated Margin % + // Calculated Margin % (nur Anzeige, nicht editierbar) print ''; if ($calcMargin !== '') { $marginClass = ''; - if ($hasMarginField && !empty($prod['margin'])) { + $warnIcon = ''; + // Extreme Margen: < 0% oder > 500% → rot mit Warnsymbol + if ((float) $calcMargin < 0 || (float) $calcMargin > 500) { + $marginClass = ' pv-margin-extreme'; + $warnIcon = ' '; + } elseif ($hasMarginField && !empty($prod['margin'])) { $diff = abs((float) $calcMargin - (float) $prod['margin']); $marginClass = $diff > 2 ? ' pv-margin-warn' : ' pv-margin-ok'; } - print ''.price($calcMargin, 0, $langs, 1, -1, 1).'%'; + print ''.$warnIcon.price($calcMargin, 0, $langs, 1, -1, 1).'%'; } else { print '-'; } diff --git a/sql/dolibarr_allversions.sql b/sql/dolibarr_allversions.sql old mode 100644 new mode 100755