This commit is contained in:
Eduard Wisch 2026-02-07 21:14:51 +01:00
commit 9627e4fea4
25 changed files with 6996 additions and 0 deletions

127
README.md Normal file
View file

@ -0,0 +1,127 @@
# Stundenzettel Modul für Dolibarr
**Version:** 1.1.0
**Autor:** Data IT Solution
**Kompatibilität:** Dolibarr 16.0+
**Lizenz:** GPL v3
## Beschreibung
Das Stundenzettel-Modul ermöglicht die Verwaltung von Stundenzetteln für Kundenaufträge in Dolibarr. Es bietet eine umfassende Dokumentation von Arbeitszeiten, verbrauchten Materialien, Mehraufwand und Notizen.
## Funktionen
### Kernfunktionen
- **Stundenzettel-Verwaltung**: Erstellen, Bearbeiten und Löschen von Stundenzetteln pro Auftrag und Datum
- **Leistungserfassung**: Zeiterfassung mit Start-/Endzeit und automatischer Dauerberechnung
- **Zeitüberlappungsprüfung**: Verhindert doppelte Zeitbuchungen auf demselben Stundenzettel
- **Produktverfolgung**: Dokumentation von verbrauchten Materialien aus dem Auftrag
- **Mehraufwand**: Erfassung zusätzlicher Produkte/Dienstleistungen, die nicht im Auftrag waren
- **Entfällt-Markierung**: Kennzeichnung von Produkten, die nicht verbaut werden müssen
- **Notizen**: Merkzettel und Notizen für den nächsten Termin
### Freigabe & Rechnungsstellung
- **Stundenzettel-Freigabe**: Sperren von Stundenzetteln nach Fertigstellung
- **Rechnungsübernahme**: Automatische Übernahme aller Produkte und Leistungen in eine Rechnung
- **Stunden-Modus**: Wahlweise Übernahme als Gesamtstunden oder pro Tag
### Integration
- **SubtotalTitle-Integration**: Unterstützung für Produktgruppen aus dem SubtotalTitle-Modul
- **Auftragsintegration**: Direkter Zugriff auf Stundenzettel über Aufträge
- **Kundenintegration**: Standard-Leistung pro Kunde konfigurierbar
## Installation
1. Modul-Ordner nach `/custom/stundenzettel/` kopieren
2. In Dolibarr einloggen
3. Unter **Einstellungen > Module/Anwendungen** das Modul "Stundenzettel" aktivieren
4. Das Modul erstellt automatisch:
- Erforderliche Datenbanktabellen
- Extrafeld "Auftragsbeschreibung" für Aufträge
- Extrafeld "Standard-Leistung" für Kunden
- Datenbank-View für Dienstleistungen
## Konfiguration
Die Moduleinstellungen finden Sie unter **Einstellungen > Module > Stundenzettel > Zahnrad-Symbol**:
| Einstellung | Beschreibung |
|-------------|--------------|
| **Zeiteingabe-Modus** | Dropdown (15-Minuten-Takt) oder Freitext (exakte Uhrzeit) |
| **Standard-Filter** | Welcher Filter in der Produktliste standardmäßig angezeigt wird |
| **Standard-Datum** | Aktuelles Datum oder Datum des letzten offenen Stundenzettels |
| **Stunden-Übernahme** | Gesamtstunden auf einer Zeile oder pro Tag eine Zeile |
### Standard-Leistung beim Kunden
Sie können beim Kunden (unter **Kunden > Kundenkarte**) eine Standard-Leistung (Dienstleistung) hinterlegen. Diese wird dann bei allen Stundenzetteln für diesen Kunden angezeigt und kann für die Rechnungsstellung verwendet werden.
## Datenbanktabellen
| Tabelle | Beschreibung |
|---------|--------------|
| `llx_stundenzettel` | Haupttabelle für Stundenzettel |
| `llx_stundenzettel_leistung` | Leistungen/Arbeitszeiten |
| `llx_stundenzettel_product` | Verbrauchte Produkte |
| `llx_stundenzettel_tracking` | Gesamtübersicht Mengen pro Auftrag |
| `llx_stundenzettel_note` | Notizen und Merkzettel |
| `llx_product_services` | View für Dienstleistungen (Extrafeld-Filter) |
## Extrafelder
| Feld | Element | Beschreibung |
|------|---------|--------------|
| `auftragsbeschreibung` | Auftrag | Zusätzliche Beschreibung für den Auftrag |
| `stundenzettel_status` | Auftrag | Status der Stundenzettel (0=Offen, 1=Freigegeben, 2=Abgerechnet) |
| `stundenzettel_default_service` | Kunde | Standard-Dienstleistung für Stundenzettel |
## Berechtigungen
| Berechtigung | Beschreibung |
|--------------|--------------|
| Lesen | Stundenzettel anzeigen |
| Erstellen/Bearbeiten | Stundenzettel erstellen und bearbeiten |
| Freigeben | Stundenzettel freigeben/sperren |
| Löschen | Stundenzettel löschen |
## Workflow
1. **Stundenzettel erstellen**: Über Auftrag > Stundenzettel-Tab
2. **Leistungen erfassen**: Arbeitszeiten mit Start-/Endzeit dokumentieren
3. **Produkte dokumentieren**: Verbrauchte Materialien aus dem Auftrag erfassen
4. **Mehraufwand hinzufügen**: Zusätzliche Produkte bei Bedarf
5. **Notizen erstellen**: Merkzettel für nächsten Termin
6. **Stundenzettel freigeben**: Nach Fertigstellung sperren
7. **In Rechnung übernehmen**: Automatische Rechnungserstellung
## Changelog
### Version 1.1.0
- Standard-Leistung beim Kunden hinterlegen (nur Dienstleistungen auswählbar)
- Stunden-Übernahme Modus (Gesamt oder pro Tag)
- Zeitüberlappungsprüfung für Leistungen
- Verbessertes Setup-Seitenlayout
- Datenbank-View für Dienstleistungs-Filter
### Version 1.0.0
- Initiale Version
- Grundlegende Stundenzettel-Verwaltung
- Leistungs- und Produkterfassung
- Mehraufwand und Entfällt-Funktionen
- Notizen-System
- Freigabe und Rechnungsübernahme
- SubtotalTitle-Integration
## Support
Bei Fragen oder Problemen wenden Sie sich an:
**Data IT Solution**
E-Mail: data@data-it-solution.de
Web: https://data-it-solution.de
## Lizenz
Dieses Modul steht unter der GNU General Public License v3. Siehe LICENSE-Datei für Details.

184
admin/setup.php Normal file
View file

