dolibarr.produktverwaltung/produktverwaltungindex.php
data 4b7046132c feat: Initiales Produktverwaltung-Modul (v1.0)
Kategorie-Baumansicht mit Inline-Editing für Dolibarr.
- Hierarchischer Kategoriebaum mit Auf-/Zuklappen
- Inline-Editing: Ref, Label, Beschreibung per Doppelklick
- Best EK mit 3-Zeichen Lieferanten-Badge
- Marge-Berechnung mit Farbmarkierung
- Kategorie-CRUD mit 20 Farb-Swatches
- Produkte ohne Kategorie Sektion
- Admin: Ref-Schema, Standard-Aufklapp-Verhalten
- Export: CSV und PDF
- Berechtigungen: read, write, delete, export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:33:47 +01:00

575 lines
21 KiB
PHP

<?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();
$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";
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,
'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;
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";
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)) {
$productsWithoutCat[$obj->rowid] = 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,
'tosell' => $obj->tosell,
'tobuy' => $obj->tobuy,
'fk_product_type' => $obj->fk_product_type,
'margin' => $hasMarginField ? $obj->preisbot_margin : null,
);
}
$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>';
}
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'];
// Calculate actual margin: ((VK - EK) / EK * 100)
$calcMargin = '';
if ($bestBuyPrice > 0 && $sellPrice > 0) {
$calcMargin = round(($sellPrice - $bestBuyPrice) / $bestBuyPrice * 100, 1);
}
print '<tr data-product-id="'.$productId.'" data-best-buy-price="'.($bestBuyPrice > 0 ? $bestBuyPrice : '').'" 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)
print '<td class="pv-col-price">';
if ($bestBuyPrice > 0) {
print price($bestBuyPrice, 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>';
}
} 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)
if ($hasMarginField) {
print '<td class="pv-col-margin">';
print !empty($prod['margin']) ? price($prod['margin'], 0, $langs, 1, -1, 1).'%' : '-';
print '</td>';
}
// Calculated Margin %
print '<td class="pv-col-margin">';
if ($calcMargin !== '') {
$marginClass = '';
if ($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>';
} 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>';
}