Version 1.5.0: Rücknahme-Bereich, Checkbox-Sichtbarkeit, Berechtigungen

- Neuer Rücknahme-Bereich (origin='returned') für zurückgenommene Produkte
- Checkbox-Logik für Bereiche: Entfällt, Mehraufwand, Rücknahme, Merkzettel
- Admin-Einstellungen für Standard-Sichtbarkeit der Bereiche
- Erweiterte Berechtigungen: eigene vs alle (read/write/delete)
- Tracking-Berechnung: qty_returned wird von Liefermenge abgezogen
- Mobile-freundliches Layout beibehalten

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-19 12:00:26 +01:00
parent fe2eb751c2
commit 585d530992
6 changed files with 576 additions and 48 deletions

View file

@ -71,6 +71,18 @@ if ($action == 'setINVOICE_HOURS_MODE') {
exit;
}
if ($action == 'setDEFAULT_SECTIONS') {
$sections = GETPOST('default_sections', 'array');
$value = implode(',', $sections);
if (dolibarr_set_const($db, 'STUNDENZETTEL_DEFAULT_SECTIONS', $value, 'chaine', 0, '', $conf->entity) > 0) {
setEventMessages($langs->trans("SetupSaved"), null, 'mesgs');
} else {
setEventMessages($langs->trans("Error"), null, 'errors');
}
header("Location: ".$_SERVER["PHP_SELF"]);
exit;
}
/*
* View
*/
@ -176,6 +188,28 @@ print '</form>';
print '</td>';
print '</tr>';
// Standard-Bereiche anzeigen
$currentSections = getDolGlobalString('STUNDENZETTEL_DEFAULT_SECTIONS', '');
$selectedSections = !empty($currentSections) ? explode(',', $currentSections) : array();
print '<tr class="oddeven">';
print '<td>'.$langs->trans("DefaultSections").'<br><small class="opacitymedium">'.$langs->trans("DefaultSectionsDesc").'</small></td>';
print '<td>';
print '<form method="POST" action="'.$_SERVER["PHP_SELF"].'" style="display: inline;">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="setDEFAULT_SECTIONS">';
print '<div style="display: flex; flex-direction: column; gap: 5px;">';
print '<label><input type="checkbox" name="default_sections[]" value="entfaellt"'.(in_array('entfaellt', $selectedSections) ? ' checked' : '').'> '.$langs->trans("Entfaellt").'</label>';
print '<label><input type="checkbox" name="default_sections[]" value="mehraufwand"'.(in_array('mehraufwand', $selectedSections) ? ' checked' : '').'> '.$langs->trans("Mehraufwand").'</label>';
print '<label><input type="checkbox" name="default_sections[]" value="ruecknahme"'.(in_array('ruecknahme', $selectedSections) ? ' checked' : '').'> '.$langs->trans("Ruecknahme").'</label>';
print '<label><input type="checkbox" name="default_sections[]" value="merkzettel"'.(in_array('merkzettel', $selectedSections) ? ' checked' : '').'> '.$langs->trans("NotesMemo").'</label>';
print '</div>';
print '</td>';
print '<td class="right">';
print '<input type="submit" class="button small" value="'.$langs->trans("Modify").'">';
print '</form>';
print '</td>';
print '</tr>';
print '</table>';
print dol_get_fiche_end();

478
card.php Executable file → Normal file
View file