@ -0,0 +1,184 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* Stundenzettel - Modulkonfiguration
*/
// Load Dolibarr environment
$res = 0;
// Von /custom/stundenzettel/admin/ zu /main.inc.php = 3 Ebenen hoch
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res && file_exists("../../../../main.inc.php")) $res = @include "../../../../main.inc.php";
if (!$res) die("Include of main fails");
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
// Load translation files
$langs->loadLangs(array("admin", "stundenzettel@stundenzettel"));
// Access control
if (!$user->admin) {
accessforbidden();
}
$action = GETPOST('action', 'aZ09');
/*
* Actions
*/
if ($action == 'setTIME_INPUT_MODE') {
$value = GETPOST('value', 'alpha');
if (dolibarr_set_const($db, 'STUNDENZETTEL_TIME_INPUT_MODE', $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;
}
if ($action == 'setDEFAULT_FILTER') {
$value = GETPOST('filter_value', 'alpha');
if (dolibarr_set_const($db, 'STUNDENZETTEL_DEFAULT_FILTER', $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;
}
if ($action == 'setDEFAULT_DATE') {
$value = GETPOST('date_value', 'alpha');
if (dolibarr_set_const($db, 'STUNDENZETTEL_DEFAULT_DATE', $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;
}
if ($action == 'setINVOICE_HOURS_MODE') {
$value = GETPOST('hours_mode_value', 'alpha');
if (dolibarr_set_const($db, 'STUNDENZETTEL_INVOICE_HOURS_MODE', $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
*/
$page_name = "StundenzettelSetup";
llxHeader('', $langs->trans($page_name), '');
// Subheader
$linkback = '<a href="'.DOL_URL_ROOT.'/admin/modules.php?restore_lastsearch_values=1">'.$langs->trans("BackToModuleList").'</a>';
print load_fiche_titre($langs->trans($page_name), $linkback, 'title_setup');
// Configuration header
$head = array();
$h = 0;
$head[$h][0] = $_SERVER["PHP_SELF"];
$head[$h][1] = $langs->trans("Settings");
$head[$h][2] = 'settings';
$h++;
print dol_get_fiche_head($head, 'settings', $langs->trans("Stundenzettel"), -1, 'clock');
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th class="titlefield">'.$langs->trans("Parameter").'</th>';
print '<th style="width: 300px;">'.$langs->trans("Value").'</th>';
print '<th class="right" style="width: 100px;"></th>';
print '</tr>';
// Zeiteingabe-Modus
$currentMode = getDolGlobalString('STUNDENZETTEL_TIME_INPUT_MODE', 'dropdown');
print '<tr class="oddeven">';
print '<td>'.$langs->trans("TimeInputMode").'</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="setTIME_INPUT_MODE">';
print '<select name="value" class="flat minwidth200">';
print '<option value="dropdown"'.($currentMode == 'dropdown' ? ' selected' : '').'>'.$langs->trans("TimeInputDropdown").'</option>';
print '<option value="text"'.($currentMode == 'text' ? ' selected' : '').'>'.$langs->trans("TimeInputText").'</option>';
print '</select>';
print '</td>';
print '<td class="right">';
print '<input type="submit" class="button small" value="'.$langs->trans("Modify").'">';
print '</form>';
print '</td>';
print '</tr>';
// Standard-Filter
$currentFilter = getDolGlobalString('STUNDENZETTEL_DEFAULT_FILTER', 'open');
print '<tr class="oddeven">';
print '<td>'.$langs->trans("DefaultFilter").'<br><small class="opacitymedium">'.$langs->trans("DefaultFilterDesc").'</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_FILTER">';
print '<select name="filter_value" class="flat minwidth200">';
print '<option value="open"'.($currentFilter == 'open' ? ' selected' : '').'>'.$langs->trans("FilterOpen").'</option>';
print '<option value="done"'.($currentFilter == 'done' ? ' selected' : '').'>'.$langs->trans("FilterDone").'</option>';
print '<option value="all"'.($currentFilter == 'all' ? ' selected' : '').'>'.$langs->trans("FilterAll").'</option>';
print '</select>';
print '</td>';
print '<td class="right">';
print '<input type="submit" class="button small" value="'.$langs->trans("Modify").'">';
print '</form>';
print '</td>';
print '</tr>';
// Standard-Datum für Stundenzettel
$currentDate = getDolGlobalString('STUNDENZETTEL_DEFAULT_DATE', 'today');
print '<tr class="oddeven">';
print '<td>'.$langs->trans("DefaultDate").'<br><small class="opacitymedium">'.$langs->trans("DefaultDateDesc").'</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_DATE">';
print '<select name="date_value" class="flat minwidth200">';
print '<option value="today"'.($currentDate == 'today' ? ' selected' : '').'>'.$langs->trans("DateToday").'</option>';
print '<option value="last_open"'.($currentDate == 'last_open' ? ' selected' : '').'>'.$langs->trans("DateLastOpen").'</option>';
print '</select>';
print '</td>';
print '<td class="right">';
print '<input type="submit" class="button small" value="'.$langs->trans("Modify").'">';
print '</form>';
print '</td>';
print '</tr>';
// Stunden-Übernahme Modus
$currentHoursMode = getDolGlobalString('STUNDENZETTEL_INVOICE_HOURS_MODE', 'total');
print '<tr class="oddeven">';
print '<td>'.$langs->trans("InvoiceHoursMode").'<br><small class="opacitymedium">'.$langs->trans("SelectInvoiceHoursMode").'</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="setINVOICE_HOURS_MODE">';
print '<select name="hours_mode_value" class="flat minwidth200">';
print '<option value="total"'.($currentHoursMode == 'total' ? ' selected' : '').'>'.$langs->trans("InvoiceHoursModeTotal").'</option>';
print '<option value="perday"'.($currentHoursMode == 'perday' ? ' selected' : '').'>'.$langs->trans("InvoiceHoursModePerDay").'</option>';
print '</select>';
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();
llxFooter();
$db->close();

61
ajax/add_leistung.php Normal file
View file

@ -0,0 +1,61 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* AJAX: Leistung hinzufügen
*/
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
if (!defined('NOREQUIREHTML')) define('NOREQUIREHTML', '1');
if (!defined('NOREQUIREAJAX')) define('NOREQUIREAJAX', '1');
// Load Dolibarr environment
$res = 0;
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res && file_exists("../../../../main.inc.php")) $res = @include "../../../../main.inc.php";
if (!$res) die("Include of main fails");
dol_include_once('/stundenzettel/class/stundenzettel.class.php');
header('Content-Type: application/json');
// Security check
if (!$user->hasRight('stundenzettel', 'write')) {
echo json_encode(array('success' => false, 'error' => 'Permission denied'));
exit;
}
$stundenzettel_id = GETPOST('stundenzettel_id', 'int');
$date = GETPOST('date', 'alpha');
$time_start = GETPOST('time_start', 'alpha');
$time_end = GETPOST('time_end', 'alpha');
$description = GETPOST('description', 'restricthtml');
if (empty($stundenzettel_id)) {
echo json_encode(array('success' => false, 'error' => 'Missing stundenzettel_id'));
exit;
}
$stundenzettel = new Stundenzettel($db);
if ($stundenzettel->fetch($stundenzettel_id) <= 0) {
echo json_encode(array('success' => false, 'error' => 'Stundenzettel not found'));
exit;
}
// Nur im Entwurf bearbeitbar
if ($stundenzettel->status != Stundenzettel::STATUS_DRAFT) {
echo json_encode(array('success' => false, 'error' => 'Stundenzettel is not in draft status'));
exit;
}
$result = $stundenzettel->addLeistung($user, $date, $time_start, $time_end, $description);
if ($result > 0) {
echo json_encode(array(
'success' => true,
'leistung_id' => $result
));
} else {
echo json_encode(array('success' => false, 'error' => 'Failed to add leistung'));
}

61
ajax/add_product.php Normal file
View file

@ -0,0 +1,61 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* AJAX: Produkt hinzufügen
*/
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
if (!defined('NOREQUIREHTML')) define('NOREQUIREHTML', '1');
if (!defined('NOREQUIREAJAX')) define('NOREQUIREAJAX', '1');
// Load Dolibarr environment
$res = 0;
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res && file_exists("../../../../main.inc.php")) $res = @include "../../../../main.inc.php";
if (!$res) die("Include of main fails");
dol_include_once('/stundenzettel/class/stundenzettel.class.php');
header('Content-Type: application/json');
// Security check
if (!$user->hasRight('stundenzettel', 'write')) {
echo json_encode(array('success' => false, 'error' => 'Permission denied'));
exit;
}
$stundenzettel_id = GETPOST('stundenzettel_id', 'int');
$fk_product = GETPOST('fk_product', 'int');
$qty = GETPOST('qty', 'int');
$label = GETPOST('label', 'alpha');
$description = GETPOST('description', 'restricthtml');
if (empty($stundenzettel_id)) {
echo json_encode(array('success' => false, 'error' => 'Missing stundenzettel_id'));
exit;
}
$stundenzettel = new Stundenzettel($db);
if ($stundenzettel->fetch($stundenzettel_id) <= 0) {
echo json_encode(array('success' => false, 'error' => 'Stundenzettel not found'));
exit;
}
// Nur im Entwurf bearbeitbar
if ($stundenzettel->status != Stundenzettel::STATUS_DRAFT) {
echo json_encode(array('success' => false, 'error' => 'Stundenzettel is not in draft status'));
exit;
}
$result = $stundenzettel->addProduct($fk_product, null, null, $qty, 0, 'added', $description);
if ($result > 0) {
echo json_encode(array(
'success' => true,
'product_line_id' => $result
));
} else {
echo json_encode(array('success' => false, 'error' => 'Failed to add product'));
}

59
ajax/update_qty.php Normal file
View file

@ -0,0 +1,59 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* AJAX: Produktmenge aktualisieren
*/
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
if (!defined('NOREQUIREMENU')) define('NOREQUIREMENU', '1');
if (!defined('NOREQUIREHTML')) define('NOREQUIREHTML', '1');
if (!defined('NOREQUIREAJAX')) define('NOREQUIREAJAX', '1');
// Load Dolibarr environment
$res = 0;
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res && file_exists("../../../../main.inc.php")) $res = @include "../../../../main.inc.php";
if (!$res) die("Include of main fails");
dol_include_once('/stundenzettel/class/stundenzettel.class.php');
header('Content-Type: application/json');
// Security check
if (!$user->hasRight('stundenzettel', 'write')) {
echo json_encode(array('success' => false, 'error' => 'Permission denied'));
exit;
}
$stundenzettel_id = GETPOST('stundenzettel_id', 'int');
$line_id = GETPOST('line_id', 'int');
$qty_done = GETPOST('qty_done', 'int');
if (empty($stundenzettel_id) || empty($line_id)) {
echo json_encode(array('success' => false, 'error' => 'Missing parameters'));
exit;
}
$stundenzettel = new Stundenzettel($db);
if ($stundenzettel->fetch($stundenzettel_id) <= 0) {
echo json_encode(array('success' => false, 'error' => 'Stundenzettel not found'));
exit;
}
// Nur im Entwurf bearbeitbar
if ($stundenzettel->status != Stundenzettel::STATUS_DRAFT) {
echo json_encode(array('success' => false, 'error' => 'Stundenzettel is not in draft status'));
exit;
}
$result = $stundenzettel->updateProductQty($line_id, $qty_done);
if ($result > 0) {
echo json_encode(array(
'success' => true,
'qty_done' => $qty_done
));
} else {
echo json_encode(array('success' => false, 'error' => 'Failed to update qty'));
}

1766
card.php Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,954 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* Stundenzettel - Business Object Klasse
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
/**
* Class Stundenzettel
*/
class Stundenzettel extends CommonObject
{
/**
* @var string ID to identify managed object
*/
public $element = 'stundenzettel';
/**
* @var string Name of table without prefix
*/
public $table_element = 'stundenzettel';
/**
* @var string Picto
*/
public $picto = 'clock';
// Status constants
const STATUS_DRAFT = 0;
const STATUS_VALIDATED = 1;
const STATUS_INVOICED = 2;
const STATUS_CANCELED = 9;
/**
* @var string Ref
*/
public $ref;
/**
* @var int Auftrag ID
*/
public $fk_commande;
/**
* @var int Rechnung ID (nach Übertrag)
*/
public $fk_facture;
/**
* @var int Kunde ID
*/
public $fk_soc;
/**
* @var int Ersteller
*/
public $fk_user_author;
/**
* @var int Freigebender User
*/
public $fk_user_valid;
/**
* @var int|string Datum des Stundenzettels
*/
public $date_stundenzettel;
/**
* @var int|string Erstelldatum
*/
public $datec;
/**
* @var int|string Freigabedatum
*/
public $date_valid;
/**
* @var int Status
*/
public $status;
/**
* @var string Private Notizen
*/
public $note_private;
/**
* @var string Öffentliche Notizen
*/
public $note_public;
/**
* @var array Leistungen
*/
public $leistungen = array();
/**
* @var array Produkte
*/
public $products = array();
/**
* @var array Notizen (abhakbare Merkzettel)
*/
public $notes = array();
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Create object into database
*
* @param User $user User that creates
* @param bool $notrigger false=launch triggers after, true=disable triggers
* @return int <0 if KO, Id of created object if OK
*/
public function create($user, $notrigger = false)
{
global $conf;
$error = 0;
// Generate ref
if (empty($this->ref)) {
$this->ref = $this->getNextNumRef();
}
$this->db->begin();
$sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." (";
$sql .= "ref, entity, fk_commande, fk_soc, fk_user_author,";
$sql .= "date_stundenzettel, datec, status, note_private, note_public";
$sql .= ") VALUES (";
$sql .= "'".$this->db->escape($this->ref)."',";
$sql .= ((int)$conf->entity).",";
$sql .= ((int)$this->fk_commande).",";
$sql .= ((int)$this->fk_soc).",";
$sql .= ((int)$user->id).",";
$sql .= "'".$this->db->idate($this->date_stundenzettel)."',";
$sql .= "'".$this->db->idate(dol_now())."',";
$sql .= "0,"; // STATUS_DRAFT
$sql .= ($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL").",";
$sql .= ($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL");
$sql .= ")";
dol_syslog(get_class($this)."::create", LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$error++;
$this->errors[] = "Error ".$this->db->lasterror();
}
if (!$error) {
$this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element);
}
if (!$error) {
$this->db->commit();
return $this->id;
} else {
$this->db->rollback();
return -1;
}
}
/**
* Load object in memory from the database
*
* @param int $id Id object
* @param string $ref Ref
* @return int <0 if KO, 0 if not found, >0 if OK
*/
public function fetch($id, $ref = null)
{
$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.date_valid, s.status, s.note_private, s.note_public, s.tms";
$sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as s";
if ($ref) {
$sql .= " WHERE s.ref = '".$this->db->escape($ref)."'";
} else {
$sql .= " WHERE s.rowid = ".((int)$id);
}
dol_syslog(get_class($this)."::fetch", LOG_DEBUG);
$resql = $this->db->query($sql);
if ($resql) {
if ($this->db->num_rows($resql)) {
$obj = $this->db->fetch_object($resql);
$this->id = $obj->rowid;
$this->ref = $obj->ref;
$this->entity = $obj->entity;
$this->fk_commande = $obj->fk_commande;
$this->fk_facture = $obj->fk_facture;
$this->fk_soc = $obj->fk_soc;
$this->fk_user_author = $obj->fk_user_author;
$this->fk_user_valid = $obj->fk_user_valid;
$this->date_stundenzettel = $this->db->jdate($obj->date_stundenzettel);
$this->datec = $this->db->jdate($obj->datec);
$this->date_valid = $this->db->jdate($obj->date_valid);
$this->status = $obj->status;
$this->note_private = $obj->note_private;
$this->note_public = $obj->note_public;
// Load lines
$this->fetchLeistungen();
$this->fetchProducts();
$this->fetchNotes();
return 1;
} else {
return 0;
}
} else {
$this->error = "Error ".$this->db->lasterror();
return -1;
}
}
/**
* Update object into database
*
* @param User $user User that modifies
* @param bool $notrigger false=launch triggers after, true=disable triggers
* @return int <0 if KO, >0 if OK
*/
public function update($user, $notrigger = false)
{
$error = 0;
$sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET";
$sql .= " date_stundenzettel = '".$this->db->idate($this->date_stundenzettel)."',";
$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 .= " WHERE rowid = ".((int)$this->id);
dol_syslog(get_class($this)."::update", LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$error++;
$this->errors[] = "Error ".$this->db->lasterror();
}
if (!$error) {
return 1;
} else {
return -1;
}
}
/**
* Validate object
*
* @param User $user User that validates
* @return int <0 if KO, >0 if OK
*/
public function validate($user)
{
$error = 0;
$this->db->begin();
$sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET";
$sql .= " status = ".self::STATUS_VALIDATED.",";
$sql .= " fk_user_valid = ".((int)$user->id).",";
$sql .= " date_valid = '".$this->db->idate(dol_now())."'";
$sql .= " WHERE rowid = ".((int)$this->id);
dol_syslog(get_class($this)."::validate", LOG_DEBUG);
$resql = $this->db->query($sql);
if (!$resql) {
$error++;
$this->errors[] = "Error ".$this->db->lasterror();
}
if (!$error) {
$this->status = self::STATUS_VALIDATED;
$this->fk_user_valid = $user->id;
$this->date_valid = dol_now();
// Update tracking table
$this->updateTracking();
$this->db->commit();
return 1;
} else {
$this->db->rollback();
return -1;
}
}
/**
* Set to draft
*
* @param User $user User that reopens
* @return int <0 if KO, >0 if OK
*/
public function setDraft($user)
{
$sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET";
$sql .= " status = ".self::STATUS_DRAFT.",";
$sql .= " fk_user_valid = NULL,";
$sql .= " date_valid = NULL";
$sql .= " WHERE rowid = ".((int)$this->id);
dol_syslog(get_class($this)."::setDraft", LOG_DEBUG);
$resql = $this->db->query($sql);
if ($resql) {
$this->status = self::STATUS_DRAFT;
return 1;
} else {
$this->error = "Error ".$this->db->lasterror();
return -1;
}
}
/**
* Delete object in database
*
* @param User $user User that deletes
* @param bool $notrigger false=launch triggers after, true=disable triggers
* @return int <0 if KO, >0 if OK
*/
public function delete($user, $notrigger = false)
{
$error = 0;
$this->db->begin();
// Delete notes
$sql = "DELETE FROM ".MAIN_DB_PREFIX."stundenzettel_note WHERE fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
if (!$resql) $error++;
// Delete leistungen
$sql = "DELETE FROM ".MAIN_DB_PREFIX."stundenzettel_leistung WHERE fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
if (!$resql) $error++;
// Delete products
$sql = "DELETE FROM ".MAIN_DB_PREFIX."stundenzettel_product WHERE fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
if (!$resql) $error++;
// Delete main record
if (!$error) {
$sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid = ".((int)$this->id);
$resql = $this->db->query($sql);
if (!$resql) $error++;
}
if (!$error) {
$this->db->commit();
return 1;
} else {
$this->db->rollback();
return -1;
}
}
/**
* Load leistungen
*
* @return int <0 if KO, number of lines if OK
*/
public function fetchLeistungen()
{
$this->leistungen = array();
$sql = "SELECT rowid, fk_user, date_leistung, time_start, time_end, duration, description, rang";
$sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel_leistung";
$sql .= " WHERE fk_stundenzettel = ".((int)$this->id);
$sql .= " ORDER BY rang, date_leistung, time_start";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$this->leistungen[] = $obj;
}
return count($this->leistungen);
}
return -1;
}
/**
* Load products
*
* @return int <0 if KO, number of lines if OK
*/
public function fetchProducts()
{
$this->products = array();
// Produkte laden, bei Freitext-Produkten Beschreibung aus commandedet holen falls leer
$sql = "SELECT sp.rowid, sp.fk_product, sp.fk_commandedet, sp.fk_manager_line,";
$sql .= " sp.product_ref, sp.product_label,";
$sql .= " CASE WHEN sp.description IS NULL OR sp.description = '' THEN cd.description ELSE sp.description END as description,";
$sql .= " sp.qty_original, sp.qty_done, sp.qty_cumulated, sp.origin, sp.rang";
$sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product as sp";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."commandedet as cd ON cd.rowid = sp.fk_commandedet";
$sql .= " WHERE sp.fk_stundenzettel = ".((int)$this->id);
$sql .= " ORDER BY sp.rang";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$this->products[] = $obj;
}
return count($this->products);
}
return -1;
}
/**
* Add a leistung
*
* @param User $user User
* @param string $date Date
* @param string $time_start Start time
* @param string $time_end End time
* @param string $description Description
* @return int <0 if KO, >0 if OK
*/
public function addLeistung($user, $date, $time_start = null, $time_end = null, $description = '')
{
global $langs;
// Calculate duration
$duration = 0;
if ($time_start && $time_end) {
$start = strtotime($time_start);
$end = strtotime($time_end);
$duration = ($end - $start) / 60; // in minutes
}
// Überlappungsprüfung: Prüfen ob für diese Zeit bereits eine Leistung existiert
if ($time_start && $time_end) {
$sqlCheck = "SELECT rowid, time_start, time_end FROM ".MAIN_DB_PREFIX."stundenzettel_leistung";
$sqlCheck .= " WHERE fk_stundenzettel = ".((int)$this->id);
$sqlCheck .= " AND time_start IS NOT NULL AND time_end IS NOT NULL";
$resqlCheck = $this->db->query($sqlCheck);
if ($resqlCheck) {
$newStart = strtotime($time_start);
$newEnd = strtotime($time_end);
while ($objCheck = $this->db->fetch_object($resqlCheck)) {
$existStart = strtotime($objCheck->time_start);
$existEnd = strtotime($objCheck->time_end);
// Überlappung: Start1 < End2 UND Start2 < End1
if ($newStart < $existEnd && $existStart < $newEnd) {
$this->error = $langs->trans("ErrorTimeOverlap", $objCheck->time_start, $objCheck->time_end);
return -2;
}
}
}
}
// Get next rang
$sql = "SELECT MAX(rang) as maxrang FROM ".MAIN_DB_PREFIX."stundenzettel_leistung WHERE fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
$rang = 0;
if ($resql) {
$obj = $this->db->fetch_object($resql);
$rang = $obj->maxrang + 1;
}
$sql = "INSERT INTO ".MAIN_DB_PREFIX."stundenzettel_leistung (";
$sql .= "fk_stundenzettel, fk_user, date_leistung, time_start, time_end, duration, description, rang";
$sql .= ") VALUES (";
$sql .= ((int)$this->id).",";
$sql .= ((int)$user->id).",";
$sql .= "'".$this->db->idate($date)."',";
$sql .= ($time_start ? "'".$this->db->escape($time_start)."'" : "NULL").",";
$sql .= ($time_end ? "'".$this->db->escape($time_end)."'" : "NULL").",";
$sql .= ((int)$duration).",";
$sql .= "'".$this->db->escape($description)."',";
$sql .= ((int)$rang);
$sql .= ")";
$resql = $this->db->query($sql);
if ($resql) {
return $this->db->last_insert_id(MAIN_DB_PREFIX."stundenzettel_leistung");
}
return -1;
}
/**
* Update a leistung
*
* @param int $leistung_id Leistung ID
* @param string $date Date
* @param string $time_start Start time
* @param string $time_end End time
* @param string $description Description
* @return int <0 if KO, >0 if OK
*/
public function updateLeistung($leistung_id, $date, $time_start = null, $time_end = null, $description = '')
{
global $langs;
// Calculate duration
$duration = 0;
if ($time_start && $time_end) {
$start = strtotime($time_start);
$end = strtotime($time_end);
$duration = ($end - $start) / 60; // in minutes
}
// Überlappungsprüfung: Prüfen ob für diese Zeit bereits eine andere Leistung existiert
if ($time_start && $time_end) {
$sqlCheck = "SELECT rowid, time_start, time_end FROM ".MAIN_DB_PREFIX."stundenzettel_leistung";
$sqlCheck .= " WHERE fk_stundenzettel = ".((int)$this->id);
$sqlCheck .= " AND rowid != ".((int)$leistung_id); // Aktuelle Leistung ausschließen
$sqlCheck .= " AND time_start IS NOT NULL AND time_end IS NOT NULL";
$resqlCheck = $this->db->query($sqlCheck);
if ($resqlCheck) {
$newStart = strtotime($time_start);
$newEnd = strtotime($time_end);
while ($objCheck = $this->db->fetch_object($resqlCheck)) {
$existStart = strtotime($objCheck->time_start);
$existEnd = strtotime($objCheck->time_end);
// Überlappung: Start1 < End2 UND Start2 < End1
if ($newStart < $existEnd && $existStart < $newEnd) {
$this->error = $langs->trans("ErrorTimeOverlap", $objCheck->time_start, $objCheck->time_end);
return -2;
}
}
}
}
$sql = "UPDATE ".MAIN_DB_PREFIX."stundenzettel_leistung SET";
$sql .= " date_leistung = '".$this->db->idate($date)."',";
$sql .= " time_start = ".($time_start ? "'".$this->db->escape($time_start)."'" : "NULL").",";
$sql .= " time_end = ".($time_end ? "'".$this->db->escape($time_end)."'" : "NULL").",";
$sql .= " duration = ".((int)$duration).",";
$sql .= " description = '".$this->db->escape($description)."'";
$sql .= " WHERE rowid = ".((int)$leistung_id);
$sql .= " AND fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
if ($resql) {
return 1;
}
$this->error = $this->db->lasterror();
return -1;
}
/**
* Delete a leistung
*
* @param int $leistung_id Leistung ID
* @return int <0 if KO, >0 if OK
*/
public function deleteLeistung($leistung_id)
{
$sql = "DELETE FROM ".MAIN_DB_PREFIX."stundenzettel_leistung";
$sql .= " WHERE rowid = ".((int)$leistung_id);
$sql .= " AND fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
if ($resql) {
return 1;
}
$this->error = $this->db->lasterror();
return -1;
}
/**
* Add a product
*
* @param int $fk_product Product ID
* @param int $fk_commandedet Commandedet ID
* @param int $fk_manager_line Manager line ID
* @param float $qty_original Original qty
* @param float $qty_done Done qty
* @param string $origin Origin (order or added)
* @param string $description Description (for free-text products)
* @return int <0 if KO, >0 if OK
*/
public function addProduct($fk_product, $fk_commandedet = null, $fk_manager_line = null, $qty_original = 0, $qty_done = 0, $origin = 'order', $description = '')
{
global $db;
// Get product info
$product_ref = '';
$product_label = '';
if ($fk_product > 0) {
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
$prod = new Product($this->db);
if ($prod->fetch($fk_product) > 0) {
$product_ref = $prod->ref;
$product_label = $prod->label;
}
}
// Get next rang
$sql = "SELECT MAX(rang) as maxrang FROM ".MAIN_DB_PREFIX."stundenzettel_product WHERE fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
$rang = 0;
if ($resql) {
$obj = $this->db->fetch_object($resql);
$rang = $obj->maxrang + 1;
}
$sql = "INSERT INTO ".MAIN_DB_PREFIX."stundenzettel_product (";
$sql .= "fk_stundenzettel, fk_product, fk_commandedet, fk_manager_line,";
$sql .= "product_ref, product_label, description, qty_original, qty_done, origin, rang";
$sql .= ") VALUES (";
$sql .= ((int)$this->id).",";
$sql .= ($fk_product > 0 ? (int)$fk_product : "NULL").",";
$sql .= ($fk_commandedet > 0 ? (int)$fk_commandedet : "NULL").",";
$sql .= ($fk_manager_line > 0 ? (int)$fk_manager_line : "NULL").",";
$sql .= "'".$this->db->escape($product_ref)."',";
$sql .= "'".$this->db->escape($product_label)."',";
$sql .= "'".$this->db->escape($description)."',";
$sql .= ((float)$qty_original).",";
$sql .= ((float)$qty_done).",";
$sql .= "'".$this->db->escape($origin)."',";
$sql .= ((int)$rang);
$sql .= ")";
$resql = $this->db->query($sql);
if ($resql) {
return $this->db->last_insert_id(MAIN_DB_PREFIX."stundenzettel_product");
}
return -1;
}
/**
* Update product qty_done
*
* @param int $line_id Line ID
* @param float $qty_done Done qty
* @return int <0 if KO, >0 if OK
*/
public function updateProductQty($line_id, $qty_done)
{
$sql = "UPDATE ".MAIN_DB_PREFIX."stundenzettel_product SET";
$sql .= " qty_done = ".((float)$qty_done);
$sql .= " WHERE rowid = ".((int)$line_id);
$sql .= " AND fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
return $resql ? 1 : -1;
}
/**
* Delete product from stundenzettel
*
* @param int $line_id Line ID
* @return int <0 if KO, >0 if OK
*/
public function deleteProduct($line_id)
{
$sql = "DELETE FROM ".MAIN_DB_PREFIX."stundenzettel_product";
$sql .= " WHERE rowid = ".((int)$line_id);
$sql .= " AND fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
return $resql ? 1 : -1;
}
/**
* Update tracking table
*
* @return int <0 if KO, >0 if OK
*/
public function updateTracking()
{
// Sum up all products for this order across all stundenzettels
foreach ($this->products as $prod) {
if (!$prod->fk_commandedet) continue;
// Get total done qty for this commandedet
$sql = "SELECT SUM(qty_done) as total_done FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sql .= " WHERE sp.fk_commandedet = ".((int)$prod->fk_commandedet);
$sql .= " AND s.status >= ".self::STATUS_VALIDATED;
$resql = $this->db->query($sql);
$total_done = 0;
if ($resql && ($obj = $this->db->fetch_object($resql))) {
$total_done = $obj->total_done;
}
// Update or insert tracking
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel_tracking";
$sql .= " WHERE fk_commande = ".((int)$this->fk_commande);
$sql .= " AND fk_commandedet = ".((int)$prod->fk_commandedet);
$resql = $this->db->query($sql);
if ($resql && $this->db->num_rows($resql) > 0) {
// Update
$obj = $this->db->fetch_object($resql);
$remaining = $prod->qty_original - $total_done;
$status = 'open';
if ($total_done >= $prod->qty_original) {
$status = 'done';
} elseif ($total_done > 0) {
$status = 'partial';
}
$sql = "UPDATE ".MAIN_DB_PREFIX."stundenzettel_tracking SET";
$sql .= " qty_delivered = ".((float)$total_done).",";
$sql .= " qty_remaining = ".((float)$remaining).",";
$sql .= " status = '".$this->db->escape($status)."'";
$sql .= " WHERE rowid = ".((int)$obj->rowid);
$this->db->query($sql);
} else {
// Insert
$sql = "INSERT INTO ".MAIN_DB_PREFIX."stundenzettel_tracking (";
$sql .= "fk_commande, fk_product, fk_commandedet, fk_manager_line,";
$sql .= "product_ref, product_label, qty_ordered, qty_delivered, qty_remaining, status";
$sql .= ") VALUES (";
$sql .= ((int)$this->fk_commande).",";
$sql .= ($prod->fk_product > 0 ? (int)$prod->fk_product : "NULL").",";
$sql .= ((int)$prod->fk_commandedet).",";
$sql .= ($prod->fk_manager_line > 0 ? (int)$prod->fk_manager_line : "NULL").",";
$sql .= "'".$this->db->escape($prod->product_ref)."',";
$sql .= "'".$this->db->escape($prod->product_label)."',";
$sql .= ((float)$prod->qty_original).",";
$sql .= ((float)$total_done).",";
$sql .= ((float)($prod->qty_original - $total_done)).",";
$sql .= "'open'";
$sql .= ")";
$this->db->query($sql);
}
}
return 1;
}
/**
* Get next reference number
*
* @return string Next ref
*/
public function getNextNumRef()
{
global $conf;
$prefix = 'SZ';
$year = date('Y');
$sql = "SELECT MAX(CAST(SUBSTRING(ref, ".(strlen($prefix.$year.'-') + 1).") AS UNSIGNED)) as maxnum";
$sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element;
$sql .= " WHERE ref LIKE '".$this->db->escape($prefix.$year)."-%'";
$sql .= " AND entity = ".((int)$conf->entity);
$resql = $this->db->query($sql);
if ($resql) {
$obj = $this->db->fetch_object($resql);
$num = $obj->maxnum + 1;
} else {
$num = 1;
}
return $prefix.$year.'-'.sprintf('%05d', $num);
}
/**
* Get status label
*
* @param int $mode 0=long label, 1=short label, 2=Picto + short label, 3=Picto, 4=Picto + long label, 5=Short label + Picto
* @return string Label
*/
public function getLibStatut($mode = 0)
{
return $this->LibStatut($this->status, $mode);
}
/**
* Return label of status
*
* @param int $status Status
* @param int $mode Mode
* @return string Label
*/
public function LibStatut($status, $mode = 0)
{
global $langs;
$langs->load("stundenzettel@stundenzettel");
if ($status == self::STATUS_DRAFT) {
$statusType = 'status0';
$label = $langs->trans("StatusDraft");
} elseif ($status == self::STATUS_VALIDATED) {
$statusType = 'status4';
$label = $langs->trans("StatusValidated");
} elseif ($status == self::STATUS_INVOICED) {
$statusType = 'status6';
$label = $langs->trans("StatusInvoiced");
} elseif ($status == self::STATUS_CANCELED) {
$statusType = 'status9';
$label = $langs->trans("StatusCanceled");
}
return dolGetStatus($label, '', '', $statusType, $mode);
}
/**
* Return URL link
*
* @param int $withpicto Picto
* @param string $option Option
* @return string Link
*/
public function getNomUrl($withpicto = 0, $option = '')
{
$result = '';
$url = dol_buildpath('/stundenzettel/card.php?id='.$this->id, 1);
$label = '<u>'.$this->ref.'</u>';
if ($withpicto) {
$result .= img_object('', $this->picto, 'class="pictofixedwidth"');
}
$result .= '<a href="'.$url.'" title="'.dol_escape_htmltag($label).'">'.$this->ref.'</a>';
return $result;
}
/**
* Load notes (checkable memos)
*
* @return int <0 if KO, number of notes if OK
*/
public function fetchNotes()
{
$this->notes = array();
$sql = "SELECT rowid, fk_user, note, checked, rang, datec";
$sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel_note";
$sql .= " WHERE fk_stundenzettel = ".((int)$this->id);
$sql .= " ORDER BY rang, datec";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$this->notes[] = $obj;
}
return count($this->notes);
}
return -1;
}
/**
* Add a note
*
* @param User $user User
* @param string $note Note text
* @return int <0 if KO, >0 if OK (rowid)
*/
public function addNote($user, $note)
{
if (empty($note)) {
return -1;
}
// Get next rang
$sql = "SELECT MAX(rang) as maxrang FROM ".MAIN_DB_PREFIX."stundenzettel_note WHERE fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
$rang = 0;
if ($resql) {
$obj = $this->db->fetch_object($resql);
$rang = $obj->maxrang + 1;
}
$sql = "INSERT INTO ".MAIN_DB_PREFIX."stundenzettel_note (";
$sql .= "fk_stundenzettel, fk_user, note, checked, rang, datec";
$sql .= ") VALUES (";
$sql .= ((int)$this->id).",";
$sql .= ((int)$user->id).",";
$sql .= "'".$this->db->escape($note)."',";
$sql .= "0,"; // not checked
$sql .= ((int)$rang).",";
$sql .= "'".$this->db->idate(dol_now())."'";
$sql .= ")";
$resql = $this->db->query($sql);
if ($resql) {
return $this->db->last_insert_id(MAIN_DB_PREFIX."stundenzettel_note");
}
return -1;
}
/**
* Update note checked status
*
* @param int $note_id Note ID
* @param int $checked 0=unchecked, 1=checked
* @return int <0 if KO, >0 if OK
*/
public function updateNoteStatus($note_id, $checked)
{
$sql = "UPDATE ".MAIN_DB_PREFIX."stundenzettel_note SET";
$sql .= " checked = ".((int)$checked);
$sql .= " WHERE rowid = ".((int)$note_id);
$sql .= " AND fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
return $resql ? 1 : -1;
}
/**
* Delete a note
*
* @param int $note_id Note ID
* @return int <0 if KO, >0 if OK
*/
public function deleteNote($note_id)
{
$sql = "DELETE FROM ".MAIN_DB_PREFIX."stundenzettel_note";
$sql .= " WHERE rowid = ".((int)$note_id);
$sql .= " AND fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);
return $resql ? 1 : -1;
}
/**
* Get unchecked notes (for display on order page)
*
* @return array Array of unchecked notes
*/
public function getUncheckedNotes()
{
$notes = array();
$sql = "SELECT rowid, fk_user, note, rang, datec";
$sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel_note";
$sql .= " WHERE fk_stundenzettel = ".((int)$this->id);
$sql .= " AND checked = 0";
$sql .= " ORDER BY rang, datec";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$notes[] = $obj;
}
}
return $notes;
}
}

