Fehler beseitigt, Leistungspositonen pro Stundenzettel, Kundenpreis berücksichtigt,

Prototyp Handy ansicht
This commit is contained in:
Eduard Wisch 2026-02-08 17:19:47 +01:00
parent 9627e4fea4
commit 6bfc565121
10 changed files with 1295 additions and 83 deletions

232
card.php
View file

@ -13,6 +13,7 @@ if (!$res) die("Include of main fails");
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php'; require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/doleditor.class.php'; require_once DOL_DOCUMENT_ROOT.'/core/class/doleditor.class.php';
dol_include_once('/stundenzettel/class/stundenzettel.class.php'); dol_include_once('/stundenzettel/class/stundenzettel.class.php');
@ -233,8 +234,9 @@ if ($action == 'add_leistung' && $permissiontoadd) {
$time_start = GETPOST('time_start', 'alpha'); $time_start = GETPOST('time_start', 'alpha');
$time_end = GETPOST('time_end', 'alpha'); $time_end = GETPOST('time_end', 'alpha');
$description = GETPOST('leistung_description', 'restricthtml'); $description = GETPOST('leistung_description', 'restricthtml');
$fk_product = GETPOST('fk_product', 'int');
$result = $object->addLeistung($user, $date, $time_start, $time_end, $description); $result = $object->addLeistung($user, $date, $time_start, $time_end, $description, $fk_product);
if ($result > 0) { if ($result > 0) {
setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); setEventMessages($langs->trans('RecordSaved'), null, 'mesgs');
} else { } else {
@ -259,8 +261,9 @@ if ($action == 'update_leistung' && $permissiontoadd) {
$time_start = GETPOST('time_start', 'alpha'); $time_start = GETPOST('time_start', 'alpha');
$time_end = GETPOST('time_end', 'alpha'); $time_end = GETPOST('time_end', 'alpha');
$description = GETPOST('leistung_description', 'restricthtml'); $description = GETPOST('leistung_description', 'restricthtml');
$fk_product = GETPOST('fk_product', 'int');
$result = $object->updateLeistung($leistung_id, $date, $time_start, $time_end, $description); $result = $object->updateLeistung($leistung_id, $date, $time_start, $time_end, $description, $fk_product);
if ($result > 0) { if ($result > 0) {
setEventMessages($langs->trans('RecordModified'), null, 'mesgs'); setEventMessages($langs->trans('RecordModified'), null, 'mesgs');
} else { } else {
@ -423,16 +426,31 @@ if ($action == 'add_product' && $permissiontoadd) {
exit; exit;
} }
// Entfällt hinzufügen (Produkt aus Auftrag das nicht verbaut wird) // Entfällt hinzufügen (Produkt aus Auftrag oder Mehraufwand das nicht verbaut wird)
if ($action == 'add_entfaellt' && $permissiontoadd) { if ($action == 'add_entfaellt' && $permissiontoadd) {
$entfaellt_product_raw = GETPOST('entfaellt_product', 'alpha'); $entfaellt_product_raw = GETPOST('entfaellt_product', 'alpha');
$qty = GETPOST('entfaellt_qty', 'int'); $qty = GETPOST('entfaellt_qty', 'int');
$reason = GETPOST('entfaellt_description', 'restricthtml'); $reason = GETPOST('entfaellt_description', 'restricthtml');
// Prüfen ob es ein Freitext-Produkt ist (Format: "freetext_ROWID") // Prüfen ob es ein Freitext-Produkt, Mehraufwand oder normales Produkt ist
$fk_product = 0; $fk_product = 0;
$freetext_description = ''; $freetext_description = '';
if (strpos($entfaellt_product_raw, 'freetext_') === 0) { $commandedet_id = 0;
$mehraufwand_id = 0;
if (strpos($entfaellt_product_raw, 'mehraufwand_') === 0) {
// Mehraufwand-Produkt (Format: "mehraufwand_ROWID")
$mehraufwand_id = (int)substr($entfaellt_product_raw, 12);
// Produkt-ID und Beschreibung aus stundenzettel_product laden
$sqlMehr = "SELECT fk_product, description FROM ".MAIN_DB_PREFIX."stundenzettel_product WHERE rowid = ".((int)$mehraufwand_id);
$resqlMehr = $db->query($sqlMehr);
if ($resqlMehr && ($objMehr = $db->fetch_object($resqlMehr))) {
$fk_product = (int)$objMehr->fk_product;
if ($fk_product == 0) {
$freetext_description = $objMehr->description;
}
}
} elseif (strpos($entfaellt_product_raw, 'freetext_') === 0) {
// Freitext-Produkt aus dem Auftrag // Freitext-Produkt aus dem Auftrag
$commandedet_id = (int)substr($entfaellt_product_raw, 9); $commandedet_id = (int)substr($entfaellt_product_raw, 9);
// Beschreibung aus commandedet laden // Beschreibung aus commandedet laden
@ -455,7 +473,18 @@ if ($action == 'add_entfaellt' && $permissiontoadd) {
if ($fk_product > 0 || !empty($freetext_description)) { if ($fk_product > 0 || !empty($freetext_description)) {
// Server-seitige Validierung: Prüfen ob Menge noch verfügbar ist // Server-seitige Validierung: Prüfen ob Menge noch verfügbar ist
if ($object->fk_commande > 0) { if ($mehraufwand_id > 0) {
// Mehraufwand-Validierung: Prüfe verfügbare Menge
$sqlCheck = "SELECT qty, qty_done FROM ".MAIN_DB_PREFIX."stundenzettel_product WHERE rowid = ".((int)$mehraufwand_id);
$resqlCheck = $db->query($sqlCheck);
if ($resqlCheck && ($objCheck = $db->fetch_object($resqlCheck))) {
$qty_available = $objCheck->qty - $objCheck->qty_done;
if ($qty > $qty_available) {
setEventMessages($langs->trans('ErrorQtyExceedsAvailable', $qty_available), null, 'errors');
$error++;
}
}
} elseif ($object->fk_commande > 0) {
if ($fk_product > 0) { if ($fk_product > 0) {
// Produkt-Validierung // Produkt-Validierung
$sqlCheck = "SELECT cd.qty,"; $sqlCheck = "SELECT cd.qty,";
@ -503,20 +532,49 @@ if ($action == 'add_entfaellt' && $permissiontoadd) {
} }
if (!$error) { if (!$error) {
// Produkt zum Stundenzettel hinzufügen mit origin='omitted' if ($mehraufwand_id > 0) {
$result = $object->addProduct( // Mehraufwand: Menge vom qty_done erhöhen statt neuen Eintrag erstellen
$fk_product, // Oder: Menge vom Mehraufwand reduzieren und als Entfällt anlegen
0, // fk_commandedet // Wir reduzieren qty des Mehraufwands und legen einen neuen Entfällt-Eintrag an
0, // fk_manager_line $sqlUpdateMehr = "UPDATE ".MAIN_DB_PREFIX."stundenzettel_product SET qty = qty - ".((int)$qty);
0, // qty_original $sqlUpdateMehr .= " WHERE rowid = ".((int)$mehraufwand_id);
$qty, // qty_done (Menge die entfällt) $db->query($sqlUpdateMehr);
'omitted', // origin (entfällt)
$description // description (Grund) // Entfällt-Eintrag mit Hinweis auf Mehraufwand anlegen
); $entfaelltDesc = $langs->trans("Mehraufwand").': '.$description;
if ($result > 0) { if (!empty($reason)) {
setEventMessages($langs->trans('RecordSaved'), null, 'mesgs'); $entfaelltDesc .= ' - '.$reason;
}
$result = $object->addProduct(
$fk_product,
0, // fk_commandedet
0, // fk_manager_line
0, // qty_original
$qty, // qty_done (Menge die entfällt)
'omitted', // origin (entfällt)
$entfaelltDesc // description (Grund)
);
if ($result > 0) {
setEventMessages($langs->trans('RecordSaved'), null, 'mesgs');
} else {
setEventMessages($object->error, null, 'errors');
}
} else { } else {
setEventMessages($object->error, null, 'errors'); // Produkt zum Stundenzettel hinzufügen mit origin='omitted'
$result = $object->addProduct(
$fk_product,
0, // fk_commandedet
0, // fk_manager_line
0, // qty_original
$qty, // qty_done (Menge die entfällt)
'omitted', // origin (entfällt)
$description // description (Grund)
);
if ($result > 0) {
setEventMessages($langs->trans('RecordSaved'), null, 'mesgs');
} else {
setEventMessages($object->error, null, 'errors');
}
} }
} }
} else { } else {
@ -724,6 +782,9 @@ $_GET['mainmenu'] = 'stundenzettel';
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-stundenzettel page-card'); llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-stundenzettel page-card');
// Mobile CSS einbinden
print '<link rel="stylesheet" type="text/css" href="'.dol_buildpath('/stundenzettel/css/stundenzettel-mobile.css', 1).'?v='.filemtime(dol_buildpath('/stundenzettel/css/stundenzettel-mobile.css', 0)).'">';
// JavaScript für Mengenprüfung // JavaScript für Mengenprüfung
print '<script type="text/javascript"> print '<script type="text/javascript">
function plusQtyWithCheck(rowid, qtyOriginal, totalQtyAllStz) { function plusQtyWithCheck(rowid, qtyOriginal, totalQtyAllStz) {
@ -859,6 +920,11 @@ elseif ($object->id > 0) {
// BEREICH: LEISTUNGEN (immer anzeigen außer bei Notizen) // BEREICH: LEISTUNGEN (immer anzeigen außer bei Notizen)
// ============================================= // =============================================
if ($subtab != 'notes') { if ($subtab != 'notes') {
// Standard-Leistung vom Kunden laden
$thirdparty = new Societe($db);
$thirdparty->fetch($object->fk_soc);
$thirdparty->fetch_optionals();
// Prüfen ob eine Leistung bearbeitet wird // Prüfen ob eine Leistung bearbeitet wird
$editLeistungId = GETPOST('edit_leistung', 'int'); $editLeistungId = GETPOST('edit_leistung', 'int');
@ -876,6 +942,7 @@ elseif ($object->id > 0) {
print '<th>'.$langs->trans("LeistungTimeStart").'</th>'; print '<th>'.$langs->trans("LeistungTimeStart").'</th>';
print '<th>'.$langs->trans("LeistungTimeEnd").'</th>'; print '<th>'.$langs->trans("LeistungTimeEnd").'</th>';
print '<th class="center">'.$langs->trans("Duration").'</th>'; print '<th class="center">'.$langs->trans("Duration").'</th>';
print '<th>'.$langs->trans("DefaultService").'</th>';
print '<th>'.$langs->trans("Description").'</th>'; print '<th>'.$langs->trans("Description").'</th>';
if ($object->status == Stundenzettel::STATUS_DRAFT && $permissiontoadd) { if ($object->status == Stundenzettel::STATUS_DRAFT && $permissiontoadd) {
print '<th class="center" width="40"></th>'; // Edit print '<th class="center" width="40"></th>'; // Edit
@ -904,6 +971,10 @@ elseif ($object->id > 0) {
print $hours.'h '.sprintf('%02d', $mins).'min'; print $hours.'h '.sprintf('%02d', $mins).'min';
} }
print '</td>'; print '</td>';
// Service/Product Selection
print '<td>';
$form->select_produits($leistung->fk_product, 'fk_product', 1, 0, 0, -1, 2, '', 0, array(), 0, '1', 0, 'maxwidth200');
print '</td>';
print '<td><textarea name="leistung_description" class="flat" rows="5" style="width: 300px; resize: vertical;">'.dol_escape_htmltag($leistung->description).'</textarea></td>'; print '<td><textarea name="leistung_description" class="flat" rows="5" style="width: 300px; resize: vertical;">'.dol_escape_htmltag($leistung->description).'</textarea></td>';
// Save Button // Save Button
print '<td class="center">'; print '<td class="center">';
@ -928,6 +999,14 @@ elseif ($object->id > 0) {
print $hours.'h '.sprintf('%02d', $mins).'min'; print $hours.'h '.sprintf('%02d', $mins).'min';
} }
print '</td>'; print '</td>';
// Leistungsposition anzeigen
print '<td>';
if (!empty($leistung->fk_product)) {
print dol_escape_htmltag($leistung->product_label);
} else {
print '<span class="opacitymedium">'.$langs->trans("NotSet").'</span>';
}
print '</td>';
print '<td>'.dol_htmlentitiesbr($leistung->description).'</td>'; print '<td>'.dol_htmlentitiesbr($leistung->description).'</td>';
if ($object->status == Stundenzettel::STATUS_DRAFT && $permissiontoadd) { if ($object->status == Stundenzettel::STATUS_DRAFT && $permissiontoadd) {
// Edit Button // Edit Button
@ -943,7 +1022,7 @@ elseif ($object->id > 0) {
} }
} }
} else { } else {
$colspan = ($object->status == Stundenzettel::STATUS_DRAFT && $permissiontoadd) ? 7 : 5; $colspan = ($object->status == Stundenzettel::STATUS_DRAFT && $permissiontoadd) ? 8 : 6;
print '<tr class="oddeven"><td colspan="'.$colspan.'" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>'; print '<tr class="oddeven"><td colspan="'.$colspan.'" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
} }
@ -989,6 +1068,16 @@ elseif ($object->id > 0) {
print '<td class="center">'; print '<td class="center">';
print '</td>'; print '</td>';
// Leistungsposition (Service/Dienstleistung)
print '<td>';
// Standard-Leistung vom Kunden holen falls vorhanden
$defaultServiceId = 0;
if (!empty($thirdparty->array_options['options_stundenzettel_default_service'])) {
$defaultServiceId = $thirdparty->array_options['options_stundenzettel_default_service'];
}
$form->select_produits($defaultServiceId, 'fk_product', 1, 0, 0, -1, 2, '', 0, array(), 0, '1', 0, 'maxwidth200');
print '</td>';
// Beschreibung (Tagesbeschreibung - größeres Textarea, 5 Zeilen) // Beschreibung (Tagesbeschreibung - größeres Textarea, 5 Zeilen)
print '<td>'; print '<td>';
print '<textarea name="leistung_description" class="flat" rows="5" style="width: 300px; resize: vertical;" placeholder="'.$langs->trans("Description").'"></textarea>'; print '<textarea name="leistung_description" class="flat" rows="5" style="width: 300px; resize: vertical;" placeholder="'.$langs->trans("Description").'"></textarea>';
@ -1012,7 +1101,7 @@ elseif ($object->id > 0) {
if ($totalDuration > 0) { if ($totalDuration > 0) {
$hours = floor($totalDuration / 60); $hours = floor($totalDuration / 60);
$mins = $totalDuration % 60; $mins = $totalDuration % 60;
$colspan = ($object->status == Stundenzettel::STATUS_DRAFT && $permissiontoadd) ? 7 : 5; $colspan = ($object->status == Stundenzettel::STATUS_DRAFT && $permissiontoadd) ? 8 : 6;
print '<tr class="liste_total">'; print '<tr class="liste_total">';
print '<td colspan="3" class="right"><strong>'.$langs->trans("Total").'</strong></td>'; print '<td colspan="3" class="right"><strong>'.$langs->trans("Total").'</strong></td>';
print '<td class="center"><strong>'.$hours.'h '.sprintf('%02d', $mins).'min</strong></td>'; print '<td class="center"><strong>'.$hours.'h '.sprintf('%02d', $mins).'min</strong></td>';
@ -1237,6 +1326,13 @@ elseif ($object->id > 0) {
print '<div class="div-table-responsive-no-min" style="margin-top: 15px;">'; print '<div class="div-table-responsive-no-min" style="margin-top: 15px;">';
print '<table class="noborder centpercent">'; print '<table class="noborder centpercent">';
print '<tr class="liste_titre"><th colspan="5" style="border-bottom: 2px solid #dee2e6;"><strong>'.$langs->trans("Entfaellt").'</strong> - <span class="opacitymedium" style="font-weight:normal;">'.$langs->trans("EntfaelltDesc").'</span></th></tr>'; print '<tr class="liste_titre"><th colspan="5" style="border-bottom: 2px solid #dee2e6;"><strong>'.$langs->trans("Entfaellt").'</strong> - <span class="opacitymedium" style="font-weight:normal;">'.$langs->trans("EntfaelltDesc").'</span></th></tr>';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Product").'</th>';
print '<th class="center" style="width:80px;">'.$langs->trans("Qty").'</th>';
print '<th style="width:200px;">'.$langs->trans("Reason").'</th>';
print '<th class="center" style="width:40px;"></th>'; // Save
print '<th class="center" style="width:40px;"></th>'; // Delete
print '</tr>';
// Zuerst bereits erfasste Entfällt-Produkte anzeigen // Zuerst bereits erfasste Entfällt-Produkte anzeigen
$entfaelltProducts = array(); $entfaelltProducts = array();
@ -1354,28 +1450,76 @@ elseif ($object->id > 0) {
print '<input type="hidden" name="action" value="add_entfaellt">'; print '<input type="hidden" name="action" value="add_entfaellt">';
// Produkt-Auswahl - NUR Produkte aus dem Auftrag mit verfügbarer Menge // Produkt-Auswahl - NUR Produkte aus dem Auftrag mit verfügbarer Menge
print '<td>'; // Mehraufwand-Produkte mit verbleibender Menge laden (aus allen Stundenzetteln des Auftrags)
if ($hasAvailableProducts) { $mehraufwandAvailable = array();
print '<select name="entfaellt_product" class="flat minwidth300" id="entfaellt_product_select" onchange="updateMaxQty(this)">'; if ($object->fk_commande > 0) {
print '<option value="" data-max="1">-- '.$langs->trans("SelectProducts").' --</option>'; $sqlMehraufwand = "SELECT sp.rowid, sp.fk_product, sp.qty, sp.qty_done, sp.description,";
foreach ($orderProducts as $op) { $sqlMehraufwand .= " p.ref as product_ref, p.label as product_label";
// Nur Produkte anzeigen wo noch etwas entfallen kann $sqlMehraufwand .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
if ($op->qty_available <= 0) continue; $sqlMehraufwand .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlMehraufwand .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = sp.fk_product";
if ($op->fk_product > 0) { $sqlMehraufwand .= " WHERE s.fk_commande = ".((int)$object->fk_commande);
print '<option value="'.$op->fk_product.'" data-commandedet="'.$op->rowid.'" data-max="'.((int)$op->qty_available).'">'; $sqlMehraufwand .= " AND sp.origin = 'additional'";
print $op->product_ref.' - '.$op->product_label; $sqlMehraufwand .= " AND (sp.qty - sp.qty_done) > 0"; // Nur wo noch etwas verfügbar ist
print ' ('.$langs->trans("QtyRemaining").': '.((int)$op->qty_available).')'; $sqlMehraufwand .= " ORDER BY p.label, sp.description";
print '</option>'; $resqlMehraufwand = $db->query($sqlMehraufwand);
} else { if ($resqlMehraufwand) {
// Freitext-Positionen aus dem Auftrag - Beschreibung als Bezeichnung while ($objM = $db->fetch_object($resqlMehraufwand)) {
$desc = strip_tags($op->description); $objM->qty_available = $objM->qty - $objM->qty_done;
$descShort = (strlen($desc) > 50) ? substr($desc, 0, 47).'...' : $desc; $mehraufwandAvailable[] = $objM;
print '<option value="freetext_'.$op->rowid.'" data-commandedet="'.$op->rowid.'" data-description="'.dol_escape_htmltag($desc).'" data-max="'.((int)$op->qty_available).'">';
print $descShort.' ('.$langs->trans("QtyRemaining").': '.((int)$op->qty_available).')';
print '</option>';
} }
} }
}
$hasMehraufwandProducts = (count($mehraufwandAvailable) > 0);
print '<td>';
if ($hasAvailableProducts || $hasMehraufwandProducts) {
print '<select name="entfaellt_product" class="flat minwidth300" id="entfaellt_product_select" onchange="updateMaxQty(this)">';
print '<option value="" data-max="1">-- '.$langs->trans("SelectProducts").' --</option>';
// Produkte aus Auftrag
if ($hasAvailableProducts) {
print '<optgroup label="'.$langs->trans("FromOrder").'">';
foreach ($orderProducts as $op) {
if ($op->qty_available <= 0) continue;
if ($op->fk_product > 0) {
print '<option value="'.$op->fk_product.'" data-commandedet="'.$op->rowid.'" data-max="'.((int)$op->qty_available).'">';
print $op->product_ref.' - '.$op->product_label;
print ' ('.$langs->trans("QtyRemaining").': '.((int)$op->qty_available).')';
print '</option>';
} else {
$desc = strip_tags($op->description);
$descShort = (strlen($desc) > 50) ? substr($desc, 0, 47).'...' : $desc;
print '<option value="freetext_'.$op->rowid.'" data-commandedet="'.$op->rowid.'" data-description="'.dol_escape_htmltag($desc).'" data-max="'.((int)$op->qty_available).'">';
print $descShort.' ('.$langs->trans("QtyRemaining").': '.((int)$op->qty_available).')';
print '</option>';
}
}
print '</optgroup>';
}
// Mehraufwand-Produkte mit verbleibender Menge
if ($hasMehraufwandProducts) {
print '<optgroup label="'.$langs->trans("Mehraufwand").'">';
foreach ($mehraufwandAvailable as $mp) {
if ($mp->fk_product > 0) {
print '<option value="mehraufwand_'.$mp->rowid.'" data-max="'.((int)$mp->qty_available).'">';
print $mp->product_ref.' - '.$mp->product_label;
print ' ('.$langs->trans("QtyRemaining").': '.((int)$mp->qty_available).')';
print '</option>';
} else {
$desc = strip_tags($mp->description);
$descShort = (strlen($desc) > 50) ? substr($desc, 0, 47).'...' : $desc;
print '<option value="mehraufwand_'.$mp->rowid.'" data-description="'.dol_escape_htmltag($desc).'" data-max="'.((int)$mp->qty_available).'">';
print $descShort.' ('.$langs->trans("QtyRemaining").': '.((int)$mp->qty_available).')';
print '</option>';
}
}
print '</optgroup>';
}
print '</select>'; print '</select>';
} else { } else {
print '<small class="opacitymedium">'.$langs->trans("AllProductsUsedOrOmitted").'</small>'; print '<small class="opacitymedium">'.$langs->trans("AllProductsUsedOrOmitted").'</small>';
@ -1389,7 +1533,7 @@ elseif ($object->id > 0) {
// Grund // Grund
print '<td style="width:200px;">'; print '<td style="width:200px;">';
print '<input type="text" name="entfaellt_description" class="flat" style="width:190px;" placeholder="'.$langs->trans("Reason").'">'; print '<input type="text" name="entfaellt_description" class="flat" style="width:100%;" placeholder="'.$langs->trans("Reason").'">';
print '</td>'; print '</td>';
// Hinzufügen-Button (colspan für beide Button-Spalten) // Hinzufügen-Button (colspan für beide Button-Spalten)
@ -1549,9 +1693,9 @@ elseif ($object->id > 0) {
print '<input type="number" name="mehraufwand_qty" class="flat" style="width:70px; text-align:center;" value="1" min="1">'; print '<input type="number" name="mehraufwand_qty" class="flat" style="width:70px; text-align:center;" value="1" min="1">';
print '</td>'; print '</td>';
// Grund (optional) // Grund
print '<td style="width:200px;">'; print '<td style="width:200px;">';
print '<input type="text" name="mehraufwand_reason" class="flat" style="width:100%;" placeholder="'.$langs->trans("Reason").' ('.$langs->trans("Optional").')">'; print '<input type="text" name="mehraufwand_reason" class="flat" style="width:100%;" placeholder="'.$langs->trans("Reason").'">';
print '</td>'; print '</td>';
// Hinzufügen-Button (colspan für Save, Delete) // Hinzufügen-Button (colspan für Save, Delete)

View file

@ -92,6 +92,16 @@ class Stundenzettel extends CommonObject
*/ */
public $note_public; public $note_public;
/**
* @var float|null Stundenpreis (NULL = Standard verwenden)
*/
public $hourly_rate;
/**
* @var int 1 = Stundenpreis wurde manuell geändert
*/
public $hourly_rate_is_custom = 0;
/** /**
* @var array Leistungen * @var array Leistungen
*/ */
@ -139,7 +149,8 @@ class Stundenzettel extends CommonObject
$sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." ("; $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." (";
$sql .= "ref, entity, fk_commande, fk_soc, fk_user_author,"; $sql .= "ref, entity, fk_commande, fk_soc, fk_user_author,";
$sql .= "date_stundenzettel, datec, status, note_private, note_public"; $sql .= "date_stundenzettel, datec, status, note_private, note_public,";
$sql .= "hourly_rate, hourly_rate_is_custom";
$sql .= ") VALUES ("; $sql .= ") VALUES (";
$sql .= "'".$this->db->escape($this->ref)."',"; $sql .= "'".$this->db->escape($this->ref)."',";
$sql .= ((int)$conf->entity).","; $sql .= ((int)$conf->entity).",";
@ -150,7 +161,9 @@ class Stundenzettel extends CommonObject
$sql .= "'".$this->db->idate(dol_now())."',"; $sql .= "'".$this->db->idate(dol_now())."',";
$sql .= "0,"; // STATUS_DRAFT $sql .= "0,"; // STATUS_DRAFT
$sql .= ($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL").","; $sql .= ($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL").",";
$sql .= ($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL"); $sql .= ($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL").",";
$sql .= (isset($this->hourly_rate) && $this->hourly_rate !== null ? ((float)$this->hourly_rate) : "NULL").",";
$sql .= ((int)$this->hourly_rate_is_custom);
$sql .= ")"; $sql .= ")";
dol_syslog(get_class($this)."::create", LOG_DEBUG); dol_syslog(get_class($this)."::create", LOG_DEBUG);
@ -184,7 +197,8 @@ class Stundenzettel extends CommonObject
{ {
$sql = "SELECT s.rowid, s.ref, s.entity, s.fk_commande, s.fk_facture, s.fk_soc,"; $sql = "SELECT s.rowid, s.ref, s.entity, s.fk_commande, s.fk_facture, s.fk_soc,";
$sql .= " s.fk_user_author, s.fk_user_valid, s.date_stundenzettel, s.datec,"; $sql .= " s.fk_user_author, s.fk_user_valid, s.date_stundenzettel, s.datec,";
$sql .= " s.date_valid, s.status, s.note_private, s.note_public, s.tms"; $sql .= " s.date_valid, s.status, s.note_private, s.note_public, s.tms,";
$sql .= " s.hourly_rate, s.hourly_rate_is_custom";
$sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as s"; $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as s";
if ($ref) { if ($ref) {
$sql .= " WHERE s.ref = '".$this->db->escape($ref)."'"; $sql .= " WHERE s.ref = '".$this->db->escape($ref)."'";
@ -212,6 +226,8 @@ class Stundenzettel extends CommonObject
$this->status = $obj->status; $this->status = $obj->status;
$this->note_private = $obj->note_private; $this->note_private = $obj->note_private;
$this->note_public = $obj->note_public; $this->note_public = $obj->note_public;
$this->hourly_rate = $obj->hourly_rate;
$this->hourly_rate_is_custom = $obj->hourly_rate_is_custom;
// Load lines // Load lines
$this->fetchLeistungen(); $this->fetchLeistungen();
@ -242,7 +258,9 @@ class Stundenzettel extends CommonObject
$sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET";
$sql .= " date_stundenzettel = '".$this->db->idate($this->date_stundenzettel)."',"; $sql .= " date_stundenzettel = '".$this->db->idate($this->date_stundenzettel)."',";
$sql .= " note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL").","; $sql .= " note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL").",";
$sql .= " note_public = ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL"); $sql .= " note_public = ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL").",";
$sql .= " hourly_rate = ".(isset($this->hourly_rate) && $this->hourly_rate !== null && $this->hourly_rate !== '' ? ((float)$this->hourly_rate) : "NULL").",";
$sql .= " hourly_rate_is_custom = ".((int)$this->hourly_rate_is_custom);
$sql .= " WHERE rowid = ".((int)$this->id); $sql .= " WHERE rowid = ".((int)$this->id);
dol_syslog(get_class($this)."::update", LOG_DEBUG); dol_syslog(get_class($this)."::update", LOG_DEBUG);
@ -378,10 +396,12 @@ class Stundenzettel extends CommonObject
{ {
$this->leistungen = array(); $this->leistungen = array();
$sql = "SELECT rowid, fk_user, date_leistung, time_start, time_end, duration, description, rang"; $sql = "SELECT l.rowid, l.fk_user, l.fk_product, l.date_leistung, l.time_start, l.time_end, l.duration, l.description, l.rang,";
$sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel_leistung"; $sql .= " p.ref as product_ref, p.label as product_label";
$sql .= " WHERE fk_stundenzettel = ".((int)$this->id); $sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel_leistung as l";
$sql .= " ORDER BY rang, date_leistung, time_start"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON l.fk_product = p.rowid";
$sql .= " WHERE l.fk_stundenzettel = ".((int)$this->id);
$sql .= " ORDER BY l.rang, l.date_leistung, l.time_start";
$resql = $this->db->query($sql); $resql = $this->db->query($sql);
if ($resql) { if ($resql) {
@ -432,7 +452,7 @@ class Stundenzettel extends CommonObject
* @param string $description Description * @param string $description Description
* @return int <0 if KO, >0 if OK * @return int <0 if KO, >0 if OK
*/ */
public function addLeistung($user, $date, $time_start = null, $time_end = null, $description = '') public function addLeistung($user, $date, $time_start = null, $time_end = null, $description = '', $fk_product = null)
{ {
global $langs; global $langs;
@ -475,10 +495,11 @@ class Stundenzettel extends CommonObject
} }
$sql = "INSERT INTO ".MAIN_DB_PREFIX."stundenzettel_leistung ("; $sql = "INSERT INTO ".MAIN_DB_PREFIX."stundenzettel_leistung (";
$sql .= "fk_stundenzettel, fk_user, date_leistung, time_start, time_end, duration, description, rang"; $sql .= "fk_stundenzettel, fk_user, fk_product, date_leistung, time_start, time_end, duration, description, rang";
$sql .= ") VALUES ("; $sql .= ") VALUES (";
$sql .= ((int)$this->id).","; $sql .= ((int)$this->id).",";
$sql .= ((int)$user->id).","; $sql .= ((int)$user->id).",";
$sql .= ($fk_product > 0 ? ((int)$fk_product) : "NULL").",";
$sql .= "'".$this->db->idate($date)."',"; $sql .= "'".$this->db->idate($date)."',";
$sql .= ($time_start ? "'".$this->db->escape($time_start)."'" : "NULL").","; $sql .= ($time_start ? "'".$this->db->escape($time_start)."'" : "NULL").",";
$sql .= ($time_end ? "'".$this->db->escape($time_end)."'" : "NULL").","; $sql .= ($time_end ? "'".$this->db->escape($time_end)."'" : "NULL").",";
@ -502,9 +523,10 @@ class Stundenzettel extends CommonObject
* @param string $time_start Start time * @param string $time_start Start time
* @param string $time_end End time * @param string $time_end End time
* @param string $description Description * @param string $description Description
* @param int $fk_product Product/Service ID
* @return int <0 if KO, >0 if OK * @return int <0 if KO, >0 if OK
*/ */
public function updateLeistung($leistung_id, $date, $time_start = null, $time_end = null, $description = '') public function updateLeistung($leistung_id, $date, $time_start = null, $time_end = null, $description = '', $fk_product = null)
{ {
global $langs; global $langs;
@ -539,6 +561,7 @@ class Stundenzettel extends CommonObject
} }
$sql = "UPDATE ".MAIN_DB_PREFIX."stundenzettel_leistung SET"; $sql = "UPDATE ".MAIN_DB_PREFIX."stundenzettel_leistung SET";
$sql .= " fk_product = ".($fk_product > 0 ? ((int)$fk_product) : "NULL").",";
$sql .= " date_leistung = '".$this->db->idate($date)."',"; $sql .= " date_leistung = '".$this->db->idate($date)."',";
$sql .= " time_start = ".($time_start ? "'".$this->db->escape($time_start)."'" : "NULL").","; $sql .= " time_start = ".($time_start ? "'".$this->db->escape($time_start)."'" : "NULL").",";
$sql .= " time_end = ".($time_end ? "'".$this->db->escape($time_end)."'" : "NULL").","; $sql .= " time_end = ".($time_end ? "'".$this->db->escape($time_end)."'" : "NULL").",";

View file

@ -53,7 +53,7 @@ class modStundenzettel extends DolibarrModules
$this->descriptionlong = "Verwaltet Stundenzettel für Kundenaufträge. Ermöglicht die Dokumentation von Arbeitszeiten, verbrauchten Materialien und Notizen. Integration mit SubtotalTitle für Produktgruppen-Unterstützung."; $this->descriptionlong = "Verwaltet Stundenzettel für Kundenaufträge. Ermöglicht die Dokumentation von Arbeitszeiten, verbrauchten Materialien und Notizen. Integration mit SubtotalTitle für Produktgruppen-Unterstützung.";
// Version // Version
$this->version = '1.1.0'; $this->version = '1.2.0';
// Autor // Autor
$this->editor_name = 'Data IT Solution'; $this->editor_name = 'Data IT Solution';
@ -107,7 +107,7 @@ class modStundenzettel extends DolibarrModules
// Tabs - Tab im Auftrag (order = commande) // Tabs - Tab im Auftrag (order = commande)
$this->tabs = array( $this->tabs = array(
'order:+stundenzettel:Stundenzettel:stundenzettel@stundenzettel:$user->hasRight("stundenzettel","read"):/custom/stundenzettel/stundenzettel_commande.php?id=__ID__' 'order:+stundenzettel:Stundenzettel:stundenzettel@stundenzettel:$user->hasRight("stundenzettel","read"):/custom/stundenzettel/stundenzettel_commande.php?id=__ID__&tab=products&noredirect=1'
); );
// Boxen/Widgets // Boxen/Widgets
@ -320,6 +320,9 @@ class modStundenzettel extends DolibarrModules
// Extrafeld "Standard-Leistung" für Kunden anlegen // Extrafeld "Standard-Leistung" für Kunden anlegen
$this->createExtraFieldDefaultService(); $this->createExtraFieldDefaultService();
// Stundenpreis-Felder hinzufügen (Update 1.2.0)
$this->addHourlyRateFields();
$sql = array(); $sql = array();
return $this->_init($sql, $options); return $this->_init($sql, $options);
@ -454,6 +457,35 @@ class modStundenzettel extends DolibarrModules
return 1; return 1;
} }
/**
* Fügt die Leistungsposition zur Leistungstabelle hinzu (Update 1.2.0)
* Wird bei jeder Modulaktivierung ausgeführt - IF NOT EXISTS verhindert Fehler
*
* @return int 1 if OK, -1 if error
*/
private function addHourlyRateFields()
{
// Spalte fk_product zur Leistungstabelle hinzufügen falls nicht vorhanden
$sql1 = "ALTER TABLE ".MAIN_DB_PREFIX."stundenzettel_leistung
ADD COLUMN IF NOT EXISTS fk_product INTEGER DEFAULT NULL
COMMENT 'Verknüpfung zur Leistungsposition (Dienstleistung)'";
$resql1 = $this->db->query($sql1);
if (!$resql1) {
// Fallback für ältere MySQL-Versionen ohne IF NOT EXISTS
$sql1b = "SHOW COLUMNS FROM ".MAIN_DB_PREFIX."stundenzettel_leistung LIKE 'fk_product'";
$resql1b = $this->db->query($sql1b);
if ($resql1b && $this->db->num_rows($resql1b) == 0) {
$sql1c = "ALTER TABLE ".MAIN_DB_PREFIX."stundenzettel_leistung
ADD COLUMN fk_product INTEGER DEFAULT NULL";
$this->db->query($sql1c);
}
}
dol_syslog("modStundenzettel::addHourlyRateFields Service product field checked/added", LOG_DEBUG);
return 1;
}
/** /**
* Funktion beim Deaktivieren des Moduls * Funktion beim Deaktivieren des Moduls
* *

View file

@ -0,0 +1,330 @@
/**
* Stundenzettel Mobile CSS
* Responsive Styles für Touch-Geräte
*
* Wird automatisch bei Bildschirmbreite < 768px aktiv
*/
/* ============================================
MOBILE STYLES (max-width: 768px)
============================================ */
@media screen and (max-width: 768px) {
/* Allgemeine Anpassungen */
.mod-stundenzettel .fichecenter {
padding: 0 5px;
}
/* Tabs kompakter */
.mod-stundenzettel .tabsElem a {
padding: 8px 10px !important;
font-size: 12px !important;
}
/* Banner kompakter */
.mod-stundenzettel .arearef {
padding: 10px !important;
}
/* ============================================
LEISTUNGEN TABELLE -> KARTEN
============================================ */
.mod-stundenzettel table.noborder tr.liste_titre th {
display: none;
}
/* Jede Zeile wird zur Karte */
.mod-stundenzettel table.noborder tr.oddeven {
display: block;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
margin: 10px 0;
padding: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.mod-stundenzettel table.noborder tr.oddeven td {
display: block;
text-align: left !important;
padding: 4px 0;
border: none !important;
width: 100% !important;
}
/* Labels vor den Werten (via CSS content) */
.mod-stundenzettel table.noborder tr.oddeven td:before {
content: attr(data-label);
font-weight: bold;
color: #666;
display: inline-block;
min-width: 80px;
margin-right: 10px;
}
/* Zeit-Inputs nebeneinander */
.mod-stundenzettel input[type="time"],
.mod-stundenzettel select[name="time_start"],
.mod-stundenzettel select[name="time_end"] {
width: 45% !important;
display: inline-block;
font-size: 16px !important; /* Verhindert Zoom auf iOS */
padding: 10px !important;
}
/* Textarea volle Breite */
.mod-stundenzettel textarea {
width: 100% !important;
min-height: 80px;
font-size: 16px !important;
padding: 10px !important;
box-sizing: border-box;
}
/* Number Inputs größer */
.mod-stundenzettel input[type="number"] {
width: 80px !important;
font-size: 18px !important;
padding: 12px 8px !important;
text-align: center !important;
}
/* Text Inputs */
.mod-stundenzettel input[type="text"] {
width: 100% !important;
font-size: 16px !important;
padding: 10px !important;
box-sizing: border-box;
}
/* ============================================
BUTTONS - TOUCH-FREUNDLICH
============================================ */
.mod-stundenzettel .fas,
.mod-stundenzettel .far {
font-size: 1.5em !important;
padding: 8px !important;
}
/* Action Buttons (Plus/Minus/Save/Delete) */
.mod-stundenzettel td .fa-plus-circle,
.mod-stundenzettel td .fa-minus-circle,
.mod-stundenzettel td .fa-save,
.mod-stundenzettel td .fa-trash,
.mod-stundenzettel td .fa-pencil-alt {
font-size: 1.8em !important;
padding: 12px !important;
display: inline-block;
}
/* Button-Reihe horizontal */
.mod-stundenzettel .mobile-actions {
display: flex;
justify-content: space-around;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #eee;
}
/* ============================================
PRODUKTE SECTION
============================================ */
/* Produkt-Karte */
.mod-stundenzettel table.noborder tr.oddeven td:first-child {
font-weight: bold;
font-size: 14px;
padding-bottom: 8px !important;
border-bottom: 1px solid #eee !important;
margin-bottom: 8px;
}
/* Mengen-Steuerung als Inline-Block */
.mod-stundenzettel .qty-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
margin: 10px 0;
}
/* Plus/Minus Buttons größer */
.mod-stundenzettel a[onclick*="plusQty"],
.mod-stundenzettel a[onclick*="Qty"] {
padding: 15px !important;
}
/* ============================================
SUMMENZEILEN
============================================ */
.mod-stundenzettel tr.liste_total {
display: block;
background: #f8f9fa;
border: 2px solid #28a745;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
text-align: center !important;
}
.mod-stundenzettel tr.liste_total td {
display: inline-block;
text-align: center !important;
padding: 5px !important;
border: none !important;
}
/* ============================================
SECTION HEADER
============================================ */
.mod-stundenzettel h3 {
font-size: 18px;
padding: 10px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px;
margin: 15px 0 10px 0;
}
.mod-stundenzettel tr.liste_titre th[colspan] {
display: block !important;
font-size: 16px;
padding: 12px !important;
background: #f8f9fa;
border-radius: 8px;
margin: 15px 0 5px 0;
}
/* ============================================
FORMULAR-CARDS (Hinzufügen)
============================================ */
.mod-stundenzettel tr.oddeven form {
display: block;
}
.mod-stundenzettel tr.oddeven td[colspan] {
padding: 10px 0 !important;
}
/* Produkt-Auswahl Dropdown */
.mod-stundenzettel select.minwidth200,
.mod-stundenzettel select.minwidth300 {
width: 100% !important;
font-size: 16px !important;
padding: 12px !important;
margin-bottom: 10px;
}
/* ============================================
MERKZETTEL/NOTIZEN
============================================ */
.mod-stundenzettel .fa-check-square,
.mod-stundenzettel .fa-square {
font-size: 1.8em !important;
padding: 10px !important;
}
/* ============================================
BADGES
============================================ */
.mod-stundenzettel .badge {
font-size: 12px !important;
padding: 4px 8px !important;
}
/* ============================================
ACTION BUTTONS (Freigeben/Löschen)
============================================ */
.mod-stundenzettel .tabsAction {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 15px;
}
.mod-stundenzettel .tabsAction a.butAction,
.mod-stundenzettel .tabsAction a.butActionDelete {
flex: 1 1 45%;
text-align: center;
padding: 15px 10px !important;
font-size: 14px !important;
border-radius: 8px;
}
/* ============================================
HIDE UNNECESSARY ELEMENTS ON MOBILE
============================================ */
.mod-stundenzettel .opacitymedium small {
display: none;
}
/* ============================================
SELECT2 DROPDOWNS FIX
============================================ */
.mod-stundenzettel .select2-container {
width: 100% !important;
}
.mod-stundenzettel .select2-selection {
min-height: 44px !important;
font-size: 16px !important;
}
}
/* ============================================
EXTRA SMALL DEVICES (< 480px)
============================================ */
@media screen and (max-width: 480px) {
/* Noch kompaktere Tabs */
.mod-stundenzettel .tabsElem a {
padding: 6px 8px !important;
font-size: 11px !important;
}
/* Buttons nebeneinander */
.mod-stundenzettel .tabsAction a.butAction,
.mod-stundenzettel .tabsAction a.butActionDelete {
flex: 1 1 100%;
}
/* Zeit-Inputs untereinander */
.mod-stundenzettel input[type="time"],
.mod-stundenzettel select[name="time_start"],
.mod-stundenzettel select[name="time_end"] {
width: 100% !important;
margin-bottom: 8px;
}
}
/* ============================================
LANDSCAPE MODE ADJUSTMENTS
============================================ */
@media screen and (max-width: 768px) and (orientation: landscape) {
.mod-stundenzettel table.noborder tr.oddeven {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.mod-stundenzettel table.noborder tr.oddeven td {
flex: 1 1 auto;
min-width: 120px;
}
}
/* ============================================
TOUCH FEEDBACK
============================================ */
@media (hover: none) and (pointer: coarse) {
.mod-stundenzettel a:active,
.mod-stundenzettel button:active {
opacity: 0.7;
transform: scale(0.98);
}
.mod-stundenzettel .fas:active,
.mod-stundenzettel .far:active {
transform: scale(1.1);
}
}

View file

@ -217,7 +217,7 @@ ResetStundenzettel = Status zurücksetzen
StundenzettelReset = Stundenzettel-Status wurde zurückgesetzt StundenzettelReset = Stundenzettel-Status wurde zurückgesetzt
# Standard-Leistung # Standard-Leistung
DefaultService = Standard-Leistung DefaultService = Leistungsposition
DefaultServiceDesc = Standard-Dienstleistung für Stundenzettel (wird beim Kunden hinterlegt) DefaultServiceDesc = Standard-Dienstleistung für Stundenzettel (wird beim Kunden hinterlegt)
DefaultServiceFromCustomer = Standard-Leistung vom Kunden DefaultServiceFromCustomer = Standard-Leistung vom Kunden
NoDefaultServiceSet = Keine Standard-Leistung hinterlegt NoDefaultServiceSet = Keine Standard-Leistung hinterlegt
@ -225,9 +225,27 @@ SetDefaultServiceInCustomer = Standard-Leistung beim Kunden hinterlegen
PlannedHours = Geplante Stunden PlannedHours = Geplante Stunden
TotalHours = Gesamtstunden TotalHours = Gesamtstunden
HoursFromOrder = Stunden aus Auftrag HoursFromOrder = Stunden aus Auftrag
NotSet = Nicht gesetzt
SelectService = Leistungsposition wählen
# Stundenpreis
HourlyRate = Stundenpreis
CustomPrice = Abweichender Preis
CustomerPrice = Kundenpreis
StandardPrice = Standardpreis
CustomHourlyRateSet = Es wurde ein abweichender Stundenpreis gesetzt
CustomerSpecificPrice = Kundenspezifischer Preis
StandardServicePrice = Standard-Produktpreis
ResetToDefault = Auf Standard zurücksetzen
Reset = Zurücksetzen
# Rechnungsübernahme Stunden # Rechnungsübernahme Stunden
InvoiceHoursMode = Stunden-Übernahme InvoiceHoursMode = Stunden-Übernahme
InvoiceHoursModeTotal = Gesamtstunden auf einer Zeile InvoiceHoursModeTotal = Gesamtstunden auf einer Zeile
InvoiceHoursModePerDay = Pro Tag eine Zeile InvoiceHoursModePerDay = Pro Tag eine Zeile
SelectInvoiceHoursMode = Wie sollen die Arbeitsstunden übernommen werden? SelectInvoiceHoursMode = Wie sollen die Arbeitsstunden übernommen werden?
# Lieferauflistung Leistungen
perStundenzettel = pro Stundenzettel
Entries = Einträge
incl = inkl.

View file

@ -87,3 +87,182 @@ function stundenzettel_prepare_head($object)
return $head; return $head;
} }
/**
* Prepare array of tabs for Stundenzettel Commande page (order-level view)
* Diese Tabs werden immer angezeigt, unabhängig davon ob ein Stundenzettel ausgewählt ist
*
* @param Commande $order Das Auftrags-Objekt
* @param int $stundenzettel_id Optional: ID des ausgewählten Stundenzettels
* @return array Array of tabs
*/
function stundenzettel_commande_prepare_head($order, $stundenzettel_id = 0)
{
global $db, $langs, $conf, $user;
$langs->load("stundenzettel@stundenzettel");
$h = 0;
$head = array();
// Tab 1: Kundenauftrag (Link zurück zum Auftrag)
$head[$h][0] = DOL_URL_ROOT.'/commande/card.php?id='.$order->id;
$head[$h][1] = $langs->trans("Order");
$head[$h][2] = 'order';
$h++;
// Tab 2: Produktliste
$head[$h][0] = dol_buildpath('/stundenzettel/stundenzettel_commande.php', 1).'?id='.$order->id.'&tab=products&noredirect=1'.($stundenzettel_id > 0 ? '&stundenzettel_id='.$stundenzettel_id : '');
$head[$h][1] = $langs->trans("ProductList");
$head[$h][2] = 'products';
$h++;
// Tab 3: Stundenzettel (aktueller/ausgewählter Stundenzettel - card.php)
// Wenn kein Stundenzettel ausgewählt, den letzten offenen für diesen Auftrag suchen
$activeStundenzettelId = $stundenzettel_id;
if ($activeStundenzettelId <= 0) {
$sqlActive = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel";
$sqlActive .= " WHERE fk_commande = ".((int)$order->id);
$sqlActive .= " AND status = 0"; // Nur Entwürfe
$sqlActive .= " ORDER BY date_stundenzettel DESC, rowid DESC";
$sqlActive .= " LIMIT 1";
$resqlActive = $db->query($sqlActive);
if ($resqlActive && $db->num_rows($resqlActive) > 0) {
$objActive = $db->fetch_object($resqlActive);
$activeStundenzettelId = $objActive->rowid;
}
}
if ($activeStundenzettelId > 0) {
// Lade Stundenzettel für Badge-Berechnung
require_once dol_buildpath('/stundenzettel/class/stundenzettel.class.php', 0);
$tmpStz = new Stundenzettel($db);
$tmpStz->fetch($activeStundenzettelId);
$tmpStz->fetchLeistungen();
$tmpStz->fetchProducts();
$nbItems = count($tmpStz->leistungen) + count($tmpStz->products);
$head[$h][0] = dol_buildpath('/stundenzettel/card.php', 1).'?id='.$activeStundenzettelId;
$head[$h][1] = $langs->trans("Stundenzettel");
if ($nbItems > 0) {
$head[$h][1] .= '<span class="badge marginleftonlyshort">'.$nbItems.'</span>';
}
$head[$h][2] = 'card';
$h++;
}
// Tab 4: Alle Stundenzettel (Liste aller Stundenzettel für diesen Auftrag)
$sql = "SELECT COUNT(*) as nb FROM ".MAIN_DB_PREFIX."stundenzettel WHERE fk_commande = ".((int)$order->id);
$resql = $db->query($sql);
$nbStundenzettel = 0;
if ($resql && ($obj = $db->fetch_object($resql))) {
$nbStundenzettel = $obj->nb;
}
$head[$h][0] = dol_buildpath('/stundenzettel/stundenzettel_commande.php', 1).'?id='.$order->id.'&tab=stundenzettel&noredirect=1'.($stundenzettel_id > 0 ? '&stundenzettel_id='.$stundenzettel_id : '');
$head[$h][1] = $langs->trans("StundenzettelList");
if ($nbStundenzettel > 0) {
$head[$h][1] .= '<span class="badge marginleftonlyshort">'.$nbStundenzettel.'</span>';
}
$head[$h][2] = 'stundenzettel';
$h++;
// Tab 4: Lieferauflistung (Tracking)
$head[$h][0] = dol_buildpath('/stundenzettel/stundenzettel_commande.php', 1).'?id='.$order->id.'&tab=tracking&noredirect=1'.($stundenzettel_id > 0 ? '&stundenzettel_id='.$stundenzettel_id : '');
$head[$h][1] = $langs->trans("DeliveryTracking");
$head[$h][2] = 'tracking';
$h++;
return $head;
}
/**
* Holt den kundenspezifischen Preis für ein Produkt
* Falls kein kundenspezifischer Preis existiert, wird der Standardpreis zurückgegeben
*
* @param DoliDB $db Datenbankverbindung
* @param int $fk_product Produkt-ID
* @param int $fk_soc Kunden-ID (Societe)
* @param Product|null $product Optional: bereits geladenes Produkt-Objekt
* @return array Array mit 'price' (HT), 'price_ttc', 'tva_tx', 'price_base_type', 'is_customer_price'
*/
function getCustomerPrice($db, $fk_product, $fk_soc, $product = null) {
global $conf;
$now = dol_now();
// Suche kundenspezifischen Preis in der Tabelle product_customer_price
$sql = "SELECT price, price_ttc, tva_tx, price_base_type";
$sql .= " FROM ".MAIN_DB_PREFIX."product_customer_price";
$sql .= " WHERE fk_product = ".((int)$fk_product);
$sql .= " AND fk_soc = ".((int)$fk_soc);
$sql .= " AND entity IN (".getEntity('productprice').")";
// Prüfe Gültigkeitszeitraum (date_begin <= now und (date_end IS NULL oder date_end >= now))
$sql .= " AND date_begin <= '".$db->idate($now)."'";
$sql .= " AND (date_end IS NULL OR date_end >= '".$db->idate($now)."')";
$sql .= " ORDER BY date_begin DESC";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$obj = $db->fetch_object($resql);
return array(
'price' => (float)$obj->price,
'price_ttc' => (float)$obj->price_ttc,
'tva_tx' => (float)$obj->tva_tx,
'price_base_type' => $obj->price_base_type,
'is_customer_price' => true
);
}
// Kein kundenspezifischer Preis gefunden - lade Standardpreis vom Produkt
if ($product === null) {
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
$product = new Product($db);
$product->fetch($fk_product);
}
return array(
'price' => (float)$product->price,
'price_ttc' => (float)$product->price_ttc,
'tva_tx' => (float)$product->tva_tx,
'price_base_type' => $product->price_base_type,
'is_customer_price' => false
);
}
/**
* Holt den effektiven Stundenpreis für einen Stundenzettel
* Berücksichtigt: 1. Manuell gesetzter Preis im Stundenzettel
* 2. Kundenspezifischer Preis
* 3. Standard-Produktpreis
*
* @param DoliDB $db Datenbankverbindung
* @param Stundenzettel $stundenzettel Das Stundenzettel-Objekt
* @param int $defaultServiceId ID der Standard-Leistung
* @return array Array mit 'price', 'source' ('custom', 'customer', 'standard')
*/
function getEffectiveHourlyRate($db, $stundenzettel, $defaultServiceId) {
// 1. Prüfe ob manueller Preis im Stundenzettel gesetzt
if ($stundenzettel->hourly_rate_is_custom && $stundenzettel->hourly_rate !== null) {
return array(
'price' => (float)$stundenzettel->hourly_rate,
'source' => 'custom'
);
}
// 2. Hole kundenspezifischen oder Standard-Preis
if ($defaultServiceId > 0) {
$priceInfo = getCustomerPrice($db, $defaultServiceId, $stundenzettel->fk_soc);
return array(
'price' => $priceInfo['price'],
'source' => $priceInfo['is_customer_price'] ? 'customer' : 'standard'
);
}
// 3. Kein Preis verfügbar
return array(
'price' => 0,
'source' => 'none'
);
}

View file

@ -22,6 +22,10 @@ CREATE TABLE llx_stundenzettel (
-- Status: 0=Entwurf, 1=Freigegeben, 2=In Rechnung übertragen, 9=Storniert -- Status: 0=Entwurf, 1=Freigegeben, 2=In Rechnung übertragen, 9=Storniert
status TINYINT DEFAULT 0 NOT NULL, status TINYINT DEFAULT 0 NOT NULL,
-- Stundenpreis (abweichend vom Standard)
hourly_rate DOUBLE(24,8) DEFAULT NULL, -- Stundenpreis (NULL = Standard verwenden)
hourly_rate_is_custom TINYINT DEFAULT 0 NOT NULL, -- 1 = manuell geändert
-- Notizen -- Notizen
note_private TEXT, note_private TEXT,
note_public TEXT, note_public TEXT,

View file

@ -7,6 +7,7 @@ CREATE TABLE llx_stundenzettel_leistung (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY, rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
fk_stundenzettel INTEGER NOT NULL, -- Verknüpfung zum Stundenzettel fk_stundenzettel INTEGER NOT NULL, -- Verknüpfung zum Stundenzettel
fk_user INTEGER DEFAULT NULL, -- Welcher Mitarbeiter fk_user INTEGER DEFAULT NULL, -- Welcher Mitarbeiter
fk_product INTEGER DEFAULT NULL, -- Verknüpfung zur Leistungsposition (Dienstleistung)
-- Zeitraum -- Zeitraum
date_leistung DATE NOT NULL, -- Datum der Leistung date_leistung DATE NOT NULL, -- Datum der Leistung

8
sql/update_1.2.0.sql Normal file
View file

@ -0,0 +1,8 @@
-- ============================================================================
-- Stundenzettel Update auf Version 1.2.0
-- Fügt Leistungsposition zu Arbeitszeiten hinzu
-- ============================================================================
-- Leistungsposition (Dienstleistung) pro Arbeitszeit
ALTER TABLE llx_stundenzettel_leistung
ADD COLUMN IF NOT EXISTS fk_product INTEGER DEFAULT NULL COMMENT 'Verknüpfung zur Leistungsposition (Dienstleistung)';

View file

@ -17,6 +17,7 @@ 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.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
dol_include_once('/stundenzettel/class/stundenzettel.class.php'); dol_include_once('/stundenzettel/class/stundenzettel.class.php');
dol_include_once('/stundenzettel/lib/stundenzettel.lib.php');
// Load translation files // Load translation files
$langs->loadLangs(array("stundenzettel@stundenzettel", "orders", "products")); $langs->loadLangs(array("stundenzettel@stundenzettel", "orders", "products"));
@ -712,11 +713,16 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
$product = new Product($db); $product = new Product($db);
$product->fetch($addProd->fk_product); $product->fetch($addProd->fk_product);
// Kundenspezifischen Preis holen (falls vorhanden)
$customerPriceInfo = getCustomerPrice($db, $addProd->fk_product, $order->socid, $product);
$usePrice = $customerPriceInfo['price'];
$useTvaTx = $customerPriceInfo['tva_tx'];
$productResult = $facture->addline( $productResult = $facture->addline(
$product->label, // 1: desc $product->label, // 1: desc
$product->price, // 2: pu_ht $usePrice, // 2: pu_ht - kundenspezifischer Preis
$addProd->total_qty, // 3: qty $addProd->total_qty, // 3: qty
$product->tva_tx, // 4: txtva $useTvaTx, // 4: txtva - kundenspezifischer MwSt-Satz
0, // 5: txlocaltax1 0, // 5: txlocaltax1
0, // 6: txlocaltax2 0, // 6: txlocaltax2
$addProd->fk_product, // 7: fk_product $addProd->fk_product, // 7: fk_product
@ -808,6 +814,137 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
} }
} }
// ============================================
// ARBEITSZEITEN aus Stundenzetteln hinzufügen
// (Gruppiert nach Leistungsposition/Produkt)
// ============================================
// Standard-Leistung vom Kunden laden (Fallback wenn keine Leistung gewählt)
$societe = new Societe($db);
$societe->fetch($order->socid);
$societe->fetch_optionals();
$defaultServiceId = isset($societe->array_options['options_stundenzettel_default_service']) ? (int)$societe->array_options['options_stundenzettel_default_service'] : 0;
// Alle Arbeitszeiten nach Leistungsposition gruppiert sammeln
$sqlHours = "SELECT ";
$sqlHours .= " COALESCE(l.fk_product, ".(int)$defaultServiceId.") as product_id,";
$sqlHours .= " p.ref as product_ref, p.label as product_label, p.tva_tx, p.fk_unit,";
$sqlHours .= " SUM(l.duration) as total_minutes";
$sqlHours .= " FROM ".MAIN_DB_PREFIX."stundenzettel s";
$sqlHours .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel_leistung l ON l.fk_stundenzettel = s.rowid";
$sqlHours .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = COALESCE(l.fk_product, ".(int)$defaultServiceId.")";
$sqlHours .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlHours .= " AND s.status >= 1"; // Nur validierte Stundenzettel
$sqlHours .= " GROUP BY COALESCE(l.fk_product, ".(int)$defaultServiceId."), p.ref, p.label, p.tva_tx, p.fk_unit";
$sqlHours .= " HAVING SUM(l.duration) > 0";
$sqlHours .= " ORDER BY p.label";
$resqlHours = $db->query($sqlHours);
$hasWorkHours = ($resqlHours && $db->num_rows($resqlHours) > 0);
if ($hasWorkHours) {
// Arbeitszeit-Section erstellen (nur wenn Sections im Auftrag)
$arbeitszeitSectionResult = 0;
if ($orderHasSections) {
$arbeitszeitSectionResult = $facture->addline(
$langs->trans('Leistungen'),
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 ($arbeitszeitSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'section',
'fk_facturedet' => $arbeitszeitSectionResult,
'title' => $langs->trans('Leistungen'),
'parent' => null
);
}
}
// Arbeitszeiten hinzufügen (pro Leistungsposition)
$arbeitszeitSubtotal = 0;
while ($objHours = $db->fetch_object($resqlHours)) {
$productId = (int)$objHours->product_id;
$hoursWorked = $objHours->total_minutes / 60;
// Produkt-Preis ermitteln (kundenspezifisch oder Standard)
$priceInfo = getCustomerPrice($db, $productId, $order->socid);
$hourlyPrice = $priceInfo['price'];
$tvaTx = !empty($objHours->tva_tx) ? $objHours->tva_tx : $priceInfo['tva_tx'];
// Beschreibung
$lineDesc = !empty($objHours->product_label) ? $objHours->product_label : $langs->trans('DefaultService');
// Rechnungszeile hinzufügen
$hoursResult = $facture->addline(
$lineDesc, // 1: desc
$hourlyPrice, // 2: pu_ht
$hoursWorked, // 3: qty (Stunden)
$tvaTx, // 4: txtva
0, // 5: txlocaltax1
0, // 6: txlocaltax2
$productId, // 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
1, // 16: type (1 = Dienstleistung)
$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
$objHours->fk_unit // 28: fk_unit
);
if ($hoursResult > 0) {
if ($orderHasSections && $arbeitszeitSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'product',
'fk_facturedet' => $hoursResult,
'title' => null,
'parent' => $arbeitszeitSectionResult
);
}
$arbeitszeitSubtotal += $hourlyPrice * $hoursWorked;
} elseif ($hoursResult < 0) {
$error++;
setEventMessages($facture->error, $facture->errors, 'errors');
}
}
// Zwischensumme für Arbeitszeit
if ($arbeitszeitSectionResult > 0 && $arbeitszeitSubtotal > 0) {
$subtotalLabel = 'Zwischensumme: '.$langs->trans('Leistungen');
$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' => $arbeitszeitSectionResult
);
}
}
}
// llx_facture_lines_manager für Rechnung erstellen // llx_facture_lines_manager für Rechnung erstellen
if (!$error && count($invoiceManagerLines) > 0) { if (!$error && count($invoiceManagerLines) > 0) {
$lineOrder = 1; $lineOrder = 1;
@ -891,7 +1028,6 @@ $_GET['mainmenu'] = 'stundenzettel';
// Stundenzettel laden wenn ID übergeben wurde // Stundenzettel laden wenn ID übergeben wurde
$stundenzettelObj = null; $stundenzettelObj = null;
if ($stundenzettel_id > 0) { if ($stundenzettel_id > 0) {
dol_include_once('/stundenzettel/lib/stundenzettel.lib.php');
$stundenzettelObj = new Stundenzettel($db); $stundenzettelObj = new Stundenzettel($db);
if ($stundenzettelObj->fetch($stundenzettel_id) <= 0) { if ($stundenzettelObj->fetch($stundenzettel_id) <= 0) {
$stundenzettelObj = null; $stundenzettelObj = null;
@ -947,22 +1083,15 @@ $hasRemainingProducts = (count($remainingProducts) > 0);
$title = $langs->trans("Stundenzettel").' - '.$order->ref; $title = $langs->trans("Stundenzettel").' - '.$order->ref;
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-stundenzettel page-commande'); llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-stundenzettel page-commande');
// Tabs - Stundenzettel-Tabs wenn aus Stundenzettel aufgerufen, sonst Auftrags-Tabs // Mobile CSS einbinden
// Aktiven Tab-Key basierend auf $tab bestimmen print '<link rel="stylesheet" type="text/css" href="'.dol_buildpath('/stundenzettel/css/stundenzettel-mobile.css', 1).'?v='.filemtime(dol_buildpath('/stundenzettel/css/stundenzettel-mobile.css', 0)).'">';
$activeTabKey = 'productlist'; // Default
if ($tab == 'stundenzettel') {
$activeTabKey = 'stundenzettel_list';
} elseif ($tab == 'tracking') {
$activeTabKey = 'tracking';
}
if ($stundenzettelObj) { // Tabs - Immer die Stundenzettel-Commande-Tabs verwenden
$head = stundenzettel_prepare_head($stundenzettelObj); // Aktiven Tab-Key basierend auf $tab bestimmen
dol_fiche_head($head, $activeTabKey, $langs->trans("Stundenzettel"), -1, 'clock'); $activeTabKey = $tab; // products, stundenzettel, oder tracking
} else {
$head = commande_prepare_head($order); $head = stundenzettel_commande_prepare_head($order, $stundenzettel_id);
dol_fiche_head($head, 'stundenzettel', $langs->trans("CustomerOrder"), -1, 'order'); dol_fiche_head($head, $activeTabKey, $langs->trans("Stundenzettel"), -1, 'clock');
}
$form = new Form($db); $form = new Form($db);
@ -1070,8 +1199,17 @@ if ($defaultServiceProduct || $plannedHours > 0) {
print '<td style="padding-right:20px;"><strong>'.img_picto('', 'service', 'class="pictofixedwidth"').$langs->trans("DefaultService").':</strong></td>'; print '<td style="padding-right:20px;"><strong>'.img_picto('', 'service', 'class="pictofixedwidth"').$langs->trans("DefaultService").':</strong></td>';
print '<td>'; print '<td>';
if ($defaultServiceProduct) { if ($defaultServiceProduct) {
// Kundenspezifischen Preis für die Standard-Leistung holen
$defaultServicePriceInfo = getCustomerPrice($db, $defaultServiceProduct->id, $order->socid, $defaultServiceProduct);
$displayPrice = $defaultServicePriceInfo['price'];
$isCustomerPrice = $defaultServicePriceInfo['is_customer_price'];
print $defaultServiceProduct->getNomUrl(1).' - '.$defaultServiceProduct->label; print $defaultServiceProduct->getNomUrl(1).' - '.$defaultServiceProduct->label;
print ' <span class="opacitymedium">('.price($defaultServiceProduct->price, 0, $langs, 1, -1, -1, $conf->currency).'/Std.)</span>'; print ' <span class="opacitymedium">('.price($displayPrice, 0, $langs, 1, -1, -1, $conf->currency).'/Std.)';
if ($isCustomerPrice) {
print ' <span class="badge badge-status4" title="'.$langs->trans("CustomerSpecificPrice").'">Kundenpreis</span>';
}
print '</span>';
} else { } else {
print '<span class="opacitymedium">'.$langs->trans("NoDefaultServiceSet").'</span>'; 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 ' <a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.$order->socid.'" class="button small">'.$langs->trans("SetDefaultServiceInCustomer").'</a>';
@ -2048,7 +2186,15 @@ if ($tab == 'tracking') {
$resqlDetails = $db->query($sqlDetails); $resqlDetails = $db->query($sqlDetails);
if ($resqlDetails) { if ($resqlDetails) {
while ($objD = $db->fetch_object($resqlDetails)) { while ($objD = $db->fetch_object($resqlDetails)) {
$key = $objD->fk_commandedet > 0 ? $objD->fk_commandedet : 'prod_'.$objD->fk_product; // Key-Logik: fk_commandedet > prod_X > freetext_hash
if ($objD->fk_commandedet > 0) {
$key = $objD->fk_commandedet;
} elseif ($objD->fk_product > 0) {
$key = 'prod_'.$objD->fk_product;
} else {
// Freitext: Hash der Beschreibung als Key
$key = 'freetext_'.md5(trim($objD->description));
}
if (!isset($trackingDetails[$key])) { if (!isset($trackingDetails[$key])) {
$trackingDetails[$key] = array(); $trackingDetails[$key] = array();
} }
@ -2082,14 +2228,15 @@ if ($tab == 'tracking') {
$sql .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp"; $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 .= " 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,"; $sql .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added')), 0) as qty_delivered,";
// Mehraufwand // Mehraufwand - nur für Produkte mit fk_product > 0 (Freitext-Mehraufwand wird separat angezeigt)
$sql .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2"; $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 .= " 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,"; $sql .= " WHERE sp2.fk_product = cd.fk_product AND cd.fk_product > 0 AND sp2.origin = 'additional' AND s2.fk_commande = ".((int)$order->id)."), 0) as qty_additional,";
// Entfällt // Entfällt - für Produkte via fk_product, für Freitext via fk_commandedet
$sql .= " COALESCE((SELECT SUM(sp3.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp3"; $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 .= " 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 .= " WHERE sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$order->id);
$sql .= " AND ((sp3.fk_product = cd.fk_product AND cd.fk_product > 0) OR sp3.fk_commandedet = cd.rowid)), 0) as qty_omitted";
$sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd"; $sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sql .= " WHERE cd.fk_commande = ".((int)$order->id); $sql .= " WHERE cd.fk_commande = ".((int)$order->id);
@ -2264,6 +2411,165 @@ if ($tab == 'tracking') {
} }
} }
// =============================================
// ZUSÄTZLICHE PRODUKTE: Mehraufwand die NICHT im Auftrag sind
// (Produkte und Freitext die direkt als Mehraufwand hinzugefügt wurden)
// =============================================
// Alle Produkt-IDs aus dem Auftrag sammeln (für Ausschluss)
$orderProductIds = array();
$sqlOrderProds = "SELECT fk_product FROM ".MAIN_DB_PREFIX."commandedet WHERE fk_commande = ".((int)$order->id)." AND fk_product > 0";
$resOrderProds = $db->query($sqlOrderProds);
if ($resOrderProds) {
while ($objOP = $db->fetch_object($resOrderProds)) {
$orderProductIds[] = $objOP->fk_product;
}
}
// Mehraufwand-Produkte laden die NICHT im Auftrag sind
$sqlMehraufwand = "SELECT sp.fk_product, sp.description,";
$sqlMehraufwand .= " p.ref as product_ref, p.label as product_label,";
$sqlMehraufwand .= " SUM(sp.qty) as qty_ordered,";
$sqlMehraufwand .= " SUM(sp.qty_done) as qty_delivered";
$sqlMehraufwand .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlMehraufwand .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlMehraufwand .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = sp.fk_product";
$sqlMehraufwand .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlMehraufwand .= " AND sp.origin = 'additional'";
if (!empty($orderProductIds)) {
$sqlMehraufwand .= " AND (sp.fk_product NOT IN (".implode(',', $orderProductIds).") OR sp.fk_product = 0)";
}
$sqlMehraufwand .= " GROUP BY sp.fk_product, sp.description, p.ref, p.label";
$sqlMehraufwand .= " ORDER BY p.ref, sp.description";
$resqlMehr = $db->query($sqlMehraufwand);
$hasMehraufwandProducts = false;
if ($resqlMehr && $db->num_rows($resqlMehr) > 0) {
// Separator-Zeile für Mehraufwand
print '<tr class="liste_titre">';
print '<td colspan="5" style="background-color: #e8f5e9;"><strong>'.$langs->trans("Mehraufwand").'</strong> <span class="opacitymedium">('.$langs->trans("MehraufwandDesc").')</span></td>';
print '</tr>';
while ($objMehr = $db->fetch_object($resqlMehr)) {
$hasMehraufwandProducts = true;
$detailRowId++;
$qty_ordered_mehr = (float)$objMehr->qty_ordered;
$qty_delivered_mehr = (float)$objMehr->qty_delivered;
$qty_remaining_mehr = $qty_ordered_mehr - $qty_delivered_mehr;
// Details für Mehraufwand laden
$detailsMehr = array();
if ($objMehr->fk_product > 0) {
$prodKey = 'prod_'.$objMehr->fk_product;
if (isset($trackingDetails[$prodKey])) {
$detailsMehr = $trackingDetails[$prodKey];
}
} else {
// Freitext: Hash der Beschreibung als Key
$freetextKey = 'freetext_'.md5(trim($objMehr->description));
if (isset($trackingDetails[$freetextKey])) {
$detailsMehr = $trackingDetails[$freetextKey];
}
}
$hasDetailsMehr = !empty($detailsMehr);
print '<tr class="oddeven">';
// Produkt/Beschreibung
print '<td>';
if ($hasDetailsMehr) {
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>';
}
print '<span class="badge badge-success" style="margin-right:5px;">'.$langs->trans("Mehraufwand").'</span>';
if ($objMehr->fk_product > 0) {
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$objMehr->fk_product.'">';
print img_picto('', 'product', 'class="pictofixedwidth"');
print $objMehr->product_ref.' - '.$objMehr->product_label;
print '</a>';
} else {
// Freitext
print img_picto('', 'generic', 'class="pictofixedwidth"');
$desc = !empty($objMehr->description) ? strip_tags($objMehr->description) : '-';
if (strlen($desc) > 50) $desc = substr($desc, 0, 47).'...';
print '<span class="opacitymedium">'.$desc.'</span>';
}
print '</td>';
// Bestellt (Mehraufwand-Menge)
print '<td class="right"><strong>'.formatQty($qty_ordered_mehr).'</strong></td>';
$total_ordered += $qty_ordered_mehr;
// Geliefert
print '<td class="right">'.formatQty($qty_delivered_mehr).'</td>';
$total_delivered += $qty_delivered_mehr;
// Verbleibend
print '<td class="right">';
if ($qty_remaining_mehr > 0) {
print '<span class="badge badge-warning">'.formatQty($qty_remaining_mehr).'</span>';
} elseif ($qty_remaining_mehr == 0) {
print '<span class="badge badge-success">0</span>';
} else {
print '<span class="badge badge-info">'.formatQty($qty_remaining_mehr).'</span>';
}
print '</td>';
$total_remaining += $qty_remaining_mehr;
// Status
print '<td>';
if ($qty_remaining_mehr <= 0) {
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($qty_delivered_mehr > 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 für Mehraufwand
if ($hasDetailsMehr) {
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;">';
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 ($detailsMehr as $det) {
print '<tr class="oddeven">';
print '<td><a href="'.dol_buildpath('/stundenzettel/card.php', 1).'?id='.$det->fk_stundenzettel.'">'.$det->stz_ref.'</a></td>';
print '<td>'.dol_print_date($db->jdate($det->date_stundenzettel), 'day').'</td>';
print '<td><span class="badge badge-success">'.$langs->trans("Mehraufwand").'</span></td>';
print '<td class="right">'.formatQty($det->qty_done).'</td>';
print '<td>'.((!empty($det->description)) ? dol_escape_htmltag($det->description) : '<span class="opacitymedium">-</span>').'</td>';
print '</tr>';
}
print '</table>';
print '</td>';
print '</tr>';
}
}
// Neue Summenzeile mit Mehraufwand
print '<tr class="liste_total">';
print '<td><strong>'.$langs->trans("Total").' ('.$langs->trans("Mehraufwand").' '.$langs->trans("incl").')</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>';
}
print '</table>'; print '</table>';
print '</div>'; print '</div>';
@ -2307,9 +2613,176 @@ if ($tab == 'tracking') {
} }
</script>'; </script>';
// =============================================
// BEREICH: LEISTUNGEN / ARBEITSZEITEN
// =============================================
print '<div class="fichehalfleft" style="margin-top: 20px;">';
print '<div class="titre inline-block">'.$langs->trans("Leistungen").' / '.$langs->trans("TotalHours").'</div>';
// Leistungen nach Leistungsposition gruppiert laden
$sqlLeistungen = "SELECT ";
$sqlLeistungen .= " COALESCE(l.fk_product, 0) as service_id,";
$sqlLeistungen .= " p.ref as service_ref, p.label as service_label,";
$sqlLeistungen .= " SUM(l.duration) as total_minutes,";
$sqlLeistungen .= " COUNT(l.rowid) as entry_count";
$sqlLeistungen .= " FROM ".MAIN_DB_PREFIX."stundenzettel_leistung l";
$sqlLeistungen .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = l.fk_stundenzettel";
$sqlLeistungen .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = l.fk_product";
$sqlLeistungen .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlLeistungen .= " GROUP BY COALESCE(l.fk_product, 0), p.ref, p.label";
$sqlLeistungen .= " ORDER BY p.ref, p.label";
$resqlLeistungen = $db->query($sqlLeistungen);
$totalMinutesAll = 0;
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("DefaultService").'</th>';
print '<th class="right">'.$langs->trans("TotalHours").'</th>';
print '<th class="right">'.$langs->trans("Entries").'</th>';
print '</tr>';
if ($resqlLeistungen) {
$numLeistungen = $db->num_rows($resqlLeistungen);
if ($numLeistungen > 0) {
while ($objL = $db->fetch_object($resqlLeistungen)) {
$totalMinutesAll += $objL->total_minutes;
$hours = floor($objL->total_minutes / 60);
$mins = $objL->total_minutes % 60;
print '<tr class="oddeven">';
// Leistungsposition
print '<td>';
if ($objL->service_id > 0) {
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$objL->service_id.'">';
print img_picto('', 'service', 'class="pictofixedwidth"');
print $objL->service_ref.' - '.$objL->service_label;
print '</a>';
} else {
print '<span class="opacitymedium">'.img_picto('', 'service', 'class="pictofixedwidth"').$langs->trans("NotSet").'</span>';
}
print '</td>';
// Stunden
print '<td class="right"><strong>'.sprintf('%d:%02d h', $hours, $mins).'</strong></td>';
// Anzahl Einträge
print '<td class="right">'.$objL->entry_count.'</td>';
print '</tr>';
}
// Summenzeile
$totalHours = floor($totalMinutesAll / 60);
$totalMins = $totalMinutesAll % 60;
print '<tr class="liste_total">';
print '<td><strong>'.$langs->trans("Total").'</strong></td>';
print '<td class="right"><strong>'.sprintf('%d:%02d h', $totalHours, $totalMins).'</strong></td>';
print '<td class="right"></td>';
print '</tr>';
} else {
print '<tr class="oddeven"><td colspan="3" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
}
}
print '</table>';
print '</div>';
// Details pro Stundenzettel
print '<br>';
print '<div class="titre inline-block">'.$langs->trans("Leistungen").' '.$langs->trans("perStundenzettel").'</div>';
$sqlLeistDetail = "SELECT l.rowid, l.fk_stundenzettel, l.fk_product, l.date_leistung,";
$sqlLeistDetail .= " l.time_start, l.time_end, l.duration, l.description,";
$sqlLeistDetail .= " s.ref as stz_ref, s.date_stundenzettel,";
$sqlLeistDetail .= " p.ref as service_ref, p.label as service_label";
$sqlLeistDetail .= " FROM ".MAIN_DB_PREFIX."stundenzettel_leistung l";
$sqlLeistDetail .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = l.fk_stundenzettel";
$sqlLeistDetail .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = l.fk_product";
$sqlLeistDetail .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlLeistDetail .= " ORDER BY s.date_stundenzettel DESC, l.date_leistung, l.time_start";
$resqlLeistDetail = $db->query($sqlLeistDetail);
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Stundenzettel").'</th>';
print '<th>'.$langs->trans("Date").'</th>';
print '<th>'.$langs->trans("LeistungTimeStart").' - '.$langs->trans("LeistungTimeEnd").'</th>';
print '<th class="right">'.$langs->trans("LeistungDuration").'</th>';
print '<th>'.$langs->trans("DefaultService").'</th>';
print '<th>'.$langs->trans("Description").'</th>';
print '</tr>';
if ($resqlLeistDetail) {
$numDetail = $db->num_rows($resqlLeistDetail);
if ($numDetail > 0) {
while ($objLD = $db->fetch_object($resqlLeistDetail)) {
$hours = floor($objLD->duration / 60);
$mins = $objLD->duration % 60;
print '<tr class="oddeven">';
// Stundenzettel
print '<td>';
print '<a href="'.dol_buildpath('/stundenzettel/card.php', 1).'?id='.$objLD->fk_stundenzettel.'">';
print $objLD->stz_ref;
print '</a>';
print '</td>';
// Datum
print '<td>'.dol_print_date($db->jdate($objLD->date_leistung), 'day').'</td>';
// Zeit
print '<td>';
if ($objLD->time_start && $objLD->time_end) {
print substr($objLD->time_start, 0, 5).' - '.substr($objLD->time_end, 0, 5);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Dauer
print '<td class="right">'.sprintf('%d:%02d h', $hours, $mins).'</td>';
// Leistungsposition
print '<td>';
if ($objLD->fk_product > 0) {
print '<span class="badge badge-primary">'.$objLD->service_ref.'</span>';
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Beschreibung
print '<td>';
if (!empty($objLD->description)) {
$desc = strip_tags($objLD->description);
if (strlen($desc) > 50) $desc = substr($desc, 0, 47).'...';
print dol_escape_htmltag($desc);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
print '</tr>';
}
} else {
print '<tr class="oddeven"><td colspan="6" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
}
}
print '</table>';
print '</div>';
print '</div>'; // fichehalfleft
// Button: In Rechnung übertragen (nur wenn alles erledigt) // Button: In Rechnung übertragen (nur wenn alles erledigt)
if ($total_remaining <= 0 && $total_delivered > 0) { if ($total_remaining <= 0 && $total_delivered > 0) {
print '<div class="center" style="margin-top:20px;">'; print '<div class="center clearboth" style="margin-top:20px;">';
print '<a class="butAction" href="?id='.$order->id.'&action=transfer_to_invoice">'; print '<a class="butAction" href="?id='.$order->id.'&action=transfer_to_invoice">';
print img_picto('', 'bill', 'class="pictofixedwidth"').$langs->trans("TransferToInvoice"); print img_picto('', 'bill', 'class="pictofixedwidth"').$langs->trans("TransferToInvoice");
print '</a>'; print '</a>';