dolibarr.stundenzettel/stundenzettel_commande.php
2026-02-07 21:14:51 +01:00

2325 lines
111 KiB
PHP

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* Stundenzettel - Auftrags-Integration
* Zeigt Produktliste aus Auftrag mit Übernahme-Möglichkeit
*/
// Load Dolibarr environment
$res = 0;
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 && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res) die("Include of main fails");
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/order.lib.php';
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
dol_include_once('/stundenzettel/class/stundenzettel.class.php');
// Load translation files
$langs->loadLangs(array("stundenzettel@stundenzettel", "orders", "products"));
/**
* Formatiert Mengen: Ganzzahlen ohne Dezimalstellen, sonst max. 2 Stellen
* @param float $qty Menge
* @return string Formatierte Menge
*/
function formatQty($qty) {
$qty = (float)$qty;
if ($qty == floor($qty)) {
return number_format($qty, 0, ',', '.');
}
// Runde auf 2 Stellen und entferne trailing zeros
$formatted = rtrim(rtrim(number_format($qty, 2, ',', '.'), '0'), ',');
return $formatted;
}
// Get parameters
$id = GETPOST('id', 'int');
$action = GETPOST('action', 'aZ09');
$tab = GETPOST('tab', 'aZ09') ?: 'products';
$stundenzettel_id = GETPOST('stundenzettel_id', 'int');
$defaultFilter = getDolGlobalString('STUNDENZETTEL_DEFAULT_FILTER', 'open');
$filter = GETPOST('filter', 'alpha') ?: $defaultFilter; // Filter: all, open, done
// Security check
if (!$user->hasRight('stundenzettel', 'read')) {
accessforbidden();
}
// Load order
$order = new Commande($db);
if ($order->fetch($id) <= 0) {
dol_print_error($db, 'Order not found');
exit;
}
// Berechtigung: Nur zugewiesener Benutzer oder Admin
$canAccess = ($order->fk_user_author == $user->id || $user->admin || $user->hasRight('commande', 'lire'));
if (!$canAccess) {
accessforbidden('You are not assigned to this order');
}
// Auto-Redirect: Wenn ein aktiver Stundenzettel für heute existiert, dorthin weiterleiten
// Nur bei erstem Aufruf
$noRedirect = GETPOST('noredirect', 'int');
if (!$noRedirect && empty($action)) {
// Suche nach offenem Stundenzettel für diesen Auftrag (heute oder generell offen)
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel";
$sql .= " WHERE fk_commande = ".((int)$order->id);
$sql .= " AND status = 0"; // Nur Entwürfe
$sql .= " ORDER BY date_stundenzettel DESC, rowid DESC";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$obj = $db->fetch_object($resql);
// Weiterleitung zum gefundenen Stundenzettel
header('Location: '.dol_buildpath('/stundenzettel/card.php?id='.$obj->rowid.'&tab=products', 1));
exit;
}
// Kein Stundenzettel vorhanden - bleibe auf dieser Seite um einen zu erstellen
}
/*
* Actions
*/
// Produkte auf Stundenzettel übernehmen
if ($action == 'transfer_products' && $user->hasRight('stundenzettel', 'write')) {
// Prüfe ob Auftrag freigegeben ist
$order->fetch_optionals();
$stzStatus = isset($order->array_options['options_stundenzettel_status']) ? (int)$order->array_options['options_stundenzettel_status'] : 0;
if ($stzStatus >= 1) {
setEventMessages($langs->trans('ErrorStundenzettelReleased'), null, 'errors');
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1');
exit;
}
$selected = GETPOST('selected', 'array');
$selected_mehraufwand = GETPOST('selected_mehraufwand', 'array');
$date_stundenzettel = GETPOST('date_stundenzettel', 'alpha');
$target_stundenzettel_id = GETPOST('stundenzettel_id', 'int'); // Vorausgewählter Stundenzettel
// Prüfe ob mindestens etwas ausgewählt wurde
if (empty($selected) && empty($selected_mehraufwand)) {
setEventMessages($langs->trans('NoProductsSelected'), null, 'errors');
} else {
// Wenn nur Mehraufwand ausgewählt, $selected als leeres Array initialisieren
if (empty($selected)) {
$selected = array();
}
// Mehraufwand-Zeilen werden direkt übernommen (ohne Bestätigungs-Dialog)
// Produkte werden direkt übernommen (ohne Bestätigungs-Dialoge)
$stundenzettel = new Stundenzettel($db);
$use_stundenzettel_id = 0;
$forceNewStundenzettel = GETPOST('force_new_stundenzettel', 'int');
// Wenn ein Stundenzettel vorgegeben wurde, prüfen ob er von heute ist
if ($target_stundenzettel_id > 0 && !$forceNewStundenzettel) {
if ($stundenzettel->fetch($target_stundenzettel_id) > 0) {
// Prüfe ob der Stundenzettel von heute ist
$stzDateStr = date('Y-m-d', $stundenzettel->date_stundenzettel);
$todayStr = date('Y-m-d');
if ($stzDateStr == $todayStr) {
// Stundenzettel ist von heute - verwenden
$use_stundenzettel_id = $target_stundenzettel_id;
}
// Sonst: neuen für heute erstellen (weiter unten)
}
}
// Wenn kein passender Stundenzettel, nach Datum suchen oder neu erstellen
if ($use_stundenzettel_id <= 0) {
// Bei force_new oder wenn Stundenzettel von anderem Tag: heutiges Datum verwenden
$date = ($forceNewStundenzettel || !$date_stundenzettel) ? dol_now() : $date_stundenzettel;
// Existierenden Stundenzettel für diesen Tag suchen oder neuen erstellen
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel";
$sql .= " WHERE fk_commande = ".((int)$order->id);
$sql .= " AND date_stundenzettel = '".$db->idate($date)."'";
$sql .= " AND status = 0"; // Nur Entwürfe
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$obj = $db->fetch_object($resql);
$use_stundenzettel_id = $obj->rowid;
$stundenzettel->fetch($use_stundenzettel_id);
} else {
// Neuen erstellen
$stundenzettel->fk_commande = $order->id;
$stundenzettel->fk_soc = $order->socid;
$stundenzettel->date_stundenzettel = $date;
$use_stundenzettel_id = $stundenzettel->create($user);
}
}
if ($use_stundenzettel_id > 0) {
// Produkte hinzufügen
foreach ($selected as $line_id) {
// Hole Zeile aus commandedet
$sql = "SELECT cd.rowid, cd.fk_product, cd.qty, cd.description,";
$sql .= " p.ref as product_ref, p.label as product_label";
$sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sql .= " WHERE cd.rowid = ".((int)$line_id);
$resql = $db->query($sql);
if ($resql && ($obj = $db->fetch_object($resql))) {
// Prüfe ob schon auf diesem Stundenzettel
$sql2 = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel_product";
$sql2 .= " WHERE fk_stundenzettel = ".((int)$use_stundenzettel_id);
$sql2 .= " AND fk_commandedet = ".((int)$line_id);
$resql2 = $db->query($sql2);
if ($resql2 && $db->num_rows($resql2) == 0) {
// Noch nicht vorhanden, hinzufügen
$stundenzettel->addProduct(
$obj->fk_product,
$obj->rowid,
null,
$obj->qty,
0,
'order',
$obj->description // Beschreibung für Freitext-Produkte
);
}
}
}
// Mehraufwand-Produkte in Produktliste übernehmen (falls ausgewählt)
// Erstellt eine neue Produktzeile (origin='added') im Ziel-Stundenzettel
if (!empty($selected_mehraufwand)) {
foreach ($selected_mehraufwand as $ma_ids) {
// $ma_ids kann mehrere IDs enthalten (kommasepariert)
$ids = explode(',', $ma_ids);
foreach ($ids as $sp_id) {
$sp_id = (int)$sp_id;
if ($sp_id <= 0) continue;
// Produktinfo aus der Mehraufwand-Zeile holen
$sqlMa = "SELECT sp.fk_product, sp.product_ref, sp.product_label, sp.description, sp.qty_done";
$sqlMa .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlMa .= " WHERE sp.rowid = ".((int)$sp_id);
$resqlMa = $db->query($sqlMa);
if ($resqlMa && ($objMa = $db->fetch_object($resqlMa))) {
$qty = max(1, (float)$objMa->qty_done); // Mindestens 1
// Prüfe ob Produkt schon auf diesem Stundenzettel (in Produktliste) existiert
$sqlCheck = "SELECT rowid, qty_done FROM ".MAIN_DB_PREFIX."stundenzettel_product";
$sqlCheck .= " WHERE fk_stundenzettel = ".((int)$use_stundenzettel_id);
$sqlCheck .= " AND origin = 'added'";
if ($objMa->fk_product > 0) {
$sqlCheck .= " AND fk_product = ".((int)$objMa->fk_product);
} else {
$sqlCheck .= " AND fk_product IS NULL AND description = '".$db->escape($objMa->description)."'";
}
$resqlCheck = $db->query($sqlCheck);
if ($resqlCheck && $db->num_rows($resqlCheck) > 0) {
// Existiert bereits - Menge erhöhen
$objExist = $db->fetch_object($resqlCheck);
$newQty = (float)$objExist->qty_done + $qty;
$sqlUpd = "UPDATE ".MAIN_DB_PREFIX."stundenzettel_product";
$sqlUpd .= " SET qty_done = ".$newQty;
$sqlUpd .= " WHERE rowid = ".((int)$objExist->rowid);
$db->query($sqlUpd);
} else {
// Neu als Produktzeile anlegen (origin='added')
$stundenzettel->addProduct(
$objMa->fk_product,
null, // kein fk_commandedet (nicht im Auftrag)
null, // qty_original
0, // qty_ordered
$qty, // qty_done
'added', // origin = in Produktliste hinzugefügt
$objMa->description
);
}
}
}
}
}
setEventMessages($langs->trans('ProductsTransferred'), null, 'mesgs');
header('Location: '.dol_buildpath('/stundenzettel/card.php?id='.$use_stundenzettel_id.'&tab=products', 1));
exit;
} else {
setEventMessages($langs->trans('ErrorCreatingStundenzettel'), null, 'errors');
}
}
}
// Notiz abhaken (von stundenzettel_commande aus)
if ($action == 'toggle_note' && $user->hasRight('stundenzettel', 'write')) {
$note_id = GETPOST('note_id', 'int');
$checked = GETPOST('checked', 'int');
$stz_id = GETPOST('stundenzettel_id', 'int');
if ($stz_id > 0) {
$stzObj = new Stundenzettel($db);
if ($stzObj->fetch($stz_id) > 0) {
$result = $stzObj->updateNoteStatus($note_id, $checked);
if ($result > 0) {
setEventMessages($langs->trans('RecordModified'), null, 'mesgs');
}
}
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1'.($stundenzettel_id > 0 ? '&stundenzettel_id='.$stundenzettel_id : ''));
exit;
}
// Stundenzettel für Auftrag freigeben (direkt oder nach Bestätigung)
// Berechtigung: write ist ausreichend (validate ist optional)
$canRelease = $user->hasRight('stundenzettel', 'write') || $user->hasRight('stundenzettel', 'validate') || $user->admin;
$doRelease = false;
if ($action == 'release_stundenzettel' && $canRelease) {
$doRelease = true;
}
if ($action == 'release_stundenzettel_confirmed' && GETPOST('confirm', 'alpha') == 'yes' && $canRelease) {
$doRelease = true;
}
// Auch confirm_release akzeptieren (Fallback)
if ($action == 'confirm_release' && GETPOST('confirm', 'alpha') == 'yes' && $canRelease) {
$doRelease = true;
}
if ($doRelease) {
// Direkt in Datenbank updaten (zuverlässiger als insertExtraFields)
$sql = "UPDATE ".MAIN_DB_PREFIX."commande_extrafields SET stundenzettel_status = 1 WHERE fk_object = ".((int)$order->id);
$resql = $db->query($sql);
if ($resql) {
// Falls noch kein Eintrag existiert, einen erstellen
if ($db->affected_rows($resql) == 0) {
$sql2 = "INSERT INTO ".MAIN_DB_PREFIX."commande_extrafields (fk_object, stundenzettel_status) VALUES (".((int)$order->id).", 1)";
$db->query($sql2);
}
setEventMessages($langs->trans('StundenzettelReleased'), null, 'mesgs');
} else {
setEventMessages($db->lasterror(), null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&filter='.$filter);
exit;
}
// Stundenzettel für Auftrag wiedereröffnen
$canReopen = $user->hasRight('stundenzettel', 'write') || $user->hasRight('stundenzettel', 'validate') || $user->admin;
if ($action == 'reopen_stundenzettel' && $canReopen) {
// Direkt in Datenbank updaten (zuverlässiger als insertExtraFields)
$sql = "UPDATE ".MAIN_DB_PREFIX."commande_extrafields SET stundenzettel_status = 0 WHERE fk_object = ".((int)$order->id);
$resql = $db->query($sql);
if ($resql) {
// Falls noch kein Eintrag existiert, einen erstellen
if ($db->affected_rows($resql) == 0) {
$sql2 = "INSERT INTO ".MAIN_DB_PREFIX."commande_extrafields (fk_object, stundenzettel_status) VALUES (".((int)$order->id).", 0)";
$db->query($sql2);
}
setEventMessages($langs->trans('StundenzettelReopened'), null, 'mesgs');
} else {
setEventMessages($db->lasterror(), null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&filter='.$filter);
exit;
}
// Stundenzettel komplett zurücksetzen (von Status 2 auf 0)
$canReset = $user->hasRight('stundenzettel', 'write') || $user->admin;
if ($action == 'reset_stundenzettel' && $canReset) {
// Direkt in Datenbank updaten (zuverlässiger als insertExtraFields)
$sql = "UPDATE ".MAIN_DB_PREFIX."commande_extrafields SET stundenzettel_status = 0 WHERE fk_object = ".((int)$order->id);
$resql = $db->query($sql);
if ($resql) {
// Falls noch kein Eintrag existiert, einen erstellen
if ($db->affected_rows($resql) == 0) {
$sql2 = "INSERT INTO ".MAIN_DB_PREFIX."commande_extrafields (fk_object, stundenzettel_status) VALUES (".((int)$order->id).", 0)";
$db->query($sql2);
}
setEventMessages($langs->trans('StundenzettelReset'), null, 'mesgs');
} else {
setEventMessages($db->lasterror(), null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&filter='.$filter);
exit;
}
// In Rechnung übernehmen
if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes' && $user->hasRight('facture', 'creer')) {
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
$db->begin();
$error = 0;
// Neue Rechnung erstellen
$facture = new Facture($db);
$facture->socid = $order->socid;
$facture->type = Facture::TYPE_STANDARD;
$facture->date = dol_now();
$facture->fk_project = $order->fk_project;
$facture->cond_reglement_id = $order->cond_reglement_id;
$facture->mode_reglement_id = $order->mode_reglement_id;
$facture->note_private = $langs->trans('CreatedFromStundenzettel').' - '.$order->ref;
$facture->linked_objects['commande'] = $order->id;
$facture_id = $facture->create($user);
if ($facture_id > 0) {
// Sammle alle Produkt-Mengen aus Stundenzetteln (gruppiert nach fk_commandedet)
$productQtys = array();
$sqlQty = "SELECT sp.fk_commandedet, SUM(sp.qty_done) as total_qty";
$sqlQty .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlQty .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlQty .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlQty .= " AND sp.origin IN ('order', 'added')";
$sqlQty .= " AND sp.fk_commandedet IS NOT NULL";
$sqlQty .= " GROUP BY sp.fk_commandedet";
$resqlQty = $db->query($sqlQty);
if ($resqlQty) {
while ($objQty = $db->fetch_object($resqlQty)) {
$productQtys[$objQty->fk_commandedet] = floatval($objQty->total_qty);
}
}
// Sammle Mehraufwand (zusätzliche Produkte mit origin = 'additional')
$additionalProducts = array();
$sqlAdd = "SELECT sp.fk_product, sp.product_label, sp.description, SUM(sp.qty_done) as total_qty";
$sqlAdd .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlAdd .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlAdd .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlAdd .= " AND sp.origin = 'additional'";
$sqlAdd .= " GROUP BY sp.fk_product, sp.product_label, sp.description";
$resqlAdd = $db->query($sqlAdd);
if ($resqlAdd) {
while ($objAdd = $db->fetch_object($resqlAdd)) {
if (floatval($objAdd->total_qty) > 0) {
$additionalProducts[] = $objAdd;
}
}
}
// Lade Section-Hierarchie aus llx_facture_lines_manager
$sqlManager = "SELECT m.rowid, m.line_type, m.fk_commandedet, m.title, m.parent_section, m.line_order, m.show_subtotal,";
$sqlManager .= " cd.fk_product, cd.qty as qty_ordered, cd.subprice, cd.tva_tx, cd.remise_percent,";
$sqlManager .= " cd.description, cd.product_type, cd.special_code, cd.fk_unit,";
$sqlManager .= " p.ref as product_ref, p.label as product_label";
$sqlManager .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m";
$sqlManager .= " LEFT JOIN ".MAIN_DB_PREFIX."commandedet cd ON cd.rowid = m.fk_commandedet";
$sqlManager .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = cd.fk_product";
$sqlManager .= " WHERE m.fk_commande = ".((int)$order->id);
$sqlManager .= " AND m.document_type = 'order'";
$sqlManager .= " ORDER BY m.line_order";
$resqlManager = $db->query($sqlManager);
if ($resqlManager) {
// Sammle alle Zeilen nach Parent-Section gruppiert
$sections = array(); // section_id => section data
$sectionProducts = array(); // section_id => array of products
$orphanProducts = array(); // Products without section
while ($obj = $db->fetch_object($resqlManager)) {
if ($obj->line_type == 'section') {
$sections[$obj->rowid] = $obj;
if (!isset($sectionProducts[$obj->rowid])) {
$sectionProducts[$obj->rowid] = array();
}
} elseif ($obj->line_type == 'product') {
$parentId = $obj->parent_section ? $obj->parent_section : 0;
if ($parentId > 0) {
if (!isset($sectionProducts[$parentId])) {
$sectionProducts[$parentId] = array();
}
$sectionProducts[$parentId][] = $obj;
} else {
$orphanProducts[] = $obj;
}
}
// subtotal wird automatisch nach Section hinzugefügt wenn show_subtotal=1
}
$rang = 0;
$invoiceManagerLines = array(); // Für llx_facture_lines_manager der Rechnung
// Verarbeite jede Section
foreach ($sections as $sectionId => $section) {
$products = isset($sectionProducts[$sectionId]) ? $sectionProducts[$sectionId] : array();
$sectionHasInvoicedProducts = false;
$sectionSubtotal = 0;
// Prüfe ob Section Produkte mit qty > 0 hat
foreach ($products as $prod) {
$qtyToInvoice = isset($productQtys[$prod->fk_commandedet]) ? $productQtys[$prod->fk_commandedet] : 0;
if ($qtyToInvoice > 0) {
$sectionHasInvoicedProducts = true;
break;
}
}
// Nur Sections mit tatsächlich verwendeten Produkten hinzufügen
if ($sectionHasInvoicedProducts) {
// Section-Titel hinzufügen (special_code = 100)
$result = $facture->addline(
$section->title,
0, // subprice
0, // qty
0, // tva_tx
0, 0, // localtax
0, // fk_product
0, // remise_percent
'', '', // dates
0, 0, '', 'HT', 0,
9, // product_type (9 = Title)
$rang++,
100, // special_code = 100 für Section
0, '', 0, 0
);
if ($result > 0) {
$invoiceManagerLines[] = array(
'type' => 'section',
'fk_facturedet' => $result,
'title' => $section->title,
'parent' => null
);
$currentSectionDetId = $result;
}
// Produkte der Section hinzufügen
foreach ($products as $prod) {
$qtyToInvoice = isset($productQtys[$prod->fk_commandedet]) ? $productQtys[$prod->fk_commandedet] : 0;
if ($qtyToInvoice > 0) {
$result = $facture->addline(
$prod->description, // 1: desc
$prod->subprice, // 2: pu_ht
$qtyToInvoice, // 3: qty
$prod->tva_tx, // 4: txtva
0, // 5: txlocaltax1
0, // 6: txlocaltax2
$prod->fk_product, // 7: fk_product
$prod->remise_percent, // 8: remise_percent
'', // 9: date_start
'', // 10: date_end
0, // 11: fk_code_ventilation
0, // 12: info_bits
0, // 13: fk_remise_except
'HT', // 14: price_base_type
0, // 15: pu_ttc
$prod->product_type, // 16: type
$rang++, // 17: rang
0, // 18: special_code
'', // 19: origin
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
0, // 23: pa_ht
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
0, // 27: fk_prev_id
$prod->fk_unit // 28: fk_unit
);
if ($result > 0) {
$invoiceManagerLines[] = array(
'type' => 'product',
'fk_facturedet' => $result,
'title' => null,
'parent' => $currentSectionDetId
);
$sectionSubtotal += $prod->subprice * $qtyToInvoice;
} elseif ($result < 0) {
$error++;
setEventMessages($facture->error, $facture->errors, 'errors');
}
}
}
// Zwischensumme hinzufügen wenn Section show_subtotal hat
if ($section->show_subtotal && $sectionSubtotal > 0) {
$subtotalLabel = 'Zwischensumme: '.$section->title;
$result = $facture->addline(
$subtotalLabel,
0, // wird automatisch berechnet
1, // qty
0, // tva_tx
0, 0, // localtax
0, // fk_product
0, // remise_percent
'', '', // dates
0, 0, '', 'HT', 0,
9, // product_type
$rang++,
102, // special_code = 102 für Subtotal
0, '', 0, 0
);
if ($result > 0) {
$invoiceManagerLines[] = array(
'type' => 'subtotal',
'fk_facturedet' => $result,
'title' => $subtotalLabel,
'parent' => $currentSectionDetId
);
}
}
}
}
// Prüfe ob im Auftrag überhaupt Sections vorhanden sind
$orderHasSections = (count($sections) > 0);
// Orphan-Produkte (ohne Section) hinzufügen
// Nur als eigene Section "Sonstige Produkte" wenn im Auftrag Sections vorhanden sind
$hasOrphansWithQty = false;
foreach ($orphanProducts as $prod) {
$qtyToInvoice = isset($productQtys[$prod->fk_commandedet]) ? $productQtys[$prod->fk_commandedet] : 0;
if ($qtyToInvoice > 0) {
$hasOrphansWithQty = true;
break;
}
}
if ($hasOrphansWithQty) {
$sonstigeSectionResult = 0;
// Nur Section erstellen wenn im Auftrag auch Sections vorhanden sind
if ($orderHasSections) {
$sonstigeSectionResult = $facture->addline(
$langs->trans('OtherProducts'),
0, 0, 0, 0, 0, 0, 0, '', '', 0, 0, '', 'HT', 0,
9, // product_type für Titel
$rang++,
100 // special_code = 100 für Section
);
if ($sonstigeSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'section',
'fk_facturedet' => $sonstigeSectionResult,
'title' => $langs->trans('OtherProducts'),
'parent' => null
);
}
}
foreach ($orphanProducts as $prod) {
$qtyToInvoice = isset($productQtys[$prod->fk_commandedet]) ? $productQtys[$prod->fk_commandedet] : 0;
if ($qtyToInvoice > 0) {
$result = $facture->addline(
$prod->description, // 1: desc
$prod->subprice, // 2: pu_ht
$qtyToInvoice, // 3: qty
$prod->tva_tx, // 4: txtva
0, // 5: txlocaltax1
0, // 6: txlocaltax2
$prod->fk_product, // 7: fk_product
$prod->remise_percent, // 8: remise_percent
'', // 9: date_start
'', // 10: date_end
0, // 11: fk_code_ventilation
0, // 12: info_bits
0, // 13: fk_remise_except
'HT', // 14: price_base_type
0, // 15: pu_ttc
$prod->product_type, // 16: type
$rang++, // 17: rang
0, // 18: special_code
'', // 19: origin
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
0, // 23: pa_ht
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
0, // 27: fk_prev_id
$prod->fk_unit // 28: fk_unit
);
if ($result > 0 && $orderHasSections && $sonstigeSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'product',
'fk_facturedet' => $result,
'title' => null,
'parent' => $sonstigeSectionResult
);
} elseif ($result < 0) {
$error++;
setEventMessages($facture->error, $facture->errors, 'errors');
}
}
}
// Zwischensumme für Sonstige Produkte
if ($sonstigeSectionResult > 0) {
$subtotalLabel = 'Zwischensumme: '.$langs->trans('OtherProducts');
$subtotalResult = $facture->addline(
$subtotalLabel,
0, 1, 0, 0, 0, 0, 0, '', '', 0, 0, '', 'HT', 0,
9, $rang++, 102, 0, '', 0, 0
);
if ($subtotalResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'subtotal',
'fk_facturedet' => $subtotalResult,
'title' => $subtotalLabel,
'parent' => $sonstigeSectionResult
);
}
}
}
// Mehraufwand hinzufügen (am Ende)
// Nur als Section wenn im Auftrag auch Sections vorhanden sind
if (count($additionalProducts) > 0) {
$mehraufwandSectionResult = 0;
// Nur Section erstellen wenn im Auftrag auch Sections vorhanden sind
if ($orderHasSections) {
$mehraufwandSectionResult = $facture->addline(
$langs->trans('Mehraufwand'),
0, 0, 0, 0, 0, 0, 0, '', '', 0, 0, '', 'HT', 0,
9, // product_type für Titel
$rang++,
100 // special_code = 100 für Section
);
if ($mehraufwandSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'section',
'fk_facturedet' => $mehraufwandSectionResult,
'title' => $langs->trans('Mehraufwand'),
'parent' => null
);
}
}
foreach ($additionalProducts as $addProd) {
$productResult = 0;
if ($addProd->fk_product > 0) {
// Katalog-Produkt
$product = new Product($db);
$product->fetch($addProd->fk_product);
$productResult = $facture->addline(
$product->label, // 1: desc
$product->price, // 2: pu_ht
$addProd->total_qty, // 3: qty
$product->tva_tx, // 4: txtva
0, // 5: txlocaltax1
0, // 6: txlocaltax2
$addProd->fk_product, // 7: fk_product
0, // 8: remise_percent
'', // 9: date_start
'', // 10: date_end
0, // 11: fk_code_ventilation
0, // 12: info_bits
0, // 13: fk_remise_except
'HT', // 14: price_base_type
0, // 15: pu_ttc
$product->type, // 16: type
$rang++, // 17: rang
0, // 18: special_code
'', // 19: origin
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
0, // 23: pa_ht
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
0, // 27: fk_prev_id
$product->fk_unit // 28: fk_unit
);
} else {
// Freitext
$productResult = $facture->addline(
$addProd->description ? $addProd->description : $addProd->product_label,
0, // 2: pu_ht
$addProd->total_qty, // 3: qty
0, // 4: txtva
0, // 5: txlocaltax1
0, // 6: txlocaltax2
0, // 7: fk_product
0, // 8: remise_percent
'', // 9: date_start
'', // 10: date_end
0, // 11: fk_code_ventilation
0, // 12: info_bits
0, // 13: fk_remise_except
'HT', // 14: price_base_type
0, // 15: pu_ttc
0, // 16: type
$rang++, // 17: rang
0, // 18: special_code
'', // 19: origin
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
0, // 23: pa_ht
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
0, // 27: fk_prev_id
0 // 28: fk_unit
);
}
if ($productResult > 0 && $orderHasSections && $mehraufwandSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'product',
'fk_facturedet' => $productResult,
'title' => null,
'parent' => $mehraufwandSectionResult
);
} elseif ($productResult < 0) {
$error++;
setEventMessages($facture->error, $facture->errors, 'errors');
}
}
// Zwischensumme für Mehraufwand
if ($mehraufwandSectionResult > 0) {
$subtotalLabel = 'Zwischensumme: '.$langs->trans('Mehraufwand');
$subtotalResult = $facture->addline(
$subtotalLabel,
0, 1, 0, 0, 0, 0, 0, '', '', 0, 0, '', 'HT', 0,
9, $rang++, 102, 0, '', 0, 0
);
if ($subtotalResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'subtotal',
'fk_facturedet' => $subtotalResult,
'title' => $subtotalLabel,
'parent' => $mehraufwandSectionResult
);
}
}
}
// llx_facture_lines_manager für Rechnung erstellen
if (!$error && count($invoiceManagerLines) > 0) {
$lineOrder = 1;
$sectionMap = array(); // old_facturedet_id => new_manager_rowid
foreach ($invoiceManagerLines as $mLine) {
$sqlInsert = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager";
$sqlInsert .= " (fk_facture, document_type, line_type, fk_facturedet, title, parent_section, line_order, show_subtotal, date_creation)";
$sqlInsert .= " VALUES (";
$sqlInsert .= ((int)$facture_id).", 'invoice', ";
$sqlInsert .= "'".$db->escape($mLine['type'])."', ";
$sqlInsert .= ($mLine['fk_facturedet'] ? (int)$mLine['fk_facturedet'] : "NULL").", ";
$sqlInsert .= ($mLine['title'] ? "'".$db->escape($mLine['title'])."'" : "NULL").", ";
// Parent-Section mapping
if ($mLine['parent'] && isset($sectionMap[$mLine['parent']])) {
$sqlInsert .= (int)$sectionMap[$mLine['parent']].", ";
} else {
$sqlInsert .= "NULL, ";
}
$sqlInsert .= $lineOrder++.", ";
$sqlInsert .= ($mLine['type'] == 'section' ? "1" : "0").", ";
$sqlInsert .= "NOW())";
$db->query($sqlInsert);
// Track section IDs for parent mapping
if ($mLine['type'] == 'section') {
$sectionMap[$mLine['fk_facturedet']] = $db->last_insert_id(MAIN_DB_PREFIX.'facture_lines_manager');
}
}
}
} else {
$error++;
}
if (!$error) {
// ============================================
// Rang-Werte in facturedet korrigieren
// Synchronisiert rang mit line_order aus facture_lines_manager
// ============================================
$sqlFixRang = "UPDATE ".MAIN_DB_PREFIX."facturedet fd
INNER JOIN ".MAIN_DB_PREFIX."facture_lines_manager flm ON flm.fk_facturedet = fd.rowid
SET fd.rang = flm.line_order
WHERE flm.fk_facture = ".((int)$facture_id);
$db->query($sqlFixRang);
// Status auf "in Rechnung übertragen" setzen (direkt per SQL)
$sqlUpdate = "UPDATE ".MAIN_DB_PREFIX."commande_extrafields SET stundenzettel_status = 2 WHERE fk_object = ".((int)$order->id);
$resqlUpdate = $db->query($sqlUpdate);
if (!$resqlUpdate || $db->affected_rows($resqlUpdate) == 0) {
$sqlInsert = "INSERT INTO ".MAIN_DB_PREFIX."commande_extrafields (fk_object, stundenzettel_status) VALUES (".((int)$order->id).", 2)";
$db->query($sqlInsert);
}
$db->commit();
setEventMessages($langs->trans('InvoiceCreated'), null, 'mesgs');
header('Location: '.DOL_URL_ROOT.'/compta/facture/card.php?id='.$facture_id);
exit;
} else {
$db->rollback();
}
} else {
$error++;
setEventMessages($facture->error, $facture->errors, 'errors');
$db->rollback();
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1');
exit;
}
/*
* View
*/
// Linkes Menü aktivieren
$_GET['mainmenu'] = 'stundenzettel';
// Stundenzettel laden wenn ID übergeben wurde
$stundenzettelObj = null;
if ($stundenzettel_id > 0) {
dol_include_once('/stundenzettel/lib/stundenzettel.lib.php');
$stundenzettelObj = new Stundenzettel($db);
if ($stundenzettelObj->fetch($stundenzettel_id) <= 0) {
$stundenzettelObj = null;
$stundenzettel_id = 0;
}
}
// Lade Extrafields für Stundenzettel-Status - direkt aus DB lesen für Zuverlässigkeit
$sqlStatus = "SELECT stundenzettel_status FROM ".MAIN_DB_PREFIX."commande_extrafields WHERE fk_object = ".((int)$order->id);
$resqlStatus = $db->query($sqlStatus);
$stundenzettelStatus = 0;
if ($resqlStatus && ($objStatus = $db->fetch_object($resqlStatus))) {
$stundenzettelStatus = (int)$objStatus->stundenzettel_status;
}
// 0 = Offen, 1 = Freigegeben, 2 = In Rechnung übertragen
// Prüfe ob alle Stundenzettel validiert sind
$allValidated = false;
$hasStundenzettel = false;
$sqlCheck = "SELECT COUNT(*) as total, SUM(CASE WHEN status >= 1 THEN 1 ELSE 0 END) as validated";
$sqlCheck .= " FROM ".MAIN_DB_PREFIX."stundenzettel WHERE fk_commande = ".((int)$order->id);
$resqlCheck = $db->query($sqlCheck);
if ($resqlCheck) {
$objCheck = $db->fetch_object($resqlCheck);
$hasStundenzettel = ($objCheck->total > 0);
$allValidated = ($objCheck->total > 0 && $objCheck->total == $objCheck->validated);
}
// Berechne verbleibende Produkte
$remainingProducts = array();
if ($hasStundenzettel) {
$sqlRemaining = "SELECT cd.rowid, cd.fk_product, cd.qty as qty_ordered, cd.description,";
$sqlRemaining .= " p.ref as product_ref, p.label as product_label,";
$sqlRemaining .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlRemaining .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlRemaining .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added', 'omitted')), 0) as qty_documented";
$sqlRemaining .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sqlRemaining .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sqlRemaining .= " WHERE cd.fk_commande = ".((int)$order->id);
$sqlRemaining .= " AND (cd.fk_product > 0 OR (cd.fk_product = 0 AND cd.description IS NOT NULL AND cd.description != ''))";
$sqlRemaining .= " AND (cd.special_code IS NULL OR cd.special_code = 0)";
$sqlRemaining .= " HAVING qty_ordered > qty_documented";
$resqlRemaining = $db->query($sqlRemaining);
if ($resqlRemaining) {
while ($objRem = $db->fetch_object($resqlRemaining)) {
$remainingProducts[] = $objRem;
}
}
}
$hasRemainingProducts = (count($remainingProducts) > 0);
$title = $langs->trans("Stundenzettel").' - '.$order->ref;
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-stundenzettel page-commande');
// Tabs - Stundenzettel-Tabs wenn aus Stundenzettel aufgerufen, sonst Auftrags-Tabs
// Aktiven Tab-Key basierend auf $tab bestimmen
$activeTabKey = 'productlist'; // Default
if ($tab == 'stundenzettel') {
$activeTabKey = 'stundenzettel_list';
} elseif ($tab == 'tracking') {
$activeTabKey = 'tracking';
}
if ($stundenzettelObj) {
$head = stundenzettel_prepare_head($stundenzettelObj);
dol_fiche_head($head, $activeTabKey, $langs->trans("Stundenzettel"), -1, 'clock');
} else {
$head = commande_prepare_head($order);
dol_fiche_head($head, 'stundenzettel', $langs->trans("CustomerOrder"), -1, 'order');
}
$form = new Form($db);
// Bestätigung für Freigabe mit Warnung (verbleibende Produkte)
if ($action == 'confirm_release_warning') {
// Zeige Warnung mit verbleibenden Produkten
if ($hasRemainingProducts && count($remainingProducts) > 0) {
// Erstelle HTML für die Produktliste
$remainingHtml = '<div class="warning" style="margin-top:10px; padding:10px;">';
$remainingHtml .= '<strong>'.$langs->trans("RemainingProductsWarning").':</strong><ul style="margin:10px 0;">';
foreach ($remainingProducts as $prod) {
$productName = $prod->fk_product > 0 ? $prod->product_ref.' - '.$prod->product_label : strip_tags($prod->description);
$qtyRemaining = $prod->qty_ordered - $prod->qty_documented;
$remainingHtml .= '<li>'.$productName.' - <strong>'.formatQty($qtyRemaining).'</strong> '.$langs->trans("QtyRemaining").'</li>';
}
$remainingHtml .= '</ul></div>';
$confirmMessage = $langs->trans('ConfirmReleaseWithRemaining').$remainingHtml.'<br><br><strong>'.$langs->trans('ConfirmReleaseFinal').'</strong>';
} else {
// Keine verbleibenden Produkte - einfache Bestätigung
$confirmMessage = $langs->trans('ConfirmReleaseFinal');
}
// Direkt zur Freigabe-Action - nur EIN Dialog
print $form->formconfirm(
$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1',
$langs->trans('ConfirmReleaseWithRemainingTitle'),
$confirmMessage,
'confirm_release', // Einfacher Action-Name
array(),
0,
0 // useajax=0 für zuverlässigere Darstellung
);
}
// Bestätigung für Rechnungsübertragung
if ($action == 'transfer_invoice') {
print $form->formconfirm(
$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1',
$langs->trans('ConfirmTransferInvoiceTitle'),
$langs->trans('ConfirmTransferInvoice'),
'confirm_transfer_invoice',
array(),
0,
1
);
}
// Info banner - Produktliste zeigt immer den Auftrag, andere Tabs den Stundenzettel
if ($tab == 'products') {
// Produktliste ist tagesbasiert - immer Auftrag anzeigen
$linkback = '<a href="'.DOL_URL_ROOT.'/commande/list.php?restore_lastsearch_values=1">'.$langs->trans("BackToList").'</a>';
dol_banner_tab($order, 'ref', $linkback, 1, 'ref', 'ref');
} elseif ($stundenzettelObj) {
$linkback = '<a href="'.dol_buildpath('/stundenzettel/list.php', 1).'?restore_lastsearch_values=1">'.$langs->trans("BackToList").'</a>';
dol_banner_tab($stundenzettelObj, 'id', $linkback, 1, 'rowid', 'ref');
} else {
$linkback = '<a href="'.DOL_URL_ROOT.'/commande/list.php?restore_lastsearch_values=1">'.$langs->trans("BackToList").'</a>';
dol_banner_tab($order, 'ref', $linkback, 1, 'ref', 'ref');
}
print '<div class="fichecenter">';
print '<div class="underbanner clearboth"></div>';
// Standard-Leistung und geplante Stunden laden
$societe = new Societe($db);
$societe->fetch($order->socid);
$societe->fetch_optionals();
// Standard-Leistung vom Kunden
$defaultServiceId = isset($societe->array_options['options_stundenzettel_default_service']) ? (int)$societe->array_options['options_stundenzettel_default_service'] : 0;
$defaultServiceProduct = null;
if ($defaultServiceId > 0) {
$defaultServiceProduct = new Product($db);
if ($defaultServiceProduct->fetch($defaultServiceId) <= 0) {
$defaultServiceProduct = null;
}
}
// Geplante Stunden aus dem Auftrag suchen (Dienstleistungen = fk_product_type = 1)
$plannedHours = 0;
$plannedHoursLine = null;
foreach ($order->lines as $line) {
// Prüfe ob es eine Dienstleistung ist
if ($line->fk_product > 0) {
$prod = new Product($db);
if ($prod->fetch($line->fk_product) > 0 && $prod->type == 1) {
// Es ist eine Dienstleistung - summiere die Menge
$plannedHours += $line->qty;
if (!$plannedHoursLine) {
$plannedHoursLine = $line; // Erste Dienstleistung merken
}
}
}
}
// Info-Box für Standard-Leistung und geplante Stunden
if ($defaultServiceProduct || $plannedHours > 0) {
print '<div class="info" style="margin-bottom:15px;">';
print '<table class="noborder" style="width:auto;">';
// Standard-Leistung
print '<tr>';
print '<td style="padding-right:20px;"><strong>'.img_picto('', 'service', 'class="pictofixedwidth"').$langs->trans("DefaultService").':</strong></td>';
print '<td>';
if ($defaultServiceProduct) {
print $defaultServiceProduct->getNomUrl(1).' - '.$defaultServiceProduct->label;
print ' <span class="opacitymedium">('.price($defaultServiceProduct->price, 0, $langs, 1, -1, -1, $conf->currency).'/Std.)</span>';
} else {
print '<span class="opacitymedium">'.$langs->trans("NoDefaultServiceSet").'</span>';
print ' <a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.$order->socid.'" class="button small">'.$langs->trans("SetDefaultServiceInCustomer").'</a>';
}
print '</td>';
print '</tr>';
// Geplante Stunden
if ($plannedHours > 0) {
print '<tr>';
print '<td style="padding-right:20px;"><strong>'.img_picto('', 'clock', 'class="pictofixedwidth"').$langs->trans("PlannedHours").':</strong></td>';
print '<td><span class="badge badge-info">'.formatQty($plannedHours).' Std.</span> '.$langs->trans("HoursFromOrder").'</td>';
print '</tr>';
}
print '</table>';
print '</div>';
}
// Auftragsbeschreibung anzeigen (Extrafeld auftragsbeschreibung)
// Extrafelder laden falls noch nicht geladen
if (!isset($order->array_options) || empty($order->array_options)) {
$order->fetch_optionals();
}
$orderDescription = isset($order->array_options['options_auftragsbeschreibung']) ? $order->array_options['options_auftragsbeschreibung'] : '';
if (!empty($orderDescription)) {
print '<div class="fichehalfleft" style="margin-bottom: 15px;">';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.img_picto('', 'note', 'class="pictofixedwidth"').$langs->trans("OrderDescription").'</th>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>'.dol_htmlentitiesbr($orderDescription).'</td>';
print '</tr>';
print '</table>';
print '</div>';
print '</div>';
print '<div class="clearboth"></div>';
}
// Notizen für nächsten Besuch (aus vorherigen Stundenzetteln)
$sql = "SELECT s.ref, s.note_public, s.date_stundenzettel";
$sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel as s";
$sql .= " WHERE s.fk_commande = ".((int)$order->id);
$sql .= " AND s.note_public IS NOT NULL AND s.note_public != ''";
$sql .= " ORDER BY s.date_stundenzettel DESC";
$sql .= " LIMIT 3";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
print '<div class="info" style="margin-bottom:20px;">';
print '<strong>'.img_picto('', 'note', 'class="pictofixedwidth"').$langs->trans("NotesForNextVisit").':</strong><br>';
while ($obj = $db->fetch_object($resql)) {
print '<div style="margin:5px 0; padding:5px; background:#fff; border-left:3px solid #0077b3;">';
print '<small class="opacitymedium">'.dol_print_date($db->jdate($obj->date_stundenzettel), 'day').' ('.$obj->ref.'):</small><br>';
print dol_htmlentitiesbr($obj->note_public);
print '</div>';
}
print '</div>';
}
// Merkzettel/Notizen aus verknüpftem Stundenzettel anzeigen
$notesStundenzettel = null;
$notesToShow = array();
// Stundenzettel für Notizen bestimmen
if ($stundenzettelObj) {
$notesStundenzettel = $stundenzettelObj;
} else {
// Offenen Stundenzettel für diesen Auftrag suchen
$sqlStz = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel";
$sqlStz .= " WHERE fk_commande = ".((int)$order->id);
$sqlStz .= " AND status = 0"; // Nur Entwürfe
$sqlStz .= " ORDER BY date_stundenzettel DESC, rowid DESC";
$sqlStz .= " LIMIT 1";
$resqlStz = $db->query($sqlStz);
if ($resqlStz && $db->num_rows($resqlStz) > 0) {
$objStz = $db->fetch_object($resqlStz);
$notesStundenzettel = new Stundenzettel($db);
$notesStundenzettel->fetch($objStz->rowid);
}
}
// Notizen aus dem Stundenzettel laden (falls vorhanden)
if ($notesStundenzettel && $notesStundenzettel->id > 0) {
$notesStundenzettel->fetchNotes();
$notesToShow = $notesStundenzettel->notes;
}
// Merkzettel anzeigen, wenn welche vorhanden sind
if (count($notesToShow) > 0) {
print '<div class="info" style="margin-bottom:15px; background-color: #fff8e1; border-left: 4px solid #ffc107; padding: 10px;">';
print '<strong>'.img_picto('', 'list', 'class="pictofixedwidth"').$langs->trans("NotesMemo").'</strong>';
print ' <small class="opacitymedium">('.$notesStundenzettel->ref.')</small>';
print '<ul style="margin: 5px 0 0 0; padding-left: 20px; list-style: none;">';
foreach ($notesToShow as $note) {
$isChecked = ($note->checked == 1);
print '<li style="margin: 5px 0; display: flex; align-items: flex-start; gap: 8px;">';
// Checkbox zum Abhaken
if ($notesStundenzettel->status == Stundenzettel::STATUS_DRAFT && $user->hasRight('stundenzettel', 'write')) {
$newChecked = $isChecked ? 0 : 1;
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&action=toggle_note&note_id='.$note->rowid.'&checked='.$newChecked.'&stundenzettel_id='.$notesStundenzettel->id.'&token='.newToken().'" style="text-decoration: none;">';
if ($isChecked) {
print '<span class="fas fa-check-square" style="color: #28a745; font-size: 1.1em;"></span>';
} else {
print '<span class="far fa-square" style="color: #6c757d; font-size: 1.1em;"></span>';
}
print '</a>';
} else {
if ($isChecked) {
print '<span class="fas fa-check-square" style="color: #28a745; font-size: 1.1em;"></span>';
} else {
print '<span class="far fa-square" style="color: #6c757d; font-size: 1.1em;"></span>';
}
}
// Notiz-Text
print '<span'.($isChecked ? ' style="text-decoration: line-through; color: #6c757d;"' : '').'>';
print dol_escape_htmltag($note->note);
print '</span>';
print '</li>';
}
print '</ul>';
print '</div>';
}
// =============================================
// TAB: ALLE STUNDENZETTEL
// =============================================
if ($tab == 'stundenzettel') {
// Auftrag-ID aus Stundenzettel verwenden wenn vorhanden, sonst aus URL
// Wichtig: Gleiche Logik wie in lib/stundenzettel.lib.php für Badge-Berechnung
$orderIdForQuery = $stundenzettelObj ? (int)$stundenzettelObj->fk_commande : (int)$order->id;
$sql = "SELECT s.rowid, s.ref, s.date_stundenzettel, s.status, s.fk_user_author,";
$sql .= " u.firstname, u.lastname,";
$sql .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp WHERE sp.fk_stundenzettel = s.rowid AND sp.origin IN ('order', 'added')), 0) as total_qty_products,";
$sql .= " COALESCE((SELECT SUM(sl.duration) FROM ".MAIN_DB_PREFIX."stundenzettel_leistung sl WHERE sl.fk_stundenzettel = s.rowid), 0) as total_duration_minutes";
$sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel as s";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user as u ON u.rowid = s.fk_user_author";
$sql .= " WHERE s.fk_commande = ".((int)$orderIdForQuery);
$sql .= " ORDER BY s.date_stundenzettel DESC, s.rowid DESC";
$resqlStzList = $db->query($sql);
$numStz = $resqlStzList ? $db->num_rows($resqlStzList) : 0;
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Ref").'</th>';
print '<th>'.$langs->trans("Date").'</th>';
print '<th>'.$langs->trans("Author").'</th>';
print '<th class="right">'.$langs->trans("Products").'</th>';
print '<th class="right">'.$langs->trans("LeistungDuration").'</th>';
print '<th>'.$langs->trans("Status").'</th>';
print '</tr>';
if ($numStz > 0) {
while ($objStz = $db->fetch_object($resqlStzList)) {
$isCurrentStz = ($stundenzettel_id > 0 && $objStz->rowid == $stundenzettel_id);
print '<tr class="oddeven"'.($isCurrentStz ? ' style="background-color: #e8f4fc;"' : '').'>';
// Ref
print '<td>';
print '<a href="'.dol_buildpath('/stundenzettel/card.php?id='.$objStz->rowid, 1).'">';
print img_picto('', 'clock', 'class="pictofixedwidth"').$objStz->ref;
print '</a>';
if ($isCurrentStz) {
print ' <span class="badge badge-info">aktuell</span>';
}
print '</td>';
// Datum
print '<td>'.dol_print_date($db->jdate($objStz->date_stundenzettel), 'day').'</td>';
// Autor
print '<td>'.$objStz->firstname.' '.$objStz->lastname.'</td>';
// Gesamtmenge Produkte
print '<td class="right">';
if ($objStz->total_qty_products > 0) {
print formatQty($objStz->total_qty_products);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Gesamtzeit Leistungen
print '<td class="right">';
if ($objStz->total_duration_minutes > 0) {
$hours = floor($objStz->total_duration_minutes / 60);
$minutes = $objStz->total_duration_minutes % 60;
print sprintf('%d:%02d h', $hours, $minutes);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Status
$stzTemp = new Stundenzettel($db);
print '<td>'.$stzTemp->LibStatut($objStz->status, 5).'</td>';
print '</tr>';
}
} else {
print '<tr class="oddeven"><td colspan="6" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
}
print '</table>';
print '</div>';
// Button: Neuen Stundenzettel erstellen
print '<div class="center" style="margin-top: 20px;">';
print '<a class="butAction" href="'.dol_buildpath('/stundenzettel/card.php?action=create&fk_commande='.$orderIdForQuery, 1).'">';
print img_picto('', 'add', 'class="pictofixedwidth"').$langs->trans("CreateStundenzettel");
print '</a>';
print '</div>';
}
// =============================================
// TAB: PRODUKTLISTE AUS AUFTRAG
// =============================================
if ($tab == 'products') {
// Farben für Sections (wie im SubtotalTitle-Modul)
$sectionColors = array('#4a90d9', '#50b87d', '#e67e22', '#9b59b6', '#e74c3c', '#1abc9c', '#f39c12', '#3498db');
// Filter-Dropdown mit localStorage-Speicherung pro Auftrag
print '<div class="marginbottomonly" style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;">';
// Filter
print '<div>';
print '<label for="filter" style="margin-right: 5px;">'.$langs->trans("Filter").':</label>';
print '<select name="filter" id="filter" class="flat" onchange="saveFilterAndRedirect(this.value)">';
print '<option value="open"'.($filter == 'open' ? ' selected' : '').'>'.$langs->trans("FilterOpen").'</option>';
print '<option value="done"'.($filter == 'done' ? ' selected' : '').'>'.$langs->trans("FilterDone").'</option>';
print '<option value="all"'.($filter == 'all' ? ' selected' : '').'>'.$langs->trans("FilterAll").'</option>';
print '</select>';
print '</div>';
// Alle ein-/ausklappen Buttons
print '<div>';
print '<button type="button" class="button small" onclick="expandAllSections()" title="'.$langs->trans("ExpandAll").'">';
print '<span class="fas fa-expand-arrows-alt"></span> '.$langs->trans("ExpandAll");
print '</button>';
print ' ';
print '<button type="button" class="button small" onclick="collapseAllSections()" title="'.$langs->trans("CollapseAll").'">';
print '<span class="fas fa-compress-arrows-alt"></span> '.$langs->trans("CollapseAll");
print '</button>';
print '</div>';
print '</div>';
// JavaScript für Filter-Speicherung pro Auftrag
print '<script>
var orderId = '.$order->id.';
var stundenzettelId = '.($stundenzettel_id > 0 ? $stundenzettel_id : 0).';
// Beim Laden: Filter aus localStorage holen (falls nicht per URL gesetzt)
document.addEventListener("DOMContentLoaded", function() {
var urlParams = new URLSearchParams(window.location.search);
// WICHTIG: Nicht umleiten wenn eine action aktiv ist (z.B. Bestätigungsdialog)
if (urlParams.has("action")) {
return; // Bei Actions keine Filter-Weiterleitung
}
if (!urlParams.has("filter")) {
var savedFilter = localStorage.getItem("stz_filter_" + orderId);
if (savedFilter && savedFilter !== "'.$filter.'") {
// Redirect mit gespeichertem Filter
saveFilterAndRedirect(savedFilter);
}
}
});
function saveFilterAndRedirect(filterValue) {
// Filter in localStorage speichern
localStorage.setItem("stz_filter_" + orderId, filterValue);
// Redirect (noredirect beibehalten um Auto-Redirect zu verhindern)
var url = "?id=" + orderId + "&tab=products&filter=" + filterValue + "&noredirect=1";
if (stundenzettelId > 0) {
url += "&stundenzettel_id=" + stundenzettelId;
}
window.location.href = url;
}
</script>';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="transfer_products">';
if ($stundenzettel_id > 0) {
print '<input type="hidden" name="stundenzettel_id" value="'.$stundenzettel_id.'">';
}
// Datum für Stundenzettel nur anzeigen, wenn kein Stundenzettel ausgewählt
if (!$stundenzettel_id) {
// Standard-Datum ermitteln basierend auf Einstellung
$defaultDateSetting = getDolGlobalString('STUNDENZETTEL_DEFAULT_DATE', 'today');
$defaultDate = date('Y-m-d');
if ($defaultDateSetting == 'last_open') {
// Datum des letzten offenen Stundenzettels für diesen Auftrag suchen
$sqlLastOpen = "SELECT date_stundenzettel FROM ".MAIN_DB_PREFIX."stundenzettel";
$sqlLastOpen .= " WHERE fk_commande = ".((int)$order->id);
$sqlLastOpen .= " AND status = 0"; // Nur Entwürfe
$sqlLastOpen .= " ORDER BY date_stundenzettel DESC, rowid DESC LIMIT 1";
$resqlLastOpen = $db->query($sqlLastOpen);
if ($resqlLastOpen && $db->num_rows($resqlLastOpen) > 0) {
$objLastOpen = $db->fetch_object($resqlLastOpen);
if ($objLastOpen->date_stundenzettel) {
$defaultDate = date('Y-m-d', $db->jdate($objLastOpen->date_stundenzettel));
}
}
}
print '<div class="marginbottomonly">';
print '<label for="date_stundenzettel">'.$langs->trans("StundenzettelDate").':</label> ';
print '<input type="date" name="date_stundenzettel" id="date_stundenzettel" value="'.$defaultDate.'" class="flat">';
print '</div>';
} else {
// Prüfen ob der ausgewählte Stundenzettel von heute ist
$stzDateStr = date('Y-m-d', $stundenzettelObj->date_stundenzettel);
$todayStr = date('Y-m-d');
print '<div class="marginbottomonly">';
if ($stzDateStr == $todayStr) {
// Stundenzettel ist von heute - normal anzeigen
print '<span class="opacitymedium">'.$langs->trans("TransferToStundenzettel").': </span>';
print '<strong>'.$stundenzettelObj->ref.'</strong> ('.dol_print_date($stundenzettelObj->date_stundenzettel, 'day').')';
} else {
// Stundenzettel ist von einem anderen Tag - Hinweis dass neuer erstellt wird
print '<span class="opacitymedium">'.$langs->trans("TransferToStundenzettel").': </span>';
print '<strong style="color: #28a745;">'.$langs->trans("NewStundenzettelForToday").'</strong>';
print ' <span class="opacitymedium">('.dol_print_date(dol_now(), 'day').')</span>';
print '<br><small class="opacitymedium">'.$langs->trans("SelectedStundenzettelNotToday", $stundenzettelObj->ref, dol_print_date($stundenzettelObj->date_stundenzettel, 'day')).'</small>';
// Hidden field für das heutige Datum
print '<input type="hidden" name="force_new_stundenzettel" value="1">';
print '<input type="hidden" name="date_stundenzettel" value="'.date('Y-m-d').'">';
}
print '</div>';
}
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th class="center" style="width:30px;"><input type="checkbox" id="checkall" onclick="toggleAll(this)"></th>';
print '<th>'.$langs->trans("Product").'</th>';
print '<th class="right">'.$langs->trans("QtyOrdered").'</th>';
print '<th class="right">'.$langs->trans("QtyDelivered").'</th>';
print '<th class="right">'.$langs->trans("QtyRemaining").'</th>';
print '<th>'.$langs->trans("Status").'</th>';
print '</tr>';
// Lade Sections und Produkte aus facture_lines_manager
$sections = array();
$products_by_section = array();
$products_without_section = array();
// Erst alle Sections laden
$sql = "SELECT m.rowid, m.title, m.line_order";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager as m";
$sql .= " WHERE m.fk_commande = ".((int)$order->id);
$sql .= " AND m.document_type = 'order'";
$sql .= " AND m.line_type = 'section'";
$sql .= " ORDER BY m.line_order";
$resql = $db->query($sql);
if ($resql) {
$colorIndex = 0;
while ($obj = $db->fetch_object($resql)) {
$sections[$obj->rowid] = array(
'title' => $obj->title,
'line_order' => $obj->line_order,
'color' => $sectionColors[$colorIndex % count($sectionColors)]
);
$products_by_section[$obj->rowid] = array();
$colorIndex++;
}
}
// Dann alle Produkte laden mit Section-Zuordnung
// Berechne qty_delivered, qty_added (Mehraufwand) und qty_removed (Entfällt) direkt aus Stundenzetteln
$sql = "SELECT m.rowid as manager_id, m.fk_commandedet, m.parent_section, m.line_order,";
$sql .= " cd.rowid, cd.fk_product, cd.qty, cd.description,";
$sql .= " p.ref as product_ref, p.label as product_label,";
// qty_delivered: Summe aller qty_done für diese Auftragszeile (origin = 'order' oder 'added')
$sql .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sql .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added')), 0) as qty_delivered,";
// qty_added: Mehraufwand für dieses Produkt (origin = 'additional')
$sql .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sql .= " WHERE sp2.fk_product = cd.fk_product AND sp2.origin = 'additional' AND s2.fk_commande = ".((int)$order->id)."), 0) as qty_added,";
// qty_removed: Entfällt für dieses Produkt (origin = 'omitted')
$sql .= " COALESCE((SELECT SUM(sp3.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp3";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s3 ON s3.rowid = sp3.fk_stundenzettel";
$sql .= " WHERE sp3.fk_product = cd.fk_product AND sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$order->id)."), 0) as qty_removed";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager as m";
$sql .= " JOIN ".MAIN_DB_PREFIX."commandedet as cd ON cd.rowid = m.fk_commandedet";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sql .= " WHERE m.fk_commande = ".((int)$order->id);
$sql .= " AND m.document_type = 'order'";
$sql .= " AND m.line_type = 'product'";
$sql .= " ORDER BY m.line_order";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
if ($obj->parent_section && isset($products_by_section[$obj->parent_section])) {
$products_by_section[$obj->parent_section][] = $obj;
} else {
$products_without_section[] = $obj;
}
}
}
// Falls keine Manager-Einträge, direkt aus commandedet laden
$hasManagerData = (count($sections) > 0 || count($products_without_section) > 0);
if (!$hasManagerData) {
// Berechne qty_delivered, qty_added (Mehraufwand) und qty_removed (Entfällt) direkt aus Stundenzetteln
$sql = "SELECT cd.rowid, cd.fk_product, cd.qty, cd.description,";
$sql .= " p.ref as product_ref, p.label as product_label,";
// qty_delivered: Summe aller qty_done für diese Auftragszeile (origin = 'order' oder 'added')
$sql .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sql .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added')), 0) as qty_delivered,";
// qty_added: Mehraufwand für dieses Produkt (origin = 'additional')
$sql .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sql .= " WHERE sp2.fk_product = cd.fk_product AND sp2.origin = 'additional' AND s2.fk_commande = ".((int)$order->id)."), 0) as qty_added,";
// qty_removed: Entfällt für dieses Produkt (origin = 'omitted')
$sql .= " COALESCE((SELECT SUM(sp3.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp3";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s3 ON s3.rowid = sp3.fk_stundenzettel";
$sql .= " WHERE sp3.fk_product = cd.fk_product AND sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$order->id)."), 0) as qty_removed";
$sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sql .= " WHERE cd.fk_commande = ".((int)$order->id);
$sql .= " AND (cd.fk_product > 0 OR (cd.fk_product = 0 AND cd.description IS NOT NULL AND cd.description != ''))";
$sql .= " AND (cd.special_code IS NULL OR cd.special_code = 0)"; // Keine SubtotalTitle-Spezialzeilen
$sql .= " ORDER BY cd.rang";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$products_without_section[] = $obj;
}
}
}
// Bereits auf dem ausgewählten Stundenzettel vorhandene Produkte laden
// NUR wenn der Stundenzettel von heute ist - ansonsten wird ein neuer erstellt
$alreadyOnStundenzettel = array();
$stzIsToday = false;
if ($stundenzettel_id > 0 && $stundenzettelObj) {
$stzDateStr = date('Y-m-d', $stundenzettelObj->date_stundenzettel);
$todayStr = date('Y-m-d');
$stzIsToday = ($stzDateStr == $todayStr);
// Nur wenn der Stundenzettel von heute ist, die bereits vorhandenen Produkte laden
if ($stzIsToday) {
$sqlExisting = "SELECT sp.fk_commandedet FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlExisting .= " WHERE sp.fk_stundenzettel = ".((int)$stundenzettel_id);
$sqlExisting .= " AND sp.fk_commandedet IS NOT NULL AND sp.fk_commandedet > 0";
$resqlExisting = $db->query($sqlExisting);
if ($resqlExisting) {
while ($objExisting = $db->fetch_object($resqlExisting)) {
$alreadyOnStundenzettel[$objExisting->fk_commandedet] = true;
}
}
}
}
// Hilfsfunktion für Produktzeile - gibt true zurück wenn angezeigt, false wenn übersprungen
$printProductRow = function($obj, $color = null, $sectionId = null) use ($langs, $filter, $alreadyOnStundenzettel) {
$qty_added = isset($obj->qty_added) ? (float)$obj->qty_added : 0;
$qty_removed = isset($obj->qty_removed) ? (float)$obj->qty_removed : 0;
// Effektive Gesamtmenge = Original + Hinzugefügt - Entfallen
$effectiveTotal = $obj->qty + $qty_added - $qty_removed;
$remaining = $effectiveTotal - $obj->qty_delivered;
$isDone = ($remaining <= 0);
// Filter anwenden
if ($filter == 'open' && $isDone) {
return false;
}
if ($filter == 'done' && !$isDone) {
return false;
}
// 'all' zeigt alles
// Styling: Nur erste Zelle bekommt border-left
$styleFirst = $color ? ' style="border-left: 4px solid '.$color.';"' : '';
$sectionClass = $sectionId ? ' section-product section_'.$sectionId : '';
print '<tr class="oddeven'.$sectionClass.'">';
// Checkbox (nur bei offenen anzeigen UND wenn noch nicht auf diesem Stundenzettel)
$isAlreadyOnStz = isset($alreadyOnStundenzettel[$obj->rowid]);
print '<td class="center"'.$styleFirst.'>';
if (!$isDone && !$isAlreadyOnStz) {
print '<input type="checkbox" name="selected[]" value="'.$obj->rowid.'" class="product-checkbox">';
} elseif ($isAlreadyOnStz) {
print '<span class="fas fa-check" style="color: #28a745;" title="'.$langs->trans("AlreadyOnStundenzettel").'"></span>';
}
print '</td>';
// Produkt
print '<td>';
if ($obj->fk_product > 0) {
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$obj->fk_product.'">';
print img_picto('', 'product', 'class="pictofixedwidth"');
print $obj->product_ref.' - '.$obj->product_label;
print '</a>';
} else {
// Freitext-Produkt: Beschreibung anzeigen
print img_picto('', 'generic', 'class="pictofixedwidth"');
$desc = !empty($obj->description) ? strip_tags($obj->description) : '-';
// Beschreibung kürzen wenn zu lang
if (strlen($desc) > 80) {
$desc = substr($desc, 0, 77).'...';
}
print '<span class="opacitymedium">'.$desc.'</span>';
}
print '</td>';
// Menge bestellt - zeigt Gesamtzahl mit Info über Änderungen
print '<td class="right">';
// Gesamtzahl (effektiv) als Hauptzahl
print '<strong>'.formatQty($effectiveTotal).'</strong>';
// Änderungen als kleine Info-Badges
if ($qty_added > 0) {
print ' <span class="badge" style="background-color: #28a745; color: #fff; font-size: 0.75em;" title="'.$langs->trans("AdditionalQty").'">+'.formatQty($qty_added).'</span>';
}
if ($qty_removed > 0) {
print ' <span class="badge" style="background-color: #dc3545; color: #fff; font-size: 0.75em;" title="'.$langs->trans("Entfaellt").'">-'.formatQty($qty_removed).'</span>';
}
print '</td>';
// Menge geliefert/verbaut
print '<td class="right">'.formatQty($obj->qty_delivered).'</td>';
// Verbleibend
print '<td class="right">';
if ($remaining > 0) {
print '<span class="badge badge-warning">'.formatQty($remaining).'</span>';
} elseif ($remaining == 0) {
print '<span class="badge badge-success">0</span>';
} else {
// Mehr verbaut als bestellt
print '<span class="badge badge-info">'.formatQty($remaining).'</span>';
}
print '</td>';
// Status
print '<td>';
if ($isDone) {
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($obj->qty_delivered > 0) {
print '<span class="badge badge-warning">'.$langs->trans("TrackingPartial").'</span>';
} else {
print '<span class="badge badge-secondary">'.$langs->trans("TrackingOpen").'</span>';
}
print '</td>';
print '</tr>';
return true;
};
$totalProducts = 0;
// Sections mit Produkten anzeigen
foreach ($sections as $sectionId => $section) {
$sectionProducts = $products_by_section[$sectionId];
if (count($sectionProducts) == 0) continue;
// Prüfen, wie viele Produkte nach Filter angezeigt werden
$visibleProductsInSection = 0;
foreach ($sectionProducts as $prod) {
$prodQtyAdded = isset($prod->qty_added) ? (float)$prod->qty_added : 0;
$prodQtyRemoved = isset($prod->qty_removed) ? (float)$prod->qty_removed : 0;
$prodEffectiveTotal = $prod->qty + $prodQtyAdded - $prodQtyRemoved;
$remaining = $prodEffectiveTotal - $prod->qty_delivered;
$isDone = ($remaining <= 0);
// Filter-Logik
if ($filter == 'open' && !$isDone) {
$visibleProductsInSection++;
} elseif ($filter == 'done' && $isDone) {
$visibleProductsInSection++;
} elseif ($filter == 'all') {
$visibleProductsInSection++;
}
}
// Section nur anzeigen wenn Produkte nach Filter vorhanden
if ($visibleProductsInSection == 0) continue;
// Section-Header mit Buffering, wird nur ausgegeben wenn Produkte angezeigt werden
// Dezente Farbgebung: nur linker Rand farbig, heller Hintergrund
$sectionHeader = '<tr class="liste_titre section-header" data-section="section_'.$sectionId.'" onclick="toggleSection(\'section_'.$sectionId.'\')" style="cursor: pointer;">';
$sectionHeader .= '<td colspan="6" style="font-weight: bold; padding: 8px; border-left: 4px solid '.$section['color'].'; background-color: #f8f8f8;">';
$sectionHeader .= '<span class="section-toggle" id="toggle_section_'.$sectionId.'" style="margin-right: 10px;">▼</span>';
$sectionHeader .= dol_escape_htmltag($section['title']);
$sectionHeader .= ' <span style="opacity: 0.6; font-weight: normal;">('.$visibleProductsInSection.' '.$langs->trans("Products").')</span>';
$sectionHeader .= '</td>';
$sectionHeader .= '</tr>';
print $sectionHeader;
// Produkte dieser Section
foreach ($sectionProducts as $prod) {
if ($printProductRow($prod, $section['color'], $sectionId)) {
$totalProducts++;
}
}
}
// Produkte ohne Section
$visibleProductsWithoutSection = 0;
foreach ($products_without_section as $prod) {
$prodQtyAdded = isset($prod->qty_added) ? (float)$prod->qty_added : 0;
$prodQtyRemoved = isset($prod->qty_removed) ? (float)$prod->qty_removed : 0;
$prodEffectiveTotal = $prod->qty + $prodQtyAdded - $prodQtyRemoved;
$remaining = $prodEffectiveTotal - $prod->qty_delivered;
$isDone = ($remaining <= 0);
// Filter-Logik
if ($filter == 'open' && !$isDone) {
$visibleProductsWithoutSection++;
} elseif ($filter == 'done' && $isDone) {
$visibleProductsWithoutSection++;
} elseif ($filter == 'all') {
$visibleProductsWithoutSection++;
}
}
if ($visibleProductsWithoutSection > 0) {
if (count($sections) > 0) {
// Trennzeile nur wenn es auch Sections gibt - einklappbar, dezente Farbgebung
print '<tr class="liste_titre section-header" data-section="section_other" onclick="toggleSection(\'section_other\')" style="cursor: pointer;">';
print '<td colspan="6" style="font-weight: bold; padding: 8px; border-left: 4px solid #666; background-color: #f8f8f8;">';
print '<span class="section-toggle" id="toggle_section_other" style="margin-right: 10px;">▼</span>';
print $langs->trans("OtherProducts").' <span style="opacity: 0.6; font-weight: normal;">('.$visibleProductsWithoutSection.' '.$langs->trans("Products").')</span>';
print '</td>';
print '</tr>';
}
foreach ($products_without_section as $prod) {
if ($printProductRow($prod, null, 'other')) {
$totalProducts++;
}
}
}
if ($totalProducts == 0) {
print '<tr class="oddeven"><td colspan="6" class="opacitymedium">'.$langs->trans("AllProductsDocumented").'</td></tr>';
}
// =============================================
// BEREICH: MEHRAUFWAND-PRODUKTE (aus allen Stundenzetteln dieses Auftrags)
// =============================================
// Mehraufwand umfasst:
// 1. origin='additional' → Mehraufwand-Bestellung (Beauftragt)
// 2. origin='added' ohne fk_commandedet → Produkt nicht im Auftrag, in Produktliste hinzugefügt (Verbaut)
// Logik:
// - Wenn NUR 'added' existiert: Beauftragt = Verbaut = Menge
// - Wenn 'additional' existiert: Beauftragt von 'additional', Verbaut von 'added'
$sqlMehraufwand = "SELECT sp.fk_product, sp.product_ref, sp.product_label, sp.description,";
// qty_additional = Menge aus Mehraufwand-Zeilen
$sqlMehraufwand .= " SUM(CASE WHEN sp.origin = 'additional' THEN sp.qty_done ELSE 0 END) as qty_additional,";
// qty_added = Menge aus Produktliste (nicht im Auftrag)
$sqlMehraufwand .= " SUM(CASE WHEN sp.origin = 'added' AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0) THEN sp.qty_done ELSE 0 END) as qty_added,";
$sqlMehraufwand .= " GROUP_CONCAT(DISTINCT s.ref SEPARATOR ', ') as stundenzettel_refs";
$sqlMehraufwand .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlMehraufwand .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlMehraufwand .= " WHERE s.fk_commande = ".((int)$order->id);
// Beide Typen: Mehraufwand ODER hinzugefügt ohne Auftragszeile
$sqlMehraufwand .= " AND (sp.origin = 'additional' OR (sp.origin = 'added' AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0)))";
$sqlMehraufwand .= " GROUP BY sp.fk_product, sp.product_ref, sp.product_label, sp.description";
$sqlMehraufwand .= " ORDER BY sp.product_ref, sp.description";
$resqlMehraufwand = $db->query($sqlMehraufwand);
$mehraufwandCount = 0;
if ($resqlMehraufwand) {
$mehraufwandCount = $db->num_rows($resqlMehraufwand);
}
if ($mehraufwandCount > 0) {
// Mehraufwand-Header - einklappbar, dezente Farbgebung
print '<tr class="liste_titre section-header" data-section="section_mehraufwand" onclick="toggleSection(\'section_mehraufwand\')" style="cursor: pointer;">';
print '<td colspan="6" style="font-weight: bold; padding: 8px; border-left: 4px solid #e67e22; background-color: #f8f8f8;">';
print '<span class="section-toggle" id="toggle_section_mehraufwand" style="margin-right: 10px;">▼</span>';
print '<span style="margin-right: 10px; color: #e67e22;">⚠</span>';
print $langs->trans("Mehraufwand");
print ' <span style="opacity: 0.6; font-weight: normal;">('.$mehraufwandCount.' '.$langs->trans("Products").')</span>';
print '</td>';
print '</tr>';
while ($objMa = $db->fetch_object($resqlMehraufwand)) {
// Logik für Beauftragt und Verbaut:
// - qty_additional = Menge aus Mehraufwand-Zeilen (origin='additional')
// - qty_added = Menge aus Produktliste ohne Auftragszeile (origin='added', kein fk_commandedet)
// Wenn NUR 'added' existiert: Beauftragt = Verbaut = qty_added
// Wenn 'additional' existiert: Beauftragt = qty_additional, Verbaut = qty_added
$qtyAdditional = (float)$objMa->qty_additional;
$qtyAdded = (float)$objMa->qty_added;
// Beauftragt: Wenn Mehraufwand existiert, dessen Menge nehmen, sonst die hinzugefügte Menge
$qtyTarget = ($qtyAdditional > 0) ? $qtyAdditional : $qtyAdded;
// Verbaut: Immer die hinzugefügte Menge (was tatsächlich installiert wurde)
$qtyDone = $qtyAdded;
$qtyRemaining = $qtyTarget - $qtyDone; // Verbleibend
// Product IDs für Checkbox holen (beide origin-Typen)
$sqlIds = "SELECT GROUP_CONCAT(DISTINCT sp.rowid ORDER BY sp.rowid) as product_ids";
$sqlIds .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlIds .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlIds .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlIds .= " AND (sp.origin = 'additional' OR (sp.origin = 'added' AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0)))";
if ($objMa->fk_product > 0) {
$sqlIds .= " AND sp.fk_product = ".((int)$objMa->fk_product);
} else {
$sqlIds .= " AND sp.fk_product IS NULL AND sp.description = '".$db->escape($objMa->description)."'";
}
$resqlIds = $db->query($sqlIds);
$productIds = '';
if ($resqlIds && ($objIds = $db->fetch_object($resqlIds))) {
$productIds = $objIds->product_ids;
}
$isDone = ($qtyRemaining <= 0 && $qtyDone > 0);
print '<tr class="oddeven section-product section_mehraufwand">';
// Checkbox - Mehraufwand immer anzeigen (für Auswahl/Übersicht)
print '<td class="center">';
if ($productIds) {
print '<input type="checkbox" name="selected_mehraufwand[]" value="'.$productIds.'" class="product-checkbox">';
}
print '</td>';
// Produkt
print '<td>';
if ($objMa->fk_product > 0) {
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$objMa->fk_product.'">';
print img_picto('', 'product', 'class="pictofixedwidth"');
print $objMa->product_ref.' - '.$objMa->product_label;
print '</a>';
} else {
print img_picto('', 'generic', 'class="pictofixedwidth"');
$desc = !empty($objMa->description) ? strip_tags($objMa->description) : $langs->trans("FreeText");
if (strlen($desc) > 80) {
$desc = substr($desc, 0, 77).'...';
}
print '<span class="opacitymedium">'.$desc.'</span>';
}
print ' <span class="badge badge-warning">'.$langs->trans("Mehraufwand").'</span>';
print '</td>';
// Menge beauftragt (Zielmenge - was verbaut werden soll)
print '<td class="right">'.formatQty($qtyTarget).'</td>';
// Menge tatsächlich verbaut
print '<td class="right">'.formatQty($qtyDone).'</td>';
// Verbleibend (Zielmenge - Verbaut)
print '<td class="right">';
if ($qtyRemaining > 0) {
print '<span class="badge badge-warning">'.formatQty($qtyRemaining).'</span>';
} elseif ($qtyRemaining < 0) {
// Mehr verbaut als geplant
print '<span class="badge badge-info">'.formatQty($qtyRemaining).'</span>';
} else {
print '<span class="badge badge-success">0</span>';
}
print '</td>';
// Status
print '<td>';
if ($qtyDone > 0 && $qtyRemaining > 0) {
print '<span class="badge badge-warning">'.$langs->trans("TrackingPartial").'</span>';
} elseif ($qtyDone == 0) {
print '<span class="badge badge-secondary">'.$langs->trans("TrackingOpen").'</span>';
} else {
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
}
print ' <small class="opacitymedium" title="'.$objMa->stundenzettel_refs.'">('.$langs->trans("FromStundenzettel").')</small>';
print '</td>';
print '</tr>';
}
}
// HINWEIS: Entfällt-Produkte werden nicht separat angezeigt
// Die Entfällt-Mengen werden bereits in der Beauftragt-Spalte der normalen Produkte
// als Abzug berücksichtigt (effectiveTotal = qty + qty_added - qty_removed)
print '</table>';
print '</div>';
// Button nur anzeigen wenn nicht freigegeben
if ($stundenzettelStatus == 0) {
print '<div class="center" style="margin-top:20px;">';
print '<input type="submit" class="button button-primary" value="'.$langs->trans("TransferProducts").'">';
print '</div>';
} elseif ($stundenzettelStatus == 1) {
// Status 1: Freigegeben - zeige Meldung mit Wiedereröffnen-Button
print '<div class="warning" style="margin-top:20px;">';
print img_picto('', 'lock', 'class="pictofixedwidth"');
print $langs->trans("StundenzettelReleasedNoChanges");
print ' <a class="button small" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=reopen_stundenzettel" style="margin-left:10px;">';
print img_picto('', 'unlock', 'class="pictofixedwidth"').$langs->trans("ReopenStundenzettel");
print '</a>';
print '</div>';
} else {
// Status 2: In Rechnung übertragen
print '<div class="info" style="margin-top:20px;">';
print img_picto('', 'check', 'class="pictofixedwidth"');
print $langs->trans("StundenzettelTransferredToInvoice");
print ' <a class="button small" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=reset_stundenzettel" style="margin-left:10px;">';
print img_picto('', 'undo', 'class="pictofixedwidth"').$langs->trans("ResetStundenzettel");
print '</a>';
print '</div>';
}
print '</form>';
// JavaScript für "Alle auswählen" und Section-Toggle
print '<script>
function toggleAll(source) {
var checkboxes = document.getElementsByClassName("product-checkbox");
for (var i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = source.checked;
}
}
// Section ein-/ausklappen
function toggleSection(sectionId) {
var rows = document.querySelectorAll("tr." + sectionId);
var toggle = document.getElementById("toggle_" + sectionId);
var isCollapsed = toggle.textContent.trim() === "▶";
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = isCollapsed ? "" : "none";
}
toggle.textContent = isCollapsed ? "▼" : "▶";
// Zustand in localStorage speichern
saveSectionState(sectionId, !isCollapsed);
}
// Alle Sections ausklappen
function expandAllSections() {
var headers = document.querySelectorAll(".section-header");
headers.forEach(function(header) {
var sectionId = header.dataset.section;
var rows = document.querySelectorAll("tr." + sectionId);
var toggle = document.getElementById("toggle_" + sectionId);
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = "";
}
if (toggle) toggle.textContent = "▼";
saveSectionState(sectionId, false);
});
}
// Alle Sections einklappen
function collapseAllSections() {
var headers = document.querySelectorAll(".section-header");
headers.forEach(function(header) {
var sectionId = header.dataset.section;
var rows = document.querySelectorAll("tr." + sectionId);
var toggle = document.getElementById("toggle_" + sectionId);
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = "none";
}
if (toggle) toggle.textContent = "▶";
saveSectionState(sectionId, true);
});
}
// Zustand in localStorage speichern
function saveSectionState(sectionId, collapsed) {
var states = JSON.parse(localStorage.getItem("stz_sections_" + orderId) || "{}");
states[sectionId] = collapsed;
localStorage.setItem("stz_sections_" + orderId, JSON.stringify(states));
}
// Zustand beim Laden wiederherstellen
document.addEventListener("DOMContentLoaded", function() {
var states = JSON.parse(localStorage.getItem("stz_sections_" + orderId) || "{}");
for (var sectionId in states) {
if (states[sectionId]) {
var rows = document.querySelectorAll("tr." + sectionId);
var toggle = document.getElementById("toggle_" + sectionId);
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = "none";
}
if (toggle) toggle.textContent = "▶";
}
}
});
</script>';
// =============================================
// AKTIONSBUTTONS FÜR STUNDENZETTEL-FREIGABE
// =============================================
if ($stundenzettelStatus < 2) {
print '<div class="tabsAction" style="margin-top: 20px;">';
if ($stundenzettelStatus == 0) {
// Status: Offen - Zeige Freigeben-Button wenn Stundenzettel vorhanden
if ($hasStundenzettel && $allValidated) {
if ($hasRemainingProducts) {
// Mit Warnung
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=confirm_release_warning">';
print img_picto('', 'lock', 'class="pictofixedwidth"').$langs->trans("ReleaseStundenzettel");
print '</a>';
} else {
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=release_stundenzettel">';
print img_picto('', 'lock', 'class="pictofixedwidth"').$langs->trans("ReleaseStundenzettel");
print '</a>';
}
} elseif ($hasStundenzettel && !$allValidated) {
print '<span class="butActionRefused" title="'.$langs->trans("AllStundenzettelMustBeValidated").'">';
print img_picto('', 'lock', 'class="pictofixedwidth"').$langs->trans("ReleaseStundenzettel");
print '</span>';
}
} elseif ($stundenzettelStatus == 1) {
// Status: Freigegeben - Zeige Wiedereröffnen und Rechnung-Button
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=reopen_stundenzettel">';
print img_picto('', 'unlock', 'class="pictofixedwidth"').$langs->trans("ReopenStundenzettel");
print '</a>';
if ($user->hasRight('facture', 'creer')) {
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=transfer_invoice">';
print img_picto('', 'bill', 'class="pictofixedwidth"').$langs->trans("TransferToInvoice");
print '</a>';
}
}
print '</div>';
}
}
// =============================================
// TAB: LIEFERAUFLISTUNG / TRACKING
// =============================================
if ($tab == 'tracking') {
$total_ordered = 0;
$total_delivered = 0;
$total_remaining = 0;
// Alle Stundenzettel-Details pro Produkt laden (für ausklappbare Ansicht)
$trackingDetails = array(); // Array[fk_commandedet] => array of entries
$sqlDetails = "SELECT sp.rowid, sp.fk_stundenzettel, sp.fk_commandedet, sp.fk_product,";
$sqlDetails .= " sp.qty_done, sp.origin, sp.description,";
$sqlDetails .= " s.ref as stz_ref, s.date_stundenzettel";
$sqlDetails .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlDetails .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlDetails .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlDetails .= " AND sp.qty_done > 0";
$sqlDetails .= " ORDER BY s.date_stundenzettel DESC, sp.rowid";
$resqlDetails = $db->query($sqlDetails);
if ($resqlDetails) {
while ($objD = $db->fetch_object($resqlDetails)) {
$key = $objD->fk_commandedet > 0 ? $objD->fk_commandedet : 'prod_'.$objD->fk_product;
if (!isset($trackingDetails[$key])) {
$trackingDetails[$key] = array();
}
$trackingDetails[$key][] = $objD;
}
}
// Buttons für Details anzeigen/verbergen
print '<div class="marginbottomonly" style="display:flex; justify-content:flex-end; gap:10px;">';
print '<button type="button" class="button small" onclick="expandAllTrackingDetails()" title="'.$langs->trans("ShowDetails").'">';
print '<span class="fas fa-list"></span> '.$langs->trans("ShowDetails");
print '</button>';
print '<button type="button" class="button small" onclick="collapseAllTrackingDetails()" title="'.$langs->trans("HideDetails").'">';
print '<span class="fas fa-minus"></span> '.$langs->trans("HideDetails");
print '</button>';
print '</div>';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent" id="tracking-table">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Product").'</th>';
print '<th class="right">'.$langs->trans("QtyOrdered").'</th>';
print '<th class="right">'.$langs->trans("QtyDelivered").'</th>';
print '<th class="right">'.$langs->trans("QtyRemaining").'</th>';
print '<th>'.$langs->trans("Status").'</th>';
print '</tr>';
// Live-Berechnung aus allen Stundenzetteln (inkl. Entwürfe)
$sql = "SELECT cd.rowid, cd.fk_product, cd.qty as qty_ordered, cd.description,";
$sql .= " p.ref as product_ref, p.label as product_label,";
$sql .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sql .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added')), 0) as qty_delivered,";
// Mehraufwand
$sql .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sql .= " WHERE sp2.fk_product = cd.fk_product AND sp2.origin = 'additional' AND s2.fk_commande = ".((int)$order->id)."), 0) as qty_additional,";
// Entfällt
$sql .= " COALESCE((SELECT SUM(sp3.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp3";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s3 ON s3.rowid = sp3.fk_stundenzettel";
$sql .= " WHERE sp3.fk_product = cd.fk_product AND sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$order->id)."), 0) as qty_omitted";
$sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sql .= " WHERE cd.fk_commande = ".((int)$order->id);
$sql .= " AND (cd.fk_product > 0 OR (cd.fk_product = 0 AND cd.description IS NOT NULL AND cd.description != ''))";
$sql .= " AND (cd.special_code IS NULL OR cd.special_code = 0)";
$sql .= " ORDER BY cd.rang";
$resql = $db->query($sql);
$detailRowId = 0;
if ($resql) {
$num = $db->num_rows($resql);
if ($num > 0) {
while ($obj = $db->fetch_object($resql)) {
$detailRowId++;
$qty_additional = (float)$obj->qty_additional;
$qty_omitted = (float)$obj->qty_omitted;
$effective_ordered = $obj->qty_ordered + $qty_additional - $qty_omitted;
$qty_remaining = $effective_ordered - $obj->qty_delivered;
// Details für dieses Produkt
$details = isset($trackingDetails[$obj->rowid]) ? $trackingDetails[$obj->rowid] : array();
// Falls keine Details über fk_commandedet, versuche über fk_product
if (empty($details) && $obj->fk_product > 0) {
$prodKey = 'prod_'.$obj->fk_product;
if (isset($trackingDetails[$prodKey])) {
$details = $trackingDetails[$prodKey];
}
}
$hasDetails = !empty($details);
print '<tr class="oddeven">';
// Produkt mit Toggle
print '<td>';
if ($hasDetails) {
print '<span class="tracking-toggle" onclick="toggleTrackingDetails(\'tdetail_'.$detailRowId.'\')" style="cursor:pointer; margin-right:5px;">';
print '<span class="fas fa-chevron-right tracking-arrow" id="tarrow_tdetail_'.$detailRowId.'"></span>';
print '</span>';
}
if ($obj->fk_product > 0) {
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$obj->fk_product.'">';
print img_picto('', 'product', 'class="pictofixedwidth"');
print $obj->product_ref.' - '.$obj->product_label;
print '</a>';
} else {
print img_picto('', 'generic', 'class="pictofixedwidth"');
$desc = !empty($obj->description) ? strip_tags($obj->description) : '-';
if (strlen($desc) > 60) $desc = substr($desc, 0, 57).'...';
print '<span class="opacitymedium">'.$desc.'</span>';
}
print '</td>';
// Bestellt (mit Badges für Mehraufwand/Entfällt)
print '<td class="right">';
print '<strong>'.formatQty($effective_ordered).'</strong>';
if ($qty_additional > 0) {
print ' <span class="badge" style="background-color: #28a745; color: #fff; font-size: 0.75em;" title="'.$langs->trans("Mehraufwand").'">+'.formatQty($qty_additional).'</span>';
}
if ($qty_omitted > 0) {
print ' <span class="badge" style="background-color: #dc3545; color: #fff; font-size: 0.75em;" title="'.$langs->trans("Entfaellt").'">-'.formatQty($qty_omitted).'</span>';
}
print '</td>';
$total_ordered += $effective_ordered;
// Geliefert/Erfasst
print '<td class="right">'.formatQty($obj->qty_delivered).'</td>';
$total_delivered += $obj->qty_delivered;
// Verbleibend
print '<td class="right">';
if ($qty_remaining > 0) {
print '<span class="badge badge-warning">'.formatQty($qty_remaining).'</span>';
} elseif ($qty_remaining == 0) {
print '<span class="badge badge-success">0</span>';
} else {
print '<span class="badge badge-info">'.formatQty($qty_remaining).'</span>';
}
print '</td>';
$total_remaining += $qty_remaining;
// Status
print '<td>';
if ($qty_remaining <= 0) {
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($obj->qty_delivered > 0) {
print '<span class="badge badge-warning">'.$langs->trans("TrackingPartial").'</span>';
} else {
print '<span class="badge badge-secondary">'.$langs->trans("TrackingOpen").'</span>';
}
print '</td>';
print '</tr>';
// Detail-Zeile (standardmäßig eingeklappt)
if ($hasDetails) {
print '<tr id="tdetail_'.$detailRowId.'" class="tracking-detail-row" style="display:none;">';
print '<td colspan="5" style="padding: 0 0 0 30px; background-color: #fafafa;">';
// Sub-Tabelle für Details
print '<table class="noborder" style="width:100%; margin: 5px 0;">';
print '<tr class="liste_titre" style="background-color: #f0f0f0;">';
print '<th style="width:120px;">'.$langs->trans("Stundenzettel").'</th>';
print '<th style="width:100px;">'.$langs->trans("Date").'</th>';
print '<th style="width:100px;">'.$langs->trans("Type").'</th>';
print '<th class="right" style="width:80px;">'.$langs->trans("Qty").'</th>';
print '<th>'.$langs->trans("Reason").'</th>';
print '</tr>';
foreach ($details as $det) {
print '<tr class="oddeven">';
// Stundenzettel-Referenz mit Link
print '<td>';
print '<a href="'.dol_buildpath('/stundenzettel/card.php', 1).'?id='.$det->fk_stundenzettel.'">';
print $det->stz_ref;
print '</a>';
print '</td>';
// Datum
print '<td>'.dol_print_date($db->jdate($det->date_stundenzettel), 'day').'</td>';
// Typ (origin)
print '<td>';
switch ($det->origin) {
case 'order':
case 'added':
print '<span class="badge badge-primary">'.$langs->trans("QtyDelivered").'</span>';
break;
case 'additional':
print '<span class="badge badge-success">'.$langs->trans("Mehraufwand").'</span>';
break;
case 'omitted':
print '<span class="badge badge-danger">'.$langs->trans("Entfaellt").'</span>';
break;
default:
print $det->origin;
}
print '</td>';
// Menge
print '<td class="right">'.formatQty($det->qty_done).'</td>';
// Grund/Beschreibung
print '<td>';
if (!empty($det->description)) {
print dol_escape_htmltag($det->description);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
print '</tr>';
}
print '</table>';
print '</td>';
print '</tr>';
}
}
// Summenzeile
print '<tr class="liste_total">';
print '<td><strong>'.$langs->trans("Total").'</strong></td>';
print '<td class="right"><strong>'.formatQty($total_ordered).'</strong></td>';
print '<td class="right"><strong>'.formatQty($total_delivered).'</strong></td>';
print '<td class="right"><strong>'.formatQty($total_remaining).'</strong></td>';
print '<td></td>';
print '</tr>';
} else {
print '<tr class="oddeven"><td colspan="5" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
}
}
print '</table>';
print '</div>';
// JavaScript für Tracking-Details
print '<script>
function toggleTrackingDetails(detailId) {
var detailRow = document.getElementById(detailId);
var arrow = document.getElementById("tarrow_" + detailId);
if (detailRow) {
var isHidden = detailRow.style.display === "none";
detailRow.style.display = isHidden ? "" : "none";
if (arrow) {
arrow.classList.toggle("fa-chevron-right", !isHidden);
arrow.classList.toggle("fa-chevron-down", isHidden);
}
}
}
function expandAllTrackingDetails() {
var detailRows = document.querySelectorAll(".tracking-detail-row");
detailRows.forEach(function(row) {
row.style.display = "";
var arrow = document.getElementById("tarrow_" + row.id);
if (arrow) {
arrow.classList.remove("fa-chevron-right");
arrow.classList.add("fa-chevron-down");
}
});
}
function collapseAllTrackingDetails() {
var detailRows = document.querySelectorAll(".tracking-detail-row");
detailRows.forEach(function(row) {
row.style.display = "none";
var arrow = document.getElementById("tarrow_" + row.id);
if (arrow) {
arrow.classList.remove("fa-chevron-down");
arrow.classList.add("fa-chevron-right");
}
});
}
</script>';
// Button: In Rechnung übertragen (nur wenn alles erledigt)
if ($total_remaining <= 0 && $total_delivered > 0) {
print '<div class="center" style="margin-top:20px;">';
print '<a class="butAction" href="?id='.$order->id.'&action=transfer_to_invoice">';
print img_picto('', 'bill', 'class="pictofixedwidth"').$langs->trans("TransferToInvoice");
print '</a>';
print '</div>';
}
}
print '</div>'; // fichecenter
dol_fiche_end();
llxFooter();
$db->close();