View file

@ -0,0 +1,469 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \defgroup stundenzettel Module Stundenzettel
* \brief Stundenzettel-Verwaltung für Aufträge
*/
/**
* \file core/modules/modStundenzettel.class.php
* \ingroup stundenzettel
* \brief Modulbeschreibung und Setup
*/
include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php';
/**
* Class modStundenzettel
*/
class modStundenzettel extends DolibarrModules
{
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
global $langs, $conf;
$this->db = $db;
// Modul-ID (muss eindeutig sein)
$this->numero = 500200;
// Familie/Kategorie
$this->family = "crm";
// Position im Menü (zwischen Einkauf=40 und Rechnung=50)
$this->module_position = '45';
// Modulname
$this->name = preg_replace('/^mod/i', '', get_class($this));
// Beschreibung
$this->description = "Stundenzettel-Verwaltung für Aufträge - Dokumentation von Arbeitszeiten und Materialverbrauch";
$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.1.0';
// Autor
$this->editor_name = 'Data IT Solution';
$this->editor_url = 'https://data-it-solution.de';
// Konstanten
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
// Pfade
$this->special = 0;
$this->picto = 'clock';
// Abhängigkeiten
$this->depends = array('modCommande'); // Aufträge erforderlich
$this->requiredby = array();
$this->conflictwith = array();
// PHP-Version
$this->phpmin = array(7, 4);
// Dolibarr-Version
$this->need_dolibarr_version = array(16, 0);
// Daten-Verzeichnisse
$this->dirs = array('/stundenzettel/temp');
// Konfiguration
$this->config_page_url = array("setup.php@stundenzettel");
// Konstanten
$this->const = array(
0 => array(
'STUNDENZETTEL_ADDON',
'chaine',
'mod_stundenzettel_standard',
'Nummernkreis für Stundenzettel',
0,
'current',
1
),
1 => array(
'STUNDENZETTEL_TIME_INPUT_MODE',
'chaine',
'dropdown',
'Zeiteingabe-Modus: dropdown (15-Min-Takt) oder text (freie Eingabe)',
0,
'current',
1
),
);
// Tabs - Tab im Auftrag (order = commande)
$this->tabs = array(
'order:+stundenzettel:Stundenzettel:stundenzettel@stundenzettel:$user->hasRight("stundenzettel","read"):/custom/stundenzettel/stundenzettel_commande.php?id=__ID__'
);
// Boxen/Widgets
$this->boxes = array(
0 => array(
'file' => 'box_stundenzettel_recent@stundenzettel',
'note' => 'Zuletzt bearbeitete Stundenzettel',
'enabledbydefaulton' => 'Home'
),
1 => array(
'file' => 'box_stundenzettel_open@stundenzettel',
'note' => 'Offene Stundenzettel',
'enabledbydefaulton' => 'Home'
),
);
// Cronjobs
$this->cronjobs = array();
// Berechtigungen
$this->rights = array();
$this->rights_class = 'stundenzettel';
$r = 0;
// Lesen
$this->rights[$r][0] = $this->numero + $r;
$this->rights[$r][1] = 'Stundenzettel lesen';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'read';
$this->rights[$r][5] = '';
$r++;
// Erstellen
$this->rights[$r][0] = $this->numero + $r;
$this->rights[$r][1] = 'Stundenzettel erstellen';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'write';
$this->rights[$r][5] = '';
$r++;
// Freigeben
$this->rights[$r][0] = $this->numero + $r;
$this->rights[$r][1] = 'Stundenzettel freigeben';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'validate';
$this->rights[$r][5] = '';
$r++;
// Löschen
$this->rights[$r][0] = $this->numero + $r;
$this->rights[$r][1] = 'Stundenzettel löschen';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'delete';
$this->rights[$r][5] = '';
$r++;
// Hauptmenü
$this->menu = array();
$r = 0;
// Top-Menü
$this->menu[$r] = array(
'fk_menu' => '',
'type' => 'top',
'titre' => 'Stundenzettel',
'prefix' => img_picto('', 'clock', 'class="pictofixedwidth"'),
'mainmenu' => 'stundenzettel',
'leftmenu' => '',
'url' => '/stundenzettel/index.php',
'langs' => 'stundenzettel@stundenzettel',
'position' => 45,
'enabled' => '$conf->stundenzettel->enabled',
'perms' => '$user->hasRight("stundenzettel", "read")',
'target' => '',
'user' => 0
);
$r++;
// Linkes Menü - Übersicht
$this->menu[$r] = array(
'fk_menu' => 'fk_mainmenu=stundenzettel',
'type' => 'left',
'titre' => 'Übersicht',
'prefix' => img_picto('', 'home', 'class="pictofixedwidth"'),
'mainmenu' => 'stundenzettel',
'leftmenu' => 'stundenzettel_index',
'url' => '/stundenzettel/index.php',
'langs' => 'stundenzettel@stundenzettel',
'position' => 100,
'enabled' => '$conf->stundenzettel->enabled',
'perms' => '$user->hasRight("stundenzettel", "read")',
'target' => '',
'user' => 0
);
$r++;
// Linkes Menü - Liste
$this->menu[$r] = array(
'fk_menu' => 'fk_mainmenu=stundenzettel',
'type' => 'left',
'titre' => 'Alle Stundenzettel',
'prefix' => img_picto('', 'list', 'class="pictofixedwidth"'),
'mainmenu' => 'stundenzettel',
'leftmenu' => 'stundenzettel_list',
'url' => '/stundenzettel/list.php',
'langs' => 'stundenzettel@stundenzettel',
'position' => 110,
'enabled' => '$conf->stundenzettel->enabled',
'perms' => '$user->hasRight("stundenzettel", "read")',
'target' => '',
'user' => 0
);
$r++;
// Linkes Menü - Neuer Stundenzettel
$this->menu[$r] = array(
'fk_menu' => 'fk_mainmenu=stundenzettel',
'type' => 'left',
'titre' => 'Neuer Stundenzettel',
'prefix' => img_picto('', 'add', 'class="pictofixedwidth"'),
'mainmenu' => 'stundenzettel',
'leftmenu' => 'stundenzettel_new',
'url' => '/stundenzettel/card.php?action=create',
'langs' => 'stundenzettel@stundenzettel',
'position' => 120,
'enabled' => '$conf->stundenzettel->enabled',
'perms' => '$user->hasRight("stundenzettel", "write")',
'target' => '',
'user' => 0
);
$r++;
// Linkes Menü - Offene Stundenzettel (Entwürfe)
$this->menu[$r] = array(
'fk_menu' => 'fk_mainmenu=stundenzettel',
'type' => 'left',
'titre' => 'Offene Stundenzettel',
'prefix' => img_picto('', 'statut0', 'class="pictofixedwidth"'),
'mainmenu' => 'stundenzettel',
'leftmenu' => 'stundenzettel_open',
'url' => '/stundenzettel/list.php?search_status=0',
'langs' => 'stundenzettel@stundenzettel',
'position' => 130,
'enabled' => '$conf->stundenzettel->enabled',
'perms' => '$user->hasRight("stundenzettel", "read")',
'target' => '',
'user' => 0
);
$r++;
// Linkes Menü - Meine Stundenzettel
$this->menu[$r] = array(
'fk_menu' => 'fk_mainmenu=stundenzettel',
'type' => 'left',
'titre' => 'Meine Stundenzettel',
'prefix' => img_picto('', 'user', 'class="pictofixedwidth"'),
'mainmenu' => 'stundenzettel',
'leftmenu' => 'stundenzettel_my',
'url' => '/stundenzettel/list.php?search_author=__USER_ID__',
'langs' => 'stundenzettel@stundenzettel',
'position' => 140,
'enabled' => '$conf->stundenzettel->enabled',
'perms' => '$user->hasRight("stundenzettel", "read")',
'target' => '',
'user' => 0
);
$r++;
// Linkes Menü - Freigegebene Stundenzettel
$this->menu[$r] = array(
'fk_menu' => 'fk_mainmenu=stundenzettel',
'type' => 'left',
'titre' => 'Freigegeben',
'prefix' => img_picto('', 'statut4', 'class="pictofixedwidth"'),
'mainmenu' => 'stundenzettel',
'leftmenu' => 'stundenzettel_validated',
'url' => '/stundenzettel/list.php?search_status=1',
'langs' => 'stundenzettel@stundenzettel',
'position' => 150,
'enabled' => '$conf->stundenzettel->enabled',
'perms' => '$user->hasRight("stundenzettel", "read")',
'target' => '',
'user' => 0
);
$r++;
}
/**
* Funktion beim Aktivieren des Moduls
*
* @param string $options Options when enabling module
* @return int 1 if OK, 0 if KO
*/
public function init($options = '')
{
global $conf;
$result = $this->_load_tables('/stundenzettel/sql/');
if ($result < 0) {
return -1;
}
// Extrafeld "Auftragsbeschreibung" für Aufträge anlegen
$this->createExtraFieldOrderDescription();
// View für Dienstleistungen erstellen (für sellist-Filter)
$this->createServicesView();
// Extrafeld "Standard-Leistung" für Kunden anlegen
$this->createExtraFieldDefaultService();
$sql = array();
return $this->_init($sql, $options);
}
/**
* Erstellt das Extrafeld "Auftragsbeschreibung" für Aufträge (commande)
*
* @return int 1 if created or exists, -1 if error
*/
private function createExtraFieldOrderDescription()
{
global $langs;
require_once DOL_DOCUMENT_ROOT.'/core/class/extrafields.class.php';
$extrafields = new ExtraFields($this->db);
// Prüfen ob Extrafeld bereits existiert
$extrafields->fetch_name_optionals_label('commande');
if (!isset($extrafields->attributes['commande']['label']['auftragsbeschreibung'])) {
// Extrafeld anlegen: Textarea für Auftragsbeschreibung
$result = $extrafields->addExtraField(
'auftragsbeschreibung', // attrname - Feldname
'Auftragsbeschreibung', // label - Anzeigename
'text', // type - Feldtyp (text = Textarea)
100, // pos - Position
'', // size - Größe (leer für text)
'commande', // elementtype - Objekttyp
0, // unique
0, // required
'', // default_value
array('options' => array()), // param
1, // alwayseditable
'', // perms
1, // list - In Liste anzeigen
'', // ishidden
0, // computed
'', // entity
'', // langfile
'', // enabled
0, // totalizable
0, // printable
array('css' => '', 'cssview' => '', 'csslist' => '') // moreparams
);
if ($result < 0) {
dol_syslog("modStundenzettel::createExtraFieldOrderDescription Error creating extrafield: ".$extrafields->error, LOG_ERR);
return -1;
}
dol_syslog("modStundenzettel::createExtraFieldOrderDescription Extrafield 'auftragsbeschreibung' created successfully", LOG_DEBUG);
}
return 1;
}
/**
* Erstellt eine View für Dienstleistungen (fk_product_type = 1)
* Diese View wird für das sellist-Extrafeld verwendet
*
* @return int 1 if created, -1 if error
*/
private function createServicesView()
{
$sql = "CREATE OR REPLACE VIEW ".MAIN_DB_PREFIX."product_services AS
SELECT rowid, ref, label, description, fk_product_type, entity, tosell, tobuy
FROM ".MAIN_DB_PREFIX."product
WHERE fk_product_type = 1";
$resql = $this->db->query($sql);
if (!$resql) {
dol_syslog("modStundenzettel::createServicesView Error: ".$this->db->lasterror(), LOG_ERR);
return -1;
}
dol_syslog("modStundenzettel::createServicesView View created successfully", LOG_DEBUG);
return 1;
}
/**
* Erstellt das Extrafeld "Standard-Leistung" für Kunden (societe)
*
* @return int 1 if created or exists, -1 if error
*/
private function createExtraFieldDefaultService()
{
global $langs;
require_once DOL_DOCUMENT_ROOT.'/core/class/extrafields.class.php';
$extrafields = new ExtraFields($this->db);
// Prüfen ob Extrafeld bereits existiert
$extrafields->fetch_name_optionals_label('societe');
if (!isset($extrafields->attributes['societe']['label']['stundenzettel_default_service'])) {
// Extrafeld anlegen: sellist mit View für nur Dienstleistungen
$result = $extrafields->addExtraField(
'stundenzettel_default_service', // attrname - Feldname
'Standard-Leistung (Stundenzettel)', // label - Anzeigename
'sellist', // type - Feldtyp (sellist = SQL-basierte Auswahlliste)
200, // pos - Position
'', // size - Größe
'societe', // elementtype - Objekttyp (Kunden/Lieferanten)
0, // unique
0, // required
'', // default_value
array('options' => array('product_services:label:rowid' => null)), // param - View mit nur Dienstleistungen
1, // alwayseditable
'', // perms
1, // list - In Liste anzeigen
'', // ishidden
0, // computed
'', // entity
'', // langfile
'$conf->stundenzettel->enabled', // enabled - nur wenn Modul aktiv
0, // totalizable
0, // printable
array('css' => '', 'cssview' => '', 'csslist' => '') // moreparams
);
if ($result < 0) {
dol_syslog("modStundenzettel::createExtraFieldDefaultService Error creating extrafield: ".$extrafields->error, LOG_ERR);
return -1;
}
dol_syslog("modStundenzettel::createExtraFieldDefaultService Extrafield 'stundenzettel_default_service' created successfully", LOG_DEBUG);
}
return 1;
}
/**
* Funktion beim Deaktivieren des Moduls
*
* @param string $options Options when disabling module
* @return int 1 if OK, 0 if KO
*/
public function remove($options = '')
{
$sql = array();
return $this->_remove($sql, $options);
}
}