@ -78,12 +78,27 @@ if ($id > 0 || !empty($ref)) {
}
}
// Permissions
// Permissions - Basis-Berechtigungen
$permissiontoread = $user->hasRight('stundenzettel', 'read');
$permissiontoadd = $user->hasRight('stundenzettel', 'write');
$permissiontoreadall = $user->hasRight('stundenzettel', 'read', 'all') || $user->admin;
$permissiontowrite = $user->hasRight('stundenzettel', 'write');
$permissiontowriteall = $user->hasRight('stundenzettel', 'write', 'all') || $user->admin;
$permissiontodelete = $user->hasRight('stundenzettel', 'delete');
$permissiontodeleteall = $user->hasRight('stundenzettel', 'delete', 'all') || $user->admin;
$permissiontovalidate = $user->hasRight('stundenzettel', 'validate');
// Prüfen ob der aktuelle Stundenzettel dem Benutzer gehört
$isOwner = ($object->id > 0 && $object->fk_user_author == $user->id);
// Effektive Berechtigungen für diesen Stundenzettel
$permissiontoadd = $permissiontowrite && ($isOwner || $permissiontowriteall || $action == 'create');
$permissiontodeleteobj = $permissiontodelete && ($isOwner || $permissiontodeleteall);
// Zugriffskontrolle: Wenn geladen und nicht berechtigt -> Zugriff verweigern
if ($object->id > 0 && !$isOwner && !$permissiontoreadall) {
accessforbidden('Sie haben keine Berechtigung, diesen Stundenzettel anzuzeigen.');
}
/*
* Actions
*/
@ -207,7 +222,7 @@ if ($action == 'confirm_setdraft' && $confirm == 'yes' && $permissiontoadd) {
}
// Delete
if ($action == 'confirm_delete' && $confirm == 'yes' && $permissiontodelete) {
if ($action == 'confirm_delete' && $confirm == 'yes' && $permissiontodeleteobj) {
$fk_commande = $object->fk_commande; // Speichern vor dem Löschen
$result = $object->delete($user);
if ($result > 0) {
@ -605,6 +620,121 @@ if ($action == 'confirm_delete_entfaellt' && $confirm == 'yes' && $permissiontoa
exit;
}
// Rücknahme hinzufügen (bereits verbautes Produkt wird zurückgenommen)
if ($action == 'add_ruecknahme' && $permissiontoadd) {
$ruecknahme_product_raw = GETPOST('ruecknahme_product', 'alpha');
$qty = (float)price2num(GETPOST('ruecknahme_qty', 'alpha'));
$reason = GETPOST('ruecknahme_description', 'restricthtml');
// Prüfen ob es ein Freitext-Produkt oder normales Produkt ist
$fk_product = 0;
$freetext_description = '';
$commandedet_id = 0;
if (strpos($ruecknahme_product_raw, 'freetext_') === 0) {
// Freitext-Produkt aus dem Auftrag
$commandedet_id = (int)substr($ruecknahme_product_raw, 9);
// Beschreibung aus commandedet laden
$sqlDesc = "SELECT description FROM ".MAIN_DB_PREFIX."commandedet WHERE rowid = ".((int)$commandedet_id);
$resqlDesc = $db->query($sqlDesc);
if ($resqlDesc && ($objDesc = $db->fetch_object($resqlDesc))) {
$freetext_description = $objDesc->description;
}
} else {
$fk_product = (int)$ruecknahme_product_raw;
}
// Beschreibung: Freitext-Beschreibung + Grund
$description = $reason;
if (!empty($freetext_description)) {
$description = strip_tags($freetext_description) . (!empty($reason) ? ' - ' . $reason : '');
}
$error = 0;
if ($fk_product > 0 || !empty($freetext_description)) {
// Server-seitige Validierung: Prüfen ob Menge bereits verbaut wurde
if ($object->fk_commande > 0) {
if ($fk_product > 0) {
// Produkt-Validierung: Prüfe bereits verbaute Menge
$sqlCheck = "SELECT COALESCE(SUM(sp.qty_done), 0) as qty_delivered,";
$sqlCheck .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sqlCheck .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sqlCheck .= " WHERE sp2.fk_product = ".((int)$fk_product)." AND sp2.origin = 'returned' AND s2.fk_commande = ".((int)$object->fk_commande)."), 0) as qty_already_returned";
$sqlCheck .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlCheck .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlCheck .= " WHERE sp.fk_product = ".((int)$fk_product);
$sqlCheck .= " AND sp.origin IN ('order', 'added')";
$sqlCheck .= " AND s.fk_commande = ".((int)$object->fk_commande);
$resqlCheck = $db->query($sqlCheck);
if ($resqlCheck && ($objCheck = $db->fetch_object($resqlCheck))) {
$qty_available = $objCheck->qty_delivered - $objCheck->qty_already_returned;
if ($qty > $qty_available) {
setEventMessages($langs->trans('ErrorQtyExceedsAvailable', $qty_available), null, 'errors');
$error++;
}
}
} elseif (!empty($commandedet_id)) {
// Freitext-Produkt Validierung
$sqlCheck = "SELECT COALESCE(SUM(sp.qty_done), 0) as qty_delivered,";
$sqlCheck .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sqlCheck .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sqlCheck .= " WHERE sp2.fk_commandedet = ".((int)$commandedet_id)." AND sp2.origin = 'returned' AND s2.fk_commande = ".((int)$object->fk_commande)."), 0) as qty_already_returned";
$sqlCheck .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlCheck .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlCheck .= " WHERE sp.fk_commandedet = ".((int)$commandedet_id);
$sqlCheck .= " AND sp.origin IN ('order', 'added')";
$sqlCheck .= " AND s.fk_commande = ".((int)$object->fk_commande);
$resqlCheck = $db->query($sqlCheck);
if ($resqlCheck && ($objCheck = $db->fetch_object($resqlCheck))) {
$qty_available = $objCheck->qty_delivered - $objCheck->qty_already_returned;
if ($qty > $qty_available) {
setEventMessages($langs->trans('ErrorQtyExceedsAvailable', $qty_available), null, 'errors');
$error++;
}
}
}
}
if (!$error) {
// Produkt zum Stundenzettel hinzufügen mit origin='returned'
$result = $object->addProduct(
$fk_product,
$commandedet_id, // fk_commandedet
0, // fk_manager_line
0, // qty_original
$qty, // qty_done (Menge die zurückgenommen wird)
'returned', // origin (rücknahme)
$description // description (Grund)
);
if ($result > 0) {
setEventMessages($langs->trans('RecordSaved'), null, 'mesgs');
} else {
setEventMessages($object->error, null, 'errors');
}
}
} else {
setEventMessages($langs->trans('ErrorNoProductSelected'), null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$object->id.'&tab=products');
exit;
}
// Rücknahme-Produkt löschen
if ($action == 'confirm_delete_ruecknahme' && $confirm == 'yes' && $permissiontoadd) {
$line_id = GETPOST('line_id', 'int');
$result = $object->deleteProduct($line_id);
if ($result > 0) {
setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs');
} else {
setEventMessages($object->error, null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$object->id.'&tab=products');
exit;
}
// Mehraufwand hinzufügen (zusätzliches Produkt nicht aus Auftrag)
if ($action == 'add_mehraufwand' && $permissiontoadd) {
$fk_product = GETPOST('mehraufwand_product', 'int');
@ -1346,7 +1476,31 @@ elseif ($object->id > 0) {
print $form->formconfirm($_SERVER['PHP_SELF'].'?id='.$object->id.'&tab=products&line_id='.$line_id, $langs->trans('Delete'), $langs->trans('ConfirmDeleteEntfaellt'), 'confirm_delete_entfaellt', '', 0, 1);
}
print '<div class="div-table-responsive-no-min" style="margin-top: 15px;">';
// Zuerst bereits erfasste Entfällt-Produkte zählen
$entfaelltProducts = array();
foreach ($object->products as $prod) {
if ($prod->origin == 'omitted') {
$entfaelltProducts[] = $prod;
}
}
$hasEntfaelltProducts = (count($entfaelltProducts) > 0);
// Bereich nur anzeigen wenn Einträge vorhanden ODER Checkbox aktiviert ODER Admin-Standard
$defaultSections = explode(',', getDolGlobalString('STUNDENZETTEL_DEFAULT_SECTIONS', ''));
$showEntfaellt = $hasEntfaelltProducts || GETPOST('show_entfaellt', 'int') || in_array('entfaellt', $defaultSections);
// Checkbox zum Ein-/Ausblenden
print '<div style="margin-top: 15px; margin-bottom: 5px;">';
print '<label style="cursor: pointer;">';
print '<input type="checkbox" id="toggle_entfaellt" '.($showEntfaellt ? 'checked' : '').' onchange="toggleSection(\'entfaellt\', this.checked)">';
print ' <span class="opacitymedium">'.$langs->trans("Entfaellt").' '.$langs->trans("ShowSection").'</span>';
if ($hasEntfaelltProducts) {
print ' <span class="badge badge-secondary">'.count($entfaelltProducts).'</span>';
}
print '</label>';
print '</div>';
print '<div id="section_entfaellt" class="div-table-responsive-no-min" style="'.($showEntfaellt ? '' : 'display:none;').'">';
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">';
@ -1357,15 +1511,7 @@ elseif ($object->id > 0) {
print '<th class="center" style="width:40px;"></th>'; // Delete
print '</tr>';
// Zuerst bereits erfasste Entfällt-Produkte anzeigen
$entfaelltProducts = array();
foreach ($object->products as $prod) {
if ($prod->origin == 'omitted') {
$entfaelltProducts[] = $prod;
}
}
if (count($entfaelltProducts) > 0) {
if ($hasEntfaelltProducts) {
foreach ($entfaelltProducts as $prod) {
print '<tr class="oddeven">';
@ -1580,20 +1726,26 @@ elseif ($object->id > 0) {
print '</td>';
print '</tr>';
// JavaScript für dynamische Max-Menge
// JavaScript für dynamische Max-Menge und Bereichs-Toggle
print '<script>
function updateMaxQty(selectElement) {
var selectedOption = selectElement.options[selectElement.selectedIndex];
var maxQty = parseInt(selectedOption.getAttribute("data-max")) || 999;
var maxQty = parseFloat(selectedOption.getAttribute("data-max")) || 999;
var qtyInput = document.getElementById("entfaellt_qty_input");
if (qtyInput) {
qtyInput.max = maxQty;
// Wenn aktuelle Menge größer als max, auf max setzen
if (parseInt(qtyInput.value) > maxQty) {
if (parseFloat(qtyInput.value) > maxQty) {
qtyInput.value = maxQty;
}
}
}
function toggleSection(sectionName, show) {
var section = document.getElementById("section_" + sectionName);
if (section) {
section.style.display = show ? "" : "none";
}
}
</script>';
print '</table>';
@ -1610,7 +1762,31 @@ elseif ($object->id > 0) {
print $form->formconfirm($_SERVER['PHP_SELF'].'?id='.$object->id.'&tab=products&line_id='.$line_id, $langs->trans('Delete'), $langs->trans('ConfirmDeleteMehraufwand'), 'confirm_delete_mehraufwand', '', 0, 1);
}
print '<div class="div-table-responsive-no-min" style="margin-top: 15px;">';
// Zuerst bereits erfasste Mehraufwand-Produkte zählen
$mehraufwandProducts = array();
foreach ($object->products as $prod) {
if ($prod->origin == 'additional') {
$mehraufwandProducts[] = $prod;
}
}
$hasMehraufwandProducts = (count($mehraufwandProducts) > 0);
// Bereich nur anzeigen wenn Einträge vorhanden ODER Checkbox aktiviert ODER Admin-Standard
$defaultSectionsMehr = explode(',', getDolGlobalString('STUNDENZETTEL_DEFAULT_SECTIONS', ''));
$showMehraufwand = $hasMehraufwandProducts || GETPOST('show_mehraufwand', 'int') || in_array('mehraufwand', $defaultSectionsMehr);
// Checkbox zum Ein-/Ausblenden
print '<div style="margin-top: 15px; margin-bottom: 5px;">';
print '<label style="cursor: pointer;">';
print '<input type="checkbox" id="toggle_mehraufwand" '.($showMehraufwand ? 'checked' : '').' onchange="toggleSection(\'mehraufwand\', this.checked)">';
print ' <span class="opacitymedium">'.$langs->trans("Mehraufwand").' '.$langs->trans("ShowSection").'</span>';
if ($hasMehraufwandProducts) {
print ' <span class="badge badge-warning">'.count($mehraufwandProducts).'</span>';
}
print '</label>';
print '</div>';
print '<div id="section_mehraufwand" class="div-table-responsive-no-min" style="'.($showMehraufwand ? '' : 'display:none;').'">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre"><th colspan="5" style="border-bottom: 2px solid #dee2e6;"><strong>'.$langs->trans("Mehraufwand").'</strong> - <span class="opacitymedium" style="font-weight:normal;">'.$langs->trans("MehraufwandDesc").'</span></th></tr>';
print '<tr class="liste_titre">';
@ -1621,15 +1797,7 @@ elseif ($object->id > 0) {
print '<th class="center" style="width:40px;"></th>'; // Delete
print '</tr>';
// Zuerst bereits erfasste Mehraufwand-Produkte anzeigen
$mehraufwandProducts = array();
foreach ($object->products as $prod) {
if ($prod->origin == 'additional') {
$mehraufwandProducts[] = $prod;
}
}
if (count($mehraufwandProducts) > 0) {
if ($hasMehraufwandProducts) {
foreach ($mehraufwandProducts as $prod) {
$qty = (float)$prod->qty_done;
@ -1759,6 +1927,240 @@ elseif ($object->id > 0) {
print '</div>';
}
// =============================================
// BEREICH: RÜCKNAHME (bereits verbaute Produkte zurücknehmen)
// =============================================
if ($object->status == Stundenzettel::STATUS_DRAFT && $permissiontoadd) {
// Bestätigung für Löschen
if ($action == 'delete_ruecknahme') {
$line_id = GETPOST('line_id', 'int');
print $form->formconfirm($_SERVER['PHP_SELF'].'?id='.$object->id.'&tab=products&line_id='.$line_id, $langs->trans('Delete'), $langs->trans('ConfirmDeleteRuecknahme'), 'confirm_delete_ruecknahme', '', 0, 1);
}
// Zähle bereits erfasste Rücknahme-Produkte
$ruecknahmeProducts = array();
foreach ($object->products as $prod) {
if ($prod->origin == 'returned') {
$ruecknahmeProducts[] = $prod;
}
}
$hasRuecknahmeProducts = (count($ruecknahmeProducts) > 0);
// Bereich nur anzeigen wenn Einträge vorhanden ODER Checkbox aktiviert ODER Admin-Standard
$defaultSectionsRueck = explode(',', getDolGlobalString('STUNDENZETTEL_DEFAULT_SECTIONS', ''));
$showRuecknahme = $hasRuecknahmeProducts || GETPOST('show_ruecknahme', 'int') || in_array('ruecknahme', $defaultSectionsRueck);
// Checkbox zum Ein-/Ausblenden
print '<div style="margin-top: 15px; margin-bottom: 5px;">';
print '<label style="cursor: pointer;">';
print '<input type="checkbox" id="toggle_ruecknahme" '.($showRuecknahme ? 'checked' : '').' onchange="toggleSection(\'ruecknahme\', this.checked)">';
print ' <span class="opacitymedium">'.$langs->trans("Ruecknahme").' '.$langs->trans("ShowSection").'</span>';
if ($hasRuecknahmeProducts) {
print ' <span class="badge badge-info">'.count($ruecknahmeProducts).'</span>';
}
print '</label>';
print '</div>';
print '<div id="section_ruecknahme" class="div-table-responsive-no-min" style="'.($showRuecknahme ? '' : 'display:none;').'">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre"><th colspan="5" style="border-bottom: 2px solid #dee2e6;"><strong>'.$langs->trans("Ruecknahme").'</strong> - <span class="opacitymedium" style="font-weight:normal;">'.$langs->trans("RuecknahmeDesc").'</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 class="mobile-hide" 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>';
// Bereits erfasste Rücknahme-Produkte anzeigen
if ($hasRuecknahmeProducts) {
foreach ($ruecknahmeProducts as $prod) {
print '<tr class="oddeven">';
// Produkt
print '<td>';
if ($prod->fk_product > 0) {
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$prod->fk_product.'">';
print img_picto('', 'product', 'class="pictofixedwidth"');
print $prod->product_ref.' - '.$prod->product_label;
print '</a>';
} else {
print img_picto('', 'generic', 'class="pictofixedwidth"');
$displayText = !empty($prod->description) ? strip_tags($prod->description) : $langs->trans("FreeText");
if (strlen($displayText) > 80) {
$displayText = substr($displayText, 0, 77).'...';
}
print '<span class="opacitymedium">'.$displayText.'</span>';
}
print ' <span class="badge badge-danger">'.$langs->trans("Ruecknahme").'</span>';
// Mobile: Grund unter Produktname anzeigen
if (!empty($prod->description)) {
print '<div class="mobile-inline-desc"><small class="opacitymedium">'.dol_trunc(strip_tags($prod->description), 50).'</small></div>';
}
print '</td>';
// Menge
print '<td class="center" style="width:80px;">';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'?id='.$object->id.'&tab=products" style="display:inline;" id="form_rn_qty_'.$prod->rowid.'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="update_qty">';
print '<input type="hidden" name="line_id" value="'.$prod->rowid.'">';
print '<input type="number" name="qty_done" value="'.formatQty($prod->qty_done).'" class="flat" style="width:70px; text-align:center;" min="0" step="any">';
print '</form>';
print '</td>';
// Grund/Beschreibung (auf Mobile ausgeblendet)
print '<td class="mobile-hide" style="width:200px;">';
if (!empty($prod->description)) {
print dol_htmlentitiesbr($prod->description);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Save
print '<td class="center" style="width:40px;">';
print '<a href="javascript:document.getElementById(\'form_rn_qty_'.$prod->rowid.'\').submit();" title="'.$langs->trans("Save").'">';
print '<span class="fas fa-save" style="color: #007bff;"></span>';
print '</a>';
print '</td>';
// Delete
print '<td class="center" style="width:40px;">';
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$object->id.'&tab=products&action=delete_ruecknahme&line_id='.$prod->rowid.'&token='.newToken().'" title="'.$langs->trans("Delete").'">';
print '<span class="fas fa-trash" style="color: #dc3545;"></span>';
print '</a>';
print '</td>';
print '</tr>';
}
}
// Formular zum Hinzufügen - nur wenn Produkte verbaut wurden
// Produkte laden die bereits verbaut wurden (aus allen Stundenzetteln dieses Auftrags)
$deliveredProducts = array();
if ($object->fk_commande > 0) {
$sqlDelivered = "SELECT cd.rowid, cd.fk_product, cd.description,";
$sqlDelivered .= " p.ref as product_ref, p.label as product_label,";
// Bereits verbaut (auf allen Stundenzetteln dieses Auftrags)
$sqlDelivered .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlDelivered .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlDelivered .= " WHERE (sp.fk_commandedet = cd.rowid OR (sp.fk_product = cd.fk_product AND cd.fk_product > 0))";
$sqlDelivered .= " AND sp.origin IN ('order', 'added') AND s.fk_commande = ".((int)$object->fk_commande)."), 0) as qty_delivered,";
// Bereits zurückgenommen
$sqlDelivered .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sqlDelivered .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sqlDelivered .= " WHERE (sp2.fk_commandedet = cd.rowid OR (sp2.fk_product = cd.fk_product AND cd.fk_product > 0))";
$sqlDelivered .= " AND sp2.origin = 'returned' AND s2.fk_commande = ".((int)$object->fk_commande)."), 0) as qty_returned";
$sqlDelivered .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sqlDelivered .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sqlDelivered .= " WHERE cd.fk_commande = ".((int)$object->fk_commande);
$sqlDelivered .= " AND (cd.fk_product > 0 OR (cd.fk_product = 0 AND cd.description IS NOT NULL AND cd.description != ''))";
$sqlDelivered .= " AND (cd.special_code IS NULL OR cd.special_code = 0)";
$sqlDelivered .= " ORDER BY cd.rang";
$resqlDelivered = $db->query($sqlDelivered);
if ($resqlDelivered) {
while ($objProd = $db->fetch_object($resqlDelivered)) {
// Verfügbare Menge = verbaut - bereits zurückgenommen
$objProd->qty_available = $objProd->qty_delivered - $objProd->qty_returned;
if ($objProd->qty_available > 0) {
$deliveredProducts[] = $objProd;
}
}
}
}
$hasDeliveredProducts = (count($deliveredProducts) > 0);
print '<tr class="liste_titre">';
print '<th colspan="5">'.$langs->trans("AddRuecknahme").'</th>';
print '</tr>';
print '<tr class="oddeven">';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'?id='.$object->id.'&tab=products" id="form_ruecknahme">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="add_ruecknahme">';
// Produkt-Auswahl - NUR Produkte die bereits verbaut wurden
print '<td>';
if ($hasDeliveredProducts) {
print '<select name="ruecknahme_product" class="flat minwidth300" id="ruecknahme_product_select" onchange="updateMaxQtyRuecknahme(this)">';
print '<option value="" data-max="1">-- '.$langs->trans("SelectProducts").' --</option>';
foreach ($deliveredProducts as $dp) {
if ($dp->fk_product > 0) {
print '<option value="'.$dp->fk_product.'" data-commandedet="'.$dp->rowid.'" data-max="'.formatQty($dp->qty_available).'">';
print $dp->product_ref.' - '.$dp->product_label;
print ' ('.$langs->trans("QtyDelivered").': '.formatQty($dp->qty_available).')';
print '</option>';
} else {
$desc = strip_tags($dp->description);
$descShort = (strlen($desc) > 50) ? substr($desc, 0, 47).'...' : $desc;
print '<option value="freetext_'.$dp->rowid.'" data-commandedet="'.$dp->rowid.'" data-description="'.dol_escape_htmltag($desc).'" data-max="'.formatQty($dp->qty_available).'">';
print $descShort.' ('.$langs->trans("QtyDelivered").': '.formatQty($dp->qty_available).')';
print '</option>';
}
}
print '</select>';
} else {
print '<small class="opacitymedium">'.$langs->trans("NoProductsDelivered").'</small>';
}
print '</td>';
// Menge (mit dynamischem max basierend auf Produktauswahl)
print '<td class="center" style="width:80px;">';
print '<input type="number" name="ruecknahme_qty" id="ruecknahme_qty_input" class="flat" style="width:70px; text-align:center;" value="1" min="0" step="any">';
print '</td>';
// Grund (Desktop: in Zeile, Mobile: ausgeblendet)
print '<td class="mobile-hide" style="width:200px;">';
print '<input type="text" name="ruecknahme_description" class="flat" style="width:100%;" placeholder="'.$langs->trans("Reason").'">';
print '</td>';
// Hinzufügen-Button (colspan für beide Button-Spalten)
print '<td class="center" colspan="2" style="width:80px;">';
print '<button type="submit" class="button" title="'.$langs->trans("Add").'" style="border: none; background: none; cursor: pointer;">';
print '<span class="fas fa-plus-circle" style="color: #28a745; font-size: 1.3em;"></span>';
print '</button>';
print '</td>';
print '</form>';
print '</tr>';
// Mobile: Grund in separater Zeile
print '<tr class="oddeven mobile-description-row">';
print '<td colspan="5">';
print '<input type="text" name="ruecknahme_description" form="form_ruecknahme" class="flat" style="width:100%;" placeholder="'.$langs->trans("Reason").'">';
print '</td>';
print '</tr>';
// JavaScript für dynamische Max-Menge
print '<script>
function updateMaxQtyRuecknahme(selectElement) {
var selectedOption = selectElement.options[selectElement.selectedIndex];
var maxQty = parseFloat(selectedOption.getAttribute("data-max")) || 999;
var qtyInput = document.getElementById("ruecknahme_qty_input");
if (qtyInput) {
qtyInput.max = maxQty;
if (parseFloat(qtyInput.value) > maxQty) {
qtyInput.value = maxQty;
}
}
}
function toggleSection(sectionName, show) {
var section = document.getElementById("section_" + sectionName);
if (section) {
section.style.display = show ? "" : "none";
}
}
</script>';
print '</table>';
print '</div>';
}
// =============================================
// BEREICH: MERKZETTEL (abhakbare Notizen)
// =============================================
@ -1768,7 +2170,25 @@ elseif ($object->id > 0) {
print $form->formconfirm($_SERVER['PHP_SELF'].'?id='.$object->id.'&note_id='.$note_id, $langs->trans('Delete'), $langs->trans('ConfirmDeleteNote'), 'confirm_delete_note', '', 0, 1);
}
print '<div class="div-table-responsive-no-min" style="margin-top: 15px;">';
// Prüfen ob Notizen vorhanden
$hasNotes = (count($object->notes) > 0);
// Bereich nur anzeigen wenn Einträge vorhanden ODER Checkbox aktiviert ODER Admin-Standard
$defaultSectionsMerk = explode(',', getDolGlobalString('STUNDENZETTEL_DEFAULT_SECTIONS', ''));
$showMerkzettel = $hasNotes || GETPOST('show_merkzettel', 'int') || in_array('merkzettel', $defaultSectionsMerk);
// Checkbox zum Ein-/Ausblenden
print '<div style="margin-top: 15px; margin-bottom: 5px;">';
print '<label style="cursor: pointer;">';
print '<input type="checkbox" id="toggle_merkzettel" '.($showMerkzettel ? 'checked' : '').' onchange="toggleSection(\'merkzettel\', this.checked)">';
print ' <span class="opacitymedium">'.$langs->trans("NotesMemo").' '.$langs->trans("ShowSection").'</span>';
if ($hasNotes) {
print ' <span class="badge badge-info">'.count($object->notes).'</span>';
}
print '</label>';
print '</div>';
print '<div id="section_merkzettel" class="div-table-responsive-no-min" style="'.($showMerkzettel ? '' : 'display:none;').'">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre"><th colspan="4" style="border-bottom: 2px solid #dee2e6;"><strong>'.$langs->trans("NotesMemo").'</strong> - <span class="opacitymedium" style="font-weight:normal;">'.$langs->trans("NotesForNextVisit").'</span></th></tr>';
@ -1938,7 +2358,7 @@ elseif ($object->id > 0) {
if ($permissiontovalidate) {
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$object->id.'&action=validate">'.$langs->trans("Validate").'</a>';
}
if ($permissiontodelete) {
if ($permissiontodeleteobj) {
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?id='.$object->id.'&action=delete">'.$langs->trans("Delete").'</a>';
}
}

38
core/modules/modStundenzettel.class.php Executable file → Normal file
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.";
// Version
$this->version = '1.4.0';
$this->version = '1.5.0';
// Autor
$this->editor_name = 'Data IT Solution';
@ -133,22 +133,38 @@ class modStundenzettel extends DolibarrModules
$r = 0;
// Lesen
// Lesen (eigene)
$this->rights[$r][0] = $this->numero + $r;
$this->rights[$r][1] = 'Stundenzettel lesen';
$this->rights[$r][3] = 0;
$this->rights[$r][1] = 'Eigene Stundenzettel lesen';
$this->rights[$r][3] = 1; // Standard aktiviert
$this->rights[$r][4] = 'read';
$this->rights[$r][5] = '';
$r++;
// Lesen (alle)
$this->rights[$r][0] = $this->numero + $r;
$this->rights[$r][1] = 'Alle Stundenzettel lesen';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'read';
$this->rights[$r][5] = 'all';
$r++;
// Erstellen
$this->rights[$r][0] = $this->numero + $r;
$this->rights[$r][1] = 'Stundenzettel erstellen';
$this->rights[$r][3] = 0;
$this->rights[$r][3] = 1; // Standard aktiviert
$this->rights[$r][4] = 'write';
$this->rights[$r][5] = '';
$r++;
// Bearbeiten (alle)
$this->rights[$r][0] = $this->numero + $r;
$this->rights[$r][1] = 'Alle Stundenzettel bearbeiten';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'write';
$this->rights[$r][5] = 'all';
$r++;
// Freigeben
$this->rights[$r][0] = $this->numero + $r;
$this->rights[$r][1] = 'Stundenzettel freigeben';
@ -157,14 +173,22 @@ class modStundenzettel extends DolibarrModules
$this->rights[$r][5] = '';
$r++;
// Löschen
// Löschen (eigene)
$this->rights[$r][0] = $this->numero + $r;
$this->rights[$r][1] = 'Stundenzettel löschen';
$this->rights[$r][1] = 'Eigene Stundenzettel löschen';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'delete';
$this->rights[$r][5] = '';
$r++;
// Löschen (alle)
$this->rights[$r][0] = $this->numero + $r;
$this->rights[$r][1] = 'Alle Stundenzettel löschen';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'delete';
$this->rights[$r][5] = 'all';
$r++;
// Hauptmenü
$this->menu = array();
$r = 0;

26
langs/de_DE/stundenzettel.lang Executable file → Normal file
View file

@ -155,6 +155,23 @@ Reason = Grund
Optional = optional
ConfirmDeleteEntfaellt = Diesen Eintrag wirklich löschen?
# Rücknahme
Ruecknahme = Rücknahme
RuecknahmeDesc = Hier können Sie bereits verbaute Produkte erfassen, die wieder zurückgenommen wurden.
AddRuecknahme = Rücknahme hinzufügen
ConfirmDeleteRuecknahme = Diese Rücknahme wirklich löschen?
QtyReturned = Zurückgenommen
ReturnedProducts = Zurückgenommene Produkte
NoProductsDelivered = Noch keine Produkte verbaut
# Bereichs-Sichtbarkeit
ShowSection = einblenden
HideSection = ausblenden
# Admin-Einstellungen Bereiche
DefaultSections = Standard-Bereiche anzeigen
DefaultSectionsDesc = Welche Bereiche sollen standardmäßig eingeblendet sein (auch wenn leer)?
# Fehler
ErrorNoOrder = Kein Auftrag ausgewählt
ErrorOrderNotFound = Auftrag nicht gefunden
@ -169,10 +186,13 @@ BoxRecentStundenzettel = Zuletzt bearbeitete Stundenzettel
BoxOpenStundenzettel = Offene Stundenzettel
# Berechtigungen
PermissionRead = Stundenzettel lesen
PermissionWrite = Stundenzettel erstellen/bearbeiten
PermissionRead = Eigene Stundenzettel lesen
PermissionReadAll = Alle Stundenzettel lesen
PermissionWrite = Stundenzettel erstellen
PermissionWriteAll = Alle Stundenzettel bearbeiten
PermissionValidate = Stundenzettel freigeben
PermissionDelete = Stundenzettel löschen
PermissionDelete = Eigene Stundenzettel löschen
PermissionDeleteAll = Alle Stundenzettel löschen
# Einstellungen
StundenzettelSetup = Stundenzettel Einstellungen

10
list.php Executable file → Normal file
View file

@ -22,6 +22,11 @@ if (!$user->hasRight('stundenzettel', 'read')) {
accessforbidden();
}
// Berechtigungen prüfen
$permissiontoreadall = $user->hasRight('stundenzettel', 'read', 'all') || $user->admin;
$permissiontowriteall = $user->hasRight('stundenzettel', 'write', 'all') || $user->admin;
$permissiontodeleteall = $user->hasRight('stundenzettel', 'delete', 'all') || $user->admin;
// Get parameters
$action = GETPOST('action', 'aZ09');
$massaction = GETPOST('massaction', 'alpha');
@ -72,6 +77,11 @@ if ($search_author > 0) {
$sql .= " AND s.fk_user_author = ".((int)$search_author);
}
// Einschränkung auf eigene Stundenzettel, wenn keine Berechtigung für alle
if (!$permissiontoreadall) {
$sql .= " AND s.fk_user_author = ".((int)$user->id);
}
$sql .= $db->order($sortfield, $sortorder);
// Count total

38
stundenzettel_commande.php Executable file → Normal file
View file

@ -1606,7 +1606,7 @@ if ($tab == 'products') {
}
// Dann alle Produkte laden mit Section-Zuordnung
// Berechne qty_delivered, qty_added (Mehraufwand) und qty_removed (Entfällt) direkt aus Stundenzetteln
// Berechne qty_delivered, qty_added (Mehraufwand), qty_removed (Entfällt) und qty_returned (Rücknahme) 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,";
@ -1621,7 +1621,11 @@ if ($tab == 'products') {
// 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 .= " WHERE sp3.fk_product = cd.fk_product AND sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$order->id)."), 0) as qty_removed,";
// qty_returned: Rücknahme für dieses Produkt (origin = 'returned')
$sql .= " COALESCE((SELECT SUM(sp4.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp4";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s4 ON s4.rowid = sp4.fk_stundenzettel";
$sql .= " WHERE sp4.fk_product = cd.fk_product AND sp4.origin = 'returned' AND s4.fk_commande = ".((int)$order->id)."), 0) as qty_returned";
$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";
@ -1644,7 +1648,7 @@ if ($tab == 'products') {
// 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
// Berechne qty_delivered, qty_added (Mehraufwand), qty_removed (Entfällt) und qty_returned (Rücknahme) 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')
@ -1658,7 +1662,11 @@ if ($tab == 'products') {
// 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 .= " WHERE sp3.fk_product = cd.fk_product AND sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$order->id)."), 0) as qty_removed,";
// qty_returned: Rücknahme für dieses Produkt (origin = 'returned')
$sql .= " COALESCE((SELECT SUM(sp4.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp4";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s4 ON s4.rowid = sp4.fk_stundenzettel";
$sql .= " WHERE sp4.fk_product = cd.fk_product AND sp4.origin = 'returned' AND s4.fk_commande = ".((int)$order->id)."), 0) as qty_returned";
$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);
@ -1701,10 +1709,13 @@ if ($tab == 'products') {
$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;
$qty_returned = isset($obj->qty_returned) ? (float)$obj->qty_returned : 0;
// Effektive Gesamtmenge = Original + Hinzugefügt - Entfallen
$effectiveTotal = $obj->qty + $qty_added - $qty_removed;
$remaining = $effectiveTotal - $obj->qty_delivered;
// Effektive Liefermenge = Geliefert - Zurückgenommen
$effectiveDelivered = $obj->qty_delivered - $qty_returned;
$remaining = $effectiveTotal - $effectiveDelivered;
$isDone = ($remaining <= 0);
// Filter anwenden
@ -1764,8 +1775,13 @@ if ($tab == 'products') {
}
print '</td>';
// Menge geliefert/verbaut
print '<td class="right">'.formatQty($obj->qty_delivered).'</td>';
// Menge geliefert/verbaut (abzüglich Rücknahmen)
print '<td class="right">';
print formatQty($effectiveDelivered);
if ($qty_returned > 0) {
print ' <span class="badge" style="background-color: #6c757d; color: #fff; font-size: 0.75em;" title="'.$langs->trans("Ruecknahme").'">-'.formatQty($qty_returned).'</span>';
}
print '</td>';
// Verbleibend
print '<td class="right">';
@ -1807,8 +1823,10 @@ if ($tab == 'products') {
foreach ($sectionProducts as $prod) {
$prodQtyAdded = isset($prod->qty_added) ? (float)$prod->qty_added : 0;
$prodQtyRemoved = isset($prod->qty_removed) ? (float)$prod->qty_removed : 0;
$prodQtyReturned = isset($prod->qty_returned) ? (float)$prod->qty_returned : 0;
$prodEffectiveTotal = $prod->qty + $prodQtyAdded - $prodQtyRemoved;
$remaining = $prodEffectiveTotal - $prod->qty_delivered;
$prodEffectiveDelivered = $prod->qty_delivered - $prodQtyReturned;
$remaining = $prodEffectiveTotal - $prodEffectiveDelivered;
$isDone = ($remaining <= 0);
// Filter-Logik
@ -1849,8 +1867,10 @@ if ($tab == 'products') {
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;
$prodQtyReturned = isset($prod->qty_returned) ? (float)$prod->qty_returned : 0;
$prodEffectiveTotal = $prod->qty + $prodQtyAdded - $prodQtyRemoved;
$remaining = $prodEffectiveTotal - $prod->qty_delivered;
$prodEffectiveDelivered = $prod->qty_delivered - $prodQtyReturned;
$remaining = $prodEffectiveTotal - $prodEffectiveDelivered;
$isDone = ($remaining <= 0);
// Filter-Logik