dolibarr.produktverwaltung/produktverwaltungindex.php
data 1c3f88e6e5 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>
2026-03-12 06:56:29 +01:00

627 lines
24 KiB
PHP
Executable file
Raw Permalink 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.

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file produktverwaltung/produktverwaltungindex.php
* \ingroup produktverwaltung
* \brief Kategorie-Baumansicht mit Produkten und Inline-Editing
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/categories/class/categorie.class.php';
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/functions.lib.php';
$langs->loadLangs(array("produktverwaltung@produktverwaltung", "products"));
// Security check
if (!$user->hasRight('produktverwaltung', 'read')) {
accessforbidden();
}
$canEdit = $user->hasRight('produktverwaltung', 'write');
$canDelete = $user->hasRight('produktverwaltung', 'delete');
$canExport = $user->hasRight('produktverwaltung', 'export');
$defaultExpanded = getDolGlobalString('PRODUKTVERWALTUNG_DEFAULT_EXPANDED', '0');
// Check if preisbot_margin extrafield exists
$hasMarginField = false;
$sql = "SELECT name FROM ".MAIN_DB_PREFIX."extrafields WHERE elementtype = 'product' AND name = 'preisbot_margin'";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$hasMarginField = true;
}
// Load category tree
$categorie = new Categorie($db);
$fullTree = $categorie->get_full_arbo(Categorie::TYPE_PRODUCT);
// Build category hierarchy
$catChildren = array(); // parent_id => array of cat data
$catData = array(); // cat_id => cat data
if (is_array($fullTree)) {
foreach ($fullTree as $cat) {
$catData[$cat['id']] = $cat;
$parentId = isset($cat['fk_parent']) ? (int) $cat['fk_parent'] : 0;
if (!isset($catChildren[$parentId])) {
$catChildren[$parentId] = array();
}
$catChildren[$parentId][] = $cat;
}
}
// 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";
}
$sql .= ", cp.fk_categorie";
$sql .= " FROM ".MAIN_DB_PREFIX."product as p";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."categorie_product as cp ON cp.fk_product = p.rowid";
if ($hasMarginField) {
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product_extrafields as pe ON pe.fk_object = p.rowid";
}
$sql .= " WHERE p.entity IN (".getEntity('product').")";
$sql .= " ORDER BY p.ref ASC";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$productData = array(
'id' => $obj->rowid,
'ref' => $obj->ref,
'label' => $obj->label,
'description' => $obj->description,
'price' => $obj->price,
'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,
'fk_categorie' => $obj->fk_categorie,
);
$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])) {
$productsPerCat[$catId] = array();
}
// Avoid duplicates (product can be in result multiple times if in multiple categories)
$productsPerCat[$catId][$obj->rowid] = $productData;
}
}
$db->free($resql);
}
// Products without category
$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";
}
$sql2 .= " FROM ".MAIN_DB_PREFIX."product as p";
if ($hasMarginField) {
$sql2 .= " LEFT JOIN ".MAIN_DB_PREFIX."product_extrafields as pe ON pe.fk_object = p.rowid";
}
$sql2 .= " WHERE p.entity IN (".getEntity('product').")";
$sql2 .= " AND p.rowid NOT IN (SELECT fk_product FROM ".MAIN_DB_PREFIX."categorie_product)";
$sql2 .= " ORDER BY p.ref ASC";
$resql2 = $db->query($sql2);
if ($resql2) {
while ($obj = $db->fetch_object($resql2)) {
$productData2 = array(
'id' => $obj->rowid,
'ref' => $obj->ref,
'label' => $obj->label,
'description' => $obj->description,
'price' => $obj->price,
'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);
}
/*
* View
*/
// Pass config to JS
$jsConfig = array(
'ajaxUrl' => dol_buildpath('/produktverwaltung/ajax/product_actions.php', 1),
'token' => newToken(),
'hasMargin' => $hasMarginField,
);
$morejs = '<script>var pvConfig = '.json_encode($jsConfig).';';
$morejs .= 'var pvLang = '.json_encode(array(
'saveSuccess' => $langs->trans('SaveSuccess'),
'saveError' => $langs->trans('SaveError'),
'confirmDelete' => $langs->trans('ConfirmDeleteProduct'),
'productDeleted' => $langs->trans('ProductDeleted'),
'deleteError' => $langs->trans('ProductDeleteError'),
'categoryAssigned' => $langs->trans('CategoryAssigned'),
'categoryRemoved' => $langs->trans('CategoryRemoved'),
'selectCategory' => $langs->trans('SelectCategory'),
'cancel' => $langs->trans('Cancel'),
'assign' => $langs->trans('AssignCategory'),
'editProduct' => $langs->trans('EditProduct'),
'ref' => $langs->trans('Ref'),
'label' => $langs->trans('Label'),
'buyingPriceNet' => $langs->trans('BuyingPriceNet'),
'sellingPriceNet' => $langs->trans('SellingPriceNet'),
'save' => $langs->trans('Save'),
'openProductCard' => $langs->trans('OpenProductCard'),
'saving' => $langs->trans('Saving'),
'description' => $langs->trans('Description'),
'calcMargin' => $langs->trans('CalcMargin'),
'onSale' => $langs->trans('OnSale'),
'onBuy' => $langs->trans('OnBuy'),
'editCategory' => $langs->trans('EditCategory'),
'addCategory' => $langs->trans('AddCategory'),
'deleteCategory' => $langs->trans('ConfirmDeleteCategory'),
'categoryDeleted' => $langs->trans('CategoryDeleted'),
'categoryLabel' => $langs->trans('Label'),
'categoryDescription' => $langs->trans('Description'),
'categoryColor' => $langs->trans('Color'),
'parentCategory' => $langs->trans('ParentCategory'),
'none' => $langs->trans('None'),
'create' => $langs->trans('Create'),
'close' => $langs->trans('Close'),
)).';</script>';
$morejs .= '<script>var pvDolibarrUrl = "'.DOL_URL_ROOT.'";</script>';
$moreCss = array('/produktverwaltung/css/produktverwaltung.css');
$moreJs = array('/produktverwaltung/js/produktverwaltung.js');
llxHeader($morejs, $langs->trans("ProduktVerwaltungArea"), '', '', 0, 0, $moreJs, $moreCss, '', 'mod-produktverwaltung page-index');
print load_fiche_titre($langs->trans("ProduktVerwaltungArea"), '', 'fa-sitemap');
// === Ref-Schema Infobox ===
$showSchema = getDolGlobalString('PRODUKTVERWALTUNG_SHOW_SCHEMA', '1');
if ($showSchema) {
$refSchema = getDolGlobalString('PRODUKTVERWALTUNG_REF_SCHEMA', 'KAT-HER-[TYP-]SPEC[-SERIE]');
$refExample = getDolGlobalString('PRODUKTVERWALTUNG_REF_EXAMPLE', '');
print '<div class="pv-ref-schema collapsed">';
print '<div class="pv-schema-header">';
print '<span class="fas fa-chevron-down"></span>';
print '<span class="fas fa-info-circle"></span>';
print ' '.$langs->trans("RefSchemaTitle").': <code>KAT-HER-[TYP-]SPEC[-SERIE]</code>';
print '</div>';
print '<div class="pv-schema-body">';
print '<pre style="margin:0;white-space:pre-wrap;">'.dol_escape_htmltag($refSchema).'</pre>';
if (!empty($refExample)) {
print '<br><strong>'.$langs->trans("RefSchemaExample").':</strong>';
print '<pre style="margin:4px 0;white-space:pre-wrap;color:#555;">'.dol_escape_htmltag($refExample).'</pre>';
}
print '</div>';
print '</div>';
}
// === Toolbar ===
print '<div class="pv-toolbar">';
print '<button type="button" class="button" onclick="pvExpandAll()">';
print '<span class="fas fa-expand-arrows-alt"></span> '.$langs->trans("ExpandAll");
print '</button>';
print '<button type="button" class="button" onclick="pvCollapseAll()">';
print '<span class="fas fa-compress-arrows-alt"></span> '.$langs->trans("CollapseAll");
print '</button>';
// Add root category
if ($canEdit) {
print '<button type="button" class="button" onclick="pvAddCategory(0)">';
print '<span class="fas fa-plus"></span> '.$langs->trans("AddCategory");
print '</button>';
}
// Search
print '<div class="pv-search">';
print '<span class="fas fa-search"></span>';
print '<input type="text" placeholder="'.$langs->trans("SearchProducts").'">';
print '</div>';
// Export buttons
if ($canExport) {
print '<div class="pv-export-buttons">';
print '<a class="button" href="'.dol_buildpath('/produktverwaltung/export.php', 1).'?format=csv&token='.newToken().'">';
print '<span class="fas fa-file-excel"></span> '.$langs->trans("ExportCSV");
print '</a>';
print '<a class="button" href="'.dol_buildpath('/produktverwaltung/export.php', 1).'?format=pdf&token='.newToken().'">';
print '<span class="fas fa-file-pdf"></span> '.$langs->trans("ExportPDF");
print '</a>';
print '</div>';
}
print '</div>';
// === Category Tree ===
print '<ul class="pv-tree">';
renderCategoryTree(0, $catChildren, $catData, $productsPerCat, $hasMarginField, $canEdit, $canDelete, $langs, $conf, $defaultExpanded);
print '</ul>';
// === Products without category ===
$countWithout = count($productsWithoutCat);
if ($countWithout > 0) {
print '<div class="pv-no-category">';
print '<div class="pv-no-category-header">';
print '<span class="fas fa-exclamation-triangle" style="color:#f0ad4e;"></span>';
print ' '.$langs->trans("ProductsWithoutCategory").' <span class="badge badge-warning">'.$countWithout.'</span>';
print '</div>';
$ncCollapsed = $defaultExpanded ? '' : ' collapsed';
print '<div class="pv-no-category-body'.$ncCollapsed.'">';
renderProductTable($productsWithoutCat, $hasMarginField, $canEdit, $canDelete, 0, $langs, $conf);
print '</div>';
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();
// ========== Helper Functions ==========
/**
* Render category tree recursively
*/
function renderCategoryTree($parentId, &$catChildren, &$catData, &$productsPerCat, $hasMarginField, $canEdit, $canDelete, $langs, $conf, $defaultExpanded = '0')
{
if (!isset($catChildren[$parentId])) {
return;
}
foreach ($catChildren[$parentId] as $cat) {
$catId = (int) $cat['id'];
$hasChildren = isset($catChildren[$catId]);
$products = isset($productsPerCat[$catId]) ? $productsPerCat[$catId] : array();
$productCount = count($products);
// Count total products including subcategories
$totalCount = countProductsRecursive($catId, $catChildren, $productsPerCat);
$color = isset($cat['color']) ? $cat['color'] : '';
print '<li>';
// Category header
print '<div class="pv-category-header">';
if ($hasChildren || $productCount > 0) {
$collapsedClass = $defaultExpanded ? '' : ' collapsed';
print '<span class="pv-toggle'.$collapsedClass.' fas fa-chevron-down"></span>';
} else {
print '<span style="width:16px;display:inline-block;"></span>';
}
if (!empty($color)) {
print '<span class="pv-cat-color" style="background:#'.dol_escape_htmltag($color).';"></span>';
}
print '<span class="pv-cat-label">'.dol_escape_htmltag($cat['label']).'</span>';
if ($totalCount > 0) {
print '<span class="pv-cat-count">('.$totalCount.')</span>';
}
// Category action buttons
if ($canEdit) {
print '<span class="pv-cat-actions">';
print '<button type="button" onclick="event.stopPropagation(); pvEditCategory('.$catId.')" title="'.$langs->trans("EditCategory").'">';
print '<span class="fas fa-pencil-alt" style="color:#3498db;font-size:0.8em;"></span>';
print '</button>';
print '<button type="button" onclick="event.stopPropagation(); pvAddCategory('.$catId.')" title="'.$langs->trans("AddCategory").'">';
print '<span class="fas fa-plus" style="color:#27ae60;font-size:0.8em;"></span>';
print '</button>';
if ($canDelete) {
print '<button type="button" onclick="event.stopPropagation(); pvDeleteCategory('.$catId.', \''.dol_escape_js($cat['label']).'\')" title="'.$langs->trans("Delete").'">';
print '<span class="fas fa-trash-alt" style="color:#e74c3c;font-size:0.8em;"></span>';
print '</button>';
}
print '</span>';
}
print '</div>';
// Children container
if ($hasChildren || $productCount > 0) {
$collapsedClass2 = $defaultExpanded ? '' : ' collapsed';
print '<div class="pv-category-children'.$collapsedClass2.'">';
// Products in this category
if ($productCount > 0) {
renderProductTable($products, $hasMarginField, $canEdit, $canDelete, $catId, $langs, $conf);
}
// Sub-categories
if ($hasChildren) {
print '<ul>';
renderCategoryTree($catId, $catChildren, $catData, $productsPerCat, $hasMarginField, $canEdit, $canDelete, $langs, $conf, $defaultExpanded);
print '</ul>';
}
print '</div>';
}
print '</li>';
}
}
/**
* Count products in category and all subcategories
*/
function countProductsRecursive($catId, &$catChildren, &$productsPerCat)
{
$count = isset($productsPerCat[$catId]) ? count($productsPerCat[$catId]) : 0;
if (isset($catChildren[$catId])) {
foreach ($catChildren[$catId] as $child) {
$count += countProductsRecursive((int) $child['id'], $catChildren, $productsPerCat);
}
}
return $count;
}
/**
* Render product table for a category
*/
function renderProductTable($products, $hasMarginField, $canEdit, $canDelete, $categoryId, $langs, $conf)
{
if (empty($products)) {
return;
}
print '<table class="pv-products">';
print '<thead><tr>';
print '<th class="pv-col-status"></th>';
print '<th class="pv-col-ref">'.$langs->trans("Ref").'</th>';
print '<th class="pv-col-label">'.$langs->trans("Label").'</th>';
print '<th class="pv-col-desc">'.$langs->trans("Description").'</th>';
print '<th class="pv-col-price">'.$langs->trans("BestBuyPrice").'</th>';
print '<th class="pv-col-price">'.$langs->trans("SellingPriceNet").'</th>';
if ($hasMarginField) {
print '<th class="pv-col-margin">'.$langs->trans("MarginPercent").'</th>';
}
print '<th class="pv-col-margin">'.$langs->trans("CalcMargin").'</th>';
print '<th class="pv-col-actions">'.$langs->trans("Actions").'</th>';
print '</tr></thead>';
print '<tbody>';
foreach ($products as $prod) {
$productId = (int) $prod['id'];
$ref = dol_escape_htmltag($prod['ref']);
$label = dol_escape_htmltag($prod['label']);
$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 ($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-corrected-buy-price="'.($correctedBuyPrice > 0 ? $correctedBuyPrice : '').'" data-sell-price="'.($sellPrice > 0 ? $sellPrice : '').'">';
// Status (tosell/tobuy) - nur aktive anzeigen
print '<td class="pv-col-status">';
if (!empty($prod['tosell'])) {
print '<span class="fas fa-shopping-cart pv-status-on" title="'.$langs->trans("OnSale").'"></span>';
}
if (!empty($prod['tobuy'])) {
print '<span class="fas fa-truck pv-status-on" title="'.$langs->trans("OnBuy").'"></span>';
}
print '</td>';
// Ref (editable)
print '<td class="pv-col-ref">';
if ($canEdit) {
print '<span class="pv-editable" data-field="ref" data-product-id="'.$productId.'" title="'.$langs->trans("ClickToEdit").'">'.$ref.'</span>';
} else {
print $ref;
}
print '</td>';
// Label (editable)
print '<td class="pv-col-label">';
if ($canEdit) {
print '<span class="pv-editable" data-field="label" data-product-id="'.$productId.'" title="'.$langs->trans("ClickToEdit").'">'.$label.'</span>';
} else {
print $label;
}
print '</td>';
// Description (editable)
print '<td class="pv-col-desc">';
if ($canEdit) {
print '<span class="pv-editable" data-field="description" data-product-id="'.$productId.'" title="'.$langs->trans("ClickToEdit").'">'.$description.'</span>';
} else {
print $description;
}
print '</td>';
// Best EK (günstigster Lieferantenpreis, bei kaufmenge > 1 korrigiert)
$displayBuyPrice = $correctedBuyPrice;
print '<td class="pv-col-price">';
if ($bestBuyPrice > 0) {
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 '-';
}
print '</td>';
// VK netto
print '<td class="pv-col-price">';
print $sellPrice > 0 ? price($sellPrice, 0, $langs, 1, -1, 2) : '-';
print '</td>';
// Stored Margin % (preisbot_margin) - editierbar per Doppelklick
if ($hasMarginField) {
print '<td class="pv-col-margin">';
$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 % (nur Anzeige, nicht editierbar)
print '<td class="pv-col-margin">';
if ($calcMargin !== '') {
$marginClass = '';
$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.'">'.$warnIcon.price($calcMargin, 0, $langs, 1, -1, 1).'%</span>';
} else {
print '-';
}
print '</td>';
// Actions
print '<td class="pv-col-actions">';
// Edit dialog
if ($canEdit) {
print '<button type="button" onclick="pvEditProduct('.$productId.')" title="'.$langs->trans("EditProduct").'">';
print '<span class="fas fa-pencil-alt" style="color:#3498db;"></span>';
print '</button>';
}
// Open in Dolibarr (dedicated popup window)
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$productId.'" onclick="return pvOpenProductWindow(this.href)" title="'.$langs->trans("OpenProductCard").'">';
print '<span class="fas fa-external-link-alt"></span>';
print '</a>';
// Assign category
if ($canEdit) {
print '<button type="button" onclick="pvAssignCategory('.$productId.', \''.dol_escape_js($ref).'\')" title="'.$langs->trans("AssignCategory").'">';
print '<span class="fas fa-folder-plus" style="color:#27ae60;"></span>';
print '</button>';
}
// Remove from category
if ($canEdit && $categoryId > 0) {
print '<button type="button" onclick="pvRemoveFromCategory('.$productId.', '.$categoryId.')" title="'.$langs->trans("RemoveFromCategory").'">';
print '<span class="fas fa-folder-minus" style="color:#e67e22;"></span>';
print '</button>';
}
// Delete
if ($canDelete) {
print '<button type="button" onclick="pvDeleteProduct('.$productId.', \''.dol_escape_js($ref).'\')" title="'.$langs->trans("Delete").'">';
print '<span class="fas fa-trash-alt" style="color:#e74c3c;"></span>';
print '</button>';
}
print '</td>';
print '</tr>';
}
print '</tbody></table>';
}