239
index.php Normal file
View file

@ -0,0 +1,239 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* Stundenzettel - Dashboard/Übersicht
*/
// Load Dolibarr environment
$res = 0;
if (!$res && file_exists("../main.inc.php")) $res = @include "../main.inc.php";
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res) die("Include of main fails");
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php';
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
// Load translation files
$langs->loadLangs(array("stundenzettel@stundenzettel", "orders"));
// Security check
if (!$user->hasRight('stundenzettel', 'read')) {
accessforbidden();
}
$action = GETPOST('action', 'aZ09');
/*
* View
*/
$form = new Form($db);
// Linkes Menü aktivieren
$_GET['mainmenu'] = 'stundenzettel';
llxHeader('', $langs->trans("Stundenzettel"), '', '', 0, 0, '', '', '', 'mod-stundenzettel page-index');
print load_fiche_titre($langs->trans("Stundenzettel"), '', 'clock');
print '<div class="fichecenter">';
// Statistik-Boxen
print '<div class="fichethirdleft">';
// Box: Offene Stundenzettel
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th colspan="2">'.$langs->trans("BoxOpenStundenzettel").'</th>';
print '</tr>';
$sql = "SELECT COUNT(*) as nb FROM ".MAIN_DB_PREFIX."stundenzettel WHERE status = 0 AND entity = ".((int)$conf->entity);
$resql = $db->query($sql);
if ($resql) {
$obj = $db->fetch_object($resql);
print '<tr class="oddeven"><td>'.$langs->trans("StatusDraft").'</td><td class="right"><span class="badge badge-warning">'.$obj->nb.'</span></td></tr>';
}
$sql = "SELECT COUNT(*) as nb FROM ".MAIN_DB_PREFIX."stundenzettel WHERE status = 1 AND entity = ".((int)$conf->entity);
$resql = $db->query($sql);
if ($resql) {
$obj = $db->fetch_object($resql);
print '<tr class="oddeven"><td>'.$langs->trans("StatusValidated").'</td><td class="right"><span class="badge badge-success">'.$obj->nb.'</span></td></tr>';
}
print '</table>';
print '</div>';
// Box: Schnellzugriff
print '<br>';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Actions").'</th>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>';
print '<a class="butAction" href="'.dol_buildpath('/stundenzettel/card.php?action=create', 1).'">';
print img_picto('', 'add', 'class="pictofixedwidth"').$langs->trans("CreateStundenzettel");
print '</a>';
print '</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>';
print '<a class="butAction" href="'.dol_buildpath('/stundenzettel/list.php', 1).'">';
print img_picto('', 'list', 'class="pictofixedwidth"').$langs->trans("StundenzettelList");
print '</a>';
print '</td>';
print '</tr>';
print '</table>';
print '</div>';
print '</div>'; // fichethirdleft
print '<div class="fichetwothirdright">';
// Zuletzt bearbeitete Stundenzettel
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("BoxRecentStundenzettel").'</th>';
print '<th>'.$langs->trans("StundenzettelDate").'</th>';
print '<th>'.$langs->trans("StundenzettelOrder").'</th>';
print '<th>'.$langs->trans("StundenzettelCustomer").'</th>';
print '<th class="right">'.$langs->trans("StundenzettelStatus").'</th>';
print '</tr>';
$sql = "SELECT s.rowid, s.ref, s.date_stundenzettel, s.status, s.fk_commande, s.fk_soc,";
$sql .= " c.ref as order_ref, soc.nom as soc_name";
$sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel as s";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."commande as c ON c.rowid = s.fk_commande";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as soc ON soc.rowid = s.fk_soc";
$sql .= " WHERE s.entity = ".((int)$conf->entity);
$sql .= " ORDER BY s.tms DESC";
$sql .= " LIMIT 10";
$resql = $db->query($sql);
if ($resql) {
$num = $db->num_rows($resql);
if ($num > 0) {
while ($obj = $db->fetch_object($resql)) {
print '<tr class="oddeven">';
// Referenz
print '<td>';
print '<a href="'.dol_buildpath('/stundenzettel/card.php?id='.$obj->rowid, 1).'">';
print img_picto('', 'clock', 'class="pictofixedwidth"').$obj->ref;
print '</a>';
print '</td>';
// Datum
print '<td>'.dol_print_date($db->jdate($obj->date_stundenzettel), 'day').'</td>';
// Auftrag
print '<td>';
if ($obj->order_ref) {
print '<a href="'.DOL_URL_ROOT.'/commande/card.php?id='.$obj->fk_commande.'">';
print img_picto('', 'order', 'class="pictofixedwidth"').$obj->order_ref;
print '</a>';
}
print '</td>';
// Kunde
print '<td>';
if ($obj->soc_name) {
print '<a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.$obj->fk_soc.'">';
print img_picto('', 'company', 'class="pictofixedwidth"').$obj->soc_name;
print '</a>';
}
print '</td>';
// Status
print '<td class="right">';
if ($obj->status == 0) {
print '<span class="badge badge-warning">'.$langs->trans("StatusDraft").'</span>';
} elseif ($obj->status == 1) {
print '<span class="badge badge-success">'.$langs->trans("StatusValidated").'</span>';
} elseif ($obj->status == 2) {
print '<span class="badge badge-info">'.$langs->trans("StatusInvoiced").'</span>';
}
print '</td>';
print '</tr>';
}
} else {
print '<tr class="oddeven"><td colspan="5" class="opacitymedium">'.$langs->trans("NoOpenStundenzettel").'</td></tr>';
}
} else {
dol_print_error($db);
}
print '</table>';
print '</div>';
// Offene Aufträge mit Stundenzettel-Funktion
print '<br>';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th colspan="4">'.$langs->trans("OpenOrders").' - '.$langs->trans("ActivateStundenzettel").'</th>';
print '</tr>';
$sql = "SELECT c.rowid, c.ref, c.date_commande, c.total_ht, soc.nom as soc_name, soc.rowid as soc_id";
$sql .= " FROM ".MAIN_DB_PREFIX."commande as c";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as soc ON soc.rowid = c.fk_soc";
$sql .= " WHERE c.entity = ".((int)$conf->entity);
$sql .= " AND c.fk_statut IN (1, 2)"; // Bestätigt oder in Bearbeitung
$sql .= " ORDER BY c.date_commande DESC";
$sql .= " LIMIT 10";
$resql = $db->query($sql);
if ($resql) {
$num = $db->num_rows($resql);
if ($num > 0) {
while ($obj = $db->fetch_object($resql)) {
print '<tr class="oddeven">';
// Auftrag
print '<td>';
print '<a href="'.DOL_URL_ROOT.'/commande/card.php?id='.$obj->rowid.'">';
print img_picto('', 'order', 'class="pictofixedwidth"').$obj->ref;
print '</a>';
print '</td>';
// Kunde
print '<td>';
print '<a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.$obj->soc_id.'">';
print $obj->soc_name;
print '</a>';
print '</td>';
// Datum
print '<td>'.dol_print_date($db->jdate($obj->date_commande), 'day').'</td>';
// Aktion
print '<td class="right">';
print '<a class="butActionSmall" href="'.dol_buildpath('/stundenzettel/stundenzettel_commande.php?id='.$obj->rowid, 1).'">';
print img_picto('', 'clock', 'class="pictofixedwidth"').$langs->trans("Stundenzettel");
print '</a>';
print '</td>';
print '</tr>';
}
} else {
print '<tr class="oddeven"><td colspan="4" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
}
}
print '</table>';
print '</div>';
print '</div>'; // fichetwothirdright
print '</div>'; // fichecenter
print '<div class="clearboth"></div>';
llxFooter();
$db->close();

View file

@ -0,0 +1,233 @@
# Stundenzettel Modul - Deutsche Sprachdatei
# Copyright (C) 2026 Data IT Solution
# Modul
Module500200Name = Stundenzettel
Module500200Desc = Stundenzettel-Verwaltung für Aufträge
# Menü
Stundenzettel = Stundenzettel
StundenzettelList = Alle Stundenzettel
StundenzettelNew = Neuer Stundenzettel
StundenzettelOverview = Übersicht
# Felder
StundenzettelRef = Stundenzettel-Nr.
StundenzettelDate = Datum
StundenzettelOrder = Auftrag
OrderDescription = Auftragsbeschreibung
StundenzettelCustomer = Kunde
StundenzettelStatus = Status
StundenzettelAuthor = Ersteller
StundenzettelValidatedBy = Freigegeben von
# Status
StatusDraft = Entwurf
StatusValidated = Freigegeben
StatusInvoiced = In Rechnung übertragen
StatusCanceled = Storniert
# Aktionen
CreateStundenzettel = Stundenzettel erstellen
EditStundenzettel = Stundenzettel bearbeiten
ValidateStundenzettel = Stundenzettel freigeben
DeleteStundenzettel = Stundenzettel löschen
CloneStundenzettel = Stundenzettel duplizieren
TransferToInvoice = In Rechnung übertragen
ActivateStundenzettel = Stundenzettel aktivieren
ReopenStundenzettel = Stundenzettel wieder öffnen
# Leistungen
Leistungen = Leistungen
LeistungNew = Neue Leistung
LeistungDate = Datum
LeistungTimeStart = Beginn
LeistungTimeEnd = Ende
LeistungDuration = Dauer
LeistungDescription = Beschreibung
AddLeistung = Leistung hinzufügen
# Produkte
Products = Produkte
ProductsDesc = Hier können Sie Produkte aus dem Auftrag erfassen, die verbraucht wurden.
ProductsFromOrder = Produkte aus Auftrag
ProductList = Produktliste
ProductsAdded = Hinzugefügte Produkte
OtherProducts = Sonstige Produkte
QtyOrdered = Beauftragt
QtyFromOrder = Beauftragt
QtyUsed = Erfasst
QtyDelivered = Verbaut
QtyDone = Erledigt
QtyRemaining = Verbleibend
QtyAdded = Hinzugefügt
QtyRemoved = Entfallen
AddProduct = Produkt hinzufügen
AddMoreProducts = Weitere Produkte hinzufügen
SelectProducts = Produkte auswählen
NoProductsInOrder = Keine Produkte im Auftrag
AllProductsUsedOrOmitted = Alle Produkte bereits verbaut oder als entfällt markiert
TransferProducts = Produkte übernehmen
TransferToStundenzettel = Übernehmen in Stundenzettel
FromOrder = Aus Auftrag
Added = Hinzugefügt
AlreadyOnStundenzettel = Bereits auf diesem Stundenzettel
BackToOrder = Zurück zum Auftrag
Add = Hinzufügen
FreeText = Freitext
# Tabs
Card = Übersicht
View = Anzeigen
# Tracking
DeliveryTracking = Lieferauflistung
TrackingStatus = Status
TrackingOpen = Offen
TrackingPartial = Teilweise erledigt
TrackingDone = Erledigt
# Notizen
Notes = Notizen
NotesForNextVisit = Notizen für nächsten Termin
NotesMemo = Merkzettel
AddNote = Notiz hinzufügen
NoteText = Notiz
ConfirmDeleteNote = Diese Notiz wirklich löschen?
NoNotesYet = Noch keine Notizen vorhanden
OpenNotes = Offene Notizen
CheckedNotes = Erledigte Notizen
MarkAsDone = Als erledigt markieren
MarkAsOpen = Wieder öffnen
# Meldungen
ConfirmValidate = Stundenzettel wirklich freigeben?
ConfirmDelete = Stundenzettel wirklich löschen?
ConfirmDeleteLeistung = Diese Leistung wirklich löschen?
RecordDeleted = Eintrag gelöscht
ConfirmTransfer = Alle Produkte in Rechnung übertragen?
AllProductsDocumented = Alle Produkte dokumentiert
NotAllProductsDocumented = Nicht alle Produkte dokumentiert
NoOpenStundenzettel = Keine offenen Stundenzettel
StundenzettelCreated = Stundenzettel erstellt
StundenzettelExistsForDate = Für diesen Auftrag und dieses Datum existiert bereits ein Stundenzettel
StundenzettelValidated = Stundenzettel freigegeben
StundenzettelDeleted = Stundenzettel gelöscht
# Mengenwarnung
ConfirmExceedOrderQty = Auftragsmenge wird überschritten!
OrderQty = Auftragsmenge
TotalUsedAllStz = Gesamtmenge alle Stundenzettel
ConfirmAdditionalWork = Mehraufwand bestätigen?
# Mehraufwand
Mehraufwand = Mehraufwand
MehraufwandDesc = Hier können Sie zusätzliche Produkte erfassen, die nicht im ursprünglichen Auftrag enthalten waren.
AddMehraufwand = Mehraufwand hinzufügen
OrFreeText = Oder Freitext
FreeTextDescription = Freitext-Beschreibung
ConfirmDeleteMehraufwand = Diesen Mehraufwand wirklich löschen?
ErrorNoProductSelected = Bitte wählen Sie ein Produkt oder geben Sie eine Beschreibung ein
AdditionalQty = Zusätzliche Menge
FromStundenzettel = Aus Stundenzettel
ProductExistsInOrder = Produkt im Auftrag gefunden, als Verbrauch hinzugefügt
MehraufwandConflictTitle = Produkt bereits als Mehraufwand vorhanden
MehraufwandConflictWarning = Folgende Produkte sind bereits als Mehraufwand erfasst
MehraufwandConflictQuestion = Möchten Sie diese Produkte trotzdem als Verbrauch übernehmen?
MehraufwandConflictExplanation = Wenn Sie fortfahren, wird das Produkt sowohl als Auftragsposition (Verbrauch) als auch als Mehraufwand geführt. Dies kann zu doppelter Berechnung führen.
MehraufwandTransferTitle = Mehraufwand übernehmen
MehraufwandTransferWarning = Folgende Produkte wurden als Mehraufwand erfasst
MehraufwandTransferQuestion = Diese Produkte werden als normale Verbrauchsposition übernommen.
MehraufwandTransferExplanation = Die Produkte werden in die Produktliste des Stundenzettels übernommen (nicht als Mehraufwand). Die ursprüngliche Mehraufwand-Zeile bleibt bestehen.
# Entfällt
Entfaellt = Entfällt
EntfaelltDesc = Hier können Sie Produkte aus dem Auftrag erfassen, die nicht verbaut werden müssen.
AddEntfaellt = Entfällt hinzufügen
Reason = Grund
Optional = optional
ConfirmDeleteEntfaellt = Diesen Eintrag wirklich löschen?
# Fehler
ErrorNoOrder = Kein Auftrag ausgewählt
ErrorOrderNotFound = Auftrag nicht gefunden
ErrorNoProducts = Keine Produkte ausgewählt
ErrorAlreadyValidated = Stundenzettel bereits freigegeben
ErrorQtyExceedsAvailable = Menge überschreitet verfügbare Menge (max. %s)
ErrorTimeOverlap = Zeitüberschneidung mit bestehender Leistung (%s - %s)
ErrorStundenzettelReleased = Die Stundenzettel für diesen Auftrag wurden bereits freigegeben und können nicht mehr geändert werden
# Widgets
BoxRecentStundenzettel = Zuletzt bearbeitete Stundenzettel
BoxOpenStundenzettel = Offene Stundenzettel
# Berechtigungen
PermissionRead = Stundenzettel lesen
PermissionWrite = Stundenzettel erstellen/bearbeiten
PermissionValidate = Stundenzettel freigeben
PermissionDelete = Stundenzettel löschen
# Einstellungen
StundenzettelSetup = Stundenzettel Einstellungen
TimeInputMode = Zeiteingabe-Modus
TimeInputDropdown = Dropdown (15-Minuten-Takt)
TimeInputText = Freitext (exakte Uhrzeit)
PreciseTimeEntry = Exakte Zeiterfassung
DefaultFilter = Standard-Filter Produktliste
DefaultFilterDesc = Welcher Filter soll standardmäßig in der Produktliste angezeigt werden?
DefaultDate = Standard-Datum für Stundenzettel
DefaultDateDesc = Welches Datum soll standardmäßig für neue Stundenzettel vorgeschlagen werden?
DateToday = Immer aktuelles Datum
DateLastOpen = Datum des letzten offenen Stundenzettels
NewStundenzettelForToday = Neuer Stundenzettel für heute
SelectedStundenzettelNotToday = Der ausgewählte Stundenzettel %s ist vom %s - es wird ein neuer für heute erstellt
# Filter
Filter = Filter
FilterAll = Alle
FilterOpen = Offen
FilterDone = Erledigt
# Sections
ExpandAll = Alles ausklappen
CollapseAll = Alles einklappen
ShowDetails = Details anzeigen
HideDetails = Details verbergen
Type = Typ
# Freigabe und Rechnung
ReleaseStundenzettel = Stundenzettel freigeben
ReopenStundenzettel = Stundenzettel wiedereröffnen
TransferToInvoice = In Rechnung übernehmen
StundenzettelReleased = Stundenzettel wurden freigegeben
StundenzettelReopened = Stundenzettel wurden wiedereröffnet
AllStundenzettelMustBeValidated = Alle Stundenzettel müssen erst validiert werden
RemainingProductsWarning = Folgende Produkte sind noch nicht vollständig dokumentiert
ConfirmReleaseWithRemainingTitle = Freigabe mit offenen Produkten?
ConfirmReleaseWithRemaining = Es gibt noch nicht vollständig dokumentierte Produkte. Wollen Sie die Stundenzettel trotzdem freigeben?
ConfirmReleaseFinalTitle = Endgültige Freigabe bestätigen
ConfirmReleaseFinal = Sind Sie WIRKLICH sicher? Nach der Freigabe können keine Änderungen mehr vorgenommen werden.
ConfirmTransferInvoiceTitle = In Rechnung übernehmen?
ConfirmTransferInvoice = Alle dokumentierten Produkte werden in eine neue Rechnung übertragen. Fortfahren?
InvoiceCreated = Rechnung wurde erstellt
CreatedFromStundenzettel = Erstellt aus Stundenzettel
StundenzettelTransferredToInvoice = Stundenzettel wurden bereits in Rechnung übertragen
StundenzettelReleasedNoChanges = Die Stundenzettel sind freigegeben. Keine Änderungen mehr möglich.
ResetStundenzettel = Status zurücksetzen
StundenzettelReset = Stundenzettel-Status wurde zurückgesetzt
# Standard-Leistung
DefaultService = Standard-Leistung
DefaultServiceDesc = Standard-Dienstleistung für Stundenzettel (wird beim Kunden hinterlegt)
DefaultServiceFromCustomer = Standard-Leistung vom Kunden
NoDefaultServiceSet = Keine Standard-Leistung hinterlegt
SetDefaultServiceInCustomer = Standard-Leistung beim Kunden hinterlegen
PlannedHours = Geplante Stunden
TotalHours = Gesamtstunden
HoursFromOrder = Stunden aus Auftrag
# Rechnungsübernahme Stunden
InvoiceHoursMode = Stunden-Übernahme
InvoiceHoursModeTotal = Gesamtstunden auf einer Zeile
InvoiceHoursModePerDay = Pro Tag eine Zeile
SelectInvoiceHoursMode = Wie sollen die Arbeitsstunden übernommen werden?

