- 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>
627 lines
24 KiB
PHP
Executable file
627 lines
24 KiB
PHP
Executable file
<?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>';
|
||
}
|