feat: Kategorie-Baumansicht mit Inline-Editing, AJAX-Aktionen und Admin-Einstellungen (v1.5)

- Kategorie-Baum mit Farben, Auf-/Zuklappen und Inline-Bearbeitung
- AJAX-Handler für Produkt- und Kategorieaktionen (Best EK, Status-Toggle)
- Admin: Ref-Schema und Standard-Aufklapp-Einstellung
- CSS/JS erweitert für Baumansicht und modale Dialoge
- bin/ zu .gitignore hinzugefügt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-12 06:56:29 +01:00
parent 4b7046132c
commit 1c3f88e6e5
22 changed files with 237 additions and 30 deletions

1
.gitignore vendored Normal file → Executable file
View file

@ -3,3 +3,4 @@
*.swo
*~
.DS_Store
bin/

0
COPYING Normal file → Executable file
View file

0
ChangeLog.md Normal file → Executable file
View file

0
PRODUKT-UMBENENNUNG.md Normal file → Executable file
View file

0
README.md Normal file → Executable file
View file

0
REFERENZ-SCHEMA.md Normal file → Executable file
View file

0
admin/about.php Normal file → Executable file
View file

0
admin/setup.php Normal file → Executable file
View file

34
ajax/product_actions.php Normal file → Executable file
View file

@ -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';

0
build/buildzip.php Normal file → Executable file
View file

0
build/makepack-produktverwaltung.conf Normal file → Executable file
View file

2
core/modules/modProduktVerwaltung.class.php Normal file → Executable file
View file

@ -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';

104
css/produktverwaltung.css Normal file → Executable file
View file

@ -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;

0
export.php Normal file → Executable file
View file

0
img/README.md Normal file → Executable file
View file

51
js/produktverwaltung.js Normal file → Executable file
View file

@ -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) {

1
langs/de_DE/produktverwaltung.lang Normal file → Executable file
View file

@ -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

0
langs/en_US/produktverwaltung.lang Normal file → Executable file
View file

0
lib/produktverwaltung.lib.php Normal file → Executable file
View file

0
modulebuilder.txt Normal file → Executable file
View file

74
produktverwaltungindex.php Normal file → Executable file
View file

@ -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 '</div>';
}
// === Archiv (tosell=0 AND tobuy=0) ===
$countArchived = count($archivedProducts);
if ($countArchived > 0) {
print '<div class="pv-archive">';
print '<div class="pv-archive-header">';
print '<span class="fas fa-archive" style="color:#95a5a6;"></span>';
print ' '.$langs->trans("ArchivedProducts").' <span class="badge" style="background:#95a5a6;color:#fff;">'.$countArchived.'</span>';
print '</div>';
print '<div class="pv-archive-body collapsed">';
renderProductTable($archivedProducts, $hasMarginField, $canEdit, $canDelete, 0, $langs, $conf);
print '</div>';
print '</div>';
}
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 '<tr data-product-id="'.$productId.'" data-best-buy-price="'.($bestBuyPrice > 0 ? $bestBuyPrice : '').'" data-sell-price="'.($sellPrice > 0 ? $sellPrice : '').'">';
print '<tr data-product-id="'.$productId.'" data-best-buy-price="'.($bestBuyPrice > 0 ? $bestBuyPrice : '').'" data-corrected-buy-price="'.($correctedBuyPrice > 0 ? $correctedBuyPrice : '').'" data-sell-price="'.($sellPrice > 0 ? $sellPrice : '').'">';
// Status (tosell/tobuy) - nur aktive anzeigen
print '<td class="pv-col-status">';
@ -493,13 +531,17 @@ function renderProductTable($products, $hasMarginField, $canEdit, $canDelete, $c
}
print '</td>';
// Best EK (günstigster Lieferantenpreis)
// Best EK (günstigster Lieferantenpreis, bei kaufmenge > 1 korrigiert)
$displayBuyPrice = $correctedBuyPrice;
print '<td class="pv-col-price">';
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 ' <span class="pv-supplier-tag">'.dol_escape_htmltag($prod['best_buy_supplier']).'</span>';
}
if ($kaufmenge > 1) {
print ' <span class="pv-supplier-tag" title="EK-Stück: '.price($bestBuyPrice, 0, $langs, 1, -1, 2).' × '.(int)$kaufmenge.'" style="background:#e67e22;">×'.(int)$kaufmenge.'</span>';
}
} else {
print '-';
}
@ -510,22 +552,32 @@ function renderProductTable($products, $hasMarginField, $canEdit, $canDelete, $c
print $sellPrice > 0 ? price($sellPrice, 0, $langs, 1, -1, 2) : '-';
print '</td>';
// Stored Margin % (preisbot_margin)
// Stored Margin % (preisbot_margin) - editierbar per Doppelklick
if ($hasMarginField) {
print '<td class="pv-col-margin">';
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 '<span class="pv-editable" data-field="margin" data-product-id="'.$productId.'">'.$marginVal.'</span>';
} else {
print $marginVal;
}
print '</td>';
}
// Calculated Margin %
// Calculated Margin % (nur Anzeige, nicht editierbar)
print '<td class="pv-col-margin">';
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 = '<span class="fas fa-exclamation-triangle" title="'.(($kaufmenge > 1) ? 'Kaufmenge: '.$kaufmenge : 'Ungewöhnliche Marge - prüfen!').'"></span> ';
} elseif ($hasMarginField && !empty($prod['margin'])) {
$diff = abs((float) $calcMargin - (float) $prod['margin']);
$marginClass = $diff > 2 ? ' pv-margin-warn' : ' pv-margin-ok';
}
print '<span class="pv-calc-margin'.$marginClass.'">'.price($calcMargin, 0, $langs, 1, -1, 1).'%</span>';
print '<span class="pv-calc-margin'.$marginClass.'">'.$warnIcon.price($calcMargin, 0, $langs, 1, -1, 1).'%</span>';
} else {
print '-';
}

0
sql/dolibarr_allversions.sql Normal file → Executable file
View file