89
lib/stundenzettel.lib.php Normal file
View file

@ -0,0 +1,89 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* Stundenzettel - Library functions
*/
/**
* Prepare array of tabs for Stundenzettel card
*
* @param Stundenzettel $object Object
* @return array Array of tabs
*/
function stundenzettel_prepare_head($object)
{
global $db, $langs, $conf, $user;
$langs->load("stundenzettel@stundenzettel");
$h = 0;
$head = array();
// Tab 1: Kundenauftrag (Link zum Auftrag) - immer am Anfang
if ($object->fk_commande > 0) {
$head[$h][0] = DOL_URL_ROOT.'/commande/card.php?id='.$object->fk_commande;
$head[$h][1] = $langs->trans("Order");
$head[$h][2] = 'order';
$h++;
}
// Tab 2: Produktliste (Link zu stundenzettel_commande.php mit Produktliste aus Auftrag)
if ($object->fk_commande > 0) {
$head[$h][0] = dol_buildpath('/stundenzettel/stundenzettel_commande.php', 1).'?id='.$object->fk_commande.'&tab=products&noredirect=1&stundenzettel_id='.$object->id;
$head[$h][1] = $langs->trans("ProductList");
$head[$h][2] = 'productlist';
$h++;
}
// Tab 3: Stundenzettel (Link zum aktiven Stundenzettel - card.php)
$nbLeistungen = 0;
if (!empty($object->leistungen)) {
$nbLeistungen = count($object->leistungen);
}
$nbProducts = 0;
if (!empty($object->products)) {
$nbProducts = count($object->products);
}
$head[$h][0] = dol_buildpath('/stundenzettel/card.php', 1).'?id='.$object->id;
$head[$h][1] = $langs->trans("Stundenzettel");
$totalItems = $nbLeistungen + $nbProducts;
if ($totalItems > 0) {
$head[$h][1] .= '<span class="badge marginleftonlyshort">'.$totalItems.'</span>';
}
$head[$h][2] = 'card';
$h++;
// Tab 4: Alle Stundenzettel (Liste aller Stundenzettel für diesen Auftrag)
if ($object->fk_commande > 0) {
// Anzahl Stundenzettel für diesen Auftrag zählen
$sql = "SELECT COUNT(*) as nb FROM ".MAIN_DB_PREFIX."stundenzettel WHERE fk_commande = ".((int)$object->fk_commande);
$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='.$object->fk_commande.'&tab=stundenzettel&noredirect=1&stundenzettel_id='.$object->id;
$head[$h][1] = $langs->trans("StundenzettelList");
if ($nbStundenzettel > 0) {
$head[$h][1] .= '<span class="badge marginleftonlyshort">'.$nbStundenzettel.'</span>';
}
$head[$h][2] = 'stundenzettel_list';
$h++;
}
// Tab 5: Lieferauflistung (Tracking)
if ($object->fk_commande > 0) {
$head[$h][0] = dol_buildpath('/stundenzettel/stundenzettel_commande.php', 1).'?id='.$object->fk_commande.'&tab=tracking&noredirect=1&stundenzettel_id='.$object->id;
$head[$h][1] = $langs->trans("DeliveryTracking");
$head[$h][2] = 'tracking';
$h++;
}
// Tab 6: Notizen
$head[$h][0] = dol_buildpath('/stundenzettel/card.php', 1).'?id='.$object->id.'&tab=notes';
$head[$h][1] = $langs->trans("Notes");
$head[$h][2] = 'notes';
$h++;
return $head;
}

216
list.php Normal file
View file

@ -0,0 +1,216 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* Stundenzettel - Liste
*/
// Load Dolibarr environment
$res = 0;
if (!$res && file_exists("../main.inc.php")) $res = @include "../main.inc.php";
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res) die("Include of main fails");
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php';
dol_include_once('/stundenzettel/class/stundenzettel.class.php');
// Load translation files
$langs->loadLangs(array("stundenzettel@stundenzettel", "orders"));
// Security check
if (!$user->hasRight('stundenzettel', 'read')) {
accessforbidden();
}
// Get parameters
$action = GETPOST('action', 'aZ09');
$massaction = GETPOST('massaction', 'alpha');
$contextpage = GETPOST('contextpage', 'aZ') ? GETPOST('contextpage', 'aZ') : 'stundenzettellist';
$search_ref = GETPOST('search_ref', 'alpha');
$search_order = GETPOST('search_order', 'alpha');
$search_soc = GETPOST('search_soc', 'alpha');
$search_status = GETPOST('search_status', 'int');
$search_author = GETPOST('search_author', 'int');
// __USER_ID__ ersetzen falls verwendet
if ($search_author == '__USER_ID__' || GETPOST('search_author', 'alpha') == '__USER_ID__') {
$search_author = $user->id;
}
$sortfield = GETPOST('sortfield', 'aZ09comma');
$sortorder = GETPOST('sortorder', 'aZ09comma');
$page = GETPOSTISSET('pageplusone') ? (GETPOST('pageplusone') - 1) : GETPOST("page", 'int');
if (empty($page) || $page < 0) $page = 0;
$limit = GETPOST('limit', 'int') ? GETPOST('limit', 'int') : $conf->liste_limit;
$offset = $limit * $page;
if (!$sortfield) $sortfield = 's.date_stundenzettel';
if (!$sortorder) $sortorder = 'DESC';
// Build SQL
$sql = "SELECT s.rowid, s.ref, s.date_stundenzettel, s.status, s.fk_commande, s.fk_soc,";
$sql .= " c.ref as order_ref, soc.nom as soc_name";
$sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel as s";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."commande as c ON c.rowid = s.fk_commande";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as soc ON soc.rowid = s.fk_soc";
$sql .= " WHERE s.entity = ".((int)$conf->entity);
if ($search_ref) {
$sql .= natural_search('s.ref', $search_ref);
}
if ($search_order) {
$sql .= natural_search('c.ref', $search_order);
}
if ($search_soc) {
$sql .= natural_search('soc.nom', $search_soc);
}
if ($search_status !== '' && $search_status >= 0) {
$sql .= " AND s.status = ".((int)$search_status);
}
if ($search_author > 0) {
$sql .= " AND s.fk_user_author = ".((int)$search_author);
}
$sql .= $db->order($sortfield, $sortorder);
// Count total
$nbtotalofrecords = '';
$resql = $db->query($sql);
if ($resql) {
$nbtotalofrecords = $db->num_rows($resql);
}
$sql .= $db->plimit($limit + 1, $offset);
/*
* View
*/
$form = new Form($db);
$formother = new FormOther($db);
$objectstatic = new Stundenzettel($db);
$title = $langs->trans("StundenzettelList");
// Linkes Menü aktivieren
$_GET['mainmenu'] = 'stundenzettel';
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-stundenzettel page-list');
$param = '';
if ($search_ref) $param .= '&search_ref='.urlencode($search_ref);
if ($search_order) $param .= '&search_order='.urlencode($search_order);
if ($search_soc) $param .= '&search_soc='.urlencode($search_soc);
if ($search_status !== '') $param .= '&search_status='.urlencode($search_status);
if ($search_author > 0) $param .= '&search_author='.urlencode($search_author);
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'" name="formfilter">';
print '<input type="hidden" name="token" value="'.newToken().'">';
$newcardbutton = '';
if ($user->hasRight('stundenzettel', 'write')) {
$newcardbutton = '<a class="butActionNew" href="'.dol_buildpath('/stundenzettel/card.php?action=create', 1).'">';
$newcardbutton .= '<span class="fa fa-plus-circle valignmiddle"></span>';
$newcardbutton .= '</a>';
}
print_barre_liste($title, $page, $_SERVER['PHP_SELF'], $param, $sortfield, $sortorder, '', $nbtotalofrecords, $nbtotalofrecords, 'clock', 0, $newcardbutton, '', $limit);
print '<div class="div-table-responsive-no-min">';
print '<table class="tagtable nobottomiftotal liste">';
// Header
print '<tr class="liste_titre_filter">';
print '<td class="liste_titre"><input type="text" class="flat" name="search_ref" value="'.dol_escape_htmltag($search_ref).'" size="10"></td>';
print '<td class="liste_titre"></td>';
print '<td class="liste_titre"><input type="text" class="flat" name="search_order" value="'.dol_escape_htmltag($search_order).'" size="10"></td>';
print '<td class="liste_titre"><input type="text" class="flat" name="search_soc" value="'.dol_escape_htmltag($search_soc).'" size="15"></td>';
print '<td class="liste_titre center">';
print '<select name="search_status" class="flat">';
print '<option value="">--</option>';
print '<option value="0"'.($search_status === '0' ? ' selected' : '').'>'.$langs->trans("StatusDraft").'</option>';
print '<option value="1"'.($search_status === '1' ? ' selected' : '').'>'.$langs->trans("StatusValidated").'</option>';
print '<option value="2"'.($search_status === '2' ? ' selected' : '').'>'.$langs->trans("StatusInvoiced").'</option>';
print '</select>';
print '</td>';
print '<td class="liste_titre center">';
print '<input type="image" class="liste_titre" name="button_search" src="'.img_picto($langs->trans("Search"), 'search.png', '', '', 1).'" title="'.dol_escape_htmltag($langs->trans("Search")).'">';
print '<input type="image" class="liste_titre" name="button_removefilter" src="'.img_picto($langs->trans("RemoveFilter"), 'searchclear.png', '', '', 1).'" title="'.dol_escape_htmltag($langs->trans("RemoveFilter")).'">';
print '</td>';
print '</tr>';
print '<tr class="liste_titre">';
print_liste_field_titre("Ref", $_SERVER['PHP_SELF'], "s.ref", "", $param, "", $sortfield, $sortorder);
print_liste_field_titre("Date", $_SERVER['PHP_SELF'], "s.date_stundenzettel", "", $param, "", $sortfield, $sortorder);
print_liste_field_titre("Order", $_SERVER['PHP_SELF'], "c.ref", "", $param, "", $sortfield, $sortorder);
print_liste_field_titre("Customer", $_SERVER['PHP_SELF'], "soc.nom", "", $param, "", $sortfield, $sortorder);
print_liste_field_titre("Status", $_SERVER['PHP_SELF'], "s.status", "", $param, "", $sortfield, $sortorder, 'center ');
print_liste_field_titre("", $_SERVER['PHP_SELF'], "", "", $param, "", $sortfield, $sortorder, 'center ');
print '</tr>';
$resql = $db->query($sql);
if ($resql) {
$num = $db->num_rows($resql);
$i = 0;
while ($i < min($num, $limit)) {
$obj = $db->fetch_object($resql);
print '<tr class="oddeven">';
// Ref
print '<td>';
print '<a href="'.dol_buildpath('/stundenzettel/card.php?id='.$obj->rowid, 1).'">';
print img_picto('', 'clock', 'class="pictofixedwidth"').$obj->ref;
print '</a>';
print '</td>';
// Datum
print '<td>'.dol_print_date($db->jdate($obj->date_stundenzettel), 'day').'</td>';
// Auftrag
print '<td>';
if ($obj->order_ref) {
print '<a href="'.DOL_URL_ROOT.'/commande/card.php?id='.$obj->fk_commande.'">';
print img_picto('', 'order', 'class="pictofixedwidth"').$obj->order_ref;
print '</a>';
}
print '</td>';
// Kunde
print '<td>';
if ($obj->soc_name) {
print '<a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.$obj->fk_soc.'">';
print $obj->soc_name;
print '</a>';
}
print '</td>';
// Status
print '<td class="center">'.$objectstatic->LibStatut($obj->status, 5).'</td>';
// Actions
print '<td class="center">';
print '<a href="'.dol_buildpath('/stundenzettel/card.php?id='.$obj->rowid, 1).'">'.img_picto($langs->trans("View"), 'eye').'</a>';
print '</td>';
print '</tr>';
$i++;
}
if ($num == 0) {
print '<tr class="oddeven"><td colspan="6" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
}
$db->free($resql);
} else {
dol_print_error($db);
}
print '</table>';
print '</div>';
print '</form>';
llxFooter();
$db->close();

View file

@ -0,0 +1,3 @@
--
-- Script run when an upgrade of Dolibarr is done. Whatever is the Dolibarr version.
--

View file

@ -0,0 +1,14 @@
-- ============================================================================
-- Extrafeld für Stundenzettel-Status am Auftrag
-- 0 = Offen (Stundenzettel können bearbeitet werden)
-- 1 = Freigegeben (Stundenzettel gesperrt, bereit für Rechnung)
-- 2 = In Rechnung übertragen
-- ============================================================================
-- Extrafeld in extrafields-Tabelle registrieren
INSERT INTO llx_extrafields (name, entity, elementtype, label, type, size, fieldunique, fieldrequired, pos, alwayseditable, perms, langs, list, printable, fielddefault, fieldcomputed, fk_user_author, fk_user_modif, datec, enabled, help)
VALUES ('stundenzettel_status', 1, 'commande', 'StundenzettelStatus', 'int', '1', 0, 0, 100, 0, '', '', 0, 0, '0', '', NULL, NULL, NOW(), '1', '')
ON DUPLICATE KEY UPDATE label = 'StundenzettelStatus';
-- Spalte in commande_extrafields hinzufügen falls nicht vorhanden
ALTER TABLE llx_commande_extrafields ADD COLUMN IF NOT EXISTS stundenzettel_status INT DEFAULT 0;

View file

@ -0,0 +1,9 @@
-- ============================================================================
-- View for services only (fk_product_type = 1)
-- Used for the "Standard-Leistung" extrafield dropdown on customers
-- ============================================================================
CREATE OR REPLACE VIEW llx_product_services AS
SELECT rowid, ref, label, description, fk_product_type, entity, tosell, tobuy
FROM llx_product
WHERE fk_product_type = 1;

View file

@ -0,0 +1 @@
-- Keys for llx_stundenzettel (already defined in main SQL)

40
sql/llx_stundenzettel.sql Normal file
View file

@ -0,0 +1,40 @@
-- ============================================================================
-- Stundenzettel Haupttabelle
-- ============================================================================
CREATE TABLE llx_stundenzettel (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
ref VARCHAR(30) NOT NULL, -- Stundenzettel-Nummer (SZ2026-00001)
entity INTEGER DEFAULT 1 NOT NULL,
-- Verknüpfungen
fk_commande INTEGER NOT NULL, -- Verknüpfung zum Auftrag
fk_facture INTEGER DEFAULT NULL, -- Verknüpfung zur Rechnung (nach Übertrag)
fk_soc INTEGER NOT NULL, -- Kunde
fk_user_author INTEGER NOT NULL, -- Ersteller
fk_user_valid INTEGER DEFAULT NULL, -- Wer hat freigegeben
-- Datum
date_stundenzettel DATE NOT NULL, -- Datum des Stundenzettels
datec DATETIME, -- Erstelldatum
date_valid DATETIME DEFAULT NULL, -- Freigabedatum
-- Status: 0=Entwurf, 1=Freigegeben, 2=In Rechnung übertragen, 9=Storniert
status TINYINT DEFAULT 0 NOT NULL,
-- Notizen
note_private TEXT,
note_public TEXT,
-- Technisch
import_key VARCHAR(14),
model_pdf VARCHAR(255),
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Indizes
UNIQUE KEY uk_stundenzettel_ref (ref, entity),
INDEX idx_stundenzettel_commande (fk_commande),
INDEX idx_stundenzettel_soc (fk_soc),
INDEX idx_stundenzettel_date (date_stundenzettel),
INDEX idx_stundenzettel_status (status)
) ENGINE=InnoDB;

View file

@ -0,0 +1 @@
-- Keys for llx_stundenzettel_leistung (already defined in main SQL)

View file

@ -0,0 +1,34 @@
-- ============================================================================
-- Stundenzettel Leistungen (Arbeitszeiten)
-- Mehrere Leistungen pro Stundenzettel möglich
-- ============================================================================
CREATE TABLE llx_stundenzettel_leistung (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
fk_stundenzettel INTEGER NOT NULL, -- Verknüpfung zum Stundenzettel
fk_user INTEGER DEFAULT NULL, -- Welcher Mitarbeiter
-- Zeitraum
date_leistung DATE NOT NULL, -- Datum der Leistung
time_start TIME DEFAULT NULL, -- Startzeit
time_end TIME DEFAULT NULL, -- Endzeit
duration INTEGER DEFAULT 0, -- Dauer in Minuten (berechnet oder manuell)
-- Beschreibung
description TEXT, -- Was wurde gemacht
-- Rang für Sortierung
rang INTEGER DEFAULT 0,
-- Technisch
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Indizes
INDEX idx_leistung_stundenzettel (fk_stundenzettel),
INDEX idx_leistung_date (date_leistung),
INDEX idx_leistung_user (fk_user),
-- Foreign Key
CONSTRAINT fk_leistung_stundenzettel FOREIGN KEY (fk_stundenzettel)
REFERENCES llx_stundenzettel(rowid) ON DELETE CASCADE
) ENGINE=InnoDB;

View file

@ -0,0 +1,29 @@
-- ============================================================================
-- Stundenzettel Notizen (abhakbare Merkzettel)
-- Mehrere Notizen pro Stundenzettel möglich
-- ============================================================================
CREATE TABLE llx_stundenzettel_note (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
fk_stundenzettel INTEGER NOT NULL, -- Verknüpfung zum Stundenzettel
fk_user INTEGER DEFAULT NULL, -- Wer hat erstellt
-- Inhalt
note TEXT NOT NULL, -- Die Notiz selbst
checked TINYINT(1) DEFAULT 0, -- 0=offen, 1=abgehakt
-- Rang für Sortierung
rang INTEGER DEFAULT 0,
-- Technisch
datec DATETIME DEFAULT NULL, -- Erstellt am
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Indizes
INDEX idx_note_stundenzettel (fk_stundenzettel),
INDEX idx_note_checked (checked),
-- Foreign Key
CONSTRAINT fk_note_stundenzettel FOREIGN KEY (fk_stundenzettel)
REFERENCES llx_stundenzettel(rowid) ON DELETE CASCADE
) ENGINE=InnoDB;

View file

@ -0,0 +1 @@
-- Keys for llx_stundenzettel_product (already defined in main SQL)

View file

@ -0,0 +1,43 @@
-- ============================================================================
-- Stundenzettel Produkte
-- Welche Produkte wurden verbaut/geliefert
-- ============================================================================
CREATE TABLE llx_stundenzettel_product (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
fk_stundenzettel INTEGER NOT NULL, -- Verknüpfung zum Stundenzettel
-- Produkt-Referenz
fk_product INTEGER DEFAULT NULL, -- Produkt-ID (kann NULL sein bei freien Zeilen)
fk_commandedet INTEGER DEFAULT NULL, -- Original-Zeile aus Auftrag
fk_manager_line INTEGER DEFAULT NULL, -- Zeile aus llx_facture_lines_manager (für Produktgruppen)
-- Produktdaten (Kopie für Historie)
product_ref VARCHAR(128), -- Produktreferenz
product_label VARCHAR(255), -- Produktbezeichnung
description TEXT, -- Beschreibung
-- Mengen
qty_original DECIMAL(24,8) DEFAULT 0, -- Ursprüngliche Menge aus Auftrag
qty_done DECIMAL(24,8) DEFAULT 0, -- Erledigte/verbaute Menge auf diesem Zettel
qty_cumulated DECIMAL(24,8) DEFAULT 0, -- Kumuliert über alle Stundenzettel
-- Herkunft: 'order' = aus Auftrag, 'added' = manuell hinzugefügt
origin VARCHAR(20) DEFAULT 'order',
-- Rang für Sortierung
rang INTEGER DEFAULT 0,
-- Technisch
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Indizes
INDEX idx_szproduct_stundenzettel (fk_stundenzettel),
INDEX idx_szproduct_product (fk_product),
INDEX idx_szproduct_commandedet (fk_commandedet),
INDEX idx_szproduct_manager (fk_manager_line),
-- Foreign Key
CONSTRAINT fk_szproduct_stundenzettel FOREIGN KEY (fk_stundenzettel)
REFERENCES llx_stundenzettel(rowid) ON DELETE CASCADE
) ENGINE=InnoDB;

View file

@ -0,0 +1 @@
-- Keys for llx_stundenzettel_tracking (already defined in main SQL)

View file

@ -0,0 +1,37 @@
-- ============================================================================
-- Stundenzettel Tracking
-- Gesamtübersicht der Mengen pro Auftrag (aggregiert über alle Stundenzettel)
-- ============================================================================
CREATE TABLE llx_stundenzettel_tracking (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
fk_commande INTEGER NOT NULL, -- Auftrag
-- Produkt-Referenz
fk_product INTEGER DEFAULT NULL, -- Produkt-ID
fk_commandedet INTEGER DEFAULT NULL, -- Original-Zeile aus Auftrag
fk_manager_line INTEGER DEFAULT NULL, -- Zeile aus llx_facture_lines_manager
-- Produktdaten
product_ref VARCHAR(128),
product_label VARCHAR(255),
-- Mengen
qty_ordered DECIMAL(24,8) DEFAULT 0, -- Bestellte Menge (aus Auftrag)
qty_delivered DECIMAL(24,8) DEFAULT 0, -- Gelieferte Menge (Summe aller Stundenzettel)
qty_added DECIMAL(24,8) DEFAULT 0, -- Zusätzlich hinzugefügt
qty_removed DECIMAL(24,8) DEFAULT 0, -- Entfallen/Storniert
qty_remaining DECIMAL(24,8) DEFAULT 0, -- Verbleibend (berechnet)
-- Status: 'open' = offen, 'partial' = teilweise, 'done' = erledigt
status VARCHAR(20) DEFAULT 'open',
-- Technisch
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Indizes
UNIQUE KEY uk_tracking_line (fk_commande, fk_commandedet),
INDEX idx_tracking_commande (fk_commande),
INDEX idx_tracking_product (fk_product),
INDEX idx_tracking_status (status)
) ENGINE=InnoDB;

2325
stundenzettel_commande.php Normal file

File diff suppressed because it is too large Load diff