diff --git a/ChangeLog.md b/ChangeLog.md index bd436ed..fd6f372 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,9 +1,14 @@ # CHANGELOG MODULE TIMESHEETWEEK FOR [DOLIBARR ERP CRM](https://www.dolibarr.org) +## 1.3.0 +- Active la génération de PDF depuis la fiche hebdomadaire avec le widget Documents Dolibarr et respecte les modèles configurés dans la page de setup. / Enables PDF generation from the weekly sheet using Dolibarr's Documents widget and honours the templates configured in the setup page. +- Introduit le modèle PDF « standard_timesheetweek » basé sur la synthèse partagée afin de produire les fichiers dans le répertoire documentaire Dolibarr. / Introduces the "standard_timesheetweek" PDF model powered by the shared summary engine so files land into Dolibarr's document directory. + ## 1.2.0 - Ajoute le support des contrats « Cadre au forfait jour » avec sélecteurs Journée/Matin/Après-midi et conversion automatique des durées en base. / Adds support for "daily rate" contracts with Full day/Morning/Afternoon selectors and automatic duration conversion. - Crée l'extrafield salarié « Contrat forfait jour » et stocke la sélection correspondante dans la colonne dédiée. / Creates the employee extrafield "Daily rate contract" and stores the related selection in the dedicated column. - Complète les traductions « LastModification » et « TotalDays » pour harmoniser l'affichage du forfait jour. / Completes the "LastModification" and "TotalDays" translations to harmonise the daily rate display. +- Adapte le PDF de synthèse pour afficher les salariés au forfait jour en jours travaillés et masque les colonnes déplacements/paniers/heures supplémentaires. / Adapts the summary PDF to show daily-rate employees in worked days and hides the trips/meals/overtime columns. ## 1.1.1 - Mise à "plat" des permissions pour régler un problème d'affichage des PDF. / "Flattening" permissions to fix a PDF display issue. diff --git a/README.md b/README.md index 5e4e1cf..3273f32 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ TimesheetWeek ajoute une gestion hebdomadaire des feuilles de temps fidèle à l - Inversion des couleurs des statuts « Scellée » et « Refusée » pour respecter les codes couleur Dolibarr. - Refonte complète de la page de configuration inspirée du module DiffusionPlans pour gérer les masques de numérotation et les modèles PDF selon les codes graphiques Dolibarr. - Sélection du masque de numérotation via des commutateurs natifs directement depuis la configuration Dolibarr. +- Génération du PDF de la feuille directement depuis la fiche hebdomadaire avec le widget Documents et respect du modèle configuré dans l'administration. - Onglet « À propos » dédié pour retrouver la version, l'éditeur et les ressources utiles du module. - README bilingue (FR/EN) pour faciliter le déploiement et l'adoption. @@ -60,6 +61,7 @@ TimesheetWeek delivers weekly timesheet management that follows Dolibarr design - Swapped colours for « Scellée » and « Refusée » statuses to match Dolibarr visual cues. - Fully redesigned setup page inspired by the DiffusionPlans module to drive numbering masks and PDF templates with Dolibarr's graphical and functional patterns. - Numbering mask selection driven by native toggle switches directly inside Dolibarr's configuration. +- PDF generation available directly from the weekly sheet through the Documents widget, honouring the template configured in the administration area. - Dedicated « À propos » tab exposing the module version, publisher and handy resources. - Bilingual (FR/EN) README to streamline rollout and user onboarding. diff --git a/admin/setup.php b/admin/setup.php index bc9c1d1..3624524 100644 --- a/admin/setup.php +++ b/admin/setup.php @@ -1,6 +1,5 @@ * * 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 @@ -26,20 +25,24 @@ // FR: Charge l'environnement Dolibarr en testant les chemins possibles. $res = 0; if (!$res && file_exists(__DIR__.'/../main.inc.php')) { - $res = require_once __DIR__.'/../main.inc.php'; + $res = require_once __DIR__.'/../main.inc.php'; } if (!$res && file_exists(__DIR__.'/../../main.inc.php')) { - $res = require_once __DIR__.'/../../main.inc.php'; + $res = require_once __DIR__.'/../../main.inc.php'; } if (!$res && file_exists(__DIR__.'/../../../main.inc.php')) { - $res = require_once __DIR__.'/../../../main.inc.php'; + $res = require_once __DIR__.'/../../../main.inc.php'; } if (!$res) { - die('Include of main fails'); + die('Include of main fails'); } require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php'; +// EN: Load document helper functions required for model toggles. +// FR: Charge les fonctions d'aide aux documents nécessaires aux commutateurs de modèles. +require_once DOL_DOCUMENT_ROOT.'/core/lib/doc.lib.php'; dol_include_once('/timesheetweek/lib/timesheetweek.lib.php'); dol_include_once('/timesheetweek/class/timesheetweek.class.php'); @@ -50,7 +53,7 @@ // EN: Only administrators can access the setup. // FR: Seuls les administrateurs peuvent accéder à la configuration. if (empty($user->admin)) { - accessforbidden(); + accessforbidden(); } // EN: Read HTTP parameters once so we can re-use them further down. @@ -58,251 +61,283 @@ $action = GETPOST('action', 'aZ09'); $value = GETPOST('value', 'alphanohtml'); $token = GETPOST('token', 'alphanohtml'); +// EN: Capture additional parameters used to reproduce Dolibarr's document model toggles. +// FR: Capture les paramètres additionnels utilisés par les bascules de modèles de document Dolibarr. +$docLabel = GETPOST('label', 'alphanohtml'); +$scanDir = GETPOST('scan_dir', 'alpha'); // EN: Helper to enable a PDF model in the database. // FR: Aide pour activer un modèle PDF dans la base. -function timesheetweekEnableDocumentModel($model) +function timesheetweekEnableDocumentModel($model, $label = '', $scandir = '') { - global $db, $conf; - - if (empty($model)) { - return 0; - } - - $sql = 'INSERT INTO '.MAIN_DB_PREFIX."document_model (nom, type, entity) VALUES ('".$db->escape($model)."', 'timesheetweek', ".((int) $conf->entity).')'; - $resql = $db->query($sql); - if ($resql) { - return 1; - } - - // EN: Ignore duplicate entries silently because the model is already stored. - // FR: Ignore les doublons car le modèle est déjà enregistré. - if ($db->lasterrno() && strpos($db->lasterror(), 'Duplicate') !== false) { - return 1; - } - - return -1; + global $db, $conf; + + if (empty($model)) { + return 0; + } + + // EN: Check if the model already exists for the current entity to avoid duplicates. + // FR: Vérifie si le modèle existe déjà pour l'entité courante afin d'éviter les doublons. + $sql = 'SELECT rowid FROM '.MAIN_DB_PREFIX."document_model WHERE nom='".$db->escape($model)."' AND type='timesheetweek' AND entity=".((int) $conf->entity); + $resql = $db->query($sql); + if (!$resql) { + return -1; + } + + $existing = $db->fetch_object($resql); + $db->free($resql); + + if ($existing) { + $fields = array(); + + // EN: Refresh label when provided to keep UI messages in sync. + // FR: Met à jour le libellé fourni pour garder l'interface cohérente. + if ($label !== '') { + $fields[] = "libelle='".$db->escape($label)."'"; + } + + // EN: Refresh directory hint when provided to ease future scans. + // FR: Met à jour le chemin fourni pour faciliter les scans ultérieurs. + if ($scandir !== '') { + $fields[] = "description='".$db->escape($scandir)."'"; + } + + if (!empty($fields)) { + $sqlUpdate = 'UPDATE '.MAIN_DB_PREFIX."document_model SET ".implode(', ', $fields).' WHERE rowid='.((int) $existing->rowid); + if (!$db->query($sqlUpdate)) { + return -1; + } + } + + return 1; + } + + $result = addDocumentModel($model, 'timesheetweek', $label, $scandir); + if ($result > 0) { + return 1; + } + + return ($result === 0) ? 1 : -1; } + // EN: Helper to disable a PDF model from the database. // FR: Aide pour désactiver un modèle PDF dans la base. function timesheetweekDisableDocumentModel($model) { - global $db, $conf; - - if (empty($model)) { - return 0; - } - - $sql = 'DELETE FROM '.MAIN_DB_PREFIX."document_model WHERE nom='".$db->escape($model)."' AND type='timesheetweek' AND entity IN (0, ".((int) $conf->entity).')'; - $resql = $db->query($sql); - if ($resql) { - return ($db->affected_rows($resql) >= 0) ? 1 : 0; - } + if (empty($model)) { + return 0; + } - return -1; + $result = delDocumentModel($model, 'timesheetweek'); + return ($result > 0) ? 1 : ($result === 0 ? 0 : -1); } // EN: Build the list of numbering modules available for the module. // FR: Construit la liste des modules de numérotation disponibles pour le module. function timesheetweekListNumberingModules(array $directories, Translate $langs, TimesheetWeek $sample, $selected) { - global $db; - - $modules = array(); - - foreach ($directories as $reldir) { - $dir = dol_buildpath($reldir.'core/modules/timesheetweek/'); - if (!is_dir($dir)) { - continue; - } - - $files = dol_dir_list($dir, 'files', 0, '^mod_.*\.php$'); - foreach ($files as $fileinfo) { - $file = $fileinfo['name']; - $classname = preg_replace('/\.php$/', '', $file); - - require_once $dir.$file; - if (!class_exists($classname)) { - continue; - } - - $module = new $classname($db); - - $label = !empty($module->name) ? $module->name : $classname; - if ($label && $langs->transnoentitiesnoconv($label) !== $label) { - $label = $langs->trans($label); - } - - $description = ''; - if (method_exists($module, 'info')) { - $description = $module->info($langs); - } elseif (!empty($module->description)) { - $description = $module->description; - } - if ($description && $langs->transnoentitiesnoconv($description) !== $description) { - $description = $langs->trans($description); - } - - $example = ''; - if (method_exists($module, 'getExample')) { - $example = $module->getExample($sample); - } - - $canBeActivated = true; - $activationError = ''; - if (method_exists($module, 'canBeActivated')) { - $canBeActivated = (bool) $module->canBeActivated($sample); - if (!$canBeActivated && !empty($module->error)) { - $activationError = $module->error; - } - } - - $modules[] = array( - 'classname' => $classname, - 'label' => $label, - 'description' => $description, - 'example' => $example, - 'active' => ($selected === $classname), - 'can_be_activated' => $canBeActivated, - 'activation_error' => $activationError, - ); - } - } - - usort($modules, function ($a, $b) { - return strcasecmp($a['label'], $b['label']); - }); - - return $modules; + global $db; + + $modules = array(); + + foreach ($directories as $reldir) { + // EN: Resolve the directory that holds the numbering module classes. + // FR: Résout le répertoire qui contient les classes de numérotation. + $dir = dol_buildpath(rtrim($reldir, '/').'/timesheetweek/core/modules/timesheetweek/'); + if (!is_dir($dir)) { + continue; + } + + $files = dol_dir_list($dir, 'files', 0, '^mod_.*\.php$'); + foreach ($files as $fileinfo) { + $file = $fileinfo['name']; + $classname = preg_replace('/\.php$/', '', $file); + + require_once $dir.$file; + if (!class_exists($classname)) { + continue; + } + + $module = new $classname($db); + + $label = !empty($module->name) ? $module->name : $classname; + if ($label && $langs->transnoentitiesnoconv($label) !== $label) { + $label = $langs->trans($label); + } + + $description = ''; + if (method_exists($module, 'info')) { + $description = $module->info($langs); + } elseif (!empty($module->description)) { + $description = $module->description; + } + if ($description && $langs->transnoentitiesnoconv($description) !== $description) { + $description = $langs->trans($description); + } + + $example = ''; + if (method_exists($module, 'getExample')) { + $example = $module->getExample($sample); + } + + $canBeActivated = true; + $activationError = ''; + if (method_exists($module, 'canBeActivated')) { + $canBeActivated = (bool) $module->canBeActivated($sample); + if (!$canBeActivated && !empty($module->error)) { + $activationError = $module->error; + } + } + + $modules[] = array( + 'classname' => $classname, + 'label' => $label, + 'description' => $description, + 'example' => $example, + 'active' => ($selected === $classname), + 'can_be_activated' => $canBeActivated, + 'activation_error' => $activationError, + ); + } + } + + usort($modules, function ($a, $b) { + return strcasecmp($a['label'], $b['label']); + }); + + return $modules; } -// EN: Build the list of available PDF models for the module. -// FR: Construit la liste des modèles PDF disponibles pour le module. +// EN: Build the list of available document models for the module. +// FR: Construit la liste des modèles de documents disponibles pour le module. function timesheetweekListDocumentModels(array $directories, Translate $langs, array $enabled, $default) { - global $db; - - $models = array(); - - foreach ($directories as $reldir) { - $dir = dol_buildpath($reldir.'core/modules/timesheetweek/doc/'); - if (!is_dir($dir)) { - continue; - } - - $files = dol_dir_list($dir, 'files', 0, '^[a-z0-9_]+\.php$'); - foreach ($files as $fileinfo) { - $file = $fileinfo['name']; - $classname = preg_replace('/\.php$/', '', $file); - - require_once $dir.$file; - if (!class_exists($classname)) { - continue; - } - - $module = new $classname($db); - if (empty($module->type) || $module->type !== 'pdf') { - continue; - } - - $name = !empty($module->name) ? $module->name : $classname; - $label = $name; - if (!empty($module->name) && $langs->transnoentitiesnoconv($module->name) !== $module->name) { - $label = $langs->trans($module->name); - } - - $description = ''; - if (!empty($module->description)) { - $description = $module->description; - } - if ($description && $langs->transnoentitiesnoconv($description) !== $description) { - $description = $langs->trans($description); - } - - $models[] = array( - 'name' => $name, - 'classname' => $classname, - 'label' => $label, - 'description' => $description, - 'is_enabled' => !empty($enabled[$name]), - 'is_default' => ($default === $name), - 'type' => $module->type, - ); - } - } - - usort($models, function ($a, $b) { - return strcasecmp($a['label'], $b['label']); - }); - - return $models; + global $db; + + $models = array(); + + foreach ($directories as $reldir) { + // EN: Resolve the directory that stores the document model definitions. + // FR: Résout le répertoire qui contient les définitions de modèles de document. + $dir = dol_buildpath(rtrim($reldir, '/').'/timesheetweek/core/modules/timesheetweek/doc/'); + if (!is_dir($dir)) { + continue; + } + + $files = dol_dir_list($dir, 'files', 0, '^[A-Za-z0-9_]+\.modules\.php$'); + foreach ($files as $fileinfo) { + $file = $fileinfo['name']; + $classname = preg_replace('/\.modules\.php$/i', '', $file); + + require_once $dir.$file; + if (!class_exists($classname)) { + continue; + } + + $module = new $classname($db); + if (empty($module->type)) { + continue; + } + + $name = !empty($module->name) ? $module->name : $classname; + $label = $name; + if (!empty($module->name) && $langs->transnoentitiesnoconv($module->name) !== $module->name) { + $label = $langs->trans($module->name); + } + + $description = ''; + if (!empty($module->description)) { + $description = $module->description; + } + if ($description && $langs->transnoentitiesnoconv($description) !== $description) { + $description = $langs->trans($description); + } + + $models[] = array( + 'name' => $name, + 'classname' => $classname, + 'label' => $label, + 'description' => $description, + 'is_enabled' => !empty($enabled[$name]), + 'is_default' => ($default === $name), + 'type' => $module->type, + 'scandir' => property_exists($module, 'scandir') ? $module->scandir : '', + ); + } + } + + usort($models, function ($a, $b) { + return strcasecmp($a['label'], $b['label']); + }); + + return $models; } // EN: Verify CSRF token when the request changes the configuration. // FR: Vérifie le jeton CSRF lorsque la requête modifie la configuration. if (in_array($action, array('setmodule', 'setdoc', 'setdocmodel', 'delmodel'), true)) { - if (function_exists('dol_verify_token')) { - if (empty($token) || dol_verify_token($token) <= 0) { - accessforbidden(); - } - } + if (function_exists('dol_verify_token')) { + if (empty($token) || dol_verify_token($token) <= 0) { + accessforbidden(); + } + } } // EN: Persist the chosen numbering module. // FR: Enregistre le module de numérotation choisi. if ($action === 'setmodule' && !empty($value)) { - $result = dolibarr_set_const($db, 'TIMESHEETWEEK_ADDON', $value, 'chaine', 0, '', $conf->entity); - if ($result > 0) { - setEventMessages($langs->trans('SetupSaved'), null, 'mesgs'); - } else { - setEventMessages($langs->trans('Error'), null, 'errors'); - } + $result = dolibarr_set_const($db, 'TIMESHEETWEEK_ADDON', $value, 'chaine', 0, '', $conf->entity); + if ($result > 0) { + setEventMessages($langs->trans('SetupSaved'), null, 'mesgs'); + } else { + setEventMessages($langs->trans('Error'), null, 'errors'); + } } // EN: Set the default PDF model while ensuring the model is enabled. // FR: Définit le modèle PDF par défaut tout en s'assurant qu'il est activé. if ($action === 'setdoc' && !empty($value)) { - $res = timesheetweekEnableDocumentModel($value); - if ($res > 0) { - $res = dolibarr_set_const($db, 'TIMESHEETWEEK_ADDON_PDF', $value, 'chaine', 0, '', $conf->entity); - } - if ($res > 0) { - setEventMessages($langs->trans('SetupSaved'), null, 'mesgs'); - } else { - setEventMessages($langs->trans('Error'), null, 'errors'); - } +$res = timesheetweekEnableDocumentModel($value, $docLabel, $scanDir); + if ($res > 0) { + $res = dolibarr_set_const($db, 'TIMESHEETWEEK_ADDON_PDF', $value, 'chaine', 0, '', $conf->entity); + } + if ($res > 0) { + setEventMessages($langs->trans('SetupSaved'), null, 'mesgs'); + } else { + setEventMessages($langs->trans('Error'), null, 'errors'); + } } // EN: Activate a PDF model without making it the default. // FR: Active un modèle PDF sans le définir comme défaut. if ($action === 'setdocmodel' && !empty($value)) { - $res = timesheetweekEnableDocumentModel($value); - if ($res > 0) { - setEventMessages($langs->trans('ModelEnabled', $value), null, 'mesgs'); - } else { - setEventMessages($langs->trans('Error'), null, 'errors'); - } +$res = timesheetweekEnableDocumentModel($value, $docLabel, $scanDir); + if ($res > 0) { + setEventMessages($langs->trans('ModelEnabled', $value), null, 'mesgs'); + } else { + setEventMessages($langs->trans('Error'), null, 'errors'); + } } // EN: Disable a PDF model and remove the default flag if needed. // FR: Désactive un modèle PDF et supprime le statut par défaut si nécessaire. if ($action === 'delmodel' && !empty($value)) { - $res = timesheetweekDisableDocumentModel($value); - if ($res > 0) { - if ($value === getDolGlobalString('TIMESHEETWEEK_ADDON_PDF')) { - dolibarr_del_const($db, 'TIMESHEETWEEK_ADDON_PDF', $conf->entity); - } - setEventMessages($langs->trans('ModelDisabled', $value), null, 'mesgs'); - } else { - setEventMessages($langs->trans('Error'), null, 'errors'); - } + $res = timesheetweekDisableDocumentModel($value); + if ($res > 0) { + if ($value === getDolGlobalString('TIMESHEETWEEK_ADDON_PDF')) { + dolibarr_del_const($db, 'TIMESHEETWEEK_ADDON_PDF', $conf->entity); + } + setEventMessages($langs->trans('ModelDisabled', $value), null, 'mesgs'); + } else { + setEventMessages($langs->trans('Error'), null, 'errors'); + } } // EN: Read the selected options so we can highlight them in the UI. // FR: Lit les options sélectionnées pour les mettre en avant dans l'interface. $selectedNumbering = getDolGlobalString('TIMESHEETWEEK_ADDON', 'mod_timesheetweek_standard'); -$defaultPdf = getDolGlobalString('TIMESHEETWEEK_ADDON_PDF', 'standard'); +$defaultPdf = getDolGlobalString('TIMESHEETWEEK_ADDON_PDF', 'standard_timesheetweek'); $directories = array_merge(array('/'), (array) $conf->modules_parts['models']); // EN: Prepare a lightweight object to test numbering module activation. @@ -315,10 +350,10 @@ function timesheetweekListDocumentModels(array $directories, Translate $langs, a $sql = 'SELECT nom FROM '.MAIN_DB_PREFIX."document_model WHERE type='timesheetweek' AND entity IN (0, ".((int) $conf->entity).')'; $resql = $db->query($sql); if ($resql) { - while ($obj = $db->fetch_object($resql)) { - $enabledModels[$obj->nom] = 1; - } - $db->free($resql); + while ($obj = $db->fetch_object($resql)) { + $enabledModels[$obj->nom] = 1; + } + $db->free($resql); } // EN: Build the metadata arrays used by the HTML rendering below. @@ -359,41 +394,41 @@ function timesheetweekListDocumentModels(array $directories, Translate $langs, a print ''; if (count($numberingModules) === 0) { - print ''.$langs->trans('TimesheetWeekNumberingEmpty').''; + print ''.$langs->trans('TimesheetWeekNumberingEmpty').''; } foreach ($numberingModules as $moduleInfo) { - $desc = $moduleInfo['description']; - $descIsPlainText = ($desc === strip_tags($desc)); - $descHtml = $descIsPlainText ? dol_escape_htmltag($desc) : $desc; - - print ''; - print ''; - print dol_escape_htmltag($moduleInfo['label']); - if ($moduleInfo['classname'] !== $moduleInfo['label']) { - print ' ('.dol_escape_htmltag($moduleInfo['classname']).')'; - } - if (!$moduleInfo['can_be_activated'] && !empty($moduleInfo['activation_error'])) { - print '
'.dol_escape_htmltag($moduleInfo['activation_error']).''; - } - print ''; - - print ''.(!empty($descHtml) ? $descHtml : ' ').''; - print ''.(!empty($moduleInfo['example']) ? dol_escape_htmltag($moduleInfo['example']) : ' ').''; - - // EN: Render the activation toggle that selects the numbering model with CSRF protection. - // FR: Affiche le commutateur d’activation qui sélectionne le modèle de numérotation avec protection CSRF. - print ''; - if ($moduleInfo['active']) { - print img_picto($langs->trans('Enabled'), 'switch_on'); - } elseif ($moduleInfo['can_be_activated']) { - $url = $_SERVER['PHP_SELF'].'?action=setmodule&value='.urlencode($moduleInfo['classname']).'&token='.$pageToken; - print ''.img_picto($langs->trans('TimesheetWeekNumberingActivate'), 'switch_off').''; - } else { - print img_picto($langs->trans('Disabled'), 'switch_off'); - } - print ''; - print ''; + $desc = $moduleInfo['description']; + $descIsPlainText = ($desc === strip_tags($desc)); + $descHtml = $descIsPlainText ? dol_escape_htmltag($desc) : $desc; + + print ''; + print ''; + print dol_escape_htmltag($moduleInfo['label']); + if ($moduleInfo['classname'] !== $moduleInfo['label']) { + print ' ('.dol_escape_htmltag($moduleInfo['classname']).')'; + } + if (!$moduleInfo['can_be_activated'] && !empty($moduleInfo['activation_error'])) { + print '
'.dol_escape_htmltag($moduleInfo['activation_error']).''; + } + print ''; + + print ''.(!empty($descHtml) ? $descHtml : ' ').''; + print ''.(!empty($moduleInfo['example']) ? dol_escape_htmltag($moduleInfo['example']) : ' ').''; + + // EN: Render the activation toggle that selects the numbering model with CSRF protection. + // FR: Affiche le commutateur d’activation qui sélectionne le modèle de numérotation avec protection CSRF. + print ''; + if ($moduleInfo['active']) { + print img_picto($langs->trans('Enabled'), 'switch_on'); + } elseif ($moduleInfo['can_be_activated']) { + $url = $_SERVER['PHP_SELF'].'?action=setmodule&value='.urlencode($moduleInfo['classname']).'&token='.$pageToken; + print ''.img_picto($langs->trans('TimesheetWeekNumberingActivate'), 'switch_off').''; + } else { + print img_picto($langs->trans('Disabled'), 'switch_off'); + } + print ''; + print ''; } print ''; @@ -417,36 +452,36 @@ function timesheetweekListDocumentModels(array $directories, Translate $langs, a print ''; if (count($documentModels) === 0) { - print ''.$langs->trans('TimesheetWeekPDFModelsEmpty').''; + print ''.$langs->trans('TimesheetWeekPDFModelsEmpty').''; } foreach ($documentModels as $modelInfo) { - print ''; - print ''.dol_escape_htmltag($modelInfo['label']).''; - print ''.(!empty($modelInfo['description']) ? dol_escape_htmltag($modelInfo['description']) : ' ').''; - print ''.dol_escape_htmltag($modelInfo['type']).''; - - print ''; - if ($modelInfo['is_enabled']) { - $url = $_SERVER['PHP_SELF'].'?action=delmodel&value='.urlencode($modelInfo['name']).'&token='.$pageToken; - print ''.img_picto($langs->trans('Disable'), 'switch_on').''; - } else { - $url = $_SERVER['PHP_SELF'].'?action=setdocmodel&value='.urlencode($modelInfo['name']).'&token='.$pageToken; - print ''.img_picto($langs->trans('Activate'), 'switch_off').''; - } - print ''; - - print ''; - if ($modelInfo['is_default']) { - print img_picto($langs->trans('Enabled'), 'on'); - } elseif ($modelInfo['is_enabled']) { - $url = $_SERVER['PHP_SELF'].'?action=setdoc&value='.urlencode($modelInfo['name']).'&token='.$pageToken; - print ''.img_picto($langs->trans('SetDefault'), 'switch_on').''; - } else { - print ' '; - } - print ''; - print ''; + print ''; + print ''.dol_escape_htmltag($modelInfo['label']).''; + print ''.(!empty($modelInfo['description']) ? dol_escape_htmltag($modelInfo['description']) : ' ').''; + print ''.dol_escape_htmltag($modelInfo['type']).''; + + print ''; + if ($modelInfo['is_enabled']) { + $url = $_SERVER['PHP_SELF'].'?action=delmodel&value='.urlencode($modelInfo['name']).'&token='.$pageToken; + print ''.img_picto($langs->trans('Disable'), 'switch_on').''; + } else { + $url = $_SERVER['PHP_SELF'].'?action=setdocmodel&value='.urlencode($modelInfo['name']).'&token='.$pageToken.'&scan_dir='.urlencode($modelInfo['scandir']).'&label='.urlencode($modelInfo['label']); + print ''.img_picto($langs->trans('Activate'), 'switch_off').''; + } + print ''; + + print ''; + if ($modelInfo['is_default']) { + print img_picto($langs->trans('Enabled'), 'on'); + } elseif ($modelInfo['is_enabled']) { + $url = $_SERVER['PHP_SELF'].'?action=setdoc&value='.urlencode($modelInfo['name']).'&token='.$pageToken.'&scan_dir='.urlencode($modelInfo['scandir']).'&label='.urlencode($modelInfo['label']); + print ''.img_picto($langs->trans('SetDefault'), 'switch_on').''; + } else { + print ' '; + } + print ''; + print ''; } print ''; diff --git a/class/timesheetweek.class.php b/class/timesheetweek.class.php index 5b53c66..57b2c88 100644 --- a/class/timesheetweek.class.php +++ b/class/timesheetweek.class.php @@ -1,10 +1,10 @@ 0 new id, <0 error - */ + * EN: Detect lazily if the database schema already stores the PDF model. + * FR: Détecte paresseusement si le schéma de base stocke déjà le modèle PDF. + * + * @return bool + */ + protected function checkModelPdfColumnAvailability() + { + if ($this->hasModelPdfColumn !== null) { + return $this->hasModelPdfColumn; + } + + $sql = "SHOW COLUMNS FROM ".MAIN_DB_PREFIX.$this->table_element." LIKE 'model_pdf'"; + $resql = $this->db->query($sql); + if ($resql) { + $this->hasModelPdfColumn = ($this->db->num_rows($resql) > 0); + $this->db->free($resql); + } else { + $this->hasModelPdfColumn = false; + } + + return $this->hasModelPdfColumn; + } + + /** + * Create + * @param User $user + * @return int >0 new id, <0 error + */ public function create($user) { global $conf; @@ -95,27 +129,59 @@ public function create($user) $this->entity = (int) $conf->entity; $now = dol_now(); - $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element."("; - $sql .= "ref, entity, fk_user, year, week, status, note, date_creation, fk_user_valid, total_hours, overtime_hours, zone1_count, zone2_count, zone3_count, zone4_count, zone5_count, meal_count"; - $sql .= ") VALUES ("; - $sql .= " '".$this->db->escape($this->ref ? $this->ref : '(PROV)')."',"; - $sql .= " ".((int) $this->entity).","; - $sql .= " ".((int) $this->fk_user).","; - $sql .= " ".((int) $this->year).","; - $sql .= " ".((int) $this->week).","; - $sql .= " ".((int) $this->status).","; - $sql .= " ".($this->note !== null ? "'".$this->db->escape($this->note)."'" : "NULL").","; - $sql .= " '".$this->db->idate($now)."',"; - $sql .= " ".(!empty($this->fk_user_valid) ? (int) $this->fk_user_valid : "NULL").","; - $sql .= " ".((float) ($this->total_hours ?: 0)).","; - $sql .= " ".((float) ($this->overtime_hours ?: 0)).","; - $sql .= " ".((int) ($this->zone1_count ?: 0)).","; - $sql .= " ".((int) ($this->zone2_count ?: 0)).","; - $sql .= " ".((int) ($this->zone3_count ?: 0)).","; - $sql .= " ".((int) ($this->zone4_count ?: 0)).","; - $sql .= " ".((int) ($this->zone5_count ?: 0)).","; - $sql .= " ".((int) ($this->meal_count ?: 0)); - $sql .= ")"; + $fields = array( + 'ref', + 'entity', + 'fk_user', + 'year', + 'week', + 'status', + 'note', + 'date_creation', + 'fk_user_valid', + 'total_hours', + 'overtime_hours', + 'zone1_count', + 'zone2_count', + 'zone3_count', + 'zone4_count', + 'zone5_count', + 'meal_count' + ); + $values = array( + "'".$this->db->escape($this->ref ? $this->ref : '(PROV)')."'", + (int) $this->entity, + (int) $this->fk_user, + (int) $this->year, + (int) $this->week, + (int) $this->status, + ($this->note !== null ? "'".$this->db->escape($this->note)."'" : 'NULL'), + "'".$this->db->idate($now)."'", + (!empty($this->fk_user_valid) ? (int) $this->fk_user_valid : 'NULL'), + (float) ($this->total_hours ?: 0), + (float) ($this->overtime_hours ?: 0), + (int) ($this->zone1_count ?: 0), + (int) ($this->zone2_count ?: 0), + (int) ($this->zone3_count ?: 0), + (int) ($this->zone4_count ?: 0), + (int) ($this->zone5_count ?: 0), + (int) ($this->meal_count ?: 0) + ); + + if ($this->checkModelPdfColumnAvailability()) { + // EN: Persist the PDF model selection when the schema supports it. + // FR: Persiste la sélection du modèle PDF lorsque le schéma le supporte. + $selectedModel = $this->model_pdf !== '' ? $this->model_pdf : getDolGlobalString('TIMESHEETWEEK_ADDON_PDF', 'standard_timesheetweek'); + if ($selectedModel !== '') { + $this->model_pdf = $selectedModel; + $values[] = "'".$this->db->escape($selectedModel)."'"; + } else { + $values[] = 'NULL'; + } + $fields[] = 'model_pdf'; + } + + $sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." (".implode(', ', $fields).") VALUES (".implode(', ', $values).")"; $this->db->begin(); @@ -132,10 +198,10 @@ public function create($user) // Replace provisional ref with (PROV) if (empty($this->ref) || strpos($this->ref, '(PROV') === 0) { $this->ref = '(PROV'.$this->id.')'; - $up = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET ref='".$this->db->escape($this->ref)."' WHERE rowid=".(int) $this->id; - // EN: Prevent provisional reference updates outside the allowed entities. - // FR: Empêche la mise à jour de référence provisoire hors des entités autorisées. - $up .= " AND entity IN (".getEntity('timesheetweek').")"; + $up = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET ref='".$this->db->escape($this->ref)."' WHERE rowid=".(int) $this->id; + // EN: Prevent provisional reference updates outside the allowed entities. + // FR: Empêche la mise à jour de référence provisoire hors des entités autorisées. + $up .= " AND entity IN (".getEntity('timesheetweek').")"; if (!$this->db->query($up)) { $this->db->rollback(); $this->error = $this->db->lasterror(); @@ -143,80 +209,85 @@ public function create($user) } } - if (!$this->createAgendaEvent($user, 'TSWK_CREATE', 'TimesheetWeekAgendaCreated', array($this->ref))) { - $this->db->rollback(); - return -1; - } + if (!$this->createAgendaEvent($user, 'TSWK_CREATE', 'TimesheetWeekAgendaCreated', array($this->ref))) { + $this->db->rollback(); + return -1; + } - $this->db->commit(); - return $this->id; + $this->db->commit(); + return $this->id; } - /** - * EN: Fetch the existing timesheet for a user/week within an entity. - * FR: Récupère la feuille existante pour un utilisateur/semaine au sein d'une entité. - * - * @param int $userId Target user id - * @param int $year Target ISO year - * @param int $week Target ISO week number - * @param int|null $entity Optional entity identifier - * @return int >0 if found (id), 0 if not found, <0 on error - */ - public function fetchByUserWeek($userId, $year, $week, $entity = null) - { - global $conf; - - $this->error = ''; - $this->errors = array(); - - // EN: Determine the entity used for the lookup. - // FR: Détermine l'entité utilisée pour la recherche. - $entityId = ($entity !== null) ? (int) $entity : (int) $conf->entity; - - $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX.$this->table_element; - $sql .= " WHERE entity=".(int) $entityId; - // EN: Double check the entity against the module permissions matrix. - // FR: Vérifie en plus que l'entité figure dans la matrice des permissions du module. - $sql .= " AND entity IN (".getEntity('timesheetweek').")"; - $sql .= " AND fk_user=".(int) $userId; - $sql .= " AND year=".(int) $year; - $sql .= " AND week=".(int) $week; - $sql .= " LIMIT 1"; - - // EN: Execute the lookup to detect an existing record. - // FR: Exécute la recherche pour détecter un enregistrement existant. - $resql = $this->db->query($sql); - if (!$resql) { - $this->error = $this->db->lasterror(); - return -1; - } - - $obj = $this->db->fetch_object($resql); - if (!$obj) { - return 0; - } - - return $this->fetch((int) $obj->rowid); - } - - /** - * Fetch by id or ref - * @param int|null $id - * @param string|null $ref - * @return int - */ + /** + * EN: Fetch the existing timesheet for a user/week within an entity. + * FR: Récupère la feuille existante pour un utilisateur/semaine au sein d'une entité. + * + * @param int $userId Target user id + * @param int $year Target ISO year + * @param int $week Target ISO week number + * @param int|null $entity Optional entity identifier + * @return int >0 if found (id), 0 if not found, <0 on error + */ + public function fetchByUserWeek($userId, $year, $week, $entity = null) + { + global $conf; + + $this->error = ''; + $this->errors = array(); + + // EN: Determine the entity used for the lookup. + // FR: Détermine l'entité utilisée pour la recherche. + $entityId = ($entity !== null) ? (int) $entity : (int) $conf->entity; + + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE entity=".(int) $entityId; + // EN: Double check the entity against the module permissions matrix. + // FR: Vérifie en plus que l'entité figure dans la matrice des permissions du module. + $sql .= " AND entity IN (".getEntity('timesheetweek').")"; + $sql .= " AND fk_user=".(int) $userId; + $sql .= " AND year=".(int) $year; + $sql .= " AND week=".(int) $week; + $sql .= " LIMIT 1"; + + // EN: Execute the lookup to detect an existing record. + // FR: Exécute la recherche pour détecter un enregistrement existant. + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + + $obj = $this->db->fetch_object($resql); + if (!$obj) { + return 0; + } + + return $this->fetch((int) $obj->rowid); + } + + /** + * Fetch by id or ref + * @param int|null $id + * @param string|null $ref + * @return int + */ public function fetch($id = null, $ref = null) { $this->error = ''; $this->errors = array(); - $sql = "SELECT t.rowid, t.ref, t.entity, t.fk_user, t.year, t.week, t.status, t.note, t.date_creation, t.tms, t.date_validation, t.fk_user_valid,"; - $sql .= " t.total_hours, t.overtime_hours, t.zone1_count, t.zone2_count, t.zone3_count, t.zone4_count, t.zone5_count, t.meal_count"; + $includeModelPdf = $this->checkModelPdfColumnAvailability(); + + $sql = "SELECT t.rowid, t.ref, t.entity, t.fk_user, t.year, t.week, t.status, t.note, t.date_creation, t.tms, t.date_validation, t.fk_user_valid,"; + $sql .= " t.total_hours, t.overtime_hours, t.zone1_count, t.zone2_count, t.zone3_count, t.zone4_count, t.zone5_count, t.meal_count"; + if ($includeModelPdf) { + $sql .= ", t.model_pdf"; + } $sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t"; - $sql .= " WHERE 1=1"; - // EN: Restrict fetch operations to the entities enabled for this module. - // FR: Restreint les opérations de récupération aux entités autorisées pour ce module. - $sql .= " AND t.entity IN (".getEntity('timesheetweek').")"; + $sql .= " WHERE 1=1"; + // EN: Restrict fetch operations to the entities enabled for this module. + // FR: Restreint les opérations de récupération aux entités autorisées pour ce module. + $sql .= " AND t.entity IN (".getEntity('timesheetweek').")"; if ($id) { $sql .= " AND t.rowid=".(int) $id; } elseif ($ref) { @@ -247,14 +318,27 @@ public function fetch($id = null, $ref = null) $this->tms = $this->db->jdate($obj->tms); $this->date_validation = $this->db->jdate($obj->date_validation); $this->fk_user_valid = (int) $obj->fk_user_valid; - $this->total_hours = (float) $obj->total_hours; - $this->overtime_hours = (float) $obj->overtime_hours; - $this->zone1_count = (int) $obj->zone1_count; - $this->zone2_count = (int) $obj->zone2_count; - $this->zone3_count = (int) $obj->zone3_count; - $this->zone4_count = (int) $obj->zone4_count; - $this->zone5_count = (int) $obj->zone5_count; - $this->meal_count = (int) $obj->meal_count; + $this->total_hours = (float) $obj->total_hours; + $this->overtime_hours = (float) $obj->overtime_hours; + $this->zone1_count = (int) $obj->zone1_count; + $this->zone2_count = (int) $obj->zone2_count; + $this->zone3_count = (int) $obj->zone3_count; + $this->zone4_count = (int) $obj->zone4_count; + $this->zone5_count = (int) $obj->zone5_count; + $this->meal_count = (int) $obj->meal_count; + if ($includeModelPdf && isset($obj->model_pdf)) { + // EN: Load the stored PDF model when the column is available. + // FR: Charge le modèle PDF stocké lorsque la colonne est disponible. + $this->model_pdf = ($obj->model_pdf !== null) ? (string) $obj->model_pdf : ''; + } else { + $this->model_pdf = ''; + } + + if ($this->model_pdf === '') { + // EN: Default back to the configured PDF model to keep generation functional. + // FR: Revient au modèle PDF configuré pour conserver une génération fonctionnelle. + $this->model_pdf = getDolGlobalString('TIMESHEETWEEK_ADDON_PDF', 'standard_timesheetweek'); + } $this->fetchLines(); @@ -262,20 +346,20 @@ public function fetch($id = null, $ref = null) } /** - * Load lines into $this->lines - * @return int - */ + * Load lines into $this->lines + * @return int + */ public function fetchLines() { $this->lines = array(); if (empty($this->id)) return 0; - $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."timesheet_week_line"; - $sql .= " WHERE fk_timesheet_week=".(int) $this->id; - // EN: Protect the fetch to ensure lines belong to an allowed entity scope. - // FR: Protège le chargement pour s'assurer que les lignes appartiennent à une entité autorisée. - $sql .= " AND entity IN (".getEntity('timesheetweek').")"; + $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."timesheet_week_line"; + $sql .= " WHERE fk_timesheet_week=".(int) $this->id; + // EN: Protect the fetch to ensure lines belong to an allowed entity scope. + // FR: Protège le chargement pour s'assurer que les lignes appartiennent à une entité autorisée. + $sql .= " AND entity IN (".getEntity('timesheetweek').")"; $sql .= " ORDER BY day_date ASC, rowid ASC"; $res = $this->db->query($sql); @@ -294,8 +378,8 @@ public function fetchLines() } /** - * @return TimesheetWeekLine[] - */ + * @return TimesheetWeekLine[] + */ public function getLines() { if ($this->lines === null || !is_array($this->lines) || !count($this->lines)) { @@ -305,10 +389,10 @@ public function getLines() } /** - * Update core fields (not lines) - * @param User $user - * @return int - */ + * Update core fields (not lines) + * @param User $user + * @return int + */ public function update($user) { $now = dol_now(); @@ -318,25 +402,30 @@ public function update($user) if ($this->ref) $sets[] = "ref='".$this->db->escape($this->ref)."'"; if ($this->fk_user) $sets[] = "fk_user=".(int) $this->fk_user; if ($this->year) $sets[] = "year=".(int) $this->year; - if ($this->week) $sets[] = "week=".(int) $this->week; - if ($this->status !== null) $sets[] = "status=".(int) $this->status; - $sets[] = "note=".($this->note !== null ? "'".$this->db->escape($this->note)."'" : "NULL"); - $sets[] = "fk_user_valid=".(!empty($this->fk_user_valid) ? (int) $this->fk_user_valid : "NULL"); - $sets[] = "total_hours=".(float) ($this->total_hours ?: 0); - $sets[] = "overtime_hours=".(float) ($this->overtime_hours ?: 0); - $sets[] = "zone1_count=".(int) ($this->zone1_count ?: 0); - $sets[] = "zone2_count=".(int) ($this->zone2_count ?: 0); - $sets[] = "zone3_count=".(int) ($this->zone3_count ?: 0); - $sets[] = "zone4_count=".(int) ($this->zone4_count ?: 0); - $sets[] = "zone5_count=".(int) ($this->zone5_count ?: 0); - $sets[] = "meal_count=".(int) ($this->meal_count ?: 0); - $sets[] = "tms='".$this->db->idate($now)."'"; - - $sql .= " ".implode(', ', $sets); - $sql .= " WHERE rowid=".(int) $this->id; - // EN: Ensure updates only target rows within authorized entities. - // FR: Garantit que les mises à jour ne visent que les lignes des entités autorisées. - $sql .= " AND entity IN (".getEntity('timesheetweek').")"; + if ($this->week) $sets[] = "week=".(int) $this->week; + if ($this->status !== null) $sets[] = "status=".(int) $this->status; + $sets[] = "note=".($this->note !== null ? "'".$this->db->escape($this->note)."'" : "NULL"); + $sets[] = "fk_user_valid=".(!empty($this->fk_user_valid) ? (int) $this->fk_user_valid : "NULL"); + $sets[] = "total_hours=".(float) ($this->total_hours ?: 0); + $sets[] = "overtime_hours=".(float) ($this->overtime_hours ?: 0); + $sets[] = "zone1_count=".(int) ($this->zone1_count ?: 0); + $sets[] = "zone2_count=".(int) ($this->zone2_count ?: 0); + $sets[] = "zone3_count=".(int) ($this->zone3_count ?: 0); + $sets[] = "zone4_count=".(int) ($this->zone4_count ?: 0); + $sets[] = "zone5_count=".(int) ($this->zone5_count ?: 0); + $sets[] = "meal_count=".(int) ($this->meal_count ?: 0); + if ($this->checkModelPdfColumnAvailability()) { + // EN: Synchronise the stored PDF model when the schema exposes the column. + // FR: Synchronise le modèle PDF stocké lorsque le schéma expose la colonne. + $sets[] = "model_pdf=".($this->model_pdf !== '' ? "'".$this->db->escape($this->model_pdf)."'" : 'NULL'); + } + $sets[] = "tms='".$this->db->idate($now)."'"; + + $sql .= " ".implode(', ', $sets); + $sql .= " WHERE rowid=".(int) $this->id; + // EN: Ensure updates only target rows within authorized entities. + // FR: Garantit que les mises à jour ne visent que les lignes des entités autorisées. + $sql .= " AND entity IN (".getEntity('timesheetweek').")"; if (!$this->db->query($sql)) { $this->error = $this->db->lasterror(); @@ -346,96 +435,239 @@ public function update($user) } /** - * Delete (and its lines) - * @param User $user - * @return int + * Delete (and its lines) + * @param User $user + * @return int + */ + + /** + * EN: Override to skip SQL updates when the schema lacks the model_pdf column. + * FR: Surcharge pour ignorer les mises à jour SQL lorsque le schéma n'a pas la colonne model_pdf. + * + * @param User $user Current user triggering the change + * @param string $model PDF model identifier + * @param string $type Optional type (kept for compatibility) + * @return int + */ + public function setDocModel($user, $model, $type = '') + { + $this->model_pdf = $model; + + if ($this->checkModelPdfColumnAvailability()) { + return parent::setDocModel($user, $model, $type); + } + + return 1; + } + /** + * EN: Generate the PDF document using the selected model. + * FR: Génère le document PDF en utilisant le modèle sélectionné. + * + * @param string $model PDF model identifier / Identifiant du modèle PDF + * @param Translate|null $outputlangs Language handler / Gestionnaire de langues + * @param int $hidedetails Hide detail lines flag / Indicateur de masquage des détails + * @param int $hidedesc Hide descriptions flag / Indicateur de masquage des descriptions + * @param int $hideref Hide references flag / Indicateur de masquage des références + * @param array $moreparams Additional parameters / Paramètres additionnels + * @return int 1 on success, <=0 otherwise / 1 si succès, <=0 sinon */ + public function generateDocument($model = '', $outputlangs = null, $hidedetails = 0, $hidedesc = 0, $hideref = 0, $moreparams = array()) + { + global $conf, $langs; + + // EN: Reset error containers before launching the generator. + // FR: Réinitialise les conteneurs d'erreurs avant de lancer le générateur. + $this->error = ''; + $this->errors = array(); + + // EN: Remember the requested model or fall back to the object and global configuration. + // FR: Retient le modèle demandé ou retombe sur la configuration de l'objet puis du global. + if (empty($model)) { + if (!empty($this->model_pdf)) { + $model = $this->model_pdf; + } else { + $model = getDolGlobalString('TIMESHEETWEEK_ADDON_PDF', 'standard_timesheetweek'); + } + } + + if (!is_object($outputlangs)) { + $outputlangs = $langs; + } + + // EN: Ensure translations are available for error and filename messages. + // FR: S'assure que les traductions sont disponibles pour les messages d'erreur et de fichier. + if ($outputlangs instanceof Translate) { + if (method_exists($outputlangs, 'loadLangs')) { + $outputlangs->loadLangs(array('main', 'timesheetweek@timesheetweek')); + } else { + $outputlangs->load('main'); + $outputlangs->load('timesheetweek@timesheetweek'); + } + } + + if (empty($model)) { + $this->error = $outputlangs instanceof Translate ? $outputlangs->trans('ErrorNoPDFForDoc') : 'No PDF model available'; + return -1; + } + + // EN: Make the TimesheetWeek document models available. + // FR: Rend disponibles les modèles de documents TimesheetWeek. + dol_include_once('/timesheetweek/core/modules/timesheetweek/modules_timesheetweek.php'); + + $modulePath = dol_buildpath('/timesheetweek/core/modules/timesheetweek/doc/pdf_'.$model.'.modules.php', 0); + + // EN: Abort if the PDF module file cannot be accessed. + // FR: Abandonne si le fichier du module PDF est inaccessible. + if (!is_readable($modulePath)) { + $this->error = $outputlangs instanceof Translate ? $outputlangs->trans('ErrorFailedToLoadFile', basename($modulePath)) : 'Unable to load PDF module'; + dol_syslog(__METHOD__.' failed: '.$this->error, LOG_ERR); + return -1; + } + + require_once $modulePath; + + $classname = 'pdf_'.$model; + + // EN: Validate the presence of the generator class before instantiation. + // FR: Valide la présence de la classe génératrice avant instanciation. + if (!class_exists($classname)) { + $this->error = $outputlangs instanceof Translate ? $outputlangs->trans('ErrorFailedToLoadFile', $classname) : 'PDF class not found'; + dol_syslog(__METHOD__.' failed: '.$this->error, LOG_ERR); + return -1; + } + + $generator = new $classname($this->db); + + // EN: Execute the PDF generation with the selected options. + // FR: Exécute la génération PDF avec les options sélectionnées. + $result = $generator->write_file($this, $outputlangs, '', $hidedetails, $hidedesc, $hideref); + + if ($result <= 0) { + // EN: Propagate detailed errors from the generator for troubleshooting. + // FR: Propage les erreurs détaillées du générateur pour faciliter le diagnostic. + if (!empty($generator->errors)) { + $this->errors = (array) $generator->errors; + $this->error = implode(', ', $this->errors); + } elseif (!empty($generator->error)) { + $this->error = $generator->error; + $this->errors = array($generator->error); + } else { + $this->error = $outputlangs instanceof Translate ? $outputlangs->trans('ErrorFailToCreateFile') : 'PDF generation failed'; + $this->errors = array($this->error); + } + dol_syslog(__METHOD__.' failed: '.$this->error, LOG_ERR); + return -1; + } + + // EN: Keep track of the last generated document path for Dolibarr widgets. + // FR: Conserve le chemin du dernier document généré pour les widgets Dolibarr. + if (!empty($generator->result['relativepath'])) { + $this->last_main_doc = $generator->result['relativepath']; + } elseif (!empty($generator->result['fullpath'])) { + $this->last_main_doc = basename($generator->result['fullpath']); + } + + // EN: Store potential warnings from the generator for later display. + // FR: Stocke les avertissements potentiels du générateur pour un affichage ultérieur. + if (!empty($generator->result['warnings'])) { + $this->warnings = $generator->result['warnings']; + } + + // EN: Memorise the model used so that the next generation reuses it. + // FR: Mémorise le modèle utilisé pour que la prochaine génération le réutilise. + if (!empty($model)) { + $this->model_pdf = $model; + } + + return 1; + } + public function delete($user) { $this->db->begin(); - $dl = "DELETE FROM ".MAIN_DB_PREFIX."timesheet_week_line WHERE fk_timesheet_week=".(int) $this->id; - // EN: Secure the cascade deletion to the permitted entities only. - // FR: Sécurise la suppression en cascade aux seules entités autorisées. - $dl .= " AND entity IN (".getEntity('timesheetweek').")"; + $dl = "DELETE FROM ".MAIN_DB_PREFIX."timesheet_week_line WHERE fk_timesheet_week=".(int) $this->id; + // EN: Secure the cascade deletion to the permitted entities only. + // FR: Sécurise la suppression en cascade aux seules entités autorisées. + $dl .= " AND entity IN (".getEntity('timesheetweek').")"; if (!$this->db->query($dl)) { $this->db->rollback(); $this->error = $this->db->lasterror(); return -1; } - $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid=".(int) $this->id; - // EN: Limit the deletion of the header to the entities allowed for the module. - // FR: Limite la suppression de l'en-tête aux entités autorisées pour le module. - $sql .= " AND entity IN (".getEntity('timesheetweek').")"; - if (!$this->db->query($sql)) { - $this->db->rollback(); - $this->error = $this->db->lasterror(); - return -1; - } + $sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element." WHERE rowid=".(int) $this->id; + // EN: Limit the deletion of the header to the entities allowed for the module. + // FR: Limite la suppression de l'en-tête aux entités autorisées pour le module. + $sql .= " AND entity IN (".getEntity('timesheetweek').")"; + if (!$this->db->query($sql)) { + $this->db->rollback(); + $this->error = $this->db->lasterror(); + return -1; + } - if (!$this->createAgendaEvent($user, 'TSWK_DELETE', 'TimesheetWeekAgendaDeleted', array($this->ref), false)) { - $this->db->rollback(); - return -1; - } + if (!$this->createAgendaEvent($user, 'TSWK_DELETE', 'TimesheetWeekAgendaDeleted', array($this->ref), false)) { + $this->db->rollback(); + return -1; + } - $this->db->commit(); - return 1; - } + $this->db->commit(); + return 1; + } /** - * Compute totals and save into object (not DB) - * @return void - */ + * Compute totals and save into object (not DB) + * @return void + */ public function computeTotals() { - $total = 0.0; - $zoneBuckets = array(1=>0, 2=>0, 3=>0, 4=>0, 5=>0); - $mealDays = 0; - $dayAggregates = array(); - if (!is_array($this->lines) || !count($this->lines)) { - $this->fetchLines(); - } - foreach ($this->lines as $l) { - $total += (float) $l->hours; - // EN: Aggregate data per day to prevent counting duplicated task entries. - // FR: Agrège les données par jour pour éviter de compter plusieurs fois les mêmes tâches. - $dayKey = !empty($l->day_date) ? $l->day_date : null; - if ($dayKey === null) { - continue; - } - if (!isset($dayAggregates[$dayKey])) { - $dayAggregates[$dayKey] = array( - 'zone' => (int) $l->zone, - 'meal' => ((int) $l->meal > 0 ? 1 : 0) - ); - } else { - if ((int) $l->zone > 0) { - $dayAggregates[$dayKey]['zone'] = (int) $l->zone; - } - if ((int) $l->meal > 0) { - $dayAggregates[$dayKey]['meal'] = 1; - } - } - } - foreach ($dayAggregates as $info) { - $zoneVal = (int) ($info['zone'] ?? 0); - if ($zoneVal >= 1 && $zoneVal <= 5) { - $zoneBuckets[$zoneVal]++; - } - if (!empty($info['meal'])) { - $mealDays++; - } - } - $this->total_hours = $total; - // EN: Persist the aggregated metrics onto the main object for later storage. - // FR: Enregistre les indicateurs agrégés sur l'objet principal pour une sauvegarde ultérieure. - $this->zone1_count = $zoneBuckets[1]; - $this->zone2_count = $zoneBuckets[2]; - $this->zone3_count = $zoneBuckets[3]; - $this->zone4_count = $zoneBuckets[4]; - $this->zone5_count = $zoneBuckets[5]; - $this->meal_count = $mealDays; + $total = 0.0; + $zoneBuckets = array(1=>0, 2=>0, 3=>0, 4=>0, 5=>0); + $mealDays = 0; + $dayAggregates = array(); + if (!is_array($this->lines) || !count($this->lines)) { + $this->fetchLines(); + } + foreach ($this->lines as $l) { + $total += (float) $l->hours; + // EN: Aggregate data per day to prevent counting duplicated task entries. + // FR: Agrège les données par jour pour éviter de compter plusieurs fois les mêmes tâches. + $dayKey = !empty($l->day_date) ? $l->day_date : null; + if ($dayKey === null) { + continue; + } + if (!isset($dayAggregates[$dayKey])) { + $dayAggregates[$dayKey] = array( + 'zone' => (int) $l->zone, + 'meal' => ((int) $l->meal > 0 ? 1 : 0) + ); + } else { + if ((int) $l->zone > 0) { + $dayAggregates[$dayKey]['zone'] = (int) $l->zone; + } + if ((int) $l->meal > 0) { + $dayAggregates[$dayKey]['meal'] = 1; + } + } + } + foreach ($dayAggregates as $info) { + $zoneVal = (int) ($info['zone'] ?? 0); + if ($zoneVal >= 1 && $zoneVal <= 5) { + $zoneBuckets[$zoneVal]++; + } + if (!empty($info['meal'])) { + $mealDays++; + } + } + $this->total_hours = $total; + // EN: Persist the aggregated metrics onto the main object for later storage. + // FR: Enregistre les indicateurs agrégés sur l'objet principal pour une sauvegarde ultérieure. + $this->zone1_count = $zoneBuckets[1]; + $this->zone2_count = $zoneBuckets[2]; + $this->zone3_count = $zoneBuckets[3]; + $this->zone4_count = $zoneBuckets[4]; + $this->zone5_count = $zoneBuckets[5]; + $this->meal_count = $mealDays; // Weekly contracted hours from user $weekly = 35.0; @@ -446,39 +678,39 @@ public function computeTotals() } } $ot = $total - $weekly; - $this->overtime_hours = ($ot > 0) ? $ot : 0.0; + $this->overtime_hours = ($ot > 0) ? $ot : 0.0; } /** - * Save totals into DB - * @return int - */ + * Save totals into DB + * @return int + */ public function updateTotalsInDB() { $this->computeTotals(); $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element; - $sql .= " SET total_hours=".(float) $this->total_hours.", overtime_hours=".(float) $this->overtime_hours; - $sql .= ", zone1_count=".(int) $this->zone1_count.", zone2_count=".(int) $this->zone2_count; - $sql .= ", zone3_count=".(int) $this->zone3_count.", zone4_count=".(int) $this->zone4_count; - $sql .= ", zone5_count=".(int) $this->zone5_count.", meal_count=".(int) $this->meal_count; - $sql .= " WHERE rowid=".(int) $this->id; - // EN: Constrain totals updates to the allowed entities for safety. - // FR: Contraint les mises à jour des totaux aux entités autorisées pour plus de sécurité. - $sql .= " AND entity IN (".getEntity('timesheetweek').")"; - return $this->db->query($sql) ? 1 : -1; + $sql .= " SET total_hours=".(float) $this->total_hours.", overtime_hours=".(float) $this->overtime_hours; + $sql .= ", zone1_count=".(int) $this->zone1_count.", zone2_count=".(int) $this->zone2_count; + $sql .= ", zone3_count=".(int) $this->zone3_count.", zone4_count=".(int) $this->zone4_count; + $sql .= ", zone5_count=".(int) $this->zone5_count.", meal_count=".(int) $this->meal_count; + $sql .= " WHERE rowid=".(int) $this->id; + // EN: Constrain totals updates to the allowed entities for safety. + // FR: Contraint les mises à jour des totaux aux entités autorisées pour plus de sécurité. + $sql .= " AND entity IN (".getEntity('timesheetweek').")"; + return $this->db->query($sql) ? 1 : -1; } /** - * Has at least one line - * @return bool - */ + * Has at least one line + * @return bool + */ public function hasAtLeastOneLine() { if (is_array($this->lines) && count($this->lines)) return true; - $sql = "SELECT COUNT(*) as nb FROM ".MAIN_DB_PREFIX."timesheet_week_line WHERE fk_timesheet_week=".(int) $this->id; - // EN: Count only lines belonging to the permitted entities for submission checks. - // FR: Compte uniquement les lignes des entités autorisées pour les vérifications de soumission. - $sql .= " AND entity IN (".getEntity('timesheetweek').")"; + $sql = "SELECT COUNT(*) as nb FROM ".MAIN_DB_PREFIX."timesheet_week_line WHERE fk_timesheet_week=".(int) $this->id; + // EN: Count only lines belonging to the permitted entities for submission checks. + // FR: Compte uniquement les lignes des entités autorisées pour les vérifications de soumission. + $sql .= " AND entity IN (".getEntity('timesheetweek').")"; $res = $this->db->query($sql); if (!$res) return false; $obj = $this->db->fetch_object($res); @@ -487,1319 +719,1393 @@ public function hasAtLeastOneLine() } /** - * Submit -> set status to SUBMITTED and set definitive ref if needed - * @param User $user - * @return int - */ - public function submit($user) - { - $now = dol_now(); - - if (!in_array((int) $this->status, array(self::STATUS_DRAFT, self::STATUS_REFUSED), true)) { - $this->error = 'BadStatusForSubmit'; - return -1; - } - if (!$this->hasAtLeastOneLine()) { - $this->error = 'NoLineToSubmit'; - return -2; - } - - $this->db->begin(); - - // Set definitive ref if provisional - if (empty($this->ref) || strpos($this->ref, '(PROV') === 0) { - $newref = $this->generateDefinitiveRef(); - if (empty($newref)) { - $this->db->rollback(); - $this->error = 'RefGenerationFailed'; - return -1; - } - $this->ref = $newref; - $upref = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET ref='".$this->db->escape($this->ref)."' WHERE rowid=".(int) $this->id; - // EN: Secure definitive reference assignment to authorized entities. - // FR: Sécurise l'attribution de la référence définitive aux entités autorisées. - $upref .= " AND entity IN (".getEntity('timesheetweek').")"; - if (!$this->db->query($upref)) { - $this->db->rollback(); - $this->error = $this->db->lasterror(); - return -1; - } - } - - $up = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET status=".(int) self::STATUS_SUBMITTED.", tms='".$this->db->idate($now)."', date_validation=NULL WHERE rowid=".(int) $this->id; - // EN: Apply the status change strictly within the accessible entities. - // FR: Applique le changement de statut strictement au sein des entités accessibles. - $up .= " AND entity IN (".getEntity('timesheetweek').")"; - if (!$this->db->query($up)) { - $this->db->rollback(); - $this->error = $this->db->lasterror(); - return -1; - } - - $this->status = self::STATUS_SUBMITTED; - $this->tms = $now; - $this->date_validation = null; - - if (!$this->createAgendaEvent($user, 'TSWK_SUBMIT', 'TimesheetWeekAgendaSubmitted', array($this->ref))) { - $this->db->rollback(); - return -1; - } - - $this->db->commit(); - return 1; - } + * Submit -> set status to SUBMITTED and set definitive ref if needed + * @param User $user + * @return int + */ + public function submit($user) + { + $now = dol_now(); - /** - * Revert to draft - * @param User $user - * @return int - */ - public function revertToDraft($user) - { - $now = dol_now(); - - if ((int) $this->status === self::STATUS_DRAFT) { - $this->error = 'AlreadyDraft'; - return 0; - } - - $this->db->begin(); - - $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element. - " SET status=".(int) self::STATUS_DRAFT.", tms='".$this->db->idate($now)."', date_validation=NULL WHERE rowid=".(int) $this->id; - // EN: Enforce the entity scope while reverting to draft. - // FR: Impose la portée d'entité lors du retour en brouillon. - $sql .= " AND entity IN (".getEntity('timesheetweek').")"; - if (!$this->db->query($sql)) { - $this->db->rollback(); - $this->error = $this->db->lasterror(); - return -1; - } - - // Remove synced time entries to keep the ERP aligned (EN) - // Supprime les temps synchronisés pour garder l'ERP aligné (FR) - if ($this->deleteElementTimeRecords() < 0) { - $this->db->rollback(); - return -1; - } - - $this->status = self::STATUS_DRAFT; - $this->tms = $now; - $this->date_validation = null; - - if (!$this->createAgendaEvent($user, 'TSWK_REOPEN', 'TimesheetWeekAgendaReopened', array($this->ref))) { - $this->db->rollback(); - return -1; - } - - $this->db->commit(); - - return 1; - } + if (!in_array((int) $this->status, array(self::STATUS_DRAFT, self::STATUS_REFUSED), true)) { + $this->error = 'BadStatusForSubmit'; + return -1; + } + if (!$this->hasAtLeastOneLine()) { + $this->error = 'NoLineToSubmit'; + return -2; + } - /** - * Approve - * @param User $user - * @return int - */ - public function approve($user) - { - $now = dol_now(); - - if ((int) $this->status !== self::STATUS_SUBMITTED) { - $this->error = 'BadStatusForApprove'; - return -1; - } - - $this->db->begin(); - - // Set validator if different - $setvalid = ''; - if (empty($this->fk_user_valid) || (int) $this->fk_user_valid !== (int) $user->id) { - $setvalid = ", fk_user_valid=".(int) $user->id; - $this->fk_user_valid = (int) $user->id; - } - - $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; - $sql .= " status=".(int) self::STATUS_APPROVED; - $sql .= ", date_validation='".$this->db->idate($now)."'"; - $sql .= ", tms='".$this->db->idate($now)."'"; - $sql .= $setvalid; - $sql .= " WHERE rowid=".(int) $this->id; - // EN: Restrict the approval to timesheets inside permitted entities. - // FR: Restreint l'approbation aux feuilles situées dans les entités autorisées. - $sql .= " AND entity IN (".getEntity('timesheetweek').")"; - - if (!$this->db->query($sql)) { - $this->db->rollback(); - $this->error = $this->db->lasterror(); - return -1; - } - - // Synchronize time consumption with Dolibarr core table (EN) - // Synchronise la consommation de temps avec la table cœur de Dolibarr (FR) - if ($this->syncElementTimeRecords() < 0) { - $this->db->rollback(); - return -1; - } - - $this->status = self::STATUS_APPROVED; - $this->date_validation = $now; - $this->tms = $now; - - if (!$this->createAgendaEvent($user, 'TSWK_APPROVE', 'TimesheetWeekAgendaApproved', array($this->ref))) { - $this->db->rollback(); - return -1; - } - - $this->db->commit(); - return 1; - } - - /** - * EN: Seal the approved timesheet to prevent further changes. - * FR : Scelle la feuille approuvée pour empêcher de nouvelles modifications. - * - * @param User $user - * @return int - */ - public function seal($user) - { - $now = dol_now(); - - if ((int) $this->status !== self::STATUS_APPROVED) { - $this->error = 'BadStatusForSeal'; - return -1; - } - - $this->db->begin(); - - $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; - $sql .= " status=".(int) self::STATUS_SEALED; - $sql .= ", tms='".$this->db->idate($now)."'"; - $sql .= " WHERE rowid=".(int) $this->id; - - if (!$this->db->query($sql)) { - $this->db->rollback(); - $this->error = $this->db->lasterror(); - return -1; - } - - // EN: Keep approval metadata intact while locking the sheet. - // FR : Conserve les métadonnées d'approbation tout en verrouillant la feuille. - $this->status = self::STATUS_SEALED; - $this->tms = $now; - - if (!$this->createAgendaEvent($user, 'TSWK_SEAL', 'TimesheetWeekAgendaSealed', array($this->ref))) { - $this->db->rollback(); - return -1; - } - - $this->db->commit(); - - return 1; - } - - /** - * EN: Revert a sealed timesheet back to the approved state. - * FR : Rouvre une feuille scellée en la repassant au statut approuvé. - * - * @param User $user - * @return int - */ - public function unseal($user) - { - $now = dol_now(); - - if ((int) $this->status !== self::STATUS_SEALED) { - $this->error = 'BadStatusForUnseal'; - return -1; - } - - $this->db->begin(); - - $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; - $sql .= " status=".(int) self::STATUS_APPROVED; - $sql .= ", tms='".$this->db->idate($now)."'"; - $sql .= " WHERE rowid=".(int) $this->id; - - if (!$this->db->query($sql)) { - $this->db->rollback(); - $this->error = $this->db->lasterror(); - return -1; - } - - // EN: Keep the original validation date while allowing edits again. - // FR : Préserve la date d'approbation d'origine tout en rouvrant l'édition. - $this->status = self::STATUS_APPROVED; - $this->tms = $now; - - if (!$this->createAgendaEvent($user, 'TSWK_UNSEAL', 'TimesheetWeekAgendaUnsealed', array($this->ref))) { - $this->db->rollback(); - return -1; - } - - $this->db->commit(); - - return 1; - } - - /** - * Synchronize element_time rows with the validated timesheet lines. - * EN: Inserts dedicated rows into llx_element_time when approving a sheet, removing stale ones first. - * FR: Insère les lignes dans llx_element_time lors de l'approbation, après avoir supprimé les enregistrements obsolètes. - * @return int - */ - protected function syncElementTimeRecords() - { - if (empty($this->id)) { - return 0; - } - - // Clean previous entries tied to this sheet to avoid duplicates (EN) - // Nettoie les entrées existantes liées à cette fiche pour éviter les doublons (FR) - if ($this->deleteElementTimeRecords() < 0) { - return -1; - } - - $lines = $this->getLines(); - if (empty($lines)) { - return 1; - } - - // Determine employee hourly rate for THM column (EN) - // Détermine le taux horaire salarié pour la colonne THM (FR) - $employeeThm = $this->resolveEmployeeThm(); - - // Prepare multilingual helper for note composition (EN) - // Prépare l'assistant multilingue pour composer la note (FR) - global $langs; - if (is_object($langs)) { - $langs->load('timesheetweek@timesheetweek'); - } - - foreach ($lines as $line) { - $durationSeconds = (int) round(((float) $line->hours) * 3600); - if ($durationSeconds <= 0) { - continue; - } - - $taskTimestamp = $this->normalizeLineDate($line->day_date); - $noteDateLabel = $taskTimestamp ? dol_print_date($taskTimestamp, '%d/%m/%Y') : dol_print_date(dol_now(), '%d/%m/%Y'); - // Try Dolibarr helper then fallback to module formatter (EN) - // Tente l'assistant Dolibarr puis bascule sur le formateur du module (FR) - if (function_exists('dol_print_duration')) { - $noteDurationLabel = dol_print_duration($durationSeconds, 'allhourmin'); - } else { - $noteDurationLabel = $this->formatDurationLabel($durationSeconds); - } - $noteDurationLabel = trim(str_replace(' ', ' ', $noteDurationLabel)); - if ($noteDurationLabel === '') { - $noteDurationValue = price2num($line->hours, 'MT'); - $noteDurationLabel = ($noteDurationValue !== '' ? number_format((float) $noteDurationValue, 2, '.', ' ') : '0.00').' h'; - } - - // Build the readable note with translations and fallback (EN) - // Construit la note lisible avec traductions et repli (FR) - if (is_object($langs)) { - $noteMessage = $langs->trans('TimesheetWeekElementTimeNote', (string) $this->ref, $noteDateLabel, $noteDurationLabel); - } else { - $noteMessage = 'Feuille de temps '.(string) $this->ref.' - '.$noteDateLabel.' - '.$noteDurationLabel; - } - - // Store a readable note for auditors and managers (EN) - // Enregistre une note lisible pour les contrôleurs et managers (FR) - $sql = "INSERT INTO ".MAIN_DB_PREFIX."element_time("; - $sql .= " fk_user, fk_element, elementtype, element_duration, element_date, thm, note, import_key"; - $sql .= ") VALUES ("; - $sql .= " ".(!empty($this->fk_user) ? (int) $this->fk_user : "NULL").","; - $sql .= " ".(!empty($line->fk_task) ? (int) $line->fk_task : "NULL").","; - $sql .= " 'task',"; - $sql .= " ".$durationSeconds.","; - $sql .= $taskTimestamp ? " '".$this->db->idate($taskTimestamp)."'," : " NULL,"; - $sql .= ($employeeThm !== null ? " ".$employeeThm : " NULL").","; - $sql .= " '".$this->db->escape($noteMessage)."',"; - $sql .= " '".$this->db->escape($this->buildElementTimeImportKey($line))."'"; - $sql .= ")"; - - if (!$this->db->query($sql)) { - $this->error = $this->db->lasterror(); - return -1; - } - } - - return 1; - } - - /** - * Delete synced rows from llx_element_time for this sheet. - * EN: Uses the custom import_key prefix to target only our module entries. - * FR: Utilise le préfixe import_key personnalisé pour ne viser que les entrées du module. - * @return int - */ - protected function deleteElementTimeRecords() - { - if (empty($this->id)) { - return 0; - } - - $prefix = $this->getElementTimeImportKeyPrefix(); - $sql = "DELETE FROM ".MAIN_DB_PREFIX."element_time"; - $sql .= " WHERE elementtype='task'"; - if ($prefix !== '') { - $sql .= " AND import_key LIKE '".$this->db->escape($prefix)."%'"; - } - - $resql = $this->db->query($sql); - if (!$resql) { - $this->error = $this->db->lasterror(); - return -1; - } - - return $this->db->affected_rows($resql); - } - - /** - * Normalize date value coming from a line. - * EN: Returns a unix timestamp or 0 if the date is missing. - * FR: Retourne un timestamp unix ou 0 si la date est absente. - * @param mixed $value - * @return int - */ - protected function normalizeLineDate($value) - { - if (empty($value)) { - return 0; - } - - if (is_numeric($value)) { - return (int) $value; - } - - $timestamp = dol_stringtotime($value, 0, 1); - if ($timestamp > 0) { - return $timestamp; - } - - $timestamp = strtotime($value); - return $timestamp ? (int) $timestamp : 0; - } - - /** - * Build a deterministic import_key for a line. - * EN: Generates a short hash starting with a module prefix for deletion filtering. - * FR: Génère un hash court préfixé pour faciliter le filtrage à la suppression. - * @param TimesheetWeekLine $line - * @return string - */ - protected function buildElementTimeImportKey($line) - { - $lineId = !empty($line->id) ? (int) $line->id : (!empty($line->rowid) ? (int) $line->rowid : 0); - $base = (string) $this->id.'-'.$lineId; - return $this->getElementTimeImportKeyPrefix().substr(md5($base), 0, 8); - } - - /** - * Provide the common prefix used for import_key values. - * EN: Ensures every record for this sheet starts with a predictable marker. - * FR: Garantit que chaque enregistrement de la fiche débute par un marqueur prévisible. - * @return string - */ - protected function getElementTimeImportKeyPrefix() - { - if (empty($this->id)) { - return ''; - } - - return 'TW'.substr(md5((string) $this->id), 0, 4); - } - - /** - * Resolve employee THM (average hourly rate) for element_time insert. - * EN: Tries the standard user fields to find the hourly cost before inserting into llx_element_time. - * FR: Explore les champs standards de l'utilisateur pour retrouver le coût horaire avant l'insertion dans llx_element_time. - * @return string|null - */ - protected function resolveEmployeeThm() - { - $employee = $this->loadUserFromCache($this->fk_user); - if (!$employee) { - return null; - } - - $candidates = array(); - if (property_exists($employee, 'thm')) { - $candidates[] = $employee->thm; - } - if (!empty($employee->array_options) && array_key_exists('options_thm', $employee->array_options)) { - $candidates[] = $employee->array_options['options_thm']; - } - - foreach ($candidates as $candidate) { - if ($candidate === '' || $candidate === null) { - continue; - } - - $value = price2num($candidate, 'MT'); - if ($value !== '') { - return (string) $value; - } - } - - return null; - } - - /** - * Format a readable duration when Dolibarr helper is unavailable. - * EN: Converts a duration in seconds to an "Hh Mmin" label for notes. - * FR: Convertit une durée en secondes en libellé « Hh Mmin » pour les notes. - * @param int $seconds - * @return string - */ - protected function formatDurationLabel($seconds) - { - $seconds = max(0, (int) $seconds); - $hours = (int) floor($seconds / 3600); - $minutes = (int) floor(($seconds % 3600) / 60); - $remainingSeconds = $seconds % 60; - - $parts = array(); - if ($hours > 0) { - $parts[] = $hours.'h'; - } - if ($minutes > 0) { - $parts[] = $minutes.'min'; - } - if ($remainingSeconds > 0 && $hours === 0) { - $parts[] = $remainingSeconds.'s'; - } - - if (empty($parts)) { - return '0 min'; - } - - return implode(' ', $parts); - } - - /** - * Refuse - * @param User $user - * @return int - */ - public function refuse($user) - { - $now = dol_now(); - - if ((int) $this->status !== self::STATUS_SUBMITTED) { - $this->error = 'BadStatusForRefuse'; - return -1; - } - - $this->db->begin(); - - $setvalid = ''; - if (empty($this->fk_user_valid) || (int) $this->fk_user_valid !== (int) $user->id) { - $setvalid = ", fk_user_valid=".(int) $user->id; - $this->fk_user_valid = (int) $user->id; - } - - $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; - $sql .= " status=".(int) self::STATUS_REFUSED; - $sql .= ", date_validation='".$this->db->idate($now)."'"; - $sql .= ", tms='".$this->db->idate($now)."'"; - $sql .= $setvalid; - $sql .= " WHERE rowid=".(int) $this->id; - - if (!$this->db->query($sql)) { - $this->db->rollback(); - $this->error = $this->db->lasterror(); - return -1; - } - - $this->status = self::STATUS_REFUSED; - $this->date_validation = $now; - $this->tms = $now; - - if (!$this->createAgendaEvent($user, 'TSWK_REFUSE', 'TimesheetWeekAgendaRefused', array($this->ref))) { - $this->db->rollback(); - return -1; - } - - $this->db->commit(); - return 1; - } + $this->db->begin(); + + // Set definitive ref if provisional + if (empty($this->ref) || strpos($this->ref, '(PROV') === 0) { + $newref = $this->generateDefinitiveRef(); + if (empty($newref)) { + $this->db->rollback(); + $this->error = 'RefGenerationFailed'; + return -1; + } + $this->ref = $newref; + $upref = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET ref='".$this->db->escape($this->ref)."' WHERE rowid=".(int) $this->id; + // EN: Secure definitive reference assignment to authorized entities. + // FR: Sécurise l'attribution de la référence définitive aux entités autorisées. + $upref .= " AND entity IN (".getEntity('timesheetweek').")"; + if (!$this->db->query($upref)) { + $this->db->rollback(); + $this->error = $this->db->lasterror(); + return -1; + } + } + + $up = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET status=".(int) self::STATUS_SUBMITTED.", tms='".$this->db->idate($now)."', date_validation=NULL WHERE rowid=".(int) $this->id; + // EN: Apply the status change strictly within the accessible entities. + // FR: Applique le changement de statut strictement au sein des entités accessibles. + $up .= " AND entity IN (".getEntity('timesheetweek').")"; + if (!$this->db->query($up)) { + $this->db->rollback(); + $this->error = $this->db->lasterror(); + return -1; + } + + $this->status = self::STATUS_SUBMITTED; + $this->tms = $now; + $this->date_validation = null; + + if (!$this->createAgendaEvent($user, 'TSWK_SUBMIT', 'TimesheetWeekAgendaSubmitted', array($this->ref))) { + $this->db->rollback(); + return -1; + } + + $this->db->commit(); + return 1; + } /** - * Generate definitive reference using addon in conf TIMESHEETWEEK_ADDON - * fallback: FHyyyyss-XXX - * @return string|false - */ - public function generateDefinitiveRef() + * Revert to draft + * @param User $user + * @return int + */ + public function revertToDraft($user) { - global $conf, $langs; - - $langs->load("other"); + $now = dol_now(); - $module = getDolGlobalString('TIMESHEETWEEK_ADDON', 'mod_timesheetweek_advanced'); - $file = '/timesheetweek/core/modules/timesheetweek/'.$module.'.php'; + if ((int) $this->status === self::STATUS_DRAFT) { + $this->error = 'AlreadyDraft'; + return 0; + } - $classname = $module; - dol_include_once($file); + $this->db->begin(); - if (class_exists($classname)) { - $mod = new $classname($this->db); - if (method_exists($mod, 'getNextValue')) { - $ref = $mod->getNextValue($this); - if ($ref) return $ref; - } + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element. + " SET status=".(int) self::STATUS_DRAFT.", tms='".$this->db->idate($now)."', date_validation=NULL WHERE rowid=".(int) $this->id; + // EN: Enforce the entity scope while reverting to draft. + // FR: Impose la portée d'entité lors du retour en brouillon. + $sql .= " AND entity IN (".getEntity('timesheetweek').")"; + if (!$this->db->query($sql)) { + $this->db->rollback(); + $this->error = $this->db->lasterror(); + return -1; } - // Fallback - $yyyy = (int) $this->year; - $ww = str_pad((int) $this->week, 2, '0', STR_PAD_LEFT); + // Remove synced time entries to keep the ERP aligned (EN) + // Supprime les temps synchronisés pour garder l'ERP aligné (FR) + if ($this->deleteElementTimeRecords() < 0) { + $this->db->rollback(); + return -1; + } - $sql = "SELECT ref FROM ".MAIN_DB_PREFIX.$this->table_element; - $sql .= " WHERE entity=".(int) $conf->entity; - // EN: Ensure the fallback generation still respects allowed entities. - // FR: Garantit que la génération de secours respecte les entités autorisées. - $sql .= " AND entity IN (".getEntity('timesheetweek').")"; - $sql .= " AND year=".(int) $this->year." AND week=".(int) $this->week; - $sql .= " AND ref LIKE 'FH".$yyyy.$ww."-%'"; - $sql .= " ORDER BY ref DESC"; - $sql .= " ".$this->db->plimit(1); + $this->status = self::STATUS_DRAFT; + $this->tms = $now; + $this->date_validation = null; - $res = $this->db->query($sql); - $seq = 0; - if ($res) { - $obj = $this->db->fetch_object($res); - if ($obj && preg_match('/^FH'.$yyyy.$ww.'-(\d{3})$/', $obj->ref, $m)) { - $seq = (int) $m[1]; - } - $this->db->free($res); + if (!$this->createAgendaEvent($user, 'TSWK_REOPEN', 'TimesheetWeekAgendaReopened', array($this->ref))) { + $this->db->rollback(); + return -1; } - $seq++; - return 'FH'.$yyyy.$ww.'-'.str_pad($seq, 3, '0', STR_PAD_LEFT); + + $this->db->commit(); + + return 1; } /** - * Return linked tasks assigned to user (project_task) - * @param int $userid - * @return array Each: task_id, task_label, project_id, project_ref, project_title - */ - public function getAssignedTasks($userid) + * Approve + * @param User $user + * @return int + */ + public function approve($user) { - global $conf; + $now = dol_now(); - $userid = (int) $userid; - if ($userid <= 0) return array(); + if ((int) $this->status !== self::STATUS_SUBMITTED) { + $this->error = 'BadStatusForApprove'; + return -1; + } - // Several ways tasks can be assigned -> llx_element_contact with element='project_task' - // Note: Some installs store user id into fk_socpeople (legacy) - // EN: Fetch extra task metadata so the card can filter items (status, progress, dates). - // FR: Récupère des métadonnées supplémentaires pour que la carte puisse filtrer (statut, avancement, dates). - $sql = "SELECT t.rowid as task_id, t.label as task_label, t.ref as task_ref,"; - $sql .= " t.progress as task_progress, t.fk_statut as task_status, t.dateo as task_date_start, t.datee as task_date_end,"; - $sql .= " p.rowid as project_id, p.ref as project_ref, p.title as project_title"; - $sql .= " FROM ".MAIN_DB_PREFIX."projet_task t"; - $sql .= " INNER JOIN ".MAIN_DB_PREFIX."projet p ON p.rowid = t.fk_projet"; - $sql .= " INNER JOIN ".MAIN_DB_PREFIX."element_contact ec ON ec.element_id = t.rowid"; - $sql .= " INNER JOIN ".MAIN_DB_PREFIX."c_type_contact ctc ON ctc.rowid = ec.fk_c_type_contact"; - $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user u ON u.rowid = ec.fk_socpeople"; // legacy mapping - $sql .= " WHERE p.entity IN (".getEntity('project').")"; - $sql .= " AND ctc.element = 'project_task'"; - $sql .= " AND (ec.fk_socpeople = ".$userid." OR u.rowid = ".$userid.")"; - // EN: Group by the extra fields to keep SQL strict mode satisfied once new columns are selected. - // FR: Ajoute les nouveaux champs dans le GROUP BY pour respecter le mode strict SQL après sélection. - $sql .= " GROUP BY t.rowid, t.label, t.ref, t.progress, t.fk_statut, t.dateo, t.datee, p.rowid, p.ref, p.title"; - $sql .= " ORDER BY p.ref, t.label"; + $this->db->begin(); - $res = $this->db->query($sql); - if (!$res) { + // Set validator if different + $setvalid = ''; + if (empty($this->fk_user_valid) || (int) $this->fk_user_valid !== (int) $user->id) { + $setvalid = ", fk_user_valid=".(int) $user->id; + $this->fk_user_valid = (int) $user->id; + } + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " status=".(int) self::STATUS_APPROVED; + $sql .= ", date_validation='".$this->db->idate($now)."'"; + $sql .= ", tms='".$this->db->idate($now)."'"; + $sql .= $setvalid; + $sql .= " WHERE rowid=".(int) $this->id; + // EN: Restrict the approval to timesheets inside permitted entities. + // FR: Restreint l'approbation aux feuilles situées dans les entités autorisées. + $sql .= " AND entity IN (".getEntity('timesheetweek').")"; + + if (!$this->db->query($sql)) { + $this->db->rollback(); $this->error = $this->db->lasterror(); - return array(); + return -1; } - $out = array(); - while ($obj = $this->db->fetch_object($res)) { - // EN: Return the enriched task information so the caller can apply advanced filters. - // FR: Retourne les informations enrichies de la tâche pour permettre des filtres avancés côté appelant. - $out[] = array( - 'task_id' => (int) $obj->task_id, - 'task_label' => (string) $obj->task_label, - 'task_ref' => (string) $obj->task_ref, - 'task_progress' => ($obj->task_progress !== null ? (float) $obj->task_progress : null), - //'task_status' => ($obj->task_status !== null ? (int) $obj->task_status : null), // EN: All status are considered to not hide closed tasks // FR: Tous les Statuts sont pris en compte pour ne pas masquer les tâches clôturées. - 'task_date_start' => ($obj->task_date_start !== null ? (string) $obj->task_date_start : null), - 'task_date_end' => ($obj->task_date_end !== null ? (string) $obj->task_date_end : null), - 'project_id' => (int) $obj->project_id, - 'project_ref' => (string) $obj->project_ref, - 'project_title' => (string) $obj->project_title, - ); + + // Synchronize time consumption with Dolibarr core table (EN) + // Synchronise la consommation de temps avec la table cœur de Dolibarr (FR) + if ($this->syncElementTimeRecords() < 0) { + $this->db->rollback(); + return -1; } - $this->db->free($res); - return $out; + + $this->status = self::STATUS_APPROVED; + $this->date_validation = $now; + $this->tms = $now; + + if (!$this->createAgendaEvent($user, 'TSWK_APPROVE', 'TimesheetWeekAgendaApproved', array($this->ref))) { + $this->db->rollback(); + return -1; + } + + $this->db->commit(); + return 1; } /** - * URL to card - * @param int $withpicto - * @param string $option - * @param int $notooltip - * @param string $morecss - * @return string - */ - public function getNomUrl($withpicto = 0, $option = '', $notooltip = 0, $morecss = '') - { - global $langs; - - $label = dol_escape_htmltag($this->ref); - $url = dol_buildpath('/timesheetweek/timesheetweek_card.php', 1).'?id='.(int) $this->id; - - $linkstart = ''; - $linkend = ''; - $labeltooltip = $langs->trans('ShowTimesheetWeek', $this->ref); - - if ($option !== 'nolink') { - $linkstart = ''; - $linkend = ''; - } - - $result = ''; - if ($withpicto) { - $tooltip = empty($notooltip) ? $labeltooltip : ''; - $picto = img_object($tooltip, $this->picto); - $result .= $linkstart.$picto.$linkend; - if ($withpicto != 1) { - $result .= ' '; - } - } - - if ($withpicto != 1 || $option === 'ref' || !$withpicto) { - $result .= $linkstart.$label.$linkend; - } - - return $result; - } - - /** - * Load user from cache - * - * @param int $userid - * @return User|null - */ - protected function loadUserFromCache($userid) - { - $userid = (int) $userid; - if ($userid <= 0) { - return null; - } - - static $cache = array(); - if (!array_key_exists($userid, $cache)) { - $cache[$userid] = null; - $tmp = new User($this->db); - if ($tmp->fetch($userid) > 0) { - $cache[$userid] = $tmp; - } - } - - return $cache[$userid]; - } - - /** - * Send automatic notification based on trigger code - * - * @param string $triggerCode - * @param User $actionUser - * @return bool - */ - protected function sendAutomaticNotification($triggerCode, User $actionUser) - { - global $langs; - - $langs->loadLangs(array('mails', 'timesheetweek@timesheetweek', 'users')); - - $employee = $this->loadUserFromCache($this->fk_user); - $validator = $this->loadUserFromCache($this->fk_user_valid); - - // FR: Génère l'URL directe vers la fiche pour l'insérer dans le modèle d'e-mail. - // EN: Build the direct link to the card so it can be injected inside the e-mail template. - $url = dol_buildpath('/timesheetweek/timesheetweek_card.php', 2).'?id='.(int) $this->id; - - $employeeName = $employee ? $employee->getFullName($langs) : ''; - $validatorName = $validator ? $validator->getFullName($langs) : ''; - $actionUserName = $actionUser->getFullName($langs); - - // FR: Base des substitutions partagées entre notifications automatiques et métier. - // EN: Base substitution array shared between automatic and business notifications. - $baseSubstitutions = array( - '__TIMESHEETWEEK_REF__' => $this->ref, - '__TIMESHEETWEEK_WEEK__' => $this->week, - '__TIMESHEETWEEK_YEAR__' => $this->year, - '__TIMESHEETWEEK_URL__' => $url, - '__TIMESHEETWEEK_EMPLOYEE_FULLNAME__' => $employeeName, - '__TIMESHEETWEEK_VALIDATOR_FULLNAME__' => $validatorName, - '__ACTION_USER_FULLNAME__' => $actionUserName, - '__RECIPIENT_FULLNAME__' => '', - ); - - if (!is_array($this->context)) { - $this->context = array(); - } - - $this->context['timesheetweek_notification'] = array( - 'trigger_code' => $triggerCode, - 'url' => $url, - 'employee_id' => $employee ? (int) $employee->id : 0, - 'validator_id' => $validator ? (int) $validator->id : 0, - 'action_user_id' => (int) $actionUser->id, - 'employee_fullname' => $employeeName, - 'validator_fullname' => $validatorName, - 'action_user_fullname' => $actionUserName, - 'base_substitutions' => $baseSubstitutions, - ); - - // FR: Conserve les substitutions pour les triggers Notification natifs. - // EN: Keep substitutions handy for native Notification triggers. - $this->context['mail_substitutions'] = $baseSubstitutions; - - // FR: Permet aux implémentations natives de récupérer toutes les informations utiles. - // EN: Allow native helpers to access every useful metadata while sending e-mails. - $this->context['timesheetweek_notification']['native_options'] = array( - 'employee' => $employee, - 'validator' => $validator, - 'base_substitutions' => $baseSubstitutions, - ); - - // FR: Les triggers accèdent aux informations via le contexte, inutile de dupliquer les paramètres. - // EN: Triggers read every detail from the context, no need to duplicate the payload locally. - $result = $this->fireNotificationTrigger($triggerCode, $actionUser); - if ($result < 0) { - $this->errors[] = $langs->trans('TimesheetWeekNotificationTriggerError', $triggerCode); - dol_syslog(__METHOD__.': unable to execute trigger '.$triggerCode, LOG_ERR); - return false; - } - - return true; - } - - /** - * Execute Dolibarr triggers when notifications must be sent. - * - * @param string $triggerCode - * @param User $actionUser - * @param array $parameters - * @return int - */ - protected function fireNotificationTrigger($triggerCode, User $actionUser) - { - global $langs, $conf, $hookmanager; - - $payload = $this->buildTriggerParameters($triggerCode, $actionUser); - - // FR: Priorité à call_trigger lorsqu'il est disponible sur l'objet. - // EN: Give priority to call_trigger whenever it exists on the object. - if (method_exists($this, 'call_trigger')) { - try { - $method = new \ReflectionMethod($this, 'call_trigger'); - $arguments = $this->mapTriggerArguments($method->getParameters(), $payload); - - return $method->invokeArgs($this, $arguments); - } catch (\Throwable $error) { - dol_syslog(__METHOD__.': '.$error->getMessage(), LOG_WARNING); - } - } - - // FR: Compatibilité avec les anciennes versions qui exposent runTrigger directement sur l'objet. - // EN: Backward compatibility for legacy releases exposing runTrigger on the object. - if (method_exists($this, 'runTrigger')) { - try { - $method = new \ReflectionMethod($this, 'runTrigger'); - $arguments = $this->mapTriggerArguments($method->getParameters(), $payload, true); - - return $method->invokeArgs($this, $arguments); - } catch (\Throwable $error) { - dol_syslog(__METHOD__.': '.$error->getMessage(), LOG_WARNING); - } - } - - // FR: Fallback ultime sur la fonction globale runTrigger si elle est disponible. - // EN: Ultimate fallback using the global runTrigger helper when available. - if (!function_exists('runTrigger')) { - dol_include_once('/core/triggers/functions_triggers.inc.php'); - } - - if (function_exists('runTrigger')) { - try { - $function = new \ReflectionFunction('runTrigger'); - $arguments = $this->mapTriggerArguments($function->getParameters(), $payload, true); - - return $function->invokeArgs($arguments); - } catch (\Throwable $error) { - dol_syslog(__METHOD__.': '.$error->getMessage(), LOG_WARNING); - } - } - - return 0; - } - - /** - * Prépare le jeu de paramètres partagé entre toutes les signatures de triggers. - * Prepare the shared payload used by every trigger signature. - * - * @param string $triggerCode - * @param User $actionUser - * @return array - */ - protected function buildTriggerParameters($triggerCode, User $actionUser) - { - global $langs, $conf, $hookmanager; - - return array( - 'action' => $triggerCode, - 'trigger' => $triggerCode, - 'event' => $triggerCode, - 'actioncode' => $triggerCode, - 'user' => $actionUser, - 'actor' => $actionUser, - 'currentuser' => $actionUser, - 'langs' => $langs, - 'language' => $langs, - 'conf' => $conf, - 'config' => $conf, - 'hookmanager' => isset($hookmanager) ? $hookmanager : null, - 'hook' => isset($hookmanager) ? $hookmanager : null, - 'object' => $this, - 'obj' => $this, - 'objectsrc' => $this, - 'context' => $this->context, - 'parameters' => array('context' => $this->context, 'timesheetweek' => $this), - 'params' => array('context' => $this->context, 'timesheetweek' => $this), - 'moreparam' => array('context' => $this->context, 'timesheetweek' => $this), - 'extrafields' => property_exists($this, 'extrafields') ? $this->extrafields : null, - 'extrafield' => property_exists($this, 'extrafields') ? $this->extrafields : null, - 'extraparams' => property_exists($this, 'extrafields') ? $this->extrafields : null, - 'parametersarray' => array('context' => $this->context, 'timesheetweek' => $this), - 'actiontype' => $triggerCode, - ); - } - - /** - * Mappe la liste des paramètres attendus par une signature de trigger vers les valeurs connues. - * Map the expected parameter list of a trigger signature to the known values. - * - * @param \ReflectionParameter[] $signature - * @param array $payload - * @param bool $injectObjectWhenMissing - * @return array - */ - protected function mapTriggerArguments(array $signature, array $payload, $injectObjectWhenMissing = false) - { - $arguments = array(); - - foreach ($signature as $parameter) { - $name = $parameter->getName(); - - if (isset($payload[$name])) { - $arguments[] = $payload[$name]; - continue; - } - - // FR: Quelques alias fréquents ne respectent pas la casse ou ajoutent un suffixe. - // EN: Handle common aliases that differ by casing or suffixes. - $lower = strtolower($name); - if (isset($payload[$lower])) { - $arguments[] = $payload[$lower]; - continue; - } - - if ($lower === 'object' && $injectObjectWhenMissing) { - $arguments[] = $this; - continue; - } - - if (strpos($lower, 'context') !== false) { - $arguments[] = $payload['context']; - continue; - } - - // FR: Valeur neutre par défaut pour ne pas interrompre le déclenchement. - // EN: Default neutral value so the trigger keeps running. - $arguments[] = null; - } - - return $arguments; - } - - /** - * Try to send an e-mail notification relying on Dolibarr native helpers. - * - * FR: Tente d'envoyer un e-mail de notification en s'appuyant sur les outils natifs de Dolibarr. - * EN: Try sending the notification e-mail using Dolibarr's native helpers. - * - * @param string $triggerCode - * @param User $actionUser - * @param User $recipient - * @param mixed $langs - * @param mixed $conf - * @param array $substitutions - * @param array $options - * @return int >0 success, 0 unsupported, <0 failure - */ - public function sendNativeMailNotification($triggerCode, User $actionUser, $recipient, $langs, $conf, array $substitutions, array $options = array()) - { - $sendto = isset($options['sendto']) ? trim((string) $options['sendto']) : ''; - if ($sendto === '') { - return 0; - } - - $methods = array('sendEmailsFromTemplate', 'sendEmailsCommon', 'sendEmailsFromModel', 'sendEmails', 'sendMails'); - - $payload = array( - 'trigger' => $triggerCode, - 'action' => $triggerCode, - 'code' => $triggerCode, - 'event' => $triggerCode, - 'user' => $actionUser, - 'actionuser' => $actionUser, - 'actor' => $actionUser, - 'currentuser' => $actionUser, - 'langs' => $langs, - 'language' => $langs, - 'conf' => $conf, - 'subject' => isset($options['subject']) ? (string) $options['subject'] : '', - 'message' => isset($options['message']) ? (string) $options['message'] : '', - 'content' => isset($options['message']) ? (string) $options['message'] : '', - 'body' => isset($options['message']) ? (string) $options['message'] : '', - 'sendto' => $sendto, - 'emailto' => $sendto, - 'email_to' => $sendto, - 'sendtolist' => $sendto, - 'sendtocc' => isset($options['cc']) ? (string) $options['cc'] : '', - 'emailcc' => isset($options['cc']) ? (string) $options['cc'] : '', - 'sendtobcc' => isset($options['bcc']) ? (string) $options['bcc'] : '', - 'emailbcc' => isset($options['bcc']) ? (string) $options['bcc'] : '', - 'replyto' => isset($options['replyto']) ? (string) $options['replyto'] : '', - 'emailreplyto' => isset($options['replyto']) ? (string) $options['replyto'] : '', - 'deliveryreceipt' => !empty($options['deliveryreceipt']) ? 1 : 0, - 'trackid' => !empty($options['trackid']) ? (string) $options['trackid'] : 'timesheetweek-'.$this->id.'-'.$triggerCode, - 'substitutions' => $substitutions, - 'substitutionarray' => $substitutions, - 'mail_substitutions' => $substitutions, - 'array_substitutions' => $substitutions, - 'files' => isset($options['files']) && is_array($options['files']) ? $options['files'] : array(), - 'filearray' => isset($options['files']) && is_array($options['files']) ? $options['files'] : array(), - 'filename' => isset($options['filenames']) && is_array($options['filenames']) ? $options['filenames'] : array(), - 'filenameList' => isset($options['filenames']) && is_array($options['filenames']) ? $options['filenames'] : array(), - 'mimetype' => isset($options['mimetypes']) && is_array($options['mimetypes']) ? $options['mimetypes'] : array(), - 'mimetypeList' => isset($options['mimetypes']) && is_array($options['mimetypes']) ? $options['mimetypes'] : array(), - 'joinfiles' => isset($options['files']) && is_array($options['files']) ? $options['files'] : array(), - 'mode' => 'email', - 'recipient' => $recipient, - 'email' => $sendto, - 'context' => $this->context, - 'moreinval' => array('context' => $this->context, 'timesheetweek' => $this), - 'params' => isset($options['params']) && is_array($options['params']) ? $options['params'] : array(), - 'options' => $options, - ); - - foreach ($methods as $methodName) { - if (!method_exists($this, $methodName)) { - continue; - } - - try { - $method = new \ReflectionMethod($this, $methodName); - $arguments = $this->mapMailMethodArguments($method->getParameters(), $payload); - $result = $method->invokeArgs($this, $arguments); - - if ($result === false) { - continue; - } - - if (is_numeric($result)) { - if ((int) $result > 0) { - return (int) $result; - } - - continue; - } - - return 1; - } catch (\Throwable $error) { - dol_syslog(__METHOD__.': '.$error->getMessage(), LOG_WARNING); - } - } - - return 0; - } - - /** - * Map native mail helper signature to known payload values. - * - * FR: Associe la signature d'une méthode d'envoi d'e-mail aux valeurs connues. - * EN: Map the parameter list of a mail helper to the known payload values. - * - * @param \ReflectionParameter[] $signature - * @param array $payload - * @return array - */ - protected function mapMailMethodArguments(array $signature, array $payload) - { - $arguments = array(); - - foreach ($signature as $parameter) { - $value = null; - $name = $parameter->getName(); - $lower = strtolower($name); - - if (isset($payload[$name])) { - $value = $payload[$name]; - } elseif (isset($payload[$lower])) { - $value = $payload[$lower]; - } else { - if ($parameter->hasType()) { - $type = $parameter->getType(); - if ($type && !$type->isBuiltin()) { - $typeName = ltrim($type->getName(), '\\'); - if ($typeName === 'User') { - $value = isset($payload['user']) ? $payload['user'] : null; - } elseif ($typeName === 'Translate') { - $value = isset($payload['langs']) ? $payload['langs'] : null; - } elseif ($typeName === 'Conf') { - $value = isset($payload['conf']) ? $payload['conf'] : null; - } elseif ($typeName === 'TimesheetWeek' || is_a($this, $typeName)) { - $value = $this; - } - } - } - - if ($value === null) { - if (strpos($lower, 'substit') !== false && isset($payload['substitutions'])) { - $value = $payload['substitutions']; - } elseif (strpos($lower, 'sendto') !== false && isset($payload['sendto'])) { - $value = $payload['sendto']; - } elseif (strpos($lower, 'subject') !== false && isset($payload['subject'])) { - $value = $payload['subject']; - } elseif ((strpos($lower, 'message') !== false || strpos($lower, 'content') !== false || strpos($lower, 'body') !== false) && isset($payload['message'])) { - $value = $payload['message']; - } elseif (strpos($lower, 'reply') !== false && isset($payload['replyto'])) { - $value = $payload['replyto']; - } elseif (strpos($lower, 'cc') !== false && isset($payload['sendtocc'])) { - $value = $payload['sendtocc']; - } elseif (strpos($lower, 'bcc') !== false && isset($payload['sendtobcc'])) { - $value = $payload['sendtobcc']; - } elseif (strpos($lower, 'user') !== false && isset($payload['user'])) { - $value = $payload['user']; - } elseif (strpos($lower, 'lang') !== false && isset($payload['langs'])) { - $value = $payload['langs']; - } elseif (strpos($lower, 'conf') !== false && isset($payload['conf'])) { - $value = $payload['conf']; - } elseif (strpos($lower, 'context') !== false && isset($payload['context'])) { - $value = $payload['context']; - } elseif (strpos($lower, 'track') !== false && isset($payload['trackid'])) { - $value = $payload['trackid']; - } elseif (strpos($lower, 'recipient') !== false && isset($payload['recipient'])) { - $value = $payload['recipient']; - } elseif (strpos($lower, 'files') !== false && isset($payload['files'])) { - $value = $payload['files']; - } elseif (strpos($lower, 'filename') !== false && isset($payload['filename'])) { - $value = $payload['filename']; - } elseif (strpos($lower, 'mimetype') !== false && isset($payload['mimetype'])) { - $value = $payload['mimetype']; - } elseif (strpos($lower, 'moreinval') !== false && isset($payload['moreinval'])) { - $value = $payload['moreinval']; - } elseif (strpos($lower, 'params') !== false && isset($payload['params'])) { - $value = $payload['params']; - } - } - } - - if ($value === null && $parameter->isDefaultValueAvailable()) { - $value = $parameter->getDefaultValue(); - } - - $arguments[] = $value; - } - - return $arguments; - } - - /** - * Fire Dolibarr business notifications (module Notification) for status changes. - * - * FR: Déclenche les notifications métiers standards de Dolibarr (module Notification) lors des changements d'état. - * EN: Fire standard Dolibarr business notifications (Notification module) when the status changes. - * - * @param string $triggerCode - * @param User $actionUser - * @return int - */ - protected function triggerBusinessNotification($triggerCode, User $actionUser) - { - if (!is_array($this->context)) { - $this->context = array(); - } - - // FR: Marqueur spécifique pour les triggers du module Notification. - // EN: Specific flag for the Notification module triggers. - $this->context['timesheetweek_business_notification'] = 1; - - if (!empty($this->context['timesheetweek_notification']['base_substitutions'])) { - // FR: Garantit que les notifications métier récupèrent les mêmes substitutions que les e-mails automatiques. - // EN: Ensure business notifications reuse the same substitutions as automatic e-mails. - $this->context['mail_substitutions'] = $this->context['timesheetweek_notification']['base_substitutions']; - } - - return $this->fireNotificationTrigger($triggerCode, $actionUser); - } - - /** - * Badge / labels - */ - public function getLibStatut($mode = 0) - { - return self::LibStatut($this->status, $mode); - } - - public static function LibStatut($status, $mode = 0) - { - global $langs; - $langs->loadLangs(array('timesheetweek@timesheetweek', 'other')); - - $statusInfo = array( - self::STATUS_DRAFT => array( - 'label' => $langs->trans('TimesheetWeekStatusDraft'), - 'picto' => 'statut0', - 'class' => 'badge badge-status badge-status0', - ), - self::STATUS_SUBMITTED => array( - 'label' => $langs->trans('TimesheetWeekStatusSubmitted'), - 'picto' => 'statut1', - 'class' => 'badge badge-status badge-status1', - ), - self::STATUS_APPROVED => array( - 'label' => $langs->trans('TimesheetWeekStatusApproved'), - 'picto' => 'statut4', - 'class' => 'badge badge-status badge-status4', - ), - self::STATUS_SEALED => array( - // EN: Swap the badge styling with the refused status to improve visual contrast. - // FR: Inverse le style de badge avec le statut refusé pour améliorer le contraste visuel. - 'label' => $langs->trans('TimesheetWeekStatusSealed'), - 'picto' => 'statut6', - 'class' => 'badge badge-status badge-status6', - ), - self::STATUS_REFUSED => array( - // EN: Apply the sealed badge colors to the refused status for clearer differentiation. - // FR: Applique les couleurs du statut scellé au statut refusé pour une meilleure différenciation. - 'label' => $langs->trans('TimesheetWeekStatusRefused'), - 'picto' => 'statut8', - 'class' => 'badge badge-status badge-status8', - ), - ); - - $info = $statusInfo[$status] ?? array( - 'label' => $langs->trans('Unknown'), - 'picto' => 'statut0', - 'class' => 'badge badge-status badge-status0', - ); - - if ((int) $mode === 5) { - return '' - .dol_escape_htmltag($info['label']).''; - } - - $picto = img_picto($info['label'], $info['picto']); - if ((int) $mode === 1) return $picto; - if ((int) $mode === 2) return $picto.' '.$info['label']; - if ((int) $mode === 3) return $info['label'].' '.$picto; - return $info['label']; - } - - /** - * Create an agenda event for this timesheet action - * - * @param User $user - * @param string $code Internal agenda code (ex: TSWK_SUBMIT) - * @param string $labelKey Translation key for event label - * @param array $labelParams Parameters passed to translation - * @param bool $linkToObject Whether to create an object link - * - * @return bool - */ - protected function createAgendaEvent($user, $code, $labelKey, array $labelParams = array(), $linkToObject = true) - { - global $conf, $langs; - - if (!function_exists('isModEnabled') || !isModEnabled('agenda')) { - return true; - } - - $langs->loadLangs(array('timesheetweek@timesheetweek', 'agenda')); - - dol_include_once('/comm/action/class/actioncomm.class.php'); - - $args = array_merge(array($labelKey), $labelParams); - $label = call_user_func_array(array($langs, 'trans'), $args); - if ($label === $labelKey) { - $label = $langs->trans('TimesheetWeekAgendaDefaultLabel', $this->ref); - } - - $now = dol_now(); - - $event = new ActionComm($this->db); - $event->type_code = 'AC_OTH_AUTO'; - $event->code = $code; - $event->label = $label; - $event->note_private = $label; - $event->fk_user_author = (int) $user->id; - $event->fk_user_mod = (int) $user->id; - $ownerId = (int) (!empty($user->id) ? $user->id : ($this->fk_user ?: 0)); - $event->userownerid = $ownerId; - if (property_exists($event, 'fk_user_action')) { - $event->fk_user_action = $ownerId; - } - $event->datep = $now; - $event->datef = $now; - $event->percentage = -1; - $event->priority = 0; - $event->fulldayevent = 0; - $event->entity = !empty($this->entity) ? (int) $this->entity : (int) $conf->entity; - - if (!empty($this->fk_user)) { - $event->userassigned = array( - (int) $this->fk_user => array('id' => (int) $this->fk_user), - ); - } - - if ($linkToObject) { - $event->elementtype = $this->element; - $event->fk_element = (int) $this->id; - } - - $res = $event->create($user); - if ($res <= 0) { - $this->error = !empty($event->error) ? $event->error : 'AgendaEventCreationFailed'; - if (!empty($event->errors)) { - $this->errors = array_merge($this->errors, $event->errors); - } - return false; - } - - if ($linkToObject && method_exists($event, 'add_object_linked')) { - $event->add_object_linked($this->element, (int) $this->id); - } - - return true; - } + * EN: Seal the approved timesheet to prevent further changes. + * FR : Scelle la feuille approuvée pour empêcher de nouvelles modifications. + * + * @param User $user + * @return int + */ + public function seal($user) + { + $now = dol_now(); + + if ((int) $this->status !== self::STATUS_APPROVED) { + $this->error = 'BadStatusForSeal'; + return -1; + } + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " status=".(int) self::STATUS_SEALED; + $sql .= ", tms='".$this->db->idate($now)."'"; + $sql .= " WHERE rowid=".(int) $this->id; + + if (!$this->db->query($sql)) { + $this->db->rollback(); + $this->error = $this->db->lasterror(); + return -1; + } + + // EN: Keep approval metadata intact while locking the sheet. + // FR : Conserve les métadonnées d'approbation tout en verrouillant la feuille. + $this->status = self::STATUS_SEALED; + $this->tms = $now; + + if (!$this->createAgendaEvent($user, 'TSWK_SEAL', 'TimesheetWeekAgendaSealed', array($this->ref))) { + $this->db->rollback(); + return -1; + } + + $this->db->commit(); + + return 1; + } + + /** + * EN: Revert a sealed timesheet back to the approved state. + * FR : Rouvre une feuille scellée en la repassant au statut approuvé. + * + * @param User $user + * @return int + */ + public function unseal($user) + { + $now = dol_now(); + + if ((int) $this->status !== self::STATUS_SEALED) { + $this->error = 'BadStatusForUnseal'; + return -1; + } + + $this->db->begin(); + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " status=".(int) self::STATUS_APPROVED; + $sql .= ", tms='".$this->db->idate($now)."'"; + $sql .= " WHERE rowid=".(int) $this->id; + + if (!$this->db->query($sql)) { + $this->db->rollback(); + $this->error = $this->db->lasterror(); + return -1; + } + + // EN: Keep the original validation date while allowing edits again. + // FR : Préserve la date d'approbation d'origine tout en rouvrant l'édition. + $this->status = self::STATUS_APPROVED; + $this->tms = $now; + + if (!$this->createAgendaEvent($user, 'TSWK_UNSEAL', 'TimesheetWeekAgendaUnsealed', array($this->ref))) { + $this->db->rollback(); + return -1; + } + + $this->db->commit(); + + return 1; + } + + /** + * Synchronize element_time rows with the validated timesheet lines. + * EN: Inserts dedicated rows into llx_element_time when approving a sheet, removing stale ones first. + * FR: Insère les lignes dans llx_element_time lors de l'approbation, après avoir supprimé les enregistrements obsolètes. + * @return int + */ + protected function syncElementTimeRecords() + { + if (empty($this->id)) { + return 0; + } + + // Clean previous entries tied to this sheet to avoid duplicates (EN) + // Nettoie les entrées existantes liées à cette fiche pour éviter les doublons (FR) + if ($this->deleteElementTimeRecords() < 0) { + return -1; + } + + $lines = $this->getLines(); + if (empty($lines)) { + return 1; + } + + // Determine employee hourly rate for THM column (EN) + // Détermine le taux horaire salarié pour la colonne THM (FR) + $employeeThm = $this->resolveEmployeeThm(); + + // Prepare multilingual helper for note composition (EN) + // Prépare l'assistant multilingue pour composer la note (FR) + global $langs; + if (is_object($langs)) { + $langs->load('timesheetweek@timesheetweek'); + } + + foreach ($lines as $line) { + $durationSeconds = (int) round(((float) $line->hours) * 3600); + if ($durationSeconds <= 0) { + continue; + } + + $taskTimestamp = $this->normalizeLineDate($line->day_date); + $noteDateLabel = $taskTimestamp ? dol_print_date($taskTimestamp, '%d/%m/%Y') : dol_print_date(dol_now(), '%d/%m/%Y'); + // Try Dolibarr helper then fallback to module formatter (EN) + // Tente l'assistant Dolibarr puis bascule sur le formateur du module (FR) + if (function_exists('dol_print_duration')) { + $noteDurationLabel = dol_print_duration($durationSeconds, 'allhourmin'); + } else { + $noteDurationLabel = $this->formatDurationLabel($durationSeconds); + } + $noteDurationLabel = trim(str_replace(' ', ' ', $noteDurationLabel)); + if ($noteDurationLabel === '') { + $noteDurationValue = price2num($line->hours, 'MT'); + $noteDurationLabel = ($noteDurationValue !== '' ? number_format((float) $noteDurationValue, 2, '.', ' ') : '0.00').' h'; + } + + // Build the readable note with translations and fallback (EN) + // Construit la note lisible avec traductions et repli (FR) + if (is_object($langs)) { + $noteMessage = $langs->trans('TimesheetWeekElementTimeNote', (string) $this->ref, $noteDateLabel, $noteDurationLabel); + } else { + $noteMessage = 'Feuille de temps '.(string) $this->ref.' - '.$noteDateLabel.' - '.$noteDurationLabel; + } + + // Store a readable note for auditors and managers (EN) + // Enregistre une note lisible pour les contrôleurs et managers (FR) + $sql = "INSERT INTO ".MAIN_DB_PREFIX."element_time("; + $sql .= " fk_user, fk_element, elementtype, element_duration, element_date, thm, note, import_key"; + $sql .= ") VALUES ("; + $sql .= " ".(!empty($this->fk_user) ? (int) $this->fk_user : "NULL").","; + $sql .= " ".(!empty($line->fk_task) ? (int) $line->fk_task : "NULL").","; + $sql .= " 'task',"; + $sql .= " ".$durationSeconds.","; + $sql .= $taskTimestamp ? " '".$this->db->idate($taskTimestamp)."'," : " NULL,"; + $sql .= ($employeeThm !== null ? " ".$employeeThm : " NULL").","; + $sql .= " '".$this->db->escape($noteMessage)."',"; + $sql .= " '".$this->db->escape($this->buildElementTimeImportKey($line))."'"; + $sql .= ")"; + + if (!$this->db->query($sql)) { + $this->error = $this->db->lasterror(); + return -1; + } + } + + return 1; + } + + /** + * Delete synced rows from llx_element_time for this sheet. + * EN: Uses the custom import_key prefix to target only our module entries. + * FR: Utilise le préfixe import_key personnalisé pour ne viser que les entrées du module. + * @return int + */ + protected function deleteElementTimeRecords() + { + if (empty($this->id)) { + return 0; + } + + $prefix = $this->getElementTimeImportKeyPrefix(); + $sql = "DELETE FROM ".MAIN_DB_PREFIX."element_time"; + $sql .= " WHERE elementtype='task'"; + if ($prefix !== '') { + $sql .= " AND import_key LIKE '".$this->db->escape($prefix)."%'"; + } + + $resql = $this->db->query($sql); + if (!$resql) { + $this->error = $this->db->lasterror(); + return -1; + } + + return $this->db->affected_rows($resql); + } + + /** + * Normalize date value coming from a line. + * EN: Returns a unix timestamp or 0 if the date is missing. + * FR: Retourne un timestamp unix ou 0 si la date est absente. + * @param mixed $value + * @return int + */ + protected function normalizeLineDate($value) + { + if (empty($value)) { + return 0; + } + + if (is_numeric($value)) { + return (int) $value; + } + + $timestamp = dol_stringtotime($value, 0, 1); + if ($timestamp > 0) { + return $timestamp; + } + + $timestamp = strtotime($value); + return $timestamp ? (int) $timestamp : 0; + } + + /** + * Build a deterministic import_key for a line. + * EN: Generates a short hash starting with a module prefix for deletion filtering. + * FR: Génère un hash court préfixé pour faciliter le filtrage à la suppression. + * @param TimesheetWeekLine $line + * @return string + */ + protected function buildElementTimeImportKey($line) + { + $lineId = !empty($line->id) ? (int) $line->id : (!empty($line->rowid) ? (int) $line->rowid : 0); + $base = (string) $this->id.'-'.$lineId; + return $this->getElementTimeImportKeyPrefix().substr(md5($base), 0, 8); + } + + /** + * Provide the common prefix used for import_key values. + * EN: Ensures every record for this sheet starts with a predictable marker. + * FR: Garantit que chaque enregistrement de la fiche débute par un marqueur prévisible. + * @return string + */ + protected function getElementTimeImportKeyPrefix() + { + if (empty($this->id)) { + return ''; + } + + return 'TW'.substr(md5((string) $this->id), 0, 4); + } + + /** + * Resolve employee THM (average hourly rate) for element_time insert. + * EN: Tries the standard user fields to find the hourly cost before inserting into llx_element_time. + * FR: Explore les champs standards de l'utilisateur pour retrouver le coût horaire avant l'insertion dans llx_element_time. + * @return string|null + */ + protected function resolveEmployeeThm() + { + $employee = $this->loadUserFromCache($this->fk_user); + if (!$employee) { + return null; + } + + $candidates = array(); + if (property_exists($employee, 'thm')) { + $candidates[] = $employee->thm; + } + if (!empty($employee->array_options) && array_key_exists('options_thm', $employee->array_options)) { + $candidates[] = $employee->array_options['options_thm']; + } + + foreach ($candidates as $candidate) { + if ($candidate === '' || $candidate === null) { + continue; + } + + $value = price2num($candidate, 'MT'); + if ($value !== '') { + return (string) $value; + } + } + + return null; + } + + /** + * Format a readable duration when Dolibarr helper is unavailable. + * EN: Converts a duration in seconds to an "Hh Mmin" label for notes. + * FR: Convertit une durée en secondes en libellé « Hh Mmin » pour les notes. + * @param int $seconds + * @return string + */ + protected function formatDurationLabel($seconds) + { + $seconds = max(0, (int) $seconds); + $hours = (int) floor($seconds / 3600); + $minutes = (int) floor(($seconds % 3600) / 60); + $remainingSeconds = $seconds % 60; + + $parts = array(); + if ($hours > 0) { + $parts[] = $hours.'h'; + } + if ($minutes > 0) { + $parts[] = $minutes.'min'; + } + if ($remainingSeconds > 0 && $hours === 0) { + $parts[] = $remainingSeconds.'s'; + } + + if (empty($parts)) { + return '0 min'; + } + + return implode(' ', $parts); + } + + /** + * Refuse + * @param User $user + * @return int + */ + public function refuse($user) + { + $now = dol_now(); + + if ((int) $this->status !== self::STATUS_SUBMITTED) { + $this->error = 'BadStatusForRefuse'; + return -1; + } + + $this->db->begin(); + + $setvalid = ''; + if (empty($this->fk_user_valid) || (int) $this->fk_user_valid !== (int) $user->id) { + $setvalid = ", fk_user_valid=".(int) $user->id; + $this->fk_user_valid = (int) $user->id; + } + + $sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET"; + $sql .= " status=".(int) self::STATUS_REFUSED; + $sql .= ", date_validation='".$this->db->idate($now)."'"; + $sql .= ", tms='".$this->db->idate($now)."'"; + $sql .= $setvalid; + $sql .= " WHERE rowid=".(int) $this->id; + + if (!$this->db->query($sql)) { + $this->db->rollback(); + $this->error = $this->db->lasterror(); + return -1; + } + + $this->status = self::STATUS_REFUSED; + $this->date_validation = $now; + $this->tms = $now; + + if (!$this->createAgendaEvent($user, 'TSWK_REFUSE', 'TimesheetWeekAgendaRefused', array($this->ref))) { + $this->db->rollback(); + return -1; + } + + $this->db->commit(); + return 1; + } + + /** + * Generate definitive reference using addon in conf TIMESHEETWEEK_ADDON + * fallback: FHyyyyss-XXX + * @return string|false + */ + public function generateDefinitiveRef() + { + global $conf, $langs; + + $langs->load("other"); + + $module = getDolGlobalString('TIMESHEETWEEK_ADDON', 'mod_timesheetweek_advanced'); + $file = '/timesheetweek/core/modules/timesheetweek/'.$module.'.php'; + + $classname = $module; + dol_include_once($file); + + if (class_exists($classname)) { + $mod = new $classname($this->db); + if (method_exists($mod, 'getNextValue')) { + $ref = $mod->getNextValue($this); + if ($ref) return $ref; + } + } + + // Fallback + $yyyy = (int) $this->year; + $ww = str_pad((int) $this->week, 2, '0', STR_PAD_LEFT); + + $sql = "SELECT ref FROM ".MAIN_DB_PREFIX.$this->table_element; + $sql .= " WHERE entity=".(int) $conf->entity; + // EN: Ensure the fallback generation still respects allowed entities. + // FR: Garantit que la génération de secours respecte les entités autorisées. + $sql .= " AND entity IN (".getEntity('timesheetweek').")"; + $sql .= " AND year=".(int) $this->year." AND week=".(int) $this->week; + $sql .= " AND ref LIKE 'FH".$yyyy.$ww."-%'"; + $sql .= " ORDER BY ref DESC"; + $sql .= " ".$this->db->plimit(1); + + $res = $this->db->query($sql); + $seq = 0; + if ($res) { + $obj = $this->db->fetch_object($res); + if ($obj && preg_match('/^FH'.$yyyy.$ww.'-(\d{3})$/', $obj->ref, $m)) { + $seq = (int) $m[1]; + } + $this->db->free($res); + } + $seq++; + return 'FH'.$yyyy.$ww.'-'.str_pad($seq, 3, '0', STR_PAD_LEFT); + } + + /** + * Return linked tasks assigned to user (project_task) + * @param int $userid + * @return array Each: task_id, task_label, project_id, project_ref, project_title + */ + public function getAssignedTasks($userid) + { + global $conf; + + $userid = (int) $userid; + if ($userid <= 0) return array(); + + // Several ways tasks can be assigned -> llx_element_contact with element='project_task' + // Note: Some installs store user id into fk_socpeople (legacy) + // EN: Fetch extra task metadata so the card can filter items (status, progress, dates). + // FR: Récupère des métadonnées supplémentaires pour que la carte puisse filtrer (statut, avancement, dates). + $sql = "SELECT t.rowid as task_id, t.label as task_label, t.ref as task_ref,"; + $sql .= " t.progress as task_progress, t.fk_statut as task_status, t.dateo as task_date_start, t.datee as task_date_end,"; + $sql .= " p.rowid as project_id, p.ref as project_ref, p.title as project_title"; + $sql .= " FROM ".MAIN_DB_PREFIX."projet_task t"; + $sql .= " INNER JOIN ".MAIN_DB_PREFIX."projet p ON p.rowid = t.fk_projet"; + $sql .= " INNER JOIN ".MAIN_DB_PREFIX."element_contact ec ON ec.element_id = t.rowid"; + $sql .= " INNER JOIN ".MAIN_DB_PREFIX."c_type_contact ctc ON ctc.rowid = ec.fk_c_type_contact"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user u ON u.rowid = ec.fk_socpeople"; // legacy mapping + $sql .= " WHERE p.entity IN (".getEntity('project').")"; + $sql .= " AND ctc.element = 'project_task'"; + $sql .= " AND (ec.fk_socpeople = ".$userid." OR u.rowid = ".$userid.")"; + // EN: Group by the extra fields to keep SQL strict mode satisfied once new columns are selected. + // FR: Ajoute les nouveaux champs dans le GROUP BY pour respecter le mode strict SQL après sélection. + $sql .= " GROUP BY t.rowid, t.label, t.ref, t.progress, t.fk_statut, t.dateo, t.datee, p.rowid, p.ref, p.title"; + $sql .= " ORDER BY p.ref, t.label"; + + $res = $this->db->query($sql); + if (!$res) { + $this->error = $this->db->lasterror(); + return array(); + } + $out = array(); + while ($obj = $this->db->fetch_object($res)) { + // EN: Return the enriched task information so the caller can apply advanced filters. + // FR: Retourne les informations enrichies de la tâche pour permettre des filtres avancés côté appelant. + $out[] = array( + 'task_id' => (int) $obj->task_id, + 'task_label' => (string) $obj->task_label, + 'task_ref' => (string) $obj->task_ref, + 'task_progress' => ($obj->task_progress !== null ? (float) $obj->task_progress : null), + //'task_status' => ($obj->task_status !== null ? (int) $obj->task_status : null), // EN: All status are considered to not hide closed tasks // FR: Tous les Statuts sont pris en compte pour ne pas masquer les tâches clôturées. + 'task_date_start' => ($obj->task_date_start !== null ? (string) $obj->task_date_start : null), + 'task_date_end' => ($obj->task_date_end !== null ? (string) $obj->task_date_end : null), + 'project_id' => (int) $obj->project_id, + 'project_ref' => (string) $obj->project_ref, + 'project_title' => (string) $obj->project_title, + ); + } + $this->db->free($res); + return $out; + } + + /** + * URL to card + * @param int $withpicto + * @param string $option + * @param int $notooltip + * @param string $morecss + * @return string + */ + public function getNomUrl($withpicto = 0, $option = '', $notooltip = 0, $morecss = '') + { + global $langs; + + $label = dol_escape_htmltag($this->ref); + $url = dol_buildpath('/timesheetweek/timesheetweek_card.php', 1).'?id='.(int) $this->id; + + $linkstart = ''; + $linkend = ''; + $labeltooltip = $langs->trans('ShowTimesheetWeek', $this->ref); + + if ($option !== 'nolink') { + $linkstart = ''; + $linkend = ''; + } + + $result = ''; + if ($withpicto) { + $tooltip = empty($notooltip) ? $labeltooltip : ''; + $picto = img_object($tooltip, $this->picto); + $result .= $linkstart.$picto.$linkend; + if ($withpicto != 1) { + $result .= ' '; + } + } + + if ($withpicto != 1 || $option === 'ref' || !$withpicto) { + $result .= $linkstart.$label.$linkend; + } + + return $result; + } + + /** + * Load user from cache + * + * @param int $userid + * @return User|null + */ + protected function loadUserFromCache($userid) + { + $userid = (int) $userid; + if ($userid <= 0) { + return null; + } + + static $cache = array(); + if (!array_key_exists($userid, $cache)) { + $cache[$userid] = null; + $tmp = new User($this->db); + if ($tmp->fetch($userid) > 0) { + $cache[$userid] = $tmp; + } + } + + return $cache[$userid]; + } + + /** + * Send automatic notification based on trigger code + * + * @param string $triggerCode + * @param User $actionUser + * @return bool + */ + protected function sendAutomaticNotification($triggerCode, User $actionUser) + { + global $langs; + + $langs->loadLangs(array('mails', 'timesheetweek@timesheetweek', 'users')); + + $employee = $this->loadUserFromCache($this->fk_user); + $validator = $this->loadUserFromCache($this->fk_user_valid); + + // FR: Génère l'URL directe vers la fiche pour l'insérer dans le modèle d'e-mail. + // EN: Build the direct link to the card so it can be injected inside the e-mail template. + $url = dol_buildpath('/timesheetweek/timesheetweek_card.php', 2).'?id='.(int) $this->id; + + $employeeName = $employee ? $employee->getFullName($langs) : ''; + $validatorName = $validator ? $validator->getFullName($langs) : ''; + $actionUserName = $actionUser->getFullName($langs); + + // FR: Base des substitutions partagées entre notifications automatiques et métier. + // EN: Base substitution array shared between automatic and business notifications. + $baseSubstitutions = array( + '__TIMESHEETWEEK_REF__' => $this->ref, + '__TIMESHEETWEEK_WEEK__' => $this->week, + '__TIMESHEETWEEK_YEAR__' => $this->year, + '__TIMESHEETWEEK_URL__' => $url, + '__TIMESHEETWEEK_EMPLOYEE_FULLNAME__' => $employeeName, + '__TIMESHEETWEEK_VALIDATOR_FULLNAME__' => $validatorName, + '__ACTION_USER_FULLNAME__' => $actionUserName, + '__RECIPIENT_FULLNAME__' => '', + ); + + if (!is_array($this->context)) { + $this->context = array(); + } + + $this->context['timesheetweek_notification'] = array( + 'trigger_code' => $triggerCode, + 'url' => $url, + 'employee_id' => $employee ? (int) $employee->id : 0, + 'validator_id' => $validator ? (int) $validator->id : 0, + 'action_user_id' => (int) $actionUser->id, + 'employee_fullname' => $employeeName, + 'validator_fullname' => $validatorName, + 'action_user_fullname' => $actionUserName, + 'base_substitutions' => $baseSubstitutions, + ); + + // FR: Conserve les substitutions pour les triggers Notification natifs. + // EN: Keep substitutions handy for native Notification triggers. + $this->context['mail_substitutions'] = $baseSubstitutions; + + // FR: Permet aux implémentations natives de récupérer toutes les informations utiles. + // EN: Allow native helpers to access every useful metadata while sending e-mails. + $this->context['timesheetweek_notification']['native_options'] = array( + 'employee' => $employee, + 'validator' => $validator, + 'base_substitutions' => $baseSubstitutions, + ); + + // FR: Les triggers accèdent aux informations via le contexte, inutile de dupliquer les paramètres. + // EN: Triggers read every detail from the context, no need to duplicate the payload locally. + $result = $this->fireNotificationTrigger($triggerCode, $actionUser); + if ($result < 0) { + $this->errors[] = $langs->trans('TimesheetWeekNotificationTriggerError', $triggerCode); + dol_syslog(__METHOD__.': unable to execute trigger '.$triggerCode, LOG_ERR); + return false; + } + + return true; + } + + /** + * Execute Dolibarr triggers when notifications must be sent. + * + * @param string $triggerCode + * @param User $actionUser + * @param array $parameters + * @return int + */ + protected function fireNotificationTrigger($triggerCode, User $actionUser) + { + global $langs, $conf, $hookmanager; + + $payload = $this->buildTriggerParameters($triggerCode, $actionUser); + + // FR: Priorité à call_trigger lorsqu'il est disponible sur l'objet. + // EN: Give priority to call_trigger whenever it exists on the object. + if (method_exists($this, 'call_trigger')) { + try { + $method = new \ReflectionMethod($this, 'call_trigger'); + $arguments = $this->mapTriggerArguments($method->getParameters(), $payload); + + return $method->invokeArgs($this, $arguments); + } catch (\Throwable $error) { + dol_syslog(__METHOD__.': '.$error->getMessage(), LOG_WARNING); + } + } + + // FR: Compatibilité avec les anciennes versions qui exposent runTrigger directement sur l'objet. + // EN: Backward compatibility for legacy releases exposing runTrigger on the object. + if (method_exists($this, 'runTrigger')) { + try { + $method = new \ReflectionMethod($this, 'runTrigger'); + $arguments = $this->mapTriggerArguments($method->getParameters(), $payload, true); + + return $method->invokeArgs($this, $arguments); + } catch (\Throwable $error) { + dol_syslog(__METHOD__.': '.$error->getMessage(), LOG_WARNING); + } + } + + // FR: Fallback ultime sur la fonction globale runTrigger si elle est disponible. + // EN: Ultimate fallback using the global runTrigger helper when available. + if (!function_exists('runTrigger')) { + dol_include_once('/core/triggers/functions_triggers.inc.php'); + } + + if (function_exists('runTrigger')) { + try { + $function = new \ReflectionFunction('runTrigger'); + $arguments = $this->mapTriggerArguments($function->getParameters(), $payload, true); + + return $function->invokeArgs($arguments); + } catch (\Throwable $error) { + dol_syslog(__METHOD__.': '.$error->getMessage(), LOG_WARNING); + } + } + + return 0; + } + + /** + * Prépare le jeu de paramètres partagé entre toutes les signatures de triggers. + * Prepare the shared payload used by every trigger signature. + * + * @param string $triggerCode + * @param User $actionUser + * @return array + */ + protected function buildTriggerParameters($triggerCode, User $actionUser) + { + global $langs, $conf, $hookmanager; + + return array( + 'action' => $triggerCode, + 'trigger' => $triggerCode, + 'event' => $triggerCode, + 'actioncode' => $triggerCode, + 'user' => $actionUser, + 'actor' => $actionUser, + 'currentuser' => $actionUser, + 'langs' => $langs, + 'language' => $langs, + 'conf' => $conf, + 'config' => $conf, + 'hookmanager' => isset($hookmanager) ? $hookmanager : null, + 'hook' => isset($hookmanager) ? $hookmanager : null, + 'object' => $this, + 'obj' => $this, + 'objectsrc' => $this, + 'context' => $this->context, + 'parameters' => array('context' => $this->context, 'timesheetweek' => $this), + 'params' => array('context' => $this->context, 'timesheetweek' => $this), + 'moreparam' => array('context' => $this->context, 'timesheetweek' => $this), + 'extrafields' => property_exists($this, 'extrafields') ? $this->extrafields : null, + 'extrafield' => property_exists($this, 'extrafields') ? $this->extrafields : null, + 'extraparams' => property_exists($this, 'extrafields') ? $this->extrafields : null, + 'parametersarray' => array('context' => $this->context, 'timesheetweek' => $this), + 'actiontype' => $triggerCode, + ); + } + + /** + * Mappe la liste des paramètres attendus par une signature de trigger vers les valeurs connues. + * Map the expected parameter list of a trigger signature to the known values. + * + * @param \ReflectionParameter[] $signature + * @param array $payload + * @param bool $injectObjectWhenMissing + * @return array + */ + protected function mapTriggerArguments(array $signature, array $payload, $injectObjectWhenMissing = false) + { + $arguments = array(); + + foreach ($signature as $parameter) { + $name = $parameter->getName(); + + if (isset($payload[$name])) { + $arguments[] = $payload[$name]; + continue; + } + + // FR: Quelques alias fréquents ne respectent pas la casse ou ajoutent un suffixe. + // EN: Handle common aliases that differ by casing or suffixes. + $lower = strtolower($name); + if (isset($payload[$lower])) { + $arguments[] = $payload[$lower]; + continue; + } + + if ($lower === 'object' && $injectObjectWhenMissing) { + $arguments[] = $this; + continue; + } + + if (strpos($lower, 'context') !== false) { + $arguments[] = $payload['context']; + continue; + } + + // FR: Valeur neutre par défaut pour ne pas interrompre le déclenchement. + // EN: Default neutral value so the trigger keeps running. + $arguments[] = null; + } + + return $arguments; + } + + /** + * Try to send an e-mail notification relying on Dolibarr native helpers. + * + * FR: Tente d'envoyer un e-mail de notification en s'appuyant sur les outils natifs de Dolibarr. + * EN: Try sending the notification e-mail using Dolibarr's native helpers. + * + * @param string $triggerCode + * @param User $actionUser + * @param User $recipient + * @param mixed $langs + * @param mixed $conf + * @param array $substitutions + * @param array $options + * @return int >0 success, 0 unsupported, <0 failure + */ + public function sendNativeMailNotification($triggerCode, User $actionUser, $recipient, $langs, $conf, array $substitutions, array $options = array()) + { + $sendto = isset($options['sendto']) ? trim((string) $options['sendto']) : ''; + if ($sendto === '') { + return 0; + } + + $methods = array('sendEmailsFromTemplate', 'sendEmailsCommon', 'sendEmailsFromModel', 'sendEmails', 'sendMails'); + + $payload = array( + 'trigger' => $triggerCode, + 'action' => $triggerCode, + 'code' => $triggerCode, + 'event' => $triggerCode, + 'user' => $actionUser, + 'actionuser' => $actionUser, + 'actor' => $actionUser, + 'currentuser' => $actionUser, + 'langs' => $langs, + 'language' => $langs, + 'conf' => $conf, + 'subject' => isset($options['subject']) ? (string) $options['subject'] : '', + 'message' => isset($options['message']) ? (string) $options['message'] : '', + 'content' => isset($options['message']) ? (string) $options['message'] : '', + 'body' => isset($options['message']) ? (string) $options['message'] : '', + 'sendto' => $sendto, + 'emailto' => $sendto, + 'email_to' => $sendto, + 'sendtolist' => $sendto, + 'sendtocc' => isset($options['cc']) ? (string) $options['cc'] : '', + 'emailcc' => isset($options['cc']) ? (string) $options['cc'] : '', + 'sendtobcc' => isset($options['bcc']) ? (string) $options['bcc'] : '', + 'emailbcc' => isset($options['bcc']) ? (string) $options['bcc'] : '', + 'replyto' => isset($options['replyto']) ? (string) $options['replyto'] : '', + 'emailreplyto' => isset($options['replyto']) ? (string) $options['replyto'] : '', + 'deliveryreceipt' => !empty($options['deliveryreceipt']) ? 1 : 0, + 'trackid' => !empty($options['trackid']) ? (string) $options['trackid'] : 'timesheetweek-'.$this->id.'-'.$triggerCode, + 'substitutions' => $substitutions, + 'substitutionarray' => $substitutions, + 'mail_substitutions' => $substitutions, + 'array_substitutions' => $substitutions, + 'files' => isset($options['files']) && is_array($options['files']) ? $options['files'] : array(), + 'filearray' => isset($options['files']) && is_array($options['files']) ? $options['files'] : array(), + 'filename' => isset($options['filenames']) && is_array($options['filenames']) ? $options['filenames'] : array(), + 'filenameList' => isset($options['filenames']) && is_array($options['filenames']) ? $options['filenames'] : array(), + 'mimetype' => isset($options['mimetypes']) && is_array($options['mimetypes']) ? $options['mimetypes'] : array(), + 'mimetypeList' => isset($options['mimetypes']) && is_array($options['mimetypes']) ? $options['mimetypes'] : array(), + 'joinfiles' => isset($options['files']) && is_array($options['files']) ? $options['files'] : array(), + 'mode' => 'email', + 'recipient' => $recipient, + 'email' => $sendto, + 'context' => $this->context, + 'moreinval' => array('context' => $this->context, 'timesheetweek' => $this), + 'params' => isset($options['params']) && is_array($options['params']) ? $options['params'] : array(), + 'options' => $options, + ); + + foreach ($methods as $methodName) { + if (!method_exists($this, $methodName)) { + continue; + } + + try { + $method = new \ReflectionMethod($this, $methodName); + $arguments = $this->mapMailMethodArguments($method->getParameters(), $payload); + $result = $method->invokeArgs($this, $arguments); + + if ($result === false) { + continue; + } + + if (is_numeric($result)) { + if ((int) $result > 0) { + return (int) $result; + } + + continue; + } + + return 1; + } catch (\Throwable $error) { + dol_syslog(__METHOD__.': '.$error->getMessage(), LOG_WARNING); + } + } + + return 0; + } + + /** + * Map native mail helper signature to known payload values. + * + * FR: Associe la signature d'une méthode d'envoi d'e-mail aux valeurs connues. + * EN: Map the parameter list of a mail helper to the known payload values. + * + * @param \ReflectionParameter[] $signature + * @param array $payload + * @return array + */ + protected function mapMailMethodArguments(array $signature, array $payload) + { + $arguments = array(); + + foreach ($signature as $parameter) { + $value = null; + $name = $parameter->getName(); + $lower = strtolower($name); + + if (isset($payload[$name])) { + $value = $payload[$name]; + } elseif (isset($payload[$lower])) { + $value = $payload[$lower]; + } else { + if ($parameter->hasType()) { + $type = $parameter->getType(); + if ($type && !$type->isBuiltin()) { + $typeName = ltrim($type->getName(), '\\'); + if ($typeName === 'User') { + $value = isset($payload['user']) ? $payload['user'] : null; + } elseif ($typeName === 'Translate') { + $value = isset($payload['langs']) ? $payload['langs'] : null; + } elseif ($typeName === 'Conf') { + $value = isset($payload['conf']) ? $payload['conf'] : null; + } elseif ($typeName === 'TimesheetWeek' || is_a($this, $typeName)) { + $value = $this; + } + } + } + + if ($value === null) { + if (strpos($lower, 'substit') !== false && isset($payload['substitutions'])) { + $value = $payload['substitutions']; + } elseif (strpos($lower, 'sendto') !== false && isset($payload['sendto'])) { + $value = $payload['sendto']; + } elseif (strpos($lower, 'subject') !== false && isset($payload['subject'])) { + $value = $payload['subject']; + } elseif ((strpos($lower, 'message') !== false || strpos($lower, 'content') !== false || strpos($lower, 'body') !== false) && isset($payload['message'])) { + $value = $payload['message']; + } elseif (strpos($lower, 'reply') !== false && isset($payload['replyto'])) { + $value = $payload['replyto']; + } elseif (strpos($lower, 'cc') !== false && isset($payload['sendtocc'])) { + $value = $payload['sendtocc']; + } elseif (strpos($lower, 'bcc') !== false && isset($payload['sendtobcc'])) { + $value = $payload['sendtobcc']; + } elseif (strpos($lower, 'user') !== false && isset($payload['user'])) { + $value = $payload['user']; + } elseif (strpos($lower, 'lang') !== false && isset($payload['langs'])) { + $value = $payload['langs']; + } elseif (strpos($lower, 'conf') !== false && isset($payload['conf'])) { + $value = $payload['conf']; + } elseif (strpos($lower, 'context') !== false && isset($payload['context'])) { + $value = $payload['context']; + } elseif (strpos($lower, 'track') !== false && isset($payload['trackid'])) { + $value = $payload['trackid']; + } elseif (strpos($lower, 'recipient') !== false && isset($payload['recipient'])) { + $value = $payload['recipient']; + } elseif (strpos($lower, 'files') !== false && isset($payload['files'])) { + $value = $payload['files']; + } elseif (strpos($lower, 'filename') !== false && isset($payload['filename'])) { + $value = $payload['filename']; + } elseif (strpos($lower, 'mimetype') !== false && isset($payload['mimetype'])) { + $value = $payload['mimetype']; + } elseif (strpos($lower, 'moreinval') !== false && isset($payload['moreinval'])) { + $value = $payload['moreinval']; + } elseif (strpos($lower, 'params') !== false && isset($payload['params'])) { + $value = $payload['params']; + } + } + } + + if ($value === null && $parameter->isDefaultValueAvailable()) { + $value = $parameter->getDefaultValue(); + } + + $arguments[] = $value; + } + + return $arguments; + } + + /** + * Fire Dolibarr business notifications (module Notification) for status changes. + * + * FR: Déclenche les notifications métiers standards de Dolibarr (module Notification) lors des changements d'état. + * EN: Fire standard Dolibarr business notifications (Notification module) when the status changes. + * + * @param string $triggerCode + * @param User $actionUser + * @return int + */ + protected function triggerBusinessNotification($triggerCode, User $actionUser) + { + if (!is_array($this->context)) { + $this->context = array(); + } + + // FR: Marqueur spécifique pour les triggers du module Notification. + // EN: Specific flag for the Notification module triggers. + $this->context['timesheetweek_business_notification'] = 1; + + if (!empty($this->context['timesheetweek_notification']['base_substitutions'])) { + // FR: Garantit que les notifications métier récupèrent les mêmes substitutions que les e-mails automatiques. + // EN: Ensure business notifications reuse the same substitutions as automatic e-mails. + $this->context['mail_substitutions'] = $this->context['timesheetweek_notification']['base_substitutions']; + } + + return $this->fireNotificationTrigger($triggerCode, $actionUser); + } + + /** + * Badge / labels + */ + public function getLibStatut($mode = 0) + { + return self::LibStatut($this->status, $mode); + } + + /** + * EN: Provide the Dolibarr badge definition for a status (class, label, colors). + * FR: Fournit la définition du badge Dolibarr pour un statut (classe, libellé, couleurs). + * + * @param int $status Timesheet status identifier / Identifiant du statut de la feuille + * @param Translate|null $translator Optional translator to reuse / Traducteur optionnel à réutiliser + * @return array Badge definition data / Données de définition du badge + */ + public static function getStatusBadgeDefinition($status, $translator = null) + { + global $langs; + + // EN: Allow PDF generation to reuse its own Translate instance when provided. + // FR: Permet à la génération PDF de réutiliser sa propre instance Translate lorsque fournie. + $activeTranslator = $translator instanceof Translate ? $translator : $langs; + + if ($activeTranslator instanceof Translate) { + if (method_exists($activeTranslator, 'loadLangs')) { + $activeTranslator->loadLangs(array('timesheetweek@timesheetweek', 'other')); + } else { + $activeTranslator->load('timesheetweek@timesheetweek'); + $activeTranslator->load('other'); + } + } + + $statusInfo = array( + self::STATUS_DRAFT => array( + 'label' => $activeTranslator instanceof Translate ? $activeTranslator->trans('TimesheetWeekStatusDraft') : 'Draft', + 'picto' => 'statut0', + 'class' => 'badge badge-status badge-status0', + 'type' => 'status0', + // EN: Provide RGB-friendly colors for PDF rendering. + // FR: Fournit des couleurs compatibles RVB pour le rendu PDF. + 'background_color' => '#adb5bd', + 'text_color' => '#212529', + ), + self::STATUS_SUBMITTED => array( + 'label' => $activeTranslator instanceof Translate ? $activeTranslator->trans('TimesheetWeekStatusSubmitted') : 'Submitted', + 'picto' => 'statut1', + 'class' => 'badge badge-status badge-status1', + 'type' => 'status1', + // EN: Provide RGB-friendly colors for PDF rendering. + // FR: Fournit des couleurs compatibles RVB pour le rendu PDF. + 'background_color' => '#0d6efd', + 'text_color' => '#ffffff', + ), + self::STATUS_APPROVED => array( + 'label' => $activeTranslator instanceof Translate ? $activeTranslator->trans('TimesheetWeekStatusApproved') : 'Approved', + 'picto' => 'statut4', + 'class' => 'badge badge-status badge-status4', + 'type' => 'status4', + // EN: Provide RGB-friendly colors for PDF rendering. + // FR: Fournit des couleurs compatibles RVB pour le rendu PDF. + 'background_color' => '#198754', + 'text_color' => '#ffffff', + ), + self::STATUS_SEALED => array( + // EN: Align sealed badge with Dolibarr default visual identity. + // FR: Aligne le badge du statut scellé sur l'identité visuelle Dolibarr par défaut. + 'label' => $activeTranslator instanceof Translate ? $activeTranslator->trans('TimesheetWeekStatusSealed') : 'Sealed', + 'picto' => 'statut6', + 'class' => 'badge badge-status badge-status6', + 'type' => 'status6', + // EN: Provide RGB-friendly colors for PDF rendering. + // FR: Fournit des couleurs compatibles RVB pour le rendu PDF. + 'background_color' => '#6f42c1', + 'text_color' => '#ffffff', + ), + self::STATUS_REFUSED => array( + // EN: Keep the refused badge matching Dolibarr styling guidelines. + // FR: Maintient le badge du statut refusé conforme aux directives Dolibarr. + 'label' => $activeTranslator instanceof Translate ? $activeTranslator->trans('TimesheetWeekStatusRefused') : 'Refused', + 'picto' => 'statut8', + 'class' => 'badge badge-status badge-status8', + 'type' => 'status8', + // EN: Provide RGB-friendly colors for PDF rendering. + // FR: Fournit des couleurs compatibles RVB pour le rendu PDF. + 'background_color' => '#dc3545', + 'text_color' => '#ffffff', + ), + ); + + $defaultLabel = $activeTranslator instanceof Translate ? $activeTranslator->trans('Unknown') : 'Unknown'; + + return $statusInfo[$status] ?? array( + 'label' => $defaultLabel, + 'picto' => 'statut0', + 'class' => 'badge badge-status badge-status0', + 'type' => 'status0', + // EN: Provide RGB-friendly colors for PDF rendering. + // FR: Fournit des couleurs compatibles RVB pour le rendu PDF. + 'background_color' => '#adb5bd', + 'text_color' => '#212529', + ); + } + + public static function LibStatut($status, $mode = 0) + { + $info = self::getStatusBadgeDefinition($status); + $label = dol_escape_htmltag($info['label']); + + if ((int) $mode === 5) { + // EN: Build Dolibarr badge output to mirror native status rendering. + // FR: Construit le badge Dolibarr pour reproduire le rendu natif des statuts. + $badgeParams = array( + 'badgeParams' => array( + 'attr' => array( + 'aria-label' => $label, + ), + ), + ); + return dolGetStatus( + $info['label'], + $info['label'], + '', + !empty($info['type']) ? $info['type'] : 'status0', + 5, + '', + $badgeParams + ); + } + + $picto = img_picto($info['label'], $info['picto']); + if ((int) $mode === 1) return $picto; + if ((int) $mode === 2) return $picto.' '.$info['label']; + if ((int) $mode === 3) return $info['label'].' '.$picto; + return $info['label']; + } + + /** + * Create an agenda event for this timesheet action + * + * @param User $user + * @param string $code Internal agenda code (ex: TSWK_SUBMIT) + * @param string $labelKey Translation key for event label + * @param array $labelParams Parameters passed to translation + * @param bool $linkToObject Whether to create an object link + * + * @return bool + */ + protected function createAgendaEvent($user, $code, $labelKey, array $labelParams = array(), $linkToObject = true) + { + global $conf, $langs; + + if (!function_exists('isModEnabled') || !isModEnabled('agenda')) { + return true; + } + + $langs->loadLangs(array('timesheetweek@timesheetweek', 'agenda')); + + dol_include_once('/comm/action/class/actioncomm.class.php'); + + $args = array_merge(array($labelKey), $labelParams); + $label = call_user_func_array(array($langs, 'trans'), $args); + if ($label === $labelKey) { + $label = $langs->trans('TimesheetWeekAgendaDefaultLabel', $this->ref); + } + + $now = dol_now(); + + $event = new ActionComm($this->db); + $event->type_code = 'AC_OTH_AUTO'; + $event->code = $code; + $event->label = $label; + $event->note_private = $label; + $event->fk_user_author = (int) $user->id; + $event->fk_user_mod = (int) $user->id; + $ownerId = (int) (!empty($user->id) ? $user->id : ($this->fk_user ?: 0)); + $event->userownerid = $ownerId; + if (property_exists($event, 'fk_user_action')) { + $event->fk_user_action = $ownerId; + } + $event->datep = $now; + $event->datef = $now; + $event->percentage = -1; + $event->priority = 0; + $event->fulldayevent = 0; + $event->entity = !empty($this->entity) ? (int) $this->entity : (int) $conf->entity; + + if (!empty($this->fk_user)) { + $event->userassigned = array( + (int) $this->fk_user => array('id' => (int) $this->fk_user), + ); + } + + if ($linkToObject) { + $event->elementtype = $this->element; + $event->fk_element = (int) $this->id; + } + + $res = $event->create($user); + if ($res <= 0) { + $this->error = !empty($event->error) ? $event->error : 'AgendaEventCreationFailed'; + if (!empty($event->errors)) { + $this->errors = array_merge($this->errors, $event->errors); + } + return false; + } + + if ($linkToObject && method_exists($event, 'add_object_linked')) { + $event->add_object_linked($this->element, (int) $this->id); + } + + return true; + } } diff --git a/core/modules/modTimesheetWeek.class.php b/core/modules/modTimesheetWeek.class.php index effc835..a92ef83 100644 --- a/core/modules/modTimesheetWeek.class.php +++ b/core/modules/modTimesheetWeek.class.php @@ -79,9 +79,41 @@ public function __construct($db) $this->editor_url = 'lesmetiersdubatiment.fr'; // Must be an external online web site $this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@timesheetweek' + // EN: Guarantee that the configuration container exists to store directory paths. + // FR: Garantir que le conteneur de configuration existe pour stocker les chemins des répertoires. + if (!isset($conf->timesheetweek) || !is_object($conf->timesheetweek)) { + $conf->timesheetweek = new stdClass(); + } + + // EN: Compute the entity-aware default directories for generated documents. + // FR: Calculer les répertoires par entité pour les documents générés. + $entity = !empty($conf->entity) ? (int) $conf->entity : 1; + $defaultDir = DOL_DATA_ROOT.($entity > 1 ? '/'.$entity : '').'/timesheetweek'; + + // EN: Register the default output directory if it was not yet defined. + // FR: Enregistrer le répertoire de sortie par défaut s'il n'était pas encore défini. + if (empty($conf->timesheetweek->dir_output)) { + $conf->timesheetweek->dir_output = $defaultDir; + } + + // EN: Ensure a temporary working directory is available beside the output path. + // FR: S'assurer qu'un répertoire temporaire est disponible à côté du chemin de sortie. + if (empty($conf->timesheetweek->dir_temp)) { + $conf->timesheetweek->dir_temp = $conf->timesheetweek->dir_output.'/temp'; + } + + // EN: Prepare the multi-entity mapping used by the Dolibarr document helpers. + // FR: Préparer la correspondance multi-entité utilisée par les assistants de documents Dolibarr. + if (empty($conf->timesheetweek->multidir_output) || !is_array($conf->timesheetweek->multidir_output)) { + $conf->timesheetweek->multidir_output = array(); + } + if (empty($conf->timesheetweek->multidir_output[$entity])) { + $conf->timesheetweek->multidir_output[$entity] = $conf->timesheetweek->dir_output; + } + // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z' - $this->version = '1.2.0'; // EN: Adds forfait-jour daily rate selectors with automatic hour conversion. - // FR: Ajoute les sélecteurs forfait jour Journée/Matin/Après-midi avec conversion automatique des heures. + $this->version = '1.3.0'; // EN: Enables PDF generation with document model management like native Dolibarr cards. + // FR: Active la génération PDF avec gestion des modèles de documents comme sur les fiches Dolibarr natives. // Url to the file with your last numberversion of this module //$this->url_last_version = 'http://www.example.com/versionmodule.txt'; @@ -743,10 +775,16 @@ public function init($options = '') dol_include_once('/core/class/extrafields.class.php'); $extrafields = new ExtraFields($this->db); $extrafields->fetch_name_optionals_label('user'); + $dailyRateVisibility = '-1'; + $dailyRateSharedEntity = '0'; if (empty($extrafields->attributes['user']['label']['lmdb_daily_rate'])) { // EN: Register the daily rate toggle on employees when the module is activated. // FR: Enregistre l'option de forfait jour sur les salariés lors de l'activation du module. - $extrafields->addExtraField('lmdb_daily_rate', 'TimesheetWeekDailyRateLabel', 'boolean', 100, '', 'user', 0, 0, '', '', 0, '', '0', '', '', '', 'timesheetweek@timesheetweek', 'isModEnabled("timesheetweek")', 0, 0); + $extrafields->addExtraField('lmdb_daily_rate', 'TimesheetWeekDailyRateLabel', 'boolean', 100, '', 'user', 0, 0, '', '', 0, '', $dailyRateVisibility, '', '', $dailyRateSharedEntity, 'timesheetweek@timesheetweek', 'isModEnabled("timesheetweek")', 0, 0); + } else { + // EN: Align the existing daily rate toggle with the latest visibility and sharing rules. + // FR: Aligne l'option de forfait jour existante avec les nouvelles règles de visibilité et de partage. + $extrafields->updateExtraField('lmdb_daily_rate', 'TimesheetWeekDailyRateLabel', 'boolean', 100, '', 'user', 0, 0, '', '', 0, '', $dailyRateVisibility, '', '', $dailyRateSharedEntity, 'timesheetweek@timesheetweek', 'isModEnabled("timesheetweek")', 0, 0); } // EN: Ensure existing installations receive the daily_rate column for time entries. @@ -781,33 +819,33 @@ public function init($options = '') $myTmpObjects = array(); $myTmpObjects['TimesheetWeek'] = array('includerefgeneration' => 0, 'includedocgeneration' => 1); - foreach ($myTmpObjects as $myTmpObjectKey => $myTmpObjectArray) { - if (!empty($myTmpObjectArray['includerefgeneration']) || !empty($myTmpObjectArray['includedocgeneration'])) { - $src = DOL_DOCUMENT_ROOT.'/install/doctemplates/'.$moduledir.'/template_timesheetweek.odt'; - $dirodt = DOL_DATA_ROOT.($conf->entity > 1 ? '/'.$conf->entity : '').'/doctemplates/'.$moduledir; - $dest = $dirodt.'/template_timesheetweek.odt'; - - if (file_exists($src) && !file_exists($dest)) { - require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; - dol_mkdir($dirodt); - $result = dol_copy($src, $dest, '0', 0); - if ($result < 0) { - $langs->load("errors"); - $this->error = $langs->trans('ErrorFailToCopyFile', $src, $dest); - return 0; - } + + foreach ($myTmpObjects as $myTmpObjectKey => $myTmpObjectArray) { + if (!empty($myTmpObjectArray['includedocgeneration'])) { + // EN: Remove legacy ODT document registrations tied to the module scope. + // FR: Supprimer les enregistrements ODT hérités liés au périmètre du module. + $sql[] = "DELETE FROM ".MAIN_DB_PREFIX."document_model WHERE LOWER(nom) LIKE '%odt' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity); + + // EN: Register the Dolibarr document models for the entity. + // FR: Enregistrer les modèles de documents Dolibarr pour l'entité. + $sql = array_merge($sql, array( + "DELETE FROM ".MAIN_DB_PREFIX."document_model WHERE nom = 'standard_".strtolower($myTmpObjectKey)."' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity), + "INSERT INTO ".MAIN_DB_PREFIX."document_model (nom, type, entity) VALUES('standard_".strtolower($myTmpObjectKey)."', '".$this->db->escape(strtolower($myTmpObjectKey))."', ".((int) $conf->entity).")" + )); } + } - $sql = array_merge($sql, array( - "DELETE FROM ".$this->db->prefix()."document_model WHERE nom = 'standard_".strtolower($myTmpObjectKey)."' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity), - "INSERT INTO ".$this->db->prefix()."document_model (nom, type, entity) VALUES('standard_".strtolower($myTmpObjectKey)."', '".$this->db->escape(strtolower($myTmpObjectKey))."', ".((int) $conf->entity).")", - "DELETE FROM ".$this->db->prefix()."document_model WHERE nom = 'generic_".strtolower($myTmpObjectKey)."_odt' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity), - "INSERT INTO ".$this->db->prefix()."document_model (nom, type, entity) VALUES('generic_".strtolower($myTmpObjectKey)."_odt', '".$this->db->escape(strtolower($myTmpObjectKey))."', ".((int) $conf->entity).")" - )); + $currentPdfModel = getDolGlobalString('TIMESHEETWEEK_ADDON_PDF', ''); + // EN: Detect whether the stored PDF model ends with an ODT suffix. + // FR: Détecter si le modèle PDF enregistré se termine par un suffixe ODT. + $hasOdtSuffix = (dol_strlen($currentPdfModel) >= 3 && strtolower(substr($currentPdfModel, -3)) === 'odt'); + // EN: Ensure the default PDF model targets the standard document when unset or still linked to an ODT definition. + // FR: S'assurer que le modèle PDF par défaut pointe sur le document standard lorsqu'il est vide ou encore lié à une définition ODT. + if ($currentPdfModel === '' || $hasOdtSuffix) { + dolibarr_set_const($this->db, 'TIMESHEETWEEK_ADDON_PDF', 'standard_timesheetweek', 'chaine', 0, '', $conf->entity); } - } - // EN: Register Multicompany sharing metadata when the module is enabled. + // EN: Register Multicompany sharing metadata when the module is enabled. // FR: Enregistrer les métadonnées de partage Multicompany lors de l'activation du module. dol_include_once('/timesheetweek/class/actions_timesheetweek.class.php'); diff --git a/core/modules/timesheetweek/doc/doc_generic_timesheetweek_odt.modules.php b/core/modules/timesheetweek/doc/doc_generic_timesheetweek_odt.modules.php deleted file mode 100644 index 47ee599..0000000 --- a/core/modules/timesheetweek/doc/doc_generic_timesheetweek_odt.modules.php +++ /dev/null @@ -1,538 +0,0 @@ - - * Copyright (C) 2012 Juanjo Menent - * Copyright (C) 2014 Marcos García - * Copyright (C) 2016 Charlie Benke - * Copyright (C) 2018-2021 Philippe Grand - * Copyright (C) 2018-2024 Frédéric France - * Copyright (C) 2024 MDW - * Copyright (C) 2025 Pierre ARDOIN - * - * 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. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * or see https://www.gnu.org/ - */ - -/** - * \file htdocs/core/modules/timesheetweek/doc/doc_generic_timesheetweek_odt.modules.php - * \ingroup timesheetweek - * \brief File of class to build ODT documents for timesheetweeks - */ - -dol_include_once('/timesheetweek/core/modules/timesheetweek/modules_timesheetweek.php'); -require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/company.lib.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/doc.lib.php'; - - -/** - * Class to build documents using ODF templates generator - */ -class doc_generic_timesheetweek_odt extends ModelePDFTimesheetWeek -{ - /** - * Issuer - * @var Societe - */ - public $emetteur; - - /** - * @var array{0:int,1:int} Minimum version of PHP required by module. - * e.g.: PHP ≥ 7.0 = array(7, 0) - */ - public $phpmin = array(7, 0); - - /** - * @var string Version, possible values are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated' or a version string like 'x.y.z'''|'development'|'dolibarr'|'experimental' Dolibarr version of the loaded document - */ - public $version = 'dolibarr'; - - - /** - * Constructor - * - * @param DoliDB $db Database handler - */ - public function __construct($db) - { - global $langs, $mysoc; - - // Load translation files required by the page - $langs->loadLangs(array("main", "companies")); - - $this->db = $db; - $this->name = "ODT templates"; - $this->description = $langs->trans("DocumentModelOdt"); - $this->scandir = 'TIMESHEETWEEK_ADDON_PDF_ODT_PATH'; // Name of constant that is used to save list of directories to scan - - // Page size for A4 format - $this->type = 'odt'; - $this->page_largeur = 0; - $this->page_hauteur = 0; - $this->format = array($this->page_largeur, $this->page_hauteur); - $this->marge_gauche = 0; - $this->marge_droite = 0; - $this->marge_haute = 0; - $this->marge_basse = 0; - - $this->option_logo = 1; // Display logo - $this->option_tva = 0; // Manage the vat option FACTURE_TVAOPTION - $this->option_modereg = 0; // Display payment mode - $this->option_condreg = 0; // Display payment terms - $this->option_multilang = 1; // Available in several languages - $this->option_escompte = 0; // Displays if there has been a discount - $this->option_credit_note = 0; // Support credit notes - $this->option_freetext = 1; // Support add of a personalised text - $this->option_draft_watermark = 0; // Support add of a watermark on drafts - - if ($mysoc === null) { - dol_syslog(get_class($this).'::__construct() Global $mysoc should not be null.'. getCallerInfoString(), LOG_ERR); - return; - } - - // Get source company - $this->emetteur = $mysoc; - if (!$this->emetteur->country_code) { - $this->emetteur->country_code = substr($langs->defaultlang, -2); // By default if not defined - } - } - - - /** - * Return description of a module - * - * @param Translate $langs Lang object to use for output - * @return string Description - */ - public function info($langs) - { - global $langs; - - // Load translation files required by the page - $langs->loadLangs(array("errors", "companies")); - - $form = new Form($this->db); - - $odtPath = trim(getDolGlobalString('TIMESHEETWEEK_ADDON_PDF_ODT_PATH')); - - $texte = $this->description.".
\n"; - $texte .= '
'; - $texte .= ''; - $texte .= ''; - $texte .= ''; - $texte .= ''; - - $texte .= ''; - - // List of directories area - $texte .= ''; - - $texte .= ''; - - $texte .= '
'; - $texttitle = $langs->trans("ListOfDirectories"); - $listofdir = explode(',', preg_replace('/[\r\n]+/', ',', $odtPath)); - $listoffiles = array(); - foreach ($listofdir as $key => $tmpdir) { - $tmpdir = trim($tmpdir); - $tmpdir = preg_replace('/DOL_DATA_ROOT/', DOL_DATA_ROOT, $tmpdir); - if (!$tmpdir) { - unset($listofdir[$key]); - continue; - } - if (!is_dir($tmpdir)) { - $texttitle .= img_warning($langs->trans("ErrorDirNotFound", $tmpdir), ''); - } else { - $tmpfiles = dol_dir_list($tmpdir, 'files', 0, '\.(ods|odt)'); - if (count($tmpfiles)) { - $listoffiles = array_merge($listoffiles, $tmpfiles); - } - } - } - $texthelp = $langs->trans("ListOfDirectoriesForModelGenODT"); - $texthelp .= '

'.$langs->trans("ExampleOfDirectoriesForModelGen").''; - // Add list of substitution keys - $texthelp .= '
'.$langs->trans("FollowingSubstitutionKeysCanBeUsed").'
'; - $texthelp .= $langs->transnoentitiesnoconv("FullListOnOnlineDocumentation"); // This contains an url, we don't modify it - - // Scan directories - $nbofiles = count($listoffiles); - if ($odtPath) { - $texte .= $langs->trans("NumberOfModelFilesFound").': '; - //$texte.=$nbofiles?'':''; - $texte .= count($listoffiles); - //$texte.=$nbofiles?'':''; - $texte .= ''; - } - - if ($nbofiles) { - $texte .= '
'; - // Show list of found files - foreach ($listoffiles as $file) { - $texte .= '- '.$file['name'].' '.img_picto('', 'listlight').''; - $texte .= '   '.img_picto('', 'delete').''; - $texte .= '
'; - } - $texte .= '
'; - } - - if (!getDolGlobalString('MAIN_NO_MULTIDIR_FOR_ODT')) { - $texte .= '
'; - $texte .= $form->textwithpicto($texttitle, $texthelp, 1, 'help', '', 1, 3, $this->name); - $texte .= '
'; - $texte .= ''; - $texte .= '
'; - $texte .= ''; - $texte .= '
'; - } else { - $texte .= '
'; - $texte .= ''; - } - - // Add input to upload a new template file. - $texte .= '
'.$langs->trans("UploadNewTemplate"); - $maxfilesizearray = getMaxFileSizeArray(); - $maxmin = $maxfilesizearray['maxmin']; - if ($maxmin > 0) { - $texte .= ''; // MAX_FILE_SIZE must precede the field type=file - } - $texte .= ' '; - $texte .= ''; - $texte .= ''; - $texte .= '
'; - - $texte .= '
'; - $texte .= '
'; - - return $texte; - } - - // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps - /** - * Function to build a document on disk using the generic odt module. - * - * @param TimesheetWeek $object Source object to generate document from - * @param Translate $outputlangs Lang output object - * @param string $srctemplatepath Full path of source filename for generator using a template file - * @param int<0,1> $hidedetails Do not show line details - * @param int<0,1> $hidedesc Do not show desc - * @param int<0,1> $hideref Do not show ref - * @return int<-1,1> 1 if OK, <=0 if KO - */ - public function write_file($object, $outputlangs, $srctemplatepath = '', $hidedetails = 0, $hidedesc = 0, $hideref = 0) - { - // phpcs:enable - global $user, $langs, $conf, $mysoc, $hookmanager; - global $action; - - if (empty($srctemplatepath)) { - dol_syslog("doc_generic_odt::write_file parameter srctemplatepath empty", LOG_WARNING); - return -1; - } - - // Add odtgeneration hook - if (!is_object($hookmanager)) { - include_once DOL_DOCUMENT_ROOT.'/core/class/hookmanager.class.php'; - $hookmanager = new HookManager($this->db); - } - $hookmanager->initHooks(array('odtgeneration')); - global $action; - - if (!is_object($outputlangs)) { - $outputlangs = $langs; - } - $sav_charset_output = $outputlangs->charset_output; - $outputlangs->charset_output = 'UTF-8'; - - $outputlangs->loadLangs(array("main", "dict", "companies", "bills")); - - if ($conf->timesheetweek->dir_output) { - // If $object is id instead of object - if (!is_object($object)) { - $id = $object; - $object = new TimesheetWeek($this->db); - $result = $object->fetch($id); - if ($result < 0) { - dol_print_error($this->db, $object->error); - return -1; - } - } - - $object->fetch_thirdparty(); - - $dir = $conf->timesheetweek->multidir_output[isset($object->entity) ? $object->entity : 1]; - $objectref = dol_sanitizeFileName($object->ref); - if (!preg_match('/specimen/i', $objectref)) { - $dir .= "/".$objectref; - } - $file = $dir."/".$objectref.".odt"; - - if (!file_exists($dir)) { - if (dol_mkdir($dir) < 0) { - $this->error = $langs->transnoentities("ErrorCanNotCreateDir", $dir); - return -1; - } - } - - if (file_exists($dir)) { - //print "srctemplatepath=".$srctemplatepath; // Src filename - $newfile = basename($srctemplatepath); - $newfiletmp = preg_replace('/\.od[ts]/i', '', $newfile); - $newfiletmp = preg_replace('/template_/i', '', $newfiletmp); - $newfiletmp = preg_replace('/modele_/i', '', $newfiletmp); - - $newfiletmp = $objectref . '_' . $newfiletmp; - //$file=$dir.'/'.$newfiletmp.'.'.dol_print_date(dol_now(),'%Y%m%d%H%M%S').'.odt'; - - // Get extension (ods or odt) - $newfileformat = substr($newfile, strrpos($newfile, '.') + 1); - if (getDolGlobalString('MAIN_DOC_USE_TIMING')) { - $format = getDolGlobalString('MAIN_DOC_USE_TIMING'); - if ($format == '1') { - $format = '%Y%m%d%H%M%S'; - } - $filename = $newfiletmp . '-' . dol_print_date(dol_now(), $format) . '.' . $newfileformat; - } else { - $filename = $newfiletmp . '.' . $newfileformat; - } - $file = $dir . '/' . $filename; - //print "newdir=".$dir; - //print "newfile=".$newfile; - //print "file=".$file; - //print "conf->societe->dir_temp=".$conf->societe->dir_temp; - - dol_mkdir($conf->timesheetweek->dir_temp); - if (!is_writable($conf->timesheetweek->dir_temp)) { - $this->error = $langs->transnoentities("ErrorFailedToWriteInTempDirectory", $conf->timesheetweek->dir_temp); - dol_syslog('Error in write_file: ' . $this->error, LOG_ERR); - return -1; - } - - // If CUSTOMER contact defined on object, we use it - $usecontact = false; - $arrayidcontact = $object->getIdContact('external', 'CUSTOMER'); - if (count($arrayidcontact) > 0) { - $usecontact = true; - $result = $object->fetch_contact($arrayidcontact[0]); - } - - // Recipient name - $contactobject = null; - if (!empty($usecontact)) { - // We can use the company of contact instead of thirdparty company - if ($object->contact->socid != $object->thirdparty->id && (!isset($conf->global->MAIN_USE_COMPANY_NAME_OF_CONTACT) || getDolGlobalInt('MAIN_USE_COMPANY_NAME_OF_CONTACT'))) { - $object->contact->fetch_thirdparty(); - $socobject = $object->contact->thirdparty; - $contactobject = $object->contact; - } else { - $socobject = $object->thirdparty; - // if we have a CUSTOMER contact and we don't use it as thirdparty recipient we store the contact object for later use - $contactobject = $object->contact; - } - } else { - $socobject = $object->thirdparty; - } - - // Make substitution - $substitutionarray = array( - '__FROM_NAME__' => $this->emetteur->name, - '__FROM_EMAIL__' => $this->emetteur->email, - '__TOTAL_TTC__' => $object->total_ttc, - '__TOTAL_HT__' => $object->total_ht, - '__TOTAL_VAT__' => $object->total_tva - ); - complete_substitutions_array($substitutionarray, $langs, $object); - // Call the ODTSubstitution hook - $parameters = array('file' => $file, 'object' => $object, 'outputlangs' => $outputlangs, 'substitutionarray' => &$substitutionarray); - $reshook = $hookmanager->executeHooks('ODTSubstitution', $parameters, $this, $action); // Note that $action and $object may have been modified by some hooks - - // Line of free text - $newfreetext = ''; - $paramfreetext = 'TIMESHEETWEEK_FREE_TEXT'; - if (getDolGlobalString($paramfreetext)) { - $newfreetext = make_substitutions(getDolGlobalString($paramfreetext), $substitutionarray); - } - - // Open and load template - require_once ODTPHP_PATH.'odf.php'; - try { - $odfHandler = new Odf( - $srctemplatepath, - array( - 'PATH_TO_TMP' => $conf->timesheetweek->dir_temp, - 'ZIP_PROXY' => getDolGlobalString('MAIN_ODF_ZIP_PROXY', 'PclZipProxy'), // PhpZipProxy or PclZipProxy. Got "bad compression method" error when using PhpZipProxy. - 'DELIMITER_LEFT' => '{', - 'DELIMITER_RIGHT' => '}' - ) - ); - } catch (Exception $e) { - $this->error = $e->getMessage(); - dol_syslog($e->getMessage(), LOG_INFO); - return -1; - } - // After construction $odfHandler->contentXml contains content and - // [!-- BEGIN row.lines --]*[!-- END row.lines --] has been replaced by - // [!-- BEGIN lines --]*[!-- END lines --] - //print html_entity_decode($odfHandler->__toString()); - //print exit; - - $object->fetch_optionals(); - - // Make substitutions into odt of freetext - try { - $odfHandler->setVars('free_text', $newfreetext, true, 'UTF-8'); - } catch (OdfException $e) { - dol_syslog($e->getMessage(), LOG_INFO); - } - - // Define substitution array - $substitutionarray = getCommonSubstitutionArray($outputlangs, 0, null, $object); - $array_object_from_properties = $this->get_substitutionarray_each_var_object($object, $outputlangs); - $array_objet = $this->get_substitutionarray_object($object, $outputlangs); - $array_user = $this->get_substitutionarray_user($user, $outputlangs); - $array_soc = $this->get_substitutionarray_mysoc($mysoc, $outputlangs); - $array_thirdparty = $this->get_substitutionarray_thirdparty($socobject, $outputlangs); - $array_other = $this->get_substitutionarray_other($outputlangs); - // retrieve contact information for use in object as contact_xxx tags - $array_thirdparty_contact = array(); - if ($usecontact && is_object($contactobject)) { - $array_thirdparty_contact = $this->get_substitutionarray_contact($contactobject, $outputlangs, 'contact'); - } - - $tmparray = array_merge($substitutionarray, $array_object_from_properties, $array_user, $array_soc, $array_thirdparty, $array_objet, $array_other, $array_thirdparty_contact); - complete_substitutions_array($tmparray, $outputlangs, $object); - - // Call the ODTSubstitution hook - $parameters = array('odfHandler' => &$odfHandler, 'file' => $file, 'object' => $object, 'outputlangs' => $outputlangs, 'substitutionarray' => &$tmparray); - $reshook = $hookmanager->executeHooks('ODTSubstitution', $parameters, $this, $action); // Note that $action and $object may have been modified by some hooks - - // retrieve the constant to apply a ratio for image size or set the ratio to 1 - if (getDolGlobalString('MAIN_DOC_ODT_IMAGE_RATIO')) { - $ratio = floatval(getDolGlobalString('MAIN_DOC_ODT_IMAGE_RATIO')); - } else { - $ratio = 1; - } - - foreach ($tmparray as $key => $value) { - try { - if (preg_match('/logo$/', $key)) { - // Image - if (file_exists($value)) { - $odfHandler->setImage($key, $value, $ratio); - } else { - $odfHandler->setVars($key, 'ErrorFileNotFound', true, 'UTF-8'); - } - } else { - // Text - $odfHandler->setVars($key, $value, true, 'UTF-8'); - } - } catch (OdfException $e) { - dol_syslog($e->getMessage(), LOG_INFO); - } - } - // Replace tags of lines - $foundtagforlines = 1; - try { - $listlines = $odfHandler->setSegment('lines'); - } catch (OdfExceptionSegmentNotFound $e) { - // We may arrive here if tags for lines not present into template - $foundtagforlines = 0; - dol_syslog($e->getMessage(), LOG_INFO); - } - - if ($foundtagforlines) { - $linenumber = 0; - foreach ($object->lines as $line) { - /** @var CommonObjectLine $line */ - $linenumber++; - - $tmparray = $this->get_substitutionarray_lines($line, $outputlangs, $linenumber); - complete_substitutions_array($tmparray, $outputlangs, $object, $line, "completesubstitutionarray_lines"); - // Call the ODTSubstitutionLine hook - $parameters = array('odfHandler' => &$odfHandler, 'file' => $file, 'object' => $object, 'outputlangs' => $outputlangs, 'substitutionarray' => &$tmparray, 'line' => $line); - $reshook = $hookmanager->executeHooks('ODTSubstitutionLine', $parameters, $this, $action); // Note that $action and $object may have been modified by some hooks - foreach ($tmparray as $key => $val) { - try { - $listlines->setVars($key, $val, true, 'UTF-8'); - } catch (SegmentException $e) { - dol_syslog($e->getMessage(), LOG_INFO); - } - } - $listlines->merge(); - } - try { - $odfHandler->mergeSegment($listlines); - } catch (OdfException $e) { - $this->error = $e->getMessage(); - dol_syslog($this->error, LOG_WARNING); - return -1; - } - } - - // Replace labels translated - $tmparray = $outputlangs->get_translations_for_substitutions(); - foreach ($tmparray as $key => $value) { - try { - $odfHandler->setVars($key, $value, true, 'UTF-8'); - } catch (OdfException $e) { - dol_syslog($e->getMessage(), LOG_INFO); - } - } - - // Call the beforeODTSave hook - - $parameters = array('odfHandler' => &$odfHandler, 'file' => $file, 'object' => $object, 'outputlangs' => $outputlangs, 'substitutionarray' => &$tmparray); - $reshook = $hookmanager->executeHooks('beforeODTSave', $parameters, $this, $action); // Note that $action and $object may have been modified by some hooks - - // Write new file - if (getDolGlobalString('MAIN_ODT_AS_PDF')) { - try { - $odfHandler->exportAsAttachedPDF($file); - } catch (Exception $e) { - $this->error = $e->getMessage(); - dol_syslog($e->getMessage(), LOG_INFO); - return -1; - } - } else { - try { - $odfHandler->saveToDisk($file); - } catch (Exception $e) { - $this->error = $e->getMessage(); - dol_syslog($e->getMessage(), LOG_INFO); - return -1; - } - } - - $parameters = array('odfHandler' => &$odfHandler, 'file' => $file, 'object' => $object, 'outputlangs' => $outputlangs, 'substitutionarray' => &$tmparray); - $reshook = $hookmanager->executeHooks('afterODTCreation', $parameters, $this, $action); // Note that $action and $object may have been modified by some hooks - - dolChmod($file); - - $odfHandler = null; // Destroy object - - $this->result = array('fullpath' => $file); - - return 1; // Success - } else { - $this->error = $langs->transnoentities("ErrorCanNotCreateDir", $dir); - return -1; - } - } - - return -1; - } -} diff --git a/core/modules/timesheetweek/doc/pdf_standard_timesheetweek.modules.php b/core/modules/timesheetweek/doc/pdf_standard_timesheetweek.modules.php index 7e17bfb..3cf53ac 100644 --- a/core/modules/timesheetweek/doc/pdf_standard_timesheetweek.modules.php +++ b/core/modules/timesheetweek/doc/pdf_standard_timesheetweek.modules.php @@ -1,17 +1,5 @@ - * Copyright (C) 2005-2012 Regis Houssin - * Copyright (C) 2008 Raphael Bertrand - * Copyright (C) 2010-2014 Juanjo Menent - * Copyright (C) 2012 Christophe Battarel - * Copyright (C) 2012 Cédric Salvador - * Copyright (C) 2012-2014 Raphaël Doursenaud - * Copyright (C) 2015 Marcos García - * Copyright (C) 2017 Ferran Marcet - * Copyright (C) 2018-2025 Frédéric France - * Copyright (C) 2024-2025 MDW - * Copyright (C) 2024 Alexandre Spangaro - * Copyright (C) 2025 Pierre ARDOIN +/* Copyright (C) 2025 Pierre Ardoin * * 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 @@ -25,1368 +13,845 @@ * * You should have received a copy of the GNU General Public License * along with this program. If not, see . - * or see https://www.gnu.org/ */ /** - * \file core/modules/timesheetweek/doc/pdf_standard.modules.php - * \ingroup timesheetweek - * \brief File of class to generate document from standard template + * \file core/modules/timesheetweek/doc/pdf_standard_timesheetweek.modules.php + * \ingroup timesheetweek + * \brief Standard PDF model for weekly timesheets. + * EN: Standard PDF model for weekly timesheets. + * FR: Modèle PDF standard pour les feuilles hebdomadaires. */ dol_include_once('/timesheetweek/core/modules/timesheetweek/modules_timesheetweek.php'); -require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/company.lib.php'; -require_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php'; +dol_include_once('/timesheetweek/lib/timesheetweek_pdf.lib.php'); +dol_include_once('/timesheetweek/lib/timesheetweek.lib.php'); +// EN: Load the TimesheetWeek class to reuse status constants and helpers. +// FR: Charge la classe TimesheetWeek pour réutiliser les constantes et helpers de statut. +dol_include_once('/timesheetweek/class/timesheetweek.class.php'); +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/functions.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/price.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php'; +require_once DOL_DOCUMENT_ROOT.'/projet/class/task.class.php'; - -/** - * Class to manage PDF template standard_timesheetweek - */ class pdf_standard_timesheetweek extends ModelePDFTimesheetWeek { /** - * @var DoliDB Database handler + * EN: Database handler reference. + * FR: Référence vers le gestionnaire de base de données. + * + * @var DoliDB */ public $db; /** - * @var int The environment ID when using a multicompany module + * EN: Internal model name used by Dolibarr. + * FR: Nom interne du modèle utilisé par Dolibarr. + * + * @var string */ - public $entity; + public $name = 'standard_timesheetweek'; /** - * @var string model name + * EN: Localized description displayed in selectors. + * FR: Description localisée affichée dans les sélecteurs. + * + * @var string */ - public $name; + public $description; /** - * @var string model description (short text) + * EN: Document type handled by the generator. + * FR: Type de document géré par le générateur. + * + * @var string */ - public $description; + public $type = 'pdf'; /** - * @var int Save the name of generated file as the main doc when generating a doc with this template + * EN: Compatibility flag for the Dolibarr version. + * FR: Indicateur de compatibilité avec la version de Dolibarr. + * + * @var string */ - public $update_main_doc_field; + public $version = 'dolibarr'; /** - * @var string document type + * EN: Ensure the generated file becomes the main document. + * FR: Assure que le fichier généré devient le document principal. + * + * @var int */ - public $type; + public $update_main_doc_field = 1; /** - * @var array{0:int,1:int} Minimum version of PHP required by module. - * e.g.: PHP ≥ 7.0 = array(7, 0) + * EN: Page width in millimeters. + * FR: Largeur de page en millimètres. + * + * @var float */ - public $phpmin = array(7, 0); + public $page_largeur; /** - * Dolibarr version of the loaded document - * @var string Version, possible values are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated' or a version string like 'x.y.z'''|'development'|'dolibarr'|'experimental' + * EN: Page height in millimeters. + * FR: Hauteur de page en millimètres. + * + * @var float */ - public $version = 'dolibarr'; + public $page_hauteur; /** - * Issuer - * @var Societe Object that emits + * EN: Format array used by TCPDF. + * FR: Tableau de format utilisé par TCPDF. + * + * @var array */ - public $emetteur; + public $format = array(); + + /** + * EN: Left margin in millimeters. + * FR: Marge gauche en millimètres. + * + * @var int + */ + public $marge_gauche; + + /** + * EN: Right margin in millimeters. + * FR: Marge droite en millimètres. + * + * @var int + */ + public $marge_droite; /** - * @var array,border-left?:bool,title:array{textkey:string,label?:string,align?:string,padding?:array{0:float,1:float,2:float,3:float}},content?:array{align?:string,padding?:array{0:float,1:float,2:float,3:float}}}> Array of document table columns + * EN: Top margin in millimeters. + * FR: Marge haute en millimètres. + * + * @var int */ - public $cols; + public $marge_haute; + /** + * EN: Bottom margin in millimeters. + * FR: Marge basse en millimètres. + * + * @var int + */ + public $marge_basse; + + /** + * EN: Corner radius for frames. + * FR: Rayon des coins pour les cadres. + * + * @var int + */ + public $corner_radius; + + /** + * EN: Issuer company reference. + * FR: Référence de la société émettrice. + * + * @var Societe + */ + public $emetteur; /** - * Constructor + * EN: Constructor. + * FR: Constructeur. * - * @param DoliDB $db Database handler + * @param DoliDB $db Database handler / Gestionnaire de base de données */ public function __construct($db) { global $langs, $mysoc; - // Translations - $langs->loadLangs(array("main", "bills")); - + // EN: Store the database handler for later use. + // FR: Conserve le gestionnaire de base de données pour les usages ultérieurs. $this->db = $db; - $this->name = "standard"; - $this->description = $langs->trans('DocumentModelStandardPDF'); - $this->update_main_doc_field = 1; // Save the name of generated file as the main doc when generating a doc with this template - // Dimension page - $this->type = 'pdf'; + // EN: Load shared translations required by the selector and generator. + // FR: Charge les traductions partagées nécessaires au sélecteur et au générateur. + if (method_exists($langs, 'loadLangs')) { + $langs->loadLangs(array('main', 'companies', 'timesheetweek@timesheetweek')); + } else { + $langs->load('main'); + $langs->load('companies'); + $langs->load('timesheetweek@timesheetweek'); + } + + // EN: Identify the template for Dolibarr interfaces and automations. + // FR: Identifie le modèle pour les interfaces et automatisations Dolibarr. + $this->description = $langs->trans('PDFStandardTimesheetWeekDescription'); + + // EN: Request the default PDF geometry from the Dolibarr helper. + // FR: Récupère la géométrie PDF par défaut depuis l'assistant Dolibarr. $formatarray = pdf_getFormat(); $this->page_largeur = $formatarray['width']; $this->page_hauteur = $formatarray['height']; $this->format = array($this->page_largeur, $this->page_hauteur); + + // EN: Apply configured margins and frame radius for consistency. + // FR: Applique les marges configurées et le rayon de cadre pour conserver la cohérence. $this->marge_gauche = getDolGlobalInt('MAIN_PDF_MARGIN_LEFT', 10); $this->marge_droite = getDolGlobalInt('MAIN_PDF_MARGIN_RIGHT', 10); $this->marge_haute = getDolGlobalInt('MAIN_PDF_MARGIN_TOP', 10); $this->marge_basse = getDolGlobalInt('MAIN_PDF_MARGIN_BOTTOM', 10); + $this->corner_radius = getDolGlobalInt('MAIN_PDF_FRAME_CORNER_RADIUS', 0); - // Define position of columns - $this->posxdesc = $this->marge_gauche + 1; // used for notes and other stuff - - $this->tabTitleHeight = 5; // default height - - // Use new system for position of columns, view $this->defineColumnField() - - $this->tva = array(); - $this->tva_array = array(); - $this->localtax1 = array(); - $this->localtax2 = array(); - $this->atleastoneratenotnull = 0; - $this->atleastonediscount = 0; - - if ($mysoc === null) { - dol_syslog(get_class($this).'::__construct() Global $mysoc should not be null.'. getCallerInfoString(), LOG_ERR); - return; - } - - // Get source company + // EN: Keep a reference to the issuer company for header helpers. + // FR: Conserve une référence vers la société émettrice pour les assistants d'en-tête. $this->emetteur = $mysoc; - if (empty($this->emetteur->country_code)) { - $this->emetteur->country_code = substr($langs->defaultlang, -2); // By default, if was not defined - } } - // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps /** - * Function to build and write pdf to disk + * EN: Generate the PDF by mirroring the interactive grid displayed on the card page. + * FR: Génère le PDF en reproduisant la grille interactive affichée sur la fiche. * - * @param TimesheetWeek $object Source object to generate document from - * @param Translate $outputlangs Lang output object - * @param string $srctemplatepath Full path of source filename for generator using a template file - * @param int<0,1> $hidedetails Do not show line details - * @param int<0,1> $hidedesc Do not show desc - * @param int<0,1> $hideref Do not show ref - * @return int<-1,1> 1 if OK, <=0 if KO + * @param TimesheetWeek $object Timesheet source / Feuille de temps source + * @param Translate $outputlangs Output language / Gestionnaire de langue de sortie + * @param string $srctemplatepath Optional template path / Chemin optionnel du gabarit + * @param int $hidedetails Hide details flag / Indicateur de masquage des détails + * @param int $hidedesc Hide descriptions flag / Indicateur de masquage des descriptions + * @param int $hideref Hide references flag / Indicateur de masquage des références + * @return int 1 on success, <=0 otherwise / 1 si succès, <=0 sinon */ public function write_file($object, $outputlangs, $srctemplatepath = '', $hidedetails = 0, $hidedesc = 0, $hideref = 0) { - // phpcs:enable - global $user, $langs, $conf, $mysoc, $hookmanager, $nblines; - - dol_syslog("write_file outputlangs->defaultlang=".(is_object($outputlangs) ? $outputlangs->defaultlang : 'null')); - + global $conf, $langs, $user; + + // EN: Fallback to global translations if none were provided. + // FR: Retombe sur les traductions globales si aucune n'a été fournie. if (!is_object($outputlangs)) { $outputlangs = $langs; } - // For backward compatibility with FPDF, force output charset to ISO, because FPDF expect text to be encoded in ISO - if (getDolGlobalInt('MAIN_USE_FPDF')) { - $outputlangs->charset_output = 'ISO-8859-1'; + + // EN: Load module translations to localize messages and filenames. + // FR: Charge les traductions du module pour localiser messages et noms de fichiers. + if (method_exists($outputlangs, 'loadLangs')) { + $outputlangs->loadLangs(array('main', 'companies', 'timesheetweek@timesheetweek')); + } else { + $outputlangs->load('main'); + $outputlangs->load('companies'); + $outputlangs->load('timesheetweek@timesheetweek'); } - - // Load translation files required by the page - $langfiles = array("main", "bills", "products", "dict", "companies", "compta"); - $outputlangs->loadLangs($langfiles); - - // Show Draft Watermark - if (getDolGlobalString('TIMESHEETWEEK_DRAFT_WATERMARK') && $object->status == $object::STATUS_DRAFT) { - $this->watermark = getDolGlobalString('TIMESHEETWEEK_DRAFT_WATERMARK'); + + $this->error = ''; + $this->errors = array(); + + // EN: Abort if the source timesheet is not properly initialized. + // FR: Abandonne si la feuille de temps source n'est pas correctement initialisée. + if (empty($object) || empty($object->id)) { + $this->error = $outputlangs->trans('ErrorRecordNotFound'); + dol_syslog(__METHOD__.' failed: '.$this->error, LOG_ERR); + return -1; } - - global $outputlangsbis; - $outputlangsbis = null; - if (getDolGlobalString('PDF_USE_ALSO_LANGUAGE_CODE') && $outputlangs->defaultlang != getDolGlobalString('PDF_USE_ALSO_LANGUAGE_CODE')) { - $outputlangsbis = new Translate('', $conf); - $outputlangsbis->setDefaultLang(getDolGlobalString('PDF_USE_ALSO_LANGUAGE_CODE')); - $outputlangsbis->loadLangs($langfiles); + + // EN: Resolve the destination directory while respecting Multicompany rules. + // FR: Résout le répertoire de destination en respectant les règles Multicompany. + $entityId = !empty($object->entity) ? (int) $object->entity : (int) $conf->entity; + $baseOutput = ''; + if (!empty($conf->timesheetweek->multidir_output[$entityId] ?? null)) { + $baseOutput = $conf->timesheetweek->multidir_output[$entityId]; + } elseif (!empty($conf->timesheetweek->dir_output)) { + $baseOutput = $conf->timesheetweek->dir_output; + } else { + $baseOutput = DOL_DATA_ROOT.'/timesheetweek'; } - - $nblines = (is_array($object->lines) ? count($object->lines) : 0); - - $hidetop = 0; - if (getDolGlobalString('MAIN_PDF_DISABLE_COL_HEAD_TITLE')) { - $hidetop = getDolGlobalString('MAIN_PDF_DISABLE_COL_HEAD_TITLE'); + + // EN: Build the sanitized document directory for the current timesheet. + // FR: Construit le répertoire de documents assaini pour la feuille courante. + $cleanRef = dol_sanitizeFileName($object->ref); + if ($cleanRef === '') { + $cleanRef = dol_sanitizeFileName('timesheetweek-'.$object->id); } - - // Loop on each lines to detect if there is at least one image to show - $realpatharray = array(); - $this->atleastonephoto = false; - /* - if (getDolGlobalInt('MAIN_GENERATE_TIMESHEETWEEK_WITH_PICTURE'))) { - $objphoto = new Product($this->db); - - for ($i = 0; $i < $nblines; $i++) { - if (empty($object->lines[$i]->fk_product)) { - continue; + $relativePath = $object->element.'/'.$cleanRef; + $targetDir = rtrim($baseOutput, '/').'/'.$relativePath; + if (dol_mkdir($targetDir) < 0) { + $this->error = $outputlangs->trans('ErrorCanNotCreateDir', $targetDir); + dol_syslog(__METHOD__.' failed: '.$this->error, LOG_ERR); + return -1; + } + + // EN: Collect employee details to reproduce the on-screen grid behaviour. + // FR: Récupère les informations salarié pour reproduire le comportement de la grille à l'écran. + $timesheetEmployee = null; + $isDailyRateEmployee = false; + $contractedHours = 35.0; + if (!empty($object->fk_user)) { + $employee = new User($this->db); + if ($employee->fetch($object->fk_user) > 0) { + $employee->fetch_optionals($employee->id, $employee->table_element); + $timesheetEmployee = $employee; + $isDailyRateEmployee = !empty($employee->array_options['options_lmdb_daily_rate']); + if (!empty($employee->weeklyhours)) { + $contractedHours = (float) $employee->weeklyhours; } - - $objphoto->fetch($object->lines[$i]->fk_product); - //var_dump($objphoto->ref);exit; - if (getDolGlobalInt('PRODUCT_USE_OLD_PATH_FOR_PHOTO')) { - $pdir[0] = get_exdir($objphoto->id, 2, 0, 0, $objphoto, 'product').$objphoto->id."/photos/"; - $pdir[1] = get_exdir(0, 0, 0, 0, $objphoto, 'product').dol_sanitizeFileName($objphoto->ref).'/'; - } else { - $pdir[0] = get_exdir(0, 0, 0, 0, $objphoto, 'product'); // default - $pdir[1] = get_exdir($objphoto->id, 2, 0, 0, $objphoto, 'product').$objphoto->id."/photos/"; // alternative + } + } + + // EN: Rebuild the week date map to display the same weekday headers as the HTML card. + // FR: Reconstruit la carte des dates de la semaine pour afficher les mêmes entêtes que la carte HTML. + $days = array('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'); + $dayLabelKeys = array( + 'Monday' => 'TimesheetWeekDayMonday', + 'Tuesday' => 'TimesheetWeekDayTuesday', + 'Wednesday' => 'TimesheetWeekDayWednesday', + 'Thursday' => 'TimesheetWeekDayThursday', + 'Friday' => 'TimesheetWeekDayFriday', + 'Saturday' => 'TimesheetWeekDaySaturday', + 'Sunday' => 'TimesheetWeekDaySunday' + ); + $weekdates = array(); + $weekStartDate = null; + $weekEndDate = null; + if (!empty($object->year) && !empty($object->week)) { + $dto = new DateTime(); + $dto->setISODate((int) $object->year, (int) $object->week); + foreach ($days as $dayName) { + $weekdates[$dayName] = $dto->format('Y-m-d'); + $dto->modify('+1 day'); + } + $weekStartDate = isset($weekdates['Monday']) ? $weekdates['Monday'] : null; + $weekEndDate = isset($weekdates['Sunday']) ? $weekdates['Sunday'] : null; + } else { + foreach ($days as $dayName) { + $weekdates[$dayName] = null; + } + } + + // EN: Preload the day-level options to replicate the card layout (zone + meal). + // FR: Précharge les options quotidiennes pour reproduire la disposition de la carte (zone + repas). + $dayMeal = array('Monday' => 0, 'Tuesday' => 0, 'Wednesday' => 0, 'Thursday' => 0, 'Friday' => 0, 'Saturday' => 0, 'Sunday' => 0); + $dayZone = array('Monday' => null, 'Tuesday' => null, 'Wednesday' => null, 'Thursday' => null, 'Friday' => null, 'Saturday' => null, 'Sunday' => null); + $hoursBy = array(); + $dailyRateBy = array(); + $taskIdsFromLines = array(); + + $sqlLines = 'SELECT fk_task, day_date, hours, daily_rate, zone, meal'; + $sqlLines .= ' FROM '.MAIN_DB_PREFIX."timesheet_week_line"; + $sqlLines .= ' WHERE fk_timesheet_week='.(int) $object->id; + $sqlLines .= ' AND entity IN ('.getEntity('timesheetweek').')'; + $resLines = $this->db->query($sqlLines); + if ($resLines) { + while ($lineObj = $this->db->fetch_object($resLines)) { + $taskId = (int) $lineObj->fk_task; + $dayDate = (string) $lineObj->day_date; + $lineHours = (float) $lineObj->hours; + $lineDailyRate = isset($lineObj->daily_rate) ? (int) $lineObj->daily_rate : 0; + $lineZone = isset($lineObj->zone) ? (int) $lineObj->zone : null; + $lineMeal = (int) $lineObj->meal; + + if (!isset($hoursBy[$taskId])) { + $hoursBy[$taskId] = array(); } - - $arephoto = false; - foreach ($pdir as $midir) { - if (!$arephoto) { - if ($conf->entity != $objphoto->entity) { - $dir = $conf->product->multidir_output[$objphoto->entity].'/'.$midir; //Check repertories of current entities - } else { - $dir = $conf->product->dir_output.'/'.$midir; //Check repertory of the current product - } - - foreach ($objphoto->liste_photos($dir, 1) as $key => $obj) { - if (!getDolGlobalInt('CAT_HIGH_QUALITY_IMAGES')) { // If CAT_HIGH_QUALITY_IMAGES not defined, we use thumb if defined and then original photo - if ($obj['photo_vignette']) { - $filename = $obj['photo_vignette']; - } else { - $filename = $obj['photo']; - } - } else { - $filename = $obj['photo']; - } - - $realpath = $dir.$filename; - $arephoto = true; - $this->atleastonephoto = true; - } - } + $hoursBy[$taskId][$dayDate] = $lineHours; + if (!isset($dailyRateBy[$taskId])) { + $dailyRateBy[$taskId] = array(); } - - if ($realpath && $arephoto) { - $realpatharray[$i] = $realpath; + $dailyRateBy[$taskId][$dayDate] = $lineDailyRate; + + $weekdayNumber = (int) date('N', strtotime($dayDate)); + $weekdayName = array(1 => 'Monday', 2 => 'Tuesday', 3 => 'Wednesday', 4 => 'Thursday', 5 => 'Friday', 6 => 'Saturday', 7 => 'Sunday'); + if (!empty($weekdayName[$weekdayNumber])) { + $weekdayKey = $weekdayName[$weekdayNumber]; + if ($lineMeal) { + $dayMeal[$weekdayKey] = 1; + } + if ($lineZone !== null) { + $dayZone[$weekdayKey] = $lineZone; + } } + + $taskIdsFromLines[$taskId] = 1; } } - */ - - //if (count($realpatharray) == 0) $this->posxpicture=$this->posxtva; - $dir_output = getMultidirOutput($object, $object->module); - if (!empty($dir_output)) { - $dir_output .= '/' . $object->element; - - $object->fetch_thirdparty(); - - $dir = null; - // Definition of $dir and $file - if ($object->specimen) { - $dir = $dir_output; - $file = $dir."/SPECIMEN.pdf"; - } else { - $objectref = dol_sanitizeFileName($object->ref); - $dir = $dir_output."/".$objectref; - $file = $dir."/".$objectref.".pdf"; + + // EN: Retrieve assigned tasks then merge unassigned ones found in the stored lines. + // FR: Récupère les tâches assignées puis fusionne celles non assignées présentes dans les lignes stockées. + $tasks = $object->getAssignedTasks($object->fk_user); + $tasksById = array(); + if (!empty($tasks)) { + foreach ($tasks as $taskRow) { + $tasksById[(int) $taskRow['task_id']] = $taskRow; } - // if ($dir === null) { - // return 0; - // } - if (!file_exists($dir)) { - if (dol_mkdir($dir) < 0) { - $this->error = $langs->transnoentities("ErrorCanNotCreateDir", $dir); - return 0; + } + if (!empty($taskIdsFromLines)) { + $missingTaskIds = array(); + foreach (array_keys($taskIdsFromLines) as $missingId) { + if (!isset($tasksById[$missingId])) { + $missingTaskIds[] = (int) $missingId; } } - - if (file_exists($dir)) { - // Add pdfgeneration hook - if (!is_object($hookmanager)) { - include_once DOL_DOCUMENT_ROOT.'/core/class/hookmanager.class.php'; - $hookmanager = new HookManager($this->db); + if (!empty($missingTaskIds)) { + $sqlMissing = 'SELECT t.rowid as task_id, t.label as task_label, t.ref as task_ref, t.progress as task_progress,'; + $sqlMissing .= ' t.fk_statut as task_status, t.dateo as task_date_start, t.datee as task_date_end,'; + $sqlMissing .= ' p.rowid as project_id, p.ref as project_ref, p.title as project_title'; + $sqlMissing .= ' FROM '.MAIN_DB_PREFIX."projet_task t"; + $sqlMissing .= ' INNER JOIN '.MAIN_DB_PREFIX."projet p ON p.rowid = t.fk_projet"; + $sqlMissing .= ' WHERE t.rowid IN ('.implode(',', array_map('intval', $missingTaskIds)).')'; + $resMissing = $this->db->query($sqlMissing); + if ($resMissing) { + while ($missingObj = $this->db->fetch_object($resMissing)) { + $tasks[] = array( + 'task_id' => (int) $missingObj->task_id, + 'task_label' => $missingObj->task_label, + 'task_ref' => $missingObj->task_ref, + 'task_progress' => ($missingObj->task_progress !== null ? (float) $missingObj->task_progress : null), + 'task_status' => ($missingObj->task_status !== null ? (int) $missingObj->task_status : null), + 'task_date_start' => ($missingObj->task_date_start !== null ? (string) $missingObj->task_date_start : null), + 'task_date_end' => ($missingObj->task_date_end !== null ? (string) $missingObj->task_date_end : null), + 'project_id' => (int) $missingObj->project_id, + 'project_ref' => $missingObj->project_ref, + 'project_title' => $missingObj->project_title + ); + } } - $hookmanager->initHooks(array('pdfgeneration')); - $parameters = array('file' => $file, 'object' => $object, 'outputlangs' => $outputlangs); - global $action; - $reshook = $hookmanager->executeHooks('beforePDFCreation', $parameters, $object, $action); // Note that $action and $object may have been modified by some hooks - - // Set nblines with the new lines content after hook - $nblines = (is_array($object->lines) ? count($object->lines) : 0); - - // Create pdf instance - $pdf = pdf_getInstance($this->format); - '@phan-var-force TCPDI|TCPDF $pdf'; - $default_font_size = pdf_getPDFFontSize($outputlangs); // Must be after pdf_getInstance - $pdf->setAutoPageBreak(true, 0); - - $heightforinfotot = 50; // Height reserved to output the info and total part and payment part - $heightforfreetext = getDolGlobalInt('MAIN_PDF_FREETEXT_HEIGHT', 5); // Height reserved to output the free text on last page - $heightforfooter = $this->marge_basse + (getDolGlobalInt('MAIN_GENERATE_DOCUMENTS_SHOW_FOOT_DETAILS') ? 12 : 22); // Height reserved to output the footer (value include bottom margin) - - if (class_exists('TCPDF')) { - $pdf->setPrintHeader(false); - $pdf->setPrintFooter(false); + } + } + + // EN: Filter out closed or out-of-range tasks exactly like the web interface. + // FR: Exclut les tâches clôturées ou hors plage exactement comme l'interface web. + $closedStatuses = array(); + if (defined('Task::STATUS_DONE')) { + $closedStatuses[] = Task::STATUS_DONE; + } + if (defined('Task::STATUS_CLOSED')) { + $closedStatuses[] = Task::STATUS_CLOSED; + } + if (defined('Task::STATUS_FINISHED')) { + $closedStatuses[] = Task::STATUS_FINISHED; + } + if (defined('Task::STATUS_CANCELLED')) { + $closedStatuses[] = Task::STATUS_CANCELLED; + } + if (defined('Task::STATUS_CANCELED')) { + $closedStatuses[] = Task::STATUS_CANCELED; + } + $closedStatuses = array_unique(array_map('intval', $closedStatuses)); + + $weekStartTs = ($weekStartDate ? strtotime($weekStartDate.' 00:00:00') : null); + $weekEndTs = ($weekEndDate ? strtotime($weekEndDate.' 23:59:59') : null); + $filteredTasks = array(); + if (!empty($tasks)) { + foreach ($tasks as $taskRow) { + $taskId = isset($taskRow['task_id']) ? (int) $taskRow['task_id'] : 0; + $hasRecordedEffort = false; + if ($taskId > 0) { + $hasRecordedEffort = (!empty($hoursBy[$taskId]) || !empty($dailyRateBy[$taskId])); } - $pdf->SetFont(pdf_getPDFFont($outputlangs)); - - // Set path to the background PDF File - if (getDolGlobalString('MAIN_ADD_PDF_BACKGROUND')) { - $logodir = $conf->mycompany->dir_output; - if (!empty($conf->mycompany->multidir_output[$object->entity])) { - $logodir = $conf->mycompany->multidir_output[$object->entity]; + if (!$hasRecordedEffort) { + // EN: Skip tasks without recorded effort to hide empty rows. + // FR: Ignore les tâches sans temps saisi pour masquer les lignes vides. + continue; + } + $progress = isset($taskRow['task_progress']) ? $taskRow['task_progress'] : null; + if ($progress !== null && (float) $progress >= 100) { + continue; + } + $statusValue = isset($taskRow['task_status']) ? $taskRow['task_status'] : null; + if ($statusValue !== null && !empty($closedStatuses) && in_array((int) $statusValue, $closedStatuses, true)) { + continue; + } + $taskStart = isset($taskRow['task_date_start']) ? $taskRow['task_date_start'] : null; + $taskEnd = isset($taskRow['task_date_end']) ? $taskRow['task_date_end'] : null; + $taskStartTs = null; + $taskEndTs = null; + if (!empty($taskStart)) { + $taskStartTs = is_numeric($taskStart) ? (int) $taskStart : strtotime($taskStart); + if ($taskStartTs === false) { + $taskStartTs = null; } - $pagecount = $pdf->setSourceFile($logodir.'/'.getDolGlobalString('MAIN_ADD_PDF_BACKGROUND')); - $tplidx = $pdf->importPage(1); } - - $pdf->Open(); - $pagenb = 0; - $pdf->SetDrawColor(128, 128, 128); - - $pdf->SetTitle($outputlangs->convToOutputCharset($object->ref)); - $pdf->SetSubject($outputlangs->transnoentities("PdfTitle")); - $pdf->SetCreator("Dolibarr ".DOL_VERSION); - $pdf->SetAuthor($outputlangs->convToOutputCharset($user->getFullName($outputlangs))); - $pdf->SetKeyWords($outputlangs->convToOutputCharset($object->ref)." ".$outputlangs->transnoentities("PdfTitle")." ".$outputlangs->convToOutputCharset($object->thirdparty->name)); - if (getDolGlobalString('MAIN_DISABLE_PDF_COMPRESSION')) { - $pdf->SetCompression(false); + if (!empty($taskEnd)) { + $taskEndTs = is_numeric($taskEnd) ? (int) $taskEnd : strtotime($taskEnd); + if ($taskEndTs === false) { + $taskEndTs = null; + } } - - // Set certificate - $cert = empty($user->conf->CERTIFICATE_CRT) ? '' : $user->conf->CERTIFICATE_CRT; - // If user has no certificate, we try to take the company one - if (!$cert) { - $cert = getDolGlobalString('CERTIFICATE_CRT'); + if ($weekStartTs !== null && $taskEndTs !== null && $taskEndTs < $weekStartTs) { + continue; } - // If a certificate is found - if ($cert) { - $info = array( - 'Name' => $this->emetteur->name, - 'Location' => getCountry($this->emetteur->country_code, ''), - 'Reason' => 'TIMESHEETWEEK', - 'ContactInfo' => $this->emetteur->email - ); - $pdf->setSignature($cert, $cert, $this->emetteur->name, '', 2, $info); + if ($weekEndTs !== null && $taskStartTs !== null && $taskStartTs > $weekEndTs) { + continue; } - - // @phan-suppress-next-line PhanPluginSuspiciousParamOrder - $pdf->SetMargins($this->marge_gauche, $this->marge_haute, $this->marge_droite); // Left, Top, Right - - - // New page - $pdf->AddPage(); - if (!empty($tplidx)) { - $pdf->useTemplate($tplidx); + $filteredTasks[] = $taskRow; + } + } + $tasks = array_values($filteredTasks); + + // EN: Group tasks per project to mimic the nested HTML structure. + // FR: Regroupe les tâches par projet pour imiter la structure HTML imbriquée. + $tasksByProject = array(); + foreach ($tasks as $taskRow) { + $projectId = (int) $taskRow['project_id']; + if (!isset($tasksByProject[$projectId])) { + $tasksByProject[$projectId] = array( + 'project_ref' => isset($taskRow['project_ref']) ? $taskRow['project_ref'] : '', + 'project_title' => isset($taskRow['project_title']) ? $taskRow['project_title'] : '', + 'tasks' => array() + ); + } + $tasksByProject[$projectId]['tasks'][] = $taskRow; + } + + // EN: Prepare computation helpers shared by the row rendering logic. + // FR: Prépare les aides de calcul partagées par la logique de rendu des lignes. + $dailyRateOptions = array(); + if ($isDailyRateEmployee) { + $dailyRateOptions = array( + 1 => $outputlangs->trans('TimesheetWeekDailyRateFullDay'), + 2 => $outputlangs->trans('TimesheetWeekDailyRateMorning'), + 3 => $outputlangs->trans('TimesheetWeekDailyRateAfternoon') + ); + } + $dailyRateHoursMap = $this->getDailyRateHoursMap(); + $dayTotalsHours = array(); + foreach ($days as $dayName) { + $dayTotalsHours[$dayName] = 0.0; + } + $grandHours = 0.0; + + // EN: Build the HTML table mirroring the editable grid layout. + // FR: Construit le tableau HTML reflétant la grille éditable. + $htmlGrid = ''; + if (empty($tasksByProject)) { + $htmlGrid .= '

'.tw_pdf_format_cell_html($outputlangs->trans('NoTasksAssigned')).'

'; + } else { + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + $dayColumnCount = count($days); + if ($dayColumnCount > 0) { + $dayWidth = 9; + for ($c = 0; $c < $dayColumnCount; $c++) { + $htmlGrid .= ''; } - $pagenb++; - - $top_shift = $this->_pagehead($pdf, $object, 1, $outputlangs, $outputlangsbis); - $pdf->SetFont('', '', $default_font_size - 1); - $pdf->MultiCell(0, 3, ''); // Set interline to 3 - $pdf->SetTextColor(0, 0, 0); - - $tab_top = 90 + $top_shift; - $tab_top_newpage = (!getDolGlobalInt('MAIN_PDF_DONOTREPEAT_HEAD') ? 42 + $top_shift : 10); - - $tab_height = $this->page_hauteur - $tab_top - $heightforfooter - $heightforfreetext; - - $tab_height_newpage = 150; - if (!getDolGlobalInt('MAIN_PDF_DONOTREPEAT_HEAD')) { - $tab_height_newpage -= $top_shift; + } + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + foreach ($days as $dayName) { + $labelKey = isset($dayLabelKeys[$dayName]) ? $dayLabelKeys[$dayName] : $dayName; + $dayLabel = $outputlangs->trans($labelKey); + $displayDate = ''; + if (!empty($weekdates[$dayName])) { + $dayTs = strtotime($weekdates[$dayName]); + if ($dayTs !== false) { + $displayDate = dol_print_date($dayTs, 'day'); + } } - - $nexY = $tab_top - 1; - - // Display notes - $notetoshow = empty($object->note_public) ? '' : $object->note_public; - // Extrafields in note - $extranote = $this->getExtrafieldsInHtml($object, $outputlangs); - if (!empty($extranote)) { - $notetoshow = dol_concatdesc($notetoshow, $extranote); + $cellContent = tw_pdf_format_cell_html($dayLabel); + if ($displayDate !== '') { + $cellContent .= '
'.tw_pdf_format_cell_html($displayDate).''; } - - $pagenb = $pdf->getPage(); - if ($notetoshow) { - $tab_top -= 2; - - $tab_width = $this->page_largeur - $this->marge_gauche - $this->marge_droite; - $pageposbeforenote = $pagenb; - - $substitutionarray = pdf_getSubstitutionArray($outputlangs, null, $object); - complete_substitutions_array($substitutionarray, $outputlangs, $object); - $notetoshow = make_substitutions($notetoshow, $substitutionarray, $outputlangs); - $notetoshow = convertBackOfficeMediasLinksToPublicLinks($notetoshow); - - $pdf->startTransaction(); - - $pdf->SetFont('', '', $default_font_size - 1); - $pdf->writeHTMLCell(190, 3, $this->posxdesc - 1, $tab_top, dol_htmlentitiesbr($notetoshow), 0, 1); - // Description - $pageposafternote = $pdf->getPage(); - $posyafter = $pdf->GetY(); - - if ($pageposafternote > $pageposbeforenote) { - $pdf->rollbackTransaction(true); - - // prepare pages to receive notes - while ($pagenb < $pageposafternote) { - $pdf->AddPage(); - $pagenb++; - if (!empty($tplidx)) { - $pdf->useTemplate($tplidx); - } - if (!getDolGlobalInt('MAIN_PDF_DONOTREPEAT_HEAD')) { - $this->_pagehead($pdf, $object, 0, $outputlangs); - } - // $this->_pagefoot($pdf,$object,$outputlangs,1); - $pdf->setTopMargin($tab_top_newpage); - // The only function to edit the bottom margin of current page to set it. - $pdf->setPageOrientation('', true, $heightforfooter + $heightforfreetext); - } - - // back to start - $pdf->setPage($pageposbeforenote); - $pdf->setPageOrientation('', true, $heightforfooter + $heightforfreetext); - $pdf->SetFont('', '', $default_font_size - 1); - $pdf->writeHTMLCell(190, 3, $this->posxdesc - 1, $tab_top, dol_htmlentitiesbr($notetoshow), 0, 1); - $pageposafternote = $pdf->getPage(); - - $posyafter = $pdf->GetY(); - - if ($posyafter > ($this->page_hauteur - ($heightforfooter + $heightforfreetext + 20))) { // There is no space left for total+free text - $pdf->AddPage('', '', true); - $pagenb++; - $pageposafternote++; - $pdf->setPage($pageposafternote); - $pdf->setTopMargin($tab_top_newpage); - // The only function to edit the bottom margin of current page to set it. - $pdf->setPageOrientation('', true, $heightforfooter + $heightforfreetext); - //$posyafter = $tab_top_newpage; - } - - - // apply note frame to previous pages - $i = $pageposbeforenote; - while ($i < $pageposafternote) { - $pdf->setPage($i); - - - $pdf->SetDrawColor(128, 128, 128); - // Draw note frame - if ($i > $pageposbeforenote) { - $height_note = $this->page_hauteur - ($tab_top_newpage + $heightforfooter); - $pdf->Rect($this->marge_gauche, $tab_top_newpage - 1, $tab_width, $height_note + 1); - } else { - $height_note = $this->page_hauteur - ($tab_top + $heightforfooter); - $pdf->Rect($this->marge_gauche, $tab_top - 1, $tab_width, $height_note + 1); - } - - // Add footer - $pdf->setPageOrientation('', true, 0); // The only function to edit the bottom margin of current page to set it. - $this->_pagefoot($pdf, $object, $outputlangs, 1); - - $i++; - } - - // apply note frame to last page - $pdf->setPage($pageposafternote); - if (!empty($tplidx)) { - $pdf->useTemplate($tplidx); + $htmlGrid .= ''; + } + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + if (!$isDailyRateEmployee) { + $htmlGrid .= ''; + $htmlGrid .= ''; + foreach ($days as $dayName) { + $zoneValue = isset($dayZone[$dayName]) && $dayZone[$dayName] !== null ? $dayZone[$dayName] : '-'; + $mealValue = !empty($dayMeal[$dayName]) ? $outputlangs->trans('Yes') : $outputlangs->trans('No'); + $zoneLabel = tw_pdf_format_cell_html($outputlangs->trans('Zone').' '.$zoneValue); + $mealLabel = tw_pdf_format_cell_html($outputlangs->trans('Meal').': '.$mealValue); + $htmlGrid .= ''; + } + $htmlGrid .= ''; + $htmlGrid .= ''; + } + $colspan = count($days) + 2; + foreach ($tasksByProject as $projectData) { + $projectPieces = array(); + if (!empty($projectData['project_ref'])) { + $projectPieces[] = '['.$projectData['project_ref'].']'; + } + if (!empty($projectData['project_title'])) { + $projectPieces[] = $projectData['project_title']; + } + $projectLabel = trim(implode(' ', $projectPieces)); + if ($projectLabel === '') { + $projectLabel = $outputlangs->trans('Project'); + } + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + if (!empty($projectData['tasks'])) { + foreach ($projectData['tasks'] as $taskRow) { + $taskLabelPieces = array(); + if (!empty($taskRow['task_ref'])) { + $taskLabelPieces[] = '['.$taskRow['task_ref'].']'; } - if (!getDolGlobalInt('MAIN_PDF_DONOTREPEAT_HEAD')) { - $this->_pagehead($pdf, $object, 0, $outputlangs); + if (!empty($taskRow['task_label'])) { + $taskLabelPieces[] = $taskRow['task_label']; } - $height_note = $posyafter - $tab_top_newpage; - $pdf->Rect($this->marge_gauche, $tab_top_newpage - 1, $tab_width, $height_note + 1); - } else { - // No pagebreak - $pdf->commitTransaction(); - $posyafter = $pdf->GetY(); - $height_note = $posyafter - $tab_top; - $pdf->Rect($this->marge_gauche, $tab_top - 1, $tab_width, $height_note + 1); - - - if ($posyafter > ($this->page_hauteur - ($heightforfooter + $heightforfreetext + 20))) { - // not enough space, need to add page - $pdf->AddPage('', '', true); - $pagenb++; - $pageposafternote++; - $pdf->setPage($pageposafternote); - if (!empty($tplidx)) { - $pdf->useTemplate($tplidx); - } - if (!getDolGlobalInt('MAIN_PDF_DONOTREPEAT_HEAD')) { - $this->_pagehead($pdf, $object, 0, $outputlangs); - } - - $posyafter = $tab_top_newpage; + $taskLabel = trim(implode(' ', $taskLabelPieces)); + if ($taskLabel === '') { + $taskLabel = $outputlangs->trans('Task'); } - } - - $tab_height -= $height_note; - $tab_top = $posyafter + 6; - } else { - $height_note = 0; - } - - // Use new auto column system - $this->prepareArrayColumnField($object, $outputlangs, $hidedetails, $hidedesc, $hideref); - - // Table simulation to know the height of the title line - $pdf->startTransaction(); - $this->pdfTabTitles($pdf, $tab_top, $tab_height, $outputlangs, $hidetop); - $pdf->rollbackTransaction(true); - - $nexY = $tab_top + $this->tabTitleHeight; - - // Loop on each lines - $pageposbeforeprintlines = $pdf->getPage(); - $pagenb = $pageposbeforeprintlines; - for ($i = 0; $i < $nblines; $i++) { - $curY = $nexY; - $pdf->SetFont('', '', $default_font_size - 1); // Into loop to work with multipage - $pdf->SetTextColor(0, 0, 0); - - // Define size of image if we need it - // $imglinesize = array(); - // if (!empty($realpatharray[$i])) { - // $imglinesize = pdf_getSizeForImage($realpatharray[$i]); - // } - - $pdf->setTopMargin($tab_top_newpage); - $pdf->setPageOrientation('', true, $heightforfooter + $heightforfreetext + $heightforinfotot); // The only function to edit the bottom margin of current page to set it. - $pageposbefore = $pdf->getPage(); - - $showpricebeforepagebreak = 1; - $posYAfterImage = 0; - $posYAfterDescription = 0; - - // if ($this->getColumnStatus('photo')) { - // // We start with Photo of product line - // if (isset($imglinesize['width']) && isset($imglinesize['height']) && ($curY + $imglinesize['height']) > ($this->page_hauteur - ($heightforfooter + $heightforfreetext + $heightforinfotot))) { // If photo too high, we moved completely on new page - // $pdf->AddPage('', '', true); - // if (!empty($tplidx)) { - // $pdf->useTemplate($tplidx); - // } - // $pdf->setPage($pageposbefore + 1); - - // $curY = $tab_top_newpage; - - // // Allows data in the first page if description is long enough to break in multiples pages - // if (getDolGlobalInt('MAIN_PDF_DATA_ON_FIRST_PAGE')) { - // $showpricebeforepagebreak = 1; - // } else { - // $showpricebeforepagebreak = 0; - // } - // } - - // if (!empty($this->cols['photo']) && isset($imglinesize['width']) && isset($imglinesize['height'])) { - // $pdf->Image($realpatharray[$i], $this->getColumnContentXStart('photo'), $curY + 1, $imglinesize['width'], $imglinesize['height'], '', '', '', 2, 300); // Use 300 dpi - // // $pdf->Image does not increase value return by getY, so we save it manually - // $posYAfterImage = $curY + $imglinesize['height']; - // } - // } - - // Description of product line - if ($this->getColumnStatus('desc')) { - $pdf->startTransaction(); - - $this->printColDescContent($pdf, $curY, 'desc', $object, $i, $outputlangs, $hideref, $hidedesc); - $pageposafter = $pdf->getPage(); - - if ($pageposafter > $pageposbefore) { // There is a pagebreak - $pdf->rollbackTransaction(true); - $pageposafter = $pageposbefore; - $pdf->setPageOrientation('', true, $heightforfooter); // The only function to edit the bottom margin of current page to set it. - - $this->printColDescContent($pdf, $curY, 'desc', $object, $i, $outputlangs, $hideref, $hidedesc); - - $pageposafter = $pdf->getPage(); - $posyafter = $pdf->GetY(); - - if ($posyafter > ($this->page_hauteur - ($heightforfooter + $heightforfreetext + $heightforinfotot))) { // There is no space left for total+free text - if ($i == ($nblines - 1)) { // No more lines, and no space left to show total, so we create a new page - $pdf->AddPage('', '', true); - if (!empty($tplidx)) { - $pdf->useTemplate($tplidx); + $htmlGrid .= ''; + $htmlGrid .= ''; + $rowTotalHours = 0.0; + foreach ($days as $dayName) { + $dayDate = isset($weekdates[$dayName]) ? $weekdates[$dayName] : null; + $cellHours = 0.0; + $cellDisplay = ''; + if ($dayDate !== null) { + if ($isDailyRateEmployee) { + $rateCode = isset($dailyRateBy[(int) $taskRow['task_id']][$dayDate]) ? (int) $dailyRateBy[(int) $taskRow['task_id']][$dayDate] : 0; + if ($rateCode > 0 && isset($dailyRateHoursMap[$rateCode])) { + $cellHours = (float) $dailyRateHoursMap[$rateCode]; + $cellDisplay = isset($dailyRateOptions[$rateCode]) ? $dailyRateOptions[$rateCode] : ''; } - $pdf->setPage($pageposafter + 1); - } - } else { - // We found a page break - // Allows data in the first page if description is long enough to break in multiples pages - if (getDolGlobalInt('MAIN_PDF_DATA_ON_FIRST_PAGE')) { - $showpricebeforepagebreak = 1; } else { - $showpricebeforepagebreak = 0; + if (isset($hoursBy[(int) $taskRow['task_id']][$dayDate])) { + $cellHours = (float) $hoursBy[(int) $taskRow['task_id']][$dayDate]; + if ($cellHours > 0) { + $cellDisplay = formatHours($cellHours); + } + } } } - } else { // No pagebreak - $pdf->commitTransaction(); - } - $posYAfterDescription = $pdf->GetY(); - } - - $nexY = max($pdf->GetY(), $posYAfterImage); - - - $pageposafter = $pdf->getPage(); - $pdf->setPage($pageposbefore); - $pdf->setTopMargin($this->marge_haute); - $pdf->setPageOrientation('', true, 0); // The only function to edit the bottom margin of current page to set it. - - // We suppose that a too long description or photo were moved completely on next page - if ($pageposafter > $pageposbefore && empty($showpricebeforepagebreak)) { - $pdf->setPage($pageposafter); - $curY = $tab_top_newpage; - } - - $pdf->SetFont('', '', $default_font_size - 1); // We reposition the default font - - // Quantity - // Enough for 6 chars - if ($this->getColumnStatus('qty')) { - $qty = pdf_getlineqty($object, $i, $outputlangs, $hidedetails); - $this->printStdColumnContent($pdf, $curY, 'qty', $qty); - $nexY = max($pdf->GetY(), $nexY); - } - - // Extrafields - if (!empty($object->lines[$i]->array_options)) { - foreach ($object->lines[$i]->array_options as $extrafieldColKey => $extrafieldValue) { - if ($this->getColumnStatus($extrafieldColKey)) { - $extrafieldValue = $this->getExtrafieldContent($object->lines[$i], $extrafieldColKey, $outputlangs); - $this->printStdColumnContent($pdf, $curY, $extrafieldColKey, $extrafieldValue); - $nexY = max($pdf->GetY(), $nexY); - } - } - } - - - $parameters = array( - 'object' => $object, - 'i' => $i, - 'pdf' => & $pdf, - 'curY' => & $curY, - 'nexY' => & $nexY, - 'outputlangs' => $outputlangs, - 'hidedetails' => $hidedetails - ); - $reshook = $hookmanager->executeHooks('printPDFline', $parameters, $this); // Note that $object may have been modified by hook - - - $sign = 1; - // Collection of totals by value of VAT in $this->tva["taux"]=total_tva - $prev_progress = $object->lines[$i]->get_prev_progress($object->id); - if ($prev_progress > 0 && $object->lines instanceof CommonInvoiceLine && !empty($object->lines[$i]->situation_percent)) { // Compute progress from previous situation - if (isModEnabled("multicurrency") && $object->multicurrency_tx != 1) { - $tvaligne = $sign * $object->lines[$i]->multicurrency_total_tva * ($object->lines[$i]->situation_percent - $prev_progress) / $object->lines[$i]->situation_percent; - } else { - $tvaligne = $sign * $object->lines[$i]->total_tva * ($object->lines[$i]->situation_percent - $prev_progress) / $object->lines[$i]->situation_percent; - } - } else { - if (isModEnabled("multicurrency") && $object->multicurrency_tx != 1) { - $tvaligne = $sign * $object->lines[$i]->multicurrency_total_tva; - } else { - $tvaligne = $sign * $object->lines[$i]->total_tva; - } - } - - $localtax1ligne = $object->lines[$i]->total_localtax1; - $localtax2ligne = $object->lines[$i]->total_localtax2; - $localtax1_rate = $object->lines[$i]->localtax1_tx; - $localtax2_rate = $object->lines[$i]->localtax2_tx; - $localtax1_type = $object->lines[$i]->localtax1_type; - $localtax2_type = $object->lines[$i]->localtax2_type; - - $vatrate = (string) $object->lines[$i]->tva_tx; - - // Retrieve type from database for backward compatibility with old records - if ((!isset($localtax1_type) || $localtax1_type == '' || !isset($localtax2_type) || $localtax2_type == '') // if tax type not defined - && (!empty($localtax1_rate) || !empty($localtax2_rate))) { // and there is local tax - $localtaxtmp_array = getLocalTaxesFromRate($vatrate, 0, $object->thirdparty, $mysoc); - $localtax1_type = isset($localtaxtmp_array[0]) ? $localtaxtmp_array[0] : ''; - $localtax2_type = isset($localtaxtmp_array[2]) ? $localtaxtmp_array[2] : ''; - } - - // retrieve global local tax - if ($localtax1_type && $localtax1ligne != 0) { - if (empty($this->localtax1[$localtax1_type][$localtax1_rate])) { - $this->localtax1[$localtax1_type][$localtax1_rate] = $localtax1ligne; - } else { - $this->localtax1[$localtax1_type][$localtax1_rate] += $localtax1ligne; - } - } - if ($localtax2_type && $localtax2ligne != 0) { - if (empty($this->localtax2[$localtax2_type][$localtax2_rate])) { - $this->localtax2[$localtax2_type][$localtax2_rate] = $localtax2ligne; - } else { - $this->localtax2[$localtax2_type][$localtax2_rate] += $localtax2ligne; + $dayTotalsHours[$dayName] += $cellHours; + $rowTotalHours += $cellHours; + $htmlGrid .= ''; } - } - - if (($object->lines[$i]->info_bits & 0x01) == 0x01) { - $vatrate .= '*'; - } - - // Fill $this->tva and $this->tva_array - if (!isset($this->tva[$vatrate])) { - $this->tva[$vatrate] = 0; - } - $this->tva[$vatrate] += $tvaligne; - $vatcode = $object->lines[$i]->vat_src_code; - if (empty($this->tva_array[$vatrate.($vatcode ? ' ('.$vatcode.')' : '')]['amount'])) { - $this->tva_array[$vatrate.($vatcode ? ' ('.$vatcode.')' : '')]['amount'] = 0; - } - $this->tva_array[$vatrate.($vatcode ? ' ('.$vatcode.')' : '')] = array('vatrate' => $vatrate, 'vatcode' => $vatcode, 'amount' => $this->tva_array[$vatrate.($vatcode ? ' ('.$vatcode.')' : '')]['amount'] + $tvaligne); - - $nexY = max($nexY, $posYAfterImage); - - // Add line - if (getDolGlobalInt('MAIN_PDF_DASH_BETWEEN_LINES') && $i < ($nblines - 1)) { - $pdf->setPage($pageposafter); - $pdf->SetLineStyle(array('dash' => '1,1', 'color' => array(80, 80, 80))); - //$pdf->SetDrawColor(190,190,200); - $pdf->line($this->marge_gauche, $nexY, $this->page_largeur - $this->marge_droite, $nexY); - $pdf->SetLineStyle(array('dash' => 0)); - } - - // Detect if some page were added automatically and output _tableau for past pages - while ($pagenb < $pageposafter) { - $pdf->setPage($pagenb); - if ($pagenb == $pageposbeforeprintlines) { - $this->_tableau($pdf, $tab_top, $this->page_hauteur - $tab_top - $heightforfooter, 0, $outputlangs, $hidetop, 1, $object->multicurrency_code, $outputlangsbis); + $grandHours += $rowTotalHours; + if ($isDailyRateEmployee) { + $displayTotal = $this->formatDays(($rowTotalHours > 0 ? ($rowTotalHours / 8.0) : 0.0), $outputlangs); } else { - $this->_tableau($pdf, $tab_top_newpage, $this->page_hauteur - $tab_top_newpage - $heightforfooter, 0, $outputlangs, 1, 1, $object->multicurrency_code, $outputlangsbis); - } - $this->_pagefoot($pdf, $object, $outputlangs, 1); - $pagenb++; - $pdf->setPage($pagenb); - $pdf->setPageOrientation('', true, 0); // The only function to edit the bottom margin of current page to set it. - if (!getDolGlobalInt('MAIN_PDF_DONOTREPEAT_HEAD')) { - $this->_pagehead($pdf, $object, 0, $outputlangs); - } - if (!empty($tplidx)) { - $pdf->useTemplate($tplidx); + $displayTotal = ($rowTotalHours > 0 ? formatHours($rowTotalHours) : ''); } + $htmlGrid .= ''; + $htmlGrid .= ''; } - - if (isset($object->lines[$i + 1]->pagebreak) && $object->lines[$i + 1]->pagebreak) { - if ($pagenb == $pageposafter) { - $this->_tableau($pdf, $tab_top, $this->page_hauteur - $tab_top - $heightforfooter, 0, $outputlangs, $hidetop, 1, $object->multicurrency_code, $outputlangsbis); - } else { - $this->_tableau($pdf, $tab_top_newpage, $this->page_hauteur - $tab_top_newpage - $heightforfooter, 0, $outputlangs, 1, 1, $object->multicurrency_code, $outputlangsbis); - } - $this->_pagefoot($pdf, $object, $outputlangs, 1); - // New page - $pdf->AddPage(); - if (!empty($tplidx)) { - $pdf->useTemplate($tplidx); - } - $pagenb++; - if (!getDolGlobalInt('MAIN_PDF_DONOTREPEAT_HEAD')) { - $this->_pagehead($pdf, $object, 0, $outputlangs); - } - } - } - - // Show square - if ($pagenb == $pageposbeforeprintlines) { - $this->_tableau($pdf, $tab_top, $this->page_hauteur - $tab_top - $heightforinfotot - $heightforfreetext - $heightforfooter, 0, $outputlangs, $hidetop, 0, $object->multicurrency_code, $outputlangsbis); - } else { - $this->_tableau($pdf, $tab_top_newpage, $this->page_hauteur - $tab_top_newpage - $heightforinfotot - $heightforfreetext - $heightforfooter, 0, $outputlangs, 1, 0, $object->multicurrency_code, $outputlangsbis); - } - $bottomlasttab = $this->page_hauteur - $heightforinfotot - $heightforfreetext - $heightforfooter + 1; - - // Display infos area - //$posy = $this->drawInfoTable($pdf, $object, $bottomlasttab, $outputlangs); - - // Display total zone - //$posy = $this->drawTotalTable($pdf, $object, $deja_regle, $bottomlasttab, $outputlangs); - - // Display payment area - /* - if ($deja_regle) - { - $posy = $this->drawPaymentsTable($pdf, $object, $posy, $outputlangs); } - */ - - // Pagefoot - $this->_pagefoot($pdf, $object, $outputlangs); - if (method_exists($pdf, 'AliasNbPages')) { - $pdf->AliasNbPages(); // @phan-suppress-current-line PhanUndeclaredMethod - } - - $pdf->Close(); - - $pdf->Output($file, 'F'); - - // Add pdfgeneration hook - $hookmanager->initHooks(array('pdfgeneration')); - $parameters = array('file' => $file, 'object' => $object, 'outputlangs' => $outputlangs); - global $action; - $reshook = $hookmanager->executeHooks('afterPDFCreation', $parameters, $this, $action); // Note that $action and $object may have been modified by some hooks - if ($reshook < 0) { - $this->error = $hookmanager->error; - $this->errors = $hookmanager->errors; + } + if ($isDailyRateEmployee) { + $htmlGrid .= ''; + $htmlGrid .= ''; + foreach ($days as $dayName) { + $dayValue = ($dayTotalsHours[$dayName] > 0 ? ($dayTotalsHours[$dayName] / 8.0) : 0.0); + $htmlGrid .= ''; } - - dolChmod($file); - - $this->result = array('fullpath' => $file); - - return 1; // No error + $grandDays = ($grandHours > 0 ? ($grandHours / 8.0) : 0.0); + $htmlGrid .= ''; + $htmlGrid .= ''; } else { - $this->error = $langs->transnoentities("ErrorCanNotCreateDir", $dir); - return 0; - } - } else { - $this->error = $langs->transnoentities("ErrorConstantNotDefined", "FAC_OUTPUTDIR"); - return 0; - } - } - - // phpcs:disable PEAR.NamingConventions.ValidFunctionName.ScopeNotCamelCaps - /** - * Return list of active generation models - * - * @param DoliDB $db Database handler - * @param int<0,max> $maxfilenamelength Max length of value to show - * @return string[]|int<-1,0> List of templates - */ - public static function liste_modeles($db, $maxfilenamelength = 0) - { - // phpcs:enable - return parent::liste_modeles($db, $maxfilenamelength); // TODO: Change the autogenerated stub - } - - // phpcs:disable PEAR.NamingConventions.ValidFunctionName.PublicUnderscore - /** - * Show table for lines - * - * @param TCPDF|TCPDI $pdf Object PDF - * @param float $tab_top Top position of table - * @param float $tab_height Height of table (rectangle) - * @param int $nexY Y (not used) - * @param Translate $outputlangs Langs object - * @param int<-1,1> $hidetop 1=Hide top bar of array and title, 0=Hide nothing, -1=Hide only title - * @param int<0,1> $hidebottom Hide bottom bar of array - * @param string $currency Currency code - * @param ?Translate $outputlangsbis Langs object bis - * @return void - */ - protected function _tableau(&$pdf, $tab_top, $tab_height, $nexY, $outputlangs, $hidetop = 0, $hidebottom = 0, $currency = '', $outputlangsbis = null) - { - global $conf; - - // Force to disable hidetop and hidebottom - $hidebottom = 0; - if ($hidetop) { - $hidetop = -1; - } - - $currency = !empty($currency) ? $currency : $conf->currency; - $default_font_size = pdf_getPDFFontSize($outputlangs); - - // Amount in (at tab_top - 1) - $pdf->SetTextColor(0, 0, 0); - $pdf->SetFont('', '', $default_font_size - 2); - - if (empty($hidetop)) { - $titre = $outputlangs->transnoentities("AmountInCurrency", $outputlangs->transnoentitiesnoconv("Currency".$currency)); - if (getDolGlobalInt('PDF_USE_ALSO_LANGUAGE_CODE') && is_object($outputlangsbis)) { - $titre .= ' - '.$outputlangsbis->transnoentities("AmountInCurrency", $outputlangsbis->transnoentitiesnoconv("Currency".$currency)); - } - - $pdf->SetXY($this->page_largeur - $this->marge_droite - ($pdf->GetStringWidth($titre) + 3), $tab_top - 4); - $pdf->MultiCell(($pdf->GetStringWidth($titre) + 3), 2, $titre); - - //$conf->global->MAIN_PDF_TITLE_BACKGROUND_COLOR='230,230,230'; - if (getDolGlobalString('MAIN_PDF_TITLE_BACKGROUND_COLOR')) { - $pdf->Rect($this->marge_gauche, $tab_top, $this->page_largeur - $this->marge_droite - $this->marge_gauche, $this->tabTitleHeight, 'F', array(), explode(',', getDolGlobalString('MAIN_PDF_TITLE_BACKGROUND_COLOR'))); + $htmlGrid .= ''; + $htmlGrid .= ''; + foreach ($days as $dayName) { + $htmlGrid .= ''; + } + $htmlGrid .= ''; + $htmlGrid .= ''; + $mealCount = array_sum($dayMeal); + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + $overtimeHours = !empty($object->overtime_hours) ? (float) $object->overtime_hours : max(0.0, $grandHours - $contractedHours); + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; + $htmlGrid .= ''; } + $htmlGrid .= ''; + $htmlGrid .= '
'.tw_pdf_format_cell_html($outputlangs->trans('ProjectTaskColumn')).''.$cellContent.''.tw_pdf_format_cell_html($outputlangs->trans('Total')).'
'.$zoneLabel.'
'.$mealLabel.'
'.tw_pdf_format_cell_html($projectLabel).'
'.tw_pdf_format_cell_html($taskLabel).''.($cellDisplay !== '' ? tw_pdf_format_cell_html($cellDisplay) : ' ').''.($displayTotal !== '' ? tw_pdf_format_cell_html($displayTotal) : ' ').'
'.tw_pdf_format_cell_html($outputlangs->trans('TimesheetWeekTotalDays')).''.tw_pdf_format_cell_html($this->formatDays($dayValue, $outputlangs)).''.tw_pdf_format_cell_html($this->formatDays($grandDays, $outputlangs)).'
'.tw_pdf_format_cell_html($outputlangs->trans('Total')).''.tw_pdf_format_cell_html(formatHours($dayTotalsHours[$dayName])).''.tw_pdf_format_cell_html(formatHours($grandHours)).'
'.tw_pdf_format_cell_html($outputlangs->trans('Meals')).''.tw_pdf_format_cell_html($mealCount).'
'.tw_pdf_format_cell_html($outputlangs->trans('Overtime').' ('.formatHours($contractedHours).')').''.tw_pdf_format_cell_html(formatHours($overtimeHours)).'
'; } - - $pdf->SetDrawColor(128, 128, 128); - $pdf->SetFont('', '', $default_font_size - 1); - - // Output Rect - $this->printRect($pdf, $this->marge_gauche, $tab_top, $this->page_largeur - $this->marge_gauche - $this->marge_droite, $tab_height, $hidetop, $hidebottom); // Rect takes a length in 3rd parameter and 4th parameter - - - $this->pdfTabTitles($pdf, $tab_top, $tab_height, $outputlangs, $hidetop); - - if (empty($hidetop)) { - $pdf->line($this->marge_gauche, $tab_top + $this->tabTitleHeight, $this->page_largeur - $this->marge_droite, $tab_top + $this->tabTitleHeight); // line takes a position y in 2nd parameter and 4th parameter - } - } - - // phpcs:disable PEAR.NamingConventions.ValidFunctionName.PublicUnderscore - /** - * Show top header of page. - * - * @param TCPDF|TCPDI $pdf Object PDF - * @param TimesheetWeek $object Object to show - * @param int<0,1> $showaddress 0=no, 1=yes - * @param Translate $outputlangs Object lang for output - * @param ?Translate $outputlangsbis Object lang for output bis - * @return float|int Return topshift value - */ - protected function _pagehead(&$pdf, $object, $showaddress, $outputlangs, $outputlangsbis = null) - { - // phpcs:enable - global $conf, $langs; - - $ltrdirection = 'L'; - if ($outputlangs->trans("DIRECTION") == 'rtl') { - $ltrdirection = 'R'; - } - - // Load traductions files required by page - $outputlangs->loadLangs(array("main", "bills", "propal", "companies")); - - $default_font_size = pdf_getPDFFontSize($outputlangs); - - pdf_pagehead($pdf, $outputlangs, $this->page_hauteur); - - // Show Draft Watermark - if (getDolGlobalString('TIMESHEETWEEK_DRAFT_WATERMARK') && $object->status == $object::STATUS_DRAFT) { - pdf_watermark($pdf, $outputlangs, $this->page_hauteur, $this->page_largeur, 'mm', dol_escape_htmltag(getDolGlobalString('TIMESHEETWEEK_DRAFT_WATERMARK'))); - } - - $pdf->SetTextColor(0, 0, 60); - $pdf->SetFont('', 'B', $default_font_size + 3); - - $w = 110; - - $posy = $this->marge_haute; - $posx = $this->page_largeur - $this->marge_droite - $w; - - $pdf->SetXY($this->marge_gauche, $posy); - - // Logo - if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) { - if ($this->emetteur->logo) { - $logodir = $conf->mycompany->dir_output; - if (!empty(getMultidirOutput($object, 'mycompany'))) { - $logodir = getMultidirOutput($object, 'mycompany'); - } - if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO')) { - $logo = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small; - } else { - $logo = $logodir.'/logos/'.$this->emetteur->logo; + + // EN: Prepare PDF header metadata with week range and reference subtitle. + // FR: Prépare les métadonnées d'entête du PDF avec la plage de semaine et la référence en sous-titre. + $weekLabel = (!empty($object->week) ? sprintf('%02d', (int) $object->week) : '00'); + $yearLabel = (!empty($object->year) ? sprintf('%04d', (int) $object->year) : (dol_strlen($weekStartDate) === 10 ? date('Y', strtotime($weekStartDate)) : date('Y'))); + $headerTitle = $outputlangs->trans('TimesheetWeek'); + $headerStatus = ''; + if (isset($object->status)) { + // EN: Reuse the shared badge definition to mirror Timesheetweek visual identity inside the PDF. + // FR: Réutilise la définition partagée des badges pour refléter l'identité visuelle de Timesheetweek dans le PDF. + $statusDefinition = TimesheetWeek::getStatusBadgeDefinition((int) $object->status, $outputlangs); + $statusLabelText = !empty($statusDefinition['label']) ? $statusDefinition['label'] : $outputlangs->trans('Unknown'); + $statusLabel = dol_escape_htmltag($statusLabelText); + $statusType = !empty($statusDefinition['type']) ? $statusDefinition['type'] : 'status0'; + $badgeParams = array( + 'badgeParams' => array( + 'attr' => array( + 'aria-label' => $statusLabel, + ), + ), + ); + if (!empty($statusDefinition['class'])) { + // EN: Append Dolibarr badge classes to keep HTML fallback consistent. + // FR: Ajoute les classes de badge Dolibarr pour conserver un fallback HTML cohérent. + $badgeParams['badgeParams']['attr']['class'] = $statusDefinition['class']; } - if (is_readable($logo)) { - $height = pdf_getHeightForLogo($logo); - $pdf->Image($logo, $this->marge_gauche, $posy, 0, $height); // width=0 (auto) - } else { - $pdf->SetTextColor(200, 0, 0); - $pdf->SetFont('', 'B', $default_font_size - 2); - $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorLogoFileNotFound", $logo), 0, 'L'); - $pdf->MultiCell($w, 3, $outputlangs->transnoentities("ErrorGoToGlobalSetup"), 0, 'L'); + if (!empty($statusDefinition['background_color']) || !empty($statusDefinition['text_color'])) { + // EN: Craft inline style from computed colors to secure PDF HTML fallback. + // FR: Construit un style inline depuis les couleurs calculées pour sécuriser le fallback HTML du PDF. + $badgeStyle = 'display:inline-block;font-weight:600;border-radius:12px;padding:2px 8px;'; + $badgeStyle .= 'background-color:'.dol_escape_htmltag(!empty($statusDefinition['background_color']) ? $statusDefinition['background_color'] : '#adb5bd').';'; + $badgeStyle .= 'color:'.dol_escape_htmltag(!empty($statusDefinition['text_color']) ? $statusDefinition['text_color'] : '#212529').';'; + $badgeParams['badgeParams']['attr']['style'] = $badgeStyle; } - } else { - $text = $this->emetteur->name; - $pdf->MultiCell($w, 4, $outputlangs->convToOutputCharset($text), 0, 'L'); + $fallbackBadgeHtml = dolGetStatus( + $statusLabel, + $statusLabel, + $statusLabel, + $statusType, + 5, + '', + $badgeParams + ); + $headerStatus = array( + 'mode' => 'badge', + 'label' => $statusLabelText, + 'backgroundColor' => !empty($statusDefinition['background_color']) ? $statusDefinition['background_color'] : '#adb5bd', + 'textColor' => !empty($statusDefinition['text_color']) ? $statusDefinition['text_color'] : '#212529', + 'html' => $fallbackBadgeHtml, + ); } - } - $pdf->SetFont('', 'B', $default_font_size + 3); - $pdf->SetXY($posx, $posy); - $pdf->SetTextColor(0, 0, 60); - $title = $outputlangs->transnoentities("PdfTitle"); - if (getDolGlobalInt('PDF_USE_ALSO_LANGUAGE_CODE') && is_object($outputlangsbis)) { - $title .= ' - '; - $title .= $outputlangsbis->transnoentities("PdfTitle"); - } - $pdf->MultiCell($w, 3, $title, '', 'R'); - $pdf->SetFont('', 'B', $default_font_size); - - $posy += 5; - $pdf->SetXY($posx, $posy); - $pdf->SetTextColor(0, 0, 60); - $textref = $outputlangs->transnoentities("Ref")." : ".$outputlangs->convToOutputCharset($object->ref); - if ($object->status == $object::STATUS_DRAFT) { - $pdf->SetTextColor(128, 0, 0); - $textref .= ' - '.$outputlangs->transnoentities("NotValidated"); + $headerWeekRange = ''; + if (!empty($object->week) && !empty($object->year)) { + // EN: Retrieve the translated week range template before injecting ISO week and year values. + // FR: Récupère le gabarit traduit de la plage de semaine avant d'injecter les valeurs ISO de semaine et d'année. + $headerWeekRangeLabel = $outputlangs->trans('TimesheetWeekSummaryHeaderWeekRange'); + // EN: Compose the final week label with the ISO week number and year to match the translated template. + // FR: Compose le libellé final avec le numéro de semaine ISO et l'année pour correspondre au gabarit traduit. + $headerWeekRange = sprintf($headerWeekRangeLabel, $weekLabel, $yearLabel); } - $pdf->MultiCell($w, 4, $textref, '', 'R'); - - $posy += 1; - $pdf->SetFont('', '', $default_font_size - 2); - - // @phan-suppress-next-line PhanUndeclaredProperty - if (property_exists($object, 'ref_client') && $object->ref_client) { - $posy += 4; - $pdf->SetXY($posx, $posy); - $pdf->SetTextColor(0, 0, 60); - // @phan-suppress-next-line PhanUndeclaredProperty - $pdf->MultiCell($w, 3, $outputlangs->transnoentities("RefCustomer")." : ".dol_trunc($outputlangs->convToOutputCharset($object->ref_client), 65), '', 'R'); + $headerSubtitle = $outputlangs->trans('TimesheetWeekPdfReferenceLabel', $object->ref); + if ($timesheetEmployee instanceof User) { + $employeeSubtitle = $outputlangs->trans('Employee').': '.$timesheetEmployee->getFullName($outputlangs); + $headerSubtitle .= "\n".$employeeSubtitle; } - - if (getDolGlobalInt('PDF_SHOW_PROJECT_TITLE')) { - $object->fetchProject(); - if (!empty($object->project->ref)) { - $posy += 3; - $pdf->SetXY($posx, $posy); - $pdf->SetTextColor(0, 0, 60); - $pdf->MultiCell($w, 3, $outputlangs->transnoentities("Project")." : ".(empty($object->project->title) ? '' : $object->project->title), '', 'R'); + if ((int) $object->status === TimesheetWeek::STATUS_APPROVED) { + // EN: Prepare the approval trace with validation date and validator name for the PDF header. + // FR: Prépare la trace d'approbation avec la date de validation et le validateur pour l'entête PDF. + $approvalDateLabel = ''; + if (!empty($object->date_validation)) { + $approvalDateLabel = dol_print_date($object->date_validation, '%d/%m/%Y'); } - } - - if (getDolGlobalInt('PDF_SHOW_PROJECT')) { - $object->fetchProject(); - if (!empty($object->project->ref)) { - $outputlangs->load("projects"); - $posy += 3; - $pdf->SetXY($posx, $posy); - $pdf->SetTextColor(0, 0, 60); - $pdf->MultiCell($w, 3, $outputlangs->transnoentities("RefProject")." : ".(empty($object->project->ref) ? '' : $object->project->ref), '', 'R'); + $validatorName = ''; + if (!empty($object->fk_user_valid)) { + $validatorUser = new User($this->db); + if ($validatorUser->fetch($object->fk_user_valid) > 0) { + $validatorName = $validatorUser->getFullName($outputlangs); + } } - } - - $posy += 4; - $pdf->SetXY($posx, $posy); - $pdf->SetTextColor(0, 0, 60); - - $title = $outputlangs->transnoentities("Date"); - if (getDolGlobalInt('PDF_USE_ALSO_LANGUAGE_CODE') && is_object($outputlangsbis)) { - $title .= ' - '.$outputlangsbis->transnoentities("Date"); - } - $pdf->MultiCell($w, 3, $title." : ".dol_print_date($object->date_creation, "day", false, $outputlangs, true), '', 'R'); - - if (!getDolGlobalString('MAIN_PDF_HIDE_CUSTOMER_CODE') && !empty($object->thirdparty->code_client)) { - $posy += 3; - $pdf->SetXY($posx, $posy); - $pdf->SetTextColor(0, 0, 60); - $pdf->MultiCell($w, 3, $outputlangs->transnoentities("CustomerCode")." : ".$outputlangs->transnoentities($object->thirdparty->code_client), '', 'R'); - } - - if (!getDolGlobalString('MAIN_PDF_HIDE_CUSTOMER_ACCOUNTING_CODE') && !empty($object->thirdparty->code_compta_client)) { - $posy += 3; - $pdf->SetXY($posx, $posy); - $pdf->SetTextColor(0, 0, 60); - $pdf->MultiCell($w, 3, $outputlangs->transnoentities("CustomerAccountancyCode")." : ".$outputlangs->transnoentities($object->thirdparty->code_compta_client), '', 'R'); - } - - // Get contact - if (getDolGlobalInt('DOC_SHOW_FIRST_SALES_REP')) { - $arrayidcontact = $object->getIdContact('internal', 'SALESREPFOLL'); - if (count($arrayidcontact) > 0) { - $usertmp = new User($this->db); - $usertmp->fetch($arrayidcontact[0]); - $posy += 4; - $pdf->SetXY($posx, $posy); - $pdf->SetTextColor(0, 0, 60); - $pdf->MultiCell($w, 3, $outputlangs->transnoentities("SalesRepresentative")." : ".$usertmp->getFullName($langs), '', 'R'); + if ($approvalDateLabel !== '' && $validatorName !== '') { + // EN: Append the approval information under the employee line to mirror the card layout. + // FR: Ajoute les informations d'approbation sous la ligne salarié pour refléter la carte Dolibarr. + $headerSubtitle .= "\n".$outputlangs->trans('TimesheetWeekPdfApprovedDetails', $approvalDateLabel, $validatorName); } } - - $posy += 1; - - $top_shift = 0; - // Show list of linked objects - $current_y = $pdf->getY(); - $posy = pdf_writeLinkedObjects($pdf, $object, $outputlangs, $posx, $posy, $w, 3, 'R', $default_font_size); - if ($current_y < $pdf->getY()) { - $top_shift = $pdf->getY() - $current_y; + + $format = pdf_getFormat(); + $pdfFormat = array($format['width'], $format['height']); + $margeGauche = getDolGlobalInt('MAIN_PDF_MARGIN_LEFT', 10); + $margeDroite = getDolGlobalInt('MAIN_PDF_MARGIN_RIGHT', 10); + $margeHaute = getDolGlobalInt('MAIN_PDF_MARGIN_TOP', 10); + $margeBasse = getDolGlobalInt('MAIN_PDF_MARGIN_BOTTOM', 10); + $footerReserve = 12; + $autoPageBreakMargin = $margeBasse + $footerReserve; + + $pdf = pdf_getInstance($pdfFormat); + $defaultFontSize = pdf_getPDFFontSize($outputlangs); + $pdf->SetPageOrientation('L'); + $pdf->SetAutoPageBreak(true, $autoPageBreakMargin); + $pdf->SetMargins($margeGauche, $margeHaute, $margeDroite); + $headerState = array('value' => 0.0, 'automatic' => false); + if (method_exists($pdf, 'setHeaderCallback') && method_exists($pdf, 'setFooterCallback')) { + $pdf->setPrintHeader(true); + $pdf->setHeaderCallback(function ($pdfInstance) use ($outputlangs, $conf, $margeGauche, $margeHaute, &$headerState, $headerTitle, $headerStatus, $headerWeekRange, $headerSubtitle) { + $headerState['value'] = tw_pdf_draw_header($pdfInstance, $outputlangs, $conf, $margeGauche, $margeHaute, $headerTitle, $headerStatus, $headerWeekRange, $headerSubtitle); + $headerState['automatic'] = true; + }); + $pdf->setPrintFooter(true); + $pdf->setFooterCallback(function ($pdfInstance) use ($outputlangs, $conf, $margeGauche, $margeDroite, $margeBasse, $autoPageBreakMargin, $object) { + tw_pdf_draw_footer($pdfInstance, $outputlangs, $conf, $margeGauche, $margeDroite, $margeBasse, $object, 0, $autoPageBreakMargin); + }); + } else { + $pdf->setPrintHeader(false); + $pdf->setPrintFooter(false); } - - if ($showaddress) { - // Sender properties - $carac_emetteur = pdf_build_address($outputlangs, $this->emetteur, $object->thirdparty, '', 0, 'source', $object); - - // Show sender - $posy = getDolGlobalInt('MAIN_PDF_USE_ISO_LOCATION') ? 40 : 42; - $posy += $top_shift; - $posx = $this->marge_gauche; - if (getDolGlobalInt('MAIN_INVERT_SENDER_RECIPIENT')) { - $posx = $this->page_largeur - $this->marge_droite - 80; - } - - $hautcadre = getDolGlobalInt('MAIN_PDF_USE_ISO_LOCATION') ? 38 : 40; - $widthrecbox = getDolGlobalInt('MAIN_PDF_USE_ISO_LOCATION') ? 92 : 82; - - - // Show sender frame - if (!getDolGlobalString('MAIN_PDF_NO_SENDER_FRAME')) { - $pdf->SetTextColor(0, 0, 0); - $pdf->SetFont('', '', $default_font_size - 2); - $pdf->SetXY($posx, $posy - 5); - $pdf->MultiCell($widthrecbox, 5, $outputlangs->transnoentities("BillFrom").":", 0, $ltrdirection); - $pdf->SetXY($posx, $posy); - $pdf->SetFillColor(230, 230, 230); - $pdf->MultiCell($widthrecbox, $hautcadre, "", 0, 'R', true); - $pdf->SetTextColor(0, 0, 60); - } - - // Show sender name - if (!getDolGlobalString('MAIN_PDF_HIDE_SENDER_NAME')) { - $pdf->SetXY($posx + 2, $posy + 3); - $pdf->SetFont('', 'B', $default_font_size); - $pdf->MultiCell($widthrecbox - 2, 4, $outputlangs->convToOutputCharset($this->emetteur->name), 0, $ltrdirection); - $posy = $pdf->getY(); - } - - // Show sender information - $pdf->SetXY($posx + 2, $posy); - $pdf->SetFont('', '', $default_font_size - 1); - $pdf->MultiCell($widthrecbox - 2, 4, $carac_emetteur, 0, $ltrdirection); - - // If BILLING contact defined, we use it - $usecontact = false; - $arrayidcontact = $object->getIdContact('external', 'BILLING'); - if (count($arrayidcontact) > 0) { - $usecontact = true; - $result = $object->fetch_contact($arrayidcontact[0]); - } - - // Recipient name - if ($usecontact && $object->contact->socid != $object->thirdparty->id && getDolGlobalInt('MAIN_USE_COMPANY_NAME_OF_CONTACT')) { - $thirdparty = $object->contact; - } else { - $thirdparty = $object->thirdparty; - } - - if (is_object($thirdparty)) { - $carac_client_name = pdfBuildThirdpartyName($thirdparty, $outputlangs); - } else { - $carac_client_name = null; - } - - $mode = 'target'; - $carac_client = pdf_build_address($outputlangs, $this->emetteur, $object->thirdparty, ($usecontact ? $object->contact : ''), ($usecontact ? 1 : 0), $mode, $object); - - // Show recipient - $widthrecbox = getDolGlobalInt('MAIN_PDF_USE_ISO_LOCATION') ? 92 : 100; - if ($this->page_largeur < 210) { - $widthrecbox = 84; // To work with US executive format - } - $posy = getDolGlobalInt('MAIN_PDF_USE_ISO_LOCATION') ? 40 : 42; - $posy += $top_shift; - $posx = $this->page_largeur - $this->marge_droite - $widthrecbox; - if (getDolGlobalInt('MAIN_INVERT_SENDER_RECIPIENT')) { - $posx = $this->marge_gauche; - } - - // Show recipient frame - if (!getDolGlobalString('MAIN_PDF_NO_RECIPENT_FRAME')) { - $pdf->SetTextColor(0, 0, 0); - $pdf->SetFont('', '', $default_font_size - 2); - $pdf->SetXY($posx + 2, $posy - 5); - $pdf->MultiCell($widthrecbox, 5, $outputlangs->transnoentities("To").":", 0, $ltrdirection); - $pdf->Rect($posx, $posy, $widthrecbox, $hautcadre); - } - - // Show recipient name - $pdf->SetXY($posx + 2, $posy + 3); - $pdf->SetFont('', 'B', $default_font_size); - // @phan-suppress-next-line PhanPluginSuspiciousParamOrder - $pdf->MultiCell($widthrecbox, 2, (string) $carac_client_name, 0, $ltrdirection); - - $posy = $pdf->getY(); - - // Show recipient information - $pdf->SetFont('', '', $default_font_size - 1); - $pdf->SetXY($posx + 2, $posy); - // @phan-suppress-next-line PhanPluginSuspiciousParamOrder - $pdf->MultiCell($widthrecbox, 4, $carac_client, 0, $ltrdirection); + if (method_exists($pdf, 'AliasNbPages')) { + $pdf->AliasNbPages(); + } elseif (method_exists($pdf, 'setAliasNbPages')) { + $pdf->setAliasNbPages(); } - - $pdf->SetTextColor(0, 0, 0); - return $top_shift; + + $pdf->SetCreator('Dolibarr '.DOL_VERSION); + $pdf->SetAuthor($user->getFullName($outputlangs)); + $pdf->SetTitle(tw_pdf_normalize_string($headerTitle)); + $pdf->SetSubject(tw_pdf_normalize_string($headerTitle)); + $pdf->SetFont(pdf_getPDFFont($outputlangs), '', $defaultFontSize); + $pdf->Open(); + + $contentTop = tw_pdf_add_landscape_page($pdf, $outputlangs, $conf, $margeGauche, $margeHaute, $margeDroite, $margeBasse, $headerState, $autoPageBreakMargin, $headerTitle, $headerStatus, $headerWeekRange, $headerSubtitle); + $pdf->SetXY($margeGauche, $contentTop + 2.0); + $pdf->writeHTMLCell(0, 0, '', '', $htmlGrid, 0, 1, 0, true, '', true); + $pdf->lastPage(); + + $filename = $cleanRef.'.pdf'; + $destinationFile = $targetDir.'/'.$filename; + $resultOutput = $pdf->Output($destinationFile, 'F'); + if ($resultOutput === false) { + // EN: Abort when TCPDF signals a failure while writing the document to disk. + // FR: Abandonne lorsque TCPDF signale un échec lors de l'écriture du document sur le disque. + $this->error = $outputlangs->trans('ErrorFailToCreateFile'); + dol_syslog(__METHOD__.' failed: '.$this->error, LOG_ERR); + return -1; + } + + $this->result = array( + 'fullpath' => $destinationFile, + 'filename' => $filename, + 'relativepath' => $relativePath.'/'.$filename, + 'warnings' => array() + ); + + return 1; } - - // phpcs:disable PEAR.NamingConventions.ValidFunctionName.PublicUnderscore + /** - * Show footer of page. Need this->emetteur object + * EN: Format a day quantity using Dolibarr price helpers to mimic the card view. + * FR: Formate une quantité de jours via les helpers de prix Dolibarr pour imiter la vue carte. * - * @param TCPDI|TCPDF $pdf PDF - * @param CommonObject $object Object to show - * @param Translate $outputlangs Object lang for output - * @param int<0,1> $hidefreetext 1=Hide free text - * @return int<0,1> Return height of bottom margin including footer text + * @param float $value Day quantity / Quantité de jours + * @param Translate $langs Language handler / Gestionnaire de langues + * @return string */ - protected function _pagefoot(&$pdf, $object, $outputlangs, $hidefreetext = 0) + protected function formatDays($value, $langs) { - global $conf; - $showdetails = !getDolGlobalInt('MAIN_GENERATE_DOCUMENTS_SHOW_FOOT_DETAILS') ? 0 : getDolGlobalInt('MAIN_GENERATE_DOCUMENTS_SHOW_FOOT_DETAILS'); - return pdf_pagefoot($pdf, $outputlangs, 'INVOICE_FREE_TEXT', $this->emetteur, $this->marge_basse, $this->marge_gauche, $this->page_hauteur, $object, $showdetails, $hidefreetext); + $value = price2num($value, '2'); + return price($value, '', $langs, null, 1, 2); } - + /** - * Define Array Column Field + * EN: Provide the mapping between daily-rate codes and their hour equivalents. + * FR: Fournit la correspondance entre les codes forfait jour et leur équivalent horaire. * - * @param CommonObject $object common object - * @param Translate $outputlangs langs - * @param int<0,1> $hidedetails Do not show line details - * @param int<0,1> $hidedesc Do not show desc - * @param int<0,1> $hideref Do not show ref - * @return void + * @return array */ - public function defineColumnField($object, $outputlangs, $hidedetails = 0, $hidedesc = 0, $hideref = 0) + protected function getDailyRateHoursMap() { - global $conf, $hookmanager; - - // Default field style for content - $this->defaultContentsFieldsStyle = array( - 'align' => 'R', // R,C,L - 'padding' => array(1, 0.5, 1, 0.5), // Like css 0 => top , 1 => right, 2 => bottom, 3 => left - ); - - // Default field style for content - $this->defaultTitlesFieldsStyle = array( - 'align' => 'C', // R,C,L - 'padding' => array(0.5, 0, 0.5, 0), // Like css 0 => top , 1 => right, 2 => bottom, 3 => left - ); - - /* - * For example - $this->cols['theColKey'] = array( - 'rank' => $rank, // int : use for ordering columns - 'width' => 20, // the column width in mm - 'title' => array( - 'textkey' => 'yourLangKey', // if there is no label, yourLangKey will be translated to replace label - 'label' => ' ', // the final label : used fore final generated text - 'align' => 'L', // text alignment : R,C,L - 'padding' => array(0.5,0.5,0.5,0.5), // Like css 0 => top , 1 => right, 2 => bottom, 3 => left - ), - 'content' => array( - 'align' => 'L', // text alignment : R,C,L - 'padding' => array(0.5,0.5,0.5,0.5), // Like css 0 => top , 1 => right, 2 => bottom, 3 => left - ), - ); - */ - - $rank = 0; // do not use negative rank - $this->cols['desc'] = array( - 'rank' => $rank, - 'width' => false, // only for desc - 'status' => true, - 'title' => array( - 'textkey' => 'Designation', // use lang key is useful in somme case with module - 'align' => 'L', - // 'textkey' => 'yourLangKey', // if there is no label, yourLangKey will be translated to replace label - // 'label' => ' ', // the final label - 'padding' => array(0.5, 0.5, 0.5, 0.5), // Like css 0 => top , 1 => right, 2 => bottom, 3 => left - ), - 'content' => array( - 'align' => 'L', - 'padding' => array(1, 0.5, 1, 1.5), // Like css 0 => top , 1 => right, 2 => bottom, 3 => left - ), + return array( + 1 => 8.0, + 2 => 4.0, + 3 => 4.0 ); - - // PHOTO - // $rank += 10; - // $this->cols['photo'] = array( - // 'rank' => $rank, - // 'width' => (!getDolGlobalInt('MAIN_DOCUMENTS_WITH_PICTURE_WIDTH') ? 20 : getDolGlobalInt('MAIN_DOCUMENTS_WITH_PICTURE_WIDTH')), // in mm - // 'status' => false, - // 'title' => array( - // 'textkey' => 'Photo', - // 'label' => ' ' - // ), - // 'content' => array( - // 'padding' => array(0, 0, 0, 0), // Like css 0 => top , 1 => right, 2 => bottom, 3 => left - // ), - // 'border-left' => false, // remove left line separator - // ); - - // if (getDolGlobalInt('MAIN_GENERATE_INVOICES_WITH_PICTURE') && !empty($this->atleastonephoto)) { - // $this->cols['photo']['status'] = true; - // } - - - $rank += 10; - $this->cols['vat'] = array( - 'rank' => $rank, - 'status' => false, - 'width' => 16, // in mm - 'title' => array( - 'textkey' => 'VAT' - ), - 'border-left' => true, // add left line separator - ); - - if (!getDolGlobalInt('MAIN_GENERATE_DOCUMENTS_WITHOUT_VAT') && !getDolGlobalInt('MAIN_GENERATE_DOCUMENTS_WITHOUT_VAT_COLUMN')) { - $this->cols['vat']['status'] = true; - } - - $rank += 10; - $this->cols['subprice'] = array( - 'rank' => $rank, - 'width' => 19, // in mm - 'status' => true, - 'title' => array( - 'textkey' => 'PriceUHT' - ), - 'border-left' => true, // add left line separator - ); - - $rank += 10; - $this->cols['qty'] = array( - 'rank' => $rank, - 'width' => 16, // in mm - 'status' => true, - 'title' => array( - 'textkey' => 'Qty' - ), - 'border-left' => true, // add left line separator - ); - - $rank += 10; - $this->cols['unit'] = array( - 'rank' => $rank, - 'width' => 11, // in mm - 'status' => false, - 'title' => array( - 'textkey' => 'Unit' - ), - 'border-left' => true, // add left line separator - ); - if (getDolGlobalInt('PRODUCT_USE_UNITS')) { - $this->cols['unit']['status'] = true; - } - - $rank += 10; - $this->cols['discount'] = array( - 'rank' => $rank, - 'width' => 13, // in mm - 'status' => false, - 'title' => array( - 'textkey' => 'ReductionShort' - ), - 'border-left' => true, // add left line separator - ); - if ($this->atleastonediscount) { - $this->cols['discount']['status'] = true; - } - - $rank += 1000; // add a big offset to be sure is the last col because default extrafield rank is 100 - $this->cols['totalexcltax'] = array( - 'rank' => $rank, - 'width' => 26, // in mm - 'status' => true, - 'title' => array( - 'textkey' => 'TotalHTShort' - ), - 'border-left' => true, // add left line separator - ); - - // Add extrafields cols - if (!empty($object->lines)) { - $line = reset($object->lines); - $this->defineColumnExtrafield($line, $outputlangs, $hidedetails); - } - - $parameters = array( - 'object' => $object, - 'outputlangs' => $outputlangs, - 'hidedetails' => $hidedetails, - 'hidedesc' => $hidedesc, - 'hideref' => $hideref - ); - - $reshook = $hookmanager->executeHooks('defineColumnField', $parameters, $this); // Note that $object may have been modified by hook - if ($reshook < 0) { - setEventMessages($hookmanager->error, $hookmanager->errors, 'errors'); - } elseif (empty($reshook)) { - // @phan-suppress-next-line PhanPluginSuspiciousParamOrderInternal - $this->cols = array_replace($this->cols, $hookmanager->resArray); // array_replace is used to preserve keys - } else { - $this->cols = $hookmanager->resArray; - } } -} + } diff --git a/langs/en_US/timesheetweek.lang b/langs/en_US/timesheetweek.lang index a1661dd..9afbe82 100644 --- a/langs/en_US/timesheetweek.lang +++ b/langs/en_US/timesheetweek.lang @@ -20,6 +20,9 @@ TimesheetWeekNumberingEmpty = No numbering module is available. TimesheetWeekNumberingActivate = Activate this mask TimesheetWeekPDFModelsHelp = Enable the PDF templates that can be generated from a weekly timesheet. TimesheetWeekPDFModelsEmpty = No PDF template is available. +PDFStandardTimesheetWeekDescription = Standard PDF model for weekly timesheets +TimesheetWeekPdfReferenceLabel = Timesheet reference: %s +TimesheetWeekPdfApprovedDetails = Approved on %s by %s NewSection=New section TIMESHEETWEEK_MYPARAM1 = My param 1 TIMESHEETWEEK_MYPARAM1Tooltip = My param 1 tooltip @@ -112,6 +115,7 @@ SealTimesheet = Seal UnsealTimesheet = Unseal ConfirmValidate = Do you confirm the approval of this timesheet? ConfirmRefuse = Do you confirm the refusal of this timesheet? +Refuse = Refuse ApproveSelection = Approve selection RefuseSelection = Refuse selection SealSelection = Seal selection @@ -133,7 +137,9 @@ TimesheetWeekSummaryColumnWeek = Week TimesheetWeekSummaryColumnStart = Start date TimesheetWeekSummaryColumnEnd = End date TimesheetWeekSummaryColumnTotalHours = Declared hours +TimesheetWeekSummaryColumnTotalDays = Declared days TimesheetWeekSummaryColumnContractHours = Contract hours +TimesheetWeekSummaryColumnContractDays = Contract days TimesheetWeekSummaryColumnOvertime = Overtime TimesheetWeekSummaryColumnMeals = Meal allowances TimesheetWeekSummaryColumnZone1 = Zone 1 trips @@ -141,13 +147,18 @@ TimesheetWeekSummaryColumnZone2 = Zone 2 trips TimesheetWeekSummaryColumnZone3 = Zone 3 trips TimesheetWeekSummaryColumnZone4 = Zone 4 trips TimesheetWeekSummaryColumnZone5 = Zone 5 trips +TimesheetWeekSummaryColumnStatus = Status TimesheetWeekSummaryColumnApprovedBy = Approved by +TimesheetWeekSummaryStatusApprovedBy = Approved by %s +TimesheetWeekSummaryStatusSealedBy = Sealed by %s TimesheetWeekSummaryUserTitle = %s TimesheetWeekSummaryTotalsLabel = Totals TimesheetWeekSummaryTableTooTall = The summary for %s contains too many rows to fit on a single page. TimesheetWeekSummaryGenerated = Summary PDF ready. TimesheetWeekSummaryFilename = Weekly timesheets summary - Week %s to %s.pdf -TimesheetWeekSummaryHeaderWeekRange = Week #%1$s %2$s to %3$s %4$s +TimesheetWeekSummaryHeaderWeekRange = Week #%1$s %2$s +TimesheetWeekPreviewPdf = PDF preview +TimesheetWeekDownloadPdf = PDF download TimesheetWeekStatusDraft = Draft TimesheetWeekStatusSubmitted = Submitted diff --git a/langs/fr_FR/timesheetweek.lang b/langs/fr_FR/timesheetweek.lang index b1a8ae9..acbcb95 100644 --- a/langs/fr_FR/timesheetweek.lang +++ b/langs/fr_FR/timesheetweek.lang @@ -20,6 +20,9 @@ TimesheetWeekNumberingEmpty = Aucun module de numérotation disponible. TimesheetWeekNumberingActivate = Activer ce masque TimesheetWeekPDFModelsHelp = Activez les modèles PDF générables depuis une feuille hebdomadaire. TimesheetWeekPDFModelsEmpty = Aucun modèle PDF disponible. +PDFStandardTimesheetWeekDescription = Modèle PDF standard pour les feuilles hebdomadaires +TimesheetWeekPdfReferenceLabel = Référence de la feuille : %s +TimesheetWeekPdfApprovedDetails = Approuvée le %s par %s NewSection=Nouvelle section TIMESHEETWEEK_MYPARAM1 = Mon paramètre 1 TIMESHEETWEEK_MYPARAM1Tooltip = Info-bulle de mon paramètre 1 @@ -111,6 +114,7 @@ SealTimesheet = Sceller UnsealTimesheet = Desceller ConfirmValidate = Confirmez-vous l'approbation de cette feuille de temps ? ConfirmRefuse = Confirmez-vous le refus de cette feuille de temps ? +Refuse = Refuser ApproveSelection = Approuver la sélection RefuseSelection = Refuser la sélection SealSelection = Sceller la sélection @@ -132,7 +136,9 @@ TimesheetWeekSummaryColumnWeek = Semaine TimesheetWeekSummaryColumnStart = Date de début TimesheetWeekSummaryColumnEnd = Date de fin TimesheetWeekSummaryColumnTotalHours = Heures déclarées +TimesheetWeekSummaryColumnTotalDays = Jours déclarés TimesheetWeekSummaryColumnContractHours = Heures au contrat +TimesheetWeekSummaryColumnContractDays = Jours contractuels TimesheetWeekSummaryColumnOvertime = Heures supplémentaires TimesheetWeekSummaryColumnMeals = Paniers repas TimesheetWeekSummaryColumnZone1 = Déplacements Zone 1 @@ -140,13 +146,18 @@ TimesheetWeekSummaryColumnZone2 = Déplacements Zone 2 TimesheetWeekSummaryColumnZone3 = Déplacements Zone 3 TimesheetWeekSummaryColumnZone4 = Déplacements Zone 4 TimesheetWeekSummaryColumnZone5 = Déplacements Zone 5 +TimesheetWeekSummaryColumnStatus = Statut TimesheetWeekSummaryColumnApprovedBy = Approuvé par +TimesheetWeekSummaryStatusApprovedBy = Approuvé par %s +TimesheetWeekSummaryStatusSealedBy = Scellée par %s TimesheetWeekSummaryUserTitle = %s TimesheetWeekSummaryTotalsLabel = Totaux TimesheetWeekSummaryTableTooTall = La synthèse pour %s contient trop de lignes pour tenir sur une seule page. TimesheetWeekSummaryGenerated = PDF de synthèse disponible. TimesheetWeekSummaryFilename = Synthese hebdomadaire des feuilles de temps - Semaine %s a %s.pdf -TimesheetWeekSummaryHeaderWeekRange = Semaine n°%1$s %2$s à %3$s %4$s +TimesheetWeekSummaryHeaderWeekRange = Semaine n°%1$s %2$s +TimesheetWeekPreviewPdf = Aperçu PDF +TimesheetWeekDownloadPdf = Téléchargement PDF TimesheetWeekStatusDraft = Brouillon TimesheetWeekStatusSubmitted = Soumise diff --git a/lib/timesheetweek_pdf.lib.php b/lib/timesheetweek_pdf.lib.php index eec15c7..825b4fd 100644 --- a/lib/timesheetweek_pdf.lib.php +++ b/lib/timesheetweek_pdf.lib.php @@ -30,6 +30,7 @@ require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php'; dol_include_once('/timesheetweek/lib/timesheetweek.lib.php'); +dol_include_once('/timesheetweek/class/timesheetweek.class.php'); defined('TIMESHEETWEEK_PDF_SUMMARY_SUBDIR') || define('TIMESHEETWEEK_PDF_SUMMARY_SUBDIR', 'summaries'); @@ -71,14 +72,31 @@ function tw_pdf_normalize_string($value) * EN: Wrap the normalised value in a safe HTML span ready for TCPDF output. * FR: Entoure la valeur normalisée dans un span HTML sûr prêt pour la sortie TCPDF. * - * @param string $value + * @param string $value EN: Value to render in the PDF cell / FR: Valeur à afficher dans la cellule PDF + * @param bool $allowHtml EN: Whether HTML tags are authorised / FR: Indique si les balises HTML sont autorisées * @return string */ -function tw_pdf_format_cell_html($value) +function tw_pdf_format_cell_html($value, $allowHtml = false) { // EN: Normalise the input before escaping to keep accents visible in PDF cells. // FR: Normalise la valeur avant échappement pour conserver les accents visibles dans les cellules PDF. $normalizedValue = tw_pdf_normalize_string($value); + if ($allowHtml) { + // EN: Harmonise explicit line breaks to the XHTML form expected by TCPDF. + // FR: Harmonise les retours à la ligne explicites au format XHTML attendu par TCPDF. + $normalizedValue = preg_replace('/(\r\n|\r|\n)/', '
', $normalizedValue); + // EN: Normalise existing break tags to the XHTML variant to stay compatible with TCPDF. + // FR: Normalise les balises de saut de ligne existantes en variante XHTML pour rester compatible avec TCPDF. + $normalizedValue = preg_replace('//i', '
', $normalizedValue); + // EN: Allow a limited set of inline tags to preserve safe styling in PDF cells. + // FR: Autorise un ensemble limité de balises inline pour préserver un style sûr dans les cellules PDF. + $allowedTags = '
'; + $sanitizedValue = strip_tags($normalizedValue, $allowedTags); + // EN: Escape stray ampersands so TCPDF receives valid HTML entities. + // FR: Échappe les esperluettes isolées pour que TCPDF reçoive des entités HTML valides. + $sanitizedValue = preg_replace('/&(?![a-zA-Z0-9#]+;)/', '&', $sanitizedValue); + return ''.$sanitizedValue.''; + } // EN: Escape HTML special characters using Dolibarr helper to protect TCPDF output. // FR: Échappe les caractères spéciaux HTML avec l'helper Dolibarr pour sécuriser la sortie TCPDF. $escapedValue = dol_escape_htmltag($normalizedValue); @@ -87,6 +105,65 @@ function tw_pdf_format_cell_html($value) return ''.$escapedValue.''; } +/** + * EN: Prepare multi-line header content while allowing simple HTML formatting. + * FR: Prépare un contenu d'entête multi-lignes en autorisant une mise en forme HTML simple. + * + * @param string $value + * @return string + */ +function tw_pdf_format_header_html($value) +{ + // EN: Normalise the input before applying header-specific adjustments. + // FR: Normalise la valeur avant d'appliquer les ajustements spécifiques à l'entête. + $normalizedValue = tw_pdf_normalize_string($value); + // EN: Convert plain line breaks into HTML breaks to mimic on-screen layout. + // FR: Convertit les retours à la ligne simples en sauts HTML pour imiter la mise en page à l'écran. + $normalizedValue = preg_replace("/(\r\n|\r|\n)/", '
', $normalizedValue); + // EN: Harmonise any existing break tags to the XHTML form expected by TCPDF. + // FR: Harmonise les balises de saut de ligne existantes au format XHTML attendu par TCPDF. + $normalizedValue = preg_replace('//i', '
', $normalizedValue); + // EN: Allow only basic formatting tags to keep the header safe. + // FR: Autorise uniquement les balises de mise en forme basiques pour sécuriser l'entête. + $allowedTags = '
'; + $sanitizedValue = strip_tags($normalizedValue, $allowedTags); + // EN: Escape lone ampersands to provide valid HTML markup to TCPDF. + // FR: Échappe les esperluettes isolées afin de fournir un HTML valide à TCPDF. + $sanitizedValue = preg_replace('/&(?![a-zA-Z0-9#]+;)/', '&', $sanitizedValue); + // EN: Return the formatted span ready to be rendered in the PDF header. + // FR: Retourne le span formaté prêt à être rendu dans l'entête PDF. + return ''.$sanitizedValue.''; +} + +/** + * EN: Convert a hexadecimal color code to an RGB triplet usable by TCPDF. + * FR: Convertit un code couleur hexadécimal en triplet RVB utilisable par TCPDF. + * + * @param string $hexColor + * @param array $fallback + * @return array + */ +function tw_pdf_hex_to_rgb($hexColor, array $fallback = array(33, 37, 41)) +{ + $normalized = trim((string) $hexColor); + if ($normalized === '') { + return $fallback; + } + if ($normalized[0] === '#') { + $normalized = substr($normalized, 1); + } + if (dol_strlen($normalized) === 3) { + $normalized = $normalized[0].$normalized[0].$normalized[1].$normalized[1].$normalized[2].$normalized[2]; + } + if (!preg_match('/^[0-9a-fA-F]{6}$/', $normalized)) { + return $fallback; + } + $red = (int) hexdec(substr($normalized, 0, 2)); + $green = (int) hexdec(substr($normalized, 2, 2)); + $blue = (int) hexdec(substr($normalized, 4, 2)); + return array($red, $green, $blue); +} + /** * EN: Render the Dolibarr-styled header containing the logo and company name. * FR: Dessine l'entête au style Dolibarr avec le logo et le nom de l'entreprise. @@ -97,11 +174,12 @@ function tw_pdf_format_cell_html($value) * @param float $leftMargin * @param float $topMargin * @param string $title + * @param string|array $status * @param string $weekRange * @param string $subtitle * @return float */ -function tw_pdf_draw_header($pdf, $langs, $conf, $leftMargin, $topMargin, $title = '', $weekRange = '', $subtitle = '') +function tw_pdf_draw_header($pdf, $langs, $conf, $leftMargin, $topMargin, $title = '', $status = '', $weekRange = '', $subtitle = '') { global $mysoc; @@ -179,7 +257,75 @@ function tw_pdf_draw_header($pdf, $langs, $conf, $leftMargin, $topMargin, $title $pdf->Cell($rightBlockWidth, 6, tw_pdf_normalize_string($trimmedTitle), 0, 0, 'R', 0, '', 0, false, 'T', 'T'); $rightBlockBottom = max($rightBlockBottom, $posY + 6.0); } - // EN: Trim the ISO week range label before output. + // EN: Insert the status badge under the title when provided by the caller. + // FR: Insère le badge de statut sous le titre lorsqu'il est fourni par l'appelant. + $statusHandled = false; + if (is_array($status)) { + // EN: Extract the plain text and Dolibarr colors for the PDF badge rendering. + // FR: Extrait le texte brut et les couleurs Dolibarr pour le rendu du badge PDF. + $badgeTextSource = !empty($status['label']) ? $status['label'] : ''; + $badgeText = tw_pdf_normalize_string($badgeTextSource); + $badgeText = trim($badgeText); + $badgeBackground = tw_pdf_hex_to_rgb(!empty($status['backgroundColor']) ? $status['backgroundColor'] : '', array(173, 181, 189)); + $badgeTextColor = tw_pdf_hex_to_rgb(!empty($status['textColor']) ? $status['textColor'] : '', array(33, 37, 41)); + if (dol_strlen($badgeText) > 0) { + // EN: Draw a rounded rectangle badge with the Timesheet colors. + // FR: Dessine un badge arrondi avec les couleurs Timesheet. + $badgeFontSize = max(6.0, $defaultFontSize - 1.0); + $pdf->SetFont('', 'B', $badgeFontSize); + $textWidth = $pdf->GetStringWidth($badgeText); + $badgePaddingX = 3.0; + $badgePaddingY = 1.4; + $badgeWidth = min($rightBlockWidth, $textWidth + (2.0 * $badgePaddingX)); + $badgeHeight = max(6.0, ($badgeFontSize * 0.6) + (2.0 * $badgePaddingY)); + $badgeX = $rightBlockX + max(0.0, $rightBlockWidth - $badgeWidth); + $badgeY = $rightBlockBottom + 1.5; + $pdf->SetFillColor($badgeBackground[0], $badgeBackground[1], $badgeBackground[2]); + if (method_exists($pdf, 'RoundedRect')) { + $pdf->RoundedRect($badgeX, $badgeY, $badgeWidth, $badgeHeight, 2.0, '1111', 'F', array(), array($badgeBackground[0], $badgeBackground[1], $badgeBackground[2])); + } else { + $pdf->Rect($badgeX, $badgeY, $badgeWidth, $badgeHeight, 'F'); + } + $pdf->SetTextColor($badgeTextColor[0], $badgeTextColor[1], $badgeTextColor[2]); + $pdf->SetXY($badgeX, $badgeY); + if (method_exists($pdf, 'Cell')) { + $pdf->Cell($badgeWidth, $badgeHeight, $badgeText, 0, 0, 'C', 0, '', 0, false, 'T', 'M'); + } else { + $pdf->MultiCell($badgeWidth, $badgeHeight, $badgeText, 0, 'C', 0, 1, '', '', true, 0, true); + } + $pdf->SetFont('', '', $defaultFontSize); + $pdf->SetTextColor(0, 0, 0); + $rightBlockBottom = max($rightBlockBottom, $badgeY + $badgeHeight); + $statusHandled = true; + } + if (!$statusHandled && !empty($status['html'])) { + // EN: Fallback to the HTML badge when structured data cannot be rendered. + // FR: Revient au badge HTML lorsque les données structurées ne peuvent pas être rendues. + $fallbackStatus = trim((string) $status['html']); + if ($fallbackStatus !== '') { + $pdf->SetFont('', '', $defaultFontSize); + $pdf->SetTextColor(0, 0, 0); + $pdf->SetXY($rightBlockX, $rightBlockBottom + 1.0); + $pdf->MultiCell($rightBlockWidth, 5, tw_pdf_format_header_html($fallbackStatus), 0, 'R', 0, 1, '', '', true, 0, true); + $rightBlockBottom = max($rightBlockBottom, $pdf->GetY()); + $statusHandled = true; + } + } + } + if (!$statusHandled) { + // EN: Preserve legacy behaviour by rendering the status as raw HTML. + // FR: Préserve le comportement historique en affichant le statut en HTML brut. + $trimmedStatus = trim((string) $status); + if (dol_strlen($trimmedStatus) > 0) { + $pdf->SetFont('', '', $defaultFontSize); + $pdf->SetTextColor(0, 0, 0); + $pdf->SetXY($rightBlockX, $rightBlockBottom + 1.0); + $pdf->MultiCell($rightBlockWidth, 5, tw_pdf_format_header_html($trimmedStatus), 0, 'R', 0, 1, '', '', true, 0, true); + $rightBlockBottom = max($rightBlockBottom, $pdf->GetY()); + } + } + +// EN: Trim the ISO week range label before output. // FR: Supprime les espaces du libellé de plage de semaines avant affichage. $trimmedWeekRange = trim((string) $weekRange); if (dol_strlen($trimmedWeekRange) > 0) { @@ -188,7 +334,7 @@ function tw_pdf_draw_header($pdf, $langs, $conf, $leftMargin, $topMargin, $title $pdf->SetXY($rightBlockX, $rightBlockBottom + 1.0); // EN: Show the ISO week range immediately below the title to mirror Dolibarr headers. // FR: Affiche la plage de semaines ISO juste sous le titre pour refléter les entêtes Dolibarr. - $pdf->MultiCell($rightBlockWidth, 5, tw_pdf_format_cell_html($trimmedWeekRange), 0, 'R', 0, 1, '', '', true, 0, true); + $pdf->MultiCell($rightBlockWidth, 5, tw_pdf_format_header_html($trimmedWeekRange), 0, 'R', 0, 1, '', '', true, 0, true); $rightBlockBottom = max($rightBlockBottom, $pdf->GetY()); } // EN: Remove unnecessary spaces around the header subtitle before rendering. @@ -198,7 +344,7 @@ function tw_pdf_draw_header($pdf, $langs, $conf, $leftMargin, $topMargin, $title $pdf->SetFont('', '', $defaultFontSize); $pdf->SetTextColor(0, 0, 0); $pdf->SetXY($rightBlockX, $rightBlockBottom + 1.0); - $pdf->MultiCell($rightBlockWidth, 5, tw_pdf_format_cell_html($trimmedSubtitle), 0, 'R', 0, 1, '', '', true, 0, true); + $pdf->MultiCell($rightBlockWidth, 5, tw_pdf_format_header_html($trimmedSubtitle), 0, 'R', 0, 1, '', '', true, 0, true); $rightBlockBottom = max($rightBlockBottom, $pdf->GetY()); } @@ -272,11 +418,12 @@ function tw_pdf_draw_footer($pdf, $langs, $conf, $leftMargin, $rightMargin, $bot * @param float $bottomMargin * @param float|null $autoPageBreakMargin * @param string $headerTitle + * @param string|array $headerStatus * @param string $headerWeekRange * @param string $headerSubtitle * @return float */ -function tw_pdf_add_landscape_page($pdf, $langs, $conf, $leftMargin, $topMargin, $rightMargin, $bottomMargin, &$headerState = null, $autoPageBreakMargin = null, $headerTitle = '', $headerWeekRange = '', $headerSubtitle = '') +function tw_pdf_add_landscape_page($pdf, $langs, $conf, $leftMargin, $topMargin, $rightMargin, $bottomMargin, &$headerState = null, $autoPageBreakMargin = null, $headerTitle = '', $headerStatus = '', $headerWeekRange = '', $headerSubtitle = '') { $pdf->AddPage('L'); // EN: Detect if TCPDF automatic callbacks manage header/footer rendering. @@ -288,9 +435,9 @@ function tw_pdf_add_landscape_page($pdf, $langs, $conf, $leftMargin, $topMargin, // FR: Recalcule la hauteur d'entête lorsqu'elle manque pour éviter les appels de pied dupliqués. $headerBottom = !empty($headerState['value']) ? (float) $headerState['value'] - : tw_pdf_draw_header($pdf, $langs, $conf, $leftMargin, $topMargin, $headerTitle, $headerWeekRange, $headerSubtitle); + : tw_pdf_draw_header($pdf, $langs, $conf, $leftMargin, $topMargin, $headerTitle, $headerStatus, $headerWeekRange, $headerSubtitle); } else { - $headerBottom = tw_pdf_draw_header($pdf, $langs, $conf, $leftMargin, $topMargin, $headerTitle, $headerWeekRange, $headerSubtitle); + $headerBottom = tw_pdf_draw_header($pdf, $langs, $conf, $leftMargin, $topMargin, $headerTitle, $headerStatus, $headerWeekRange, $headerSubtitle); tw_pdf_draw_footer($pdf, $langs, $conf, $leftMargin, $rightMargin, $bottomMargin, null, 0, $autoPageBreakMargin); if (is_array($headerState)) { // EN: Store the header height for further pages when callbacks remain disabled. @@ -334,17 +481,21 @@ function tw_pdf_print_user_banner($pdf, $langs, $userObject, $defaultFontSize) * @param float[] $columnWidths * @param string[] $values * @param float $lineHeight + * @param bool[] $htmlFlags * @return float */ -function tw_pdf_estimate_row_height($pdf, array $columnWidths, array $values, $lineHeight) +function tw_pdf_estimate_row_height($pdf, array $columnWidths, array $values, $lineHeight, array $htmlFlags = array()) { - // EN: Track the maximum number of lines across the row to harmonise heights. - // FR: Suit le nombre maximal de lignes pour harmoniser les hauteurs. $maxLines = 1; foreach ($values as $index => $value) { - $text = tw_pdf_format_cell_html($value); - $plain = dol_string_nohtmltag($text); - $currentLines = max(1, $pdf->getNumLines($plain, $columnWidths[$index])); + $allowHtml = !empty($htmlFlags[$index]); + $formatted = tw_pdf_format_cell_html($value, $allowHtml); + $plainSource = $allowHtml ? preg_replace('//i', " +", $formatted) : $formatted; + $plain = dol_string_nohtmltag($plainSource); + $plain = html_entity_decode($plain, ENT_QUOTES | ENT_HTML401, 'UTF-8'); + $width = isset($columnWidths[$index]) ? (float) $columnWidths[$index] : 0.0; + $currentLines = ($width > 0.0) ? max(1, $pdf->getNumLines($plain, $width)) : 1; $maxLines = max($maxLines, $currentLines); } return $lineHeight * $maxLines; @@ -366,40 +517,36 @@ function tw_pdf_render_row($pdf, array $columnWidths, array $values, $lineHeight $border = $options['border'] ?? 1; $fill = !empty($options['fill']); $alignments = $options['alignments'] ?? array(); - // EN: Compute the shared height to align every cell in the row. - // FR: Calcule la hauteur commune pour aligner toutes les cellules de la ligne. - $rowHeight = tw_pdf_estimate_row_height($pdf, $columnWidths, $values, $lineHeight); + $htmlFlags = $options['html_flags'] ?? array(); + $rowHeight = tw_pdf_estimate_row_height($pdf, $columnWidths, $values, $lineHeight, $htmlFlags); $initialX = $pdf->GetX(); $initialY = $pdf->GetY(); $offset = 0.0; foreach ($values as $index => $value) { - $width = $columnWidths[$index]; + $width = isset($columnWidths[$index]) ? (float) $columnWidths[$index] : 0.0; $align = $alignments[$index] ?? 'L'; - // EN: Position each cell manually to guarantee column alignment. - // FR: Positionne chaque cellule manuellement pour garantir l'alignement des colonnes. + $allowHtml = !empty($htmlFlags[$index]); $pdf->SetXY($initialX + $offset, $initialY); $pdf->MultiCell( - $width, - $rowHeight, - tw_pdf_format_cell_html($value), - $border, - $align, - $fill, - 0, - '', - '', - true, - 0, - true, - true, - $rowHeight, - 'T', - false + $width, + $rowHeight, + tw_pdf_format_cell_html($value, $allowHtml), + $border, + $align, + $fill, + 0, + '', + '', + true, + 0, + true, + true, + $rowHeight, + 'T', + false ); $offset += $width; } - // EN: Move the cursor under the row for the next drawing operations. - // FR: Replace le curseur sous la ligne pour les prochaines opérations de dessin. $pdf->SetXY($initialX, $initialY + $rowHeight); } @@ -416,28 +563,29 @@ function tw_pdf_render_row($pdf, array $columnWidths, array $values, $lineHeight * @param string[] $totalsRow * @param float $lineHeight * @param float $contentWidth + * @param bool[] $htmlFlags + * @param float[] $recordLineHeights * @return float */ -function tw_pdf_estimate_user_table_height($pdf, $langs, $userObject, array $columnWidths, array $columnLabels, array $recordRows, array $totalsRow, $lineHeight, $contentWidth) +function tw_pdf_estimate_user_table_height($pdf, $langs, $userObject, array $columnWidths, array $columnLabels, array $recordRows, array $totalsRow, $lineHeight, $contentWidth, array $htmlFlags = array(), array $recordLineHeights = array()) { $bannerText = $langs->trans('TimesheetWeekSummaryUserTitle', $userObject->getFullName($langs)); $bannerPlain = dol_string_nohtmltag(tw_pdf_format_cell_html($bannerText)); - // EN: Evaluate banner height using the same line width as the MultiCell call. - // FR: Évalue la hauteur de la bannière en utilisant la même largeur de ligne que l'appel MultiCell. $bannerLines = max(1, $pdf->getNumLines($bannerPlain, $contentWidth)); $bannerHeight = 6 * $bannerLines; - - // EN: Account for the spacing introduced before the table header. - // FR: Prend en compte l'espacement introduit avant l'entête du tableau. - $headerHeight = tw_pdf_estimate_row_height($pdf, $columnWidths, $columnLabels, $lineHeight); + + $headerHeight = tw_pdf_estimate_row_height($pdf, $columnWidths, $columnLabels, $lineHeight, $htmlFlags); $totalHeight = $bannerHeight + 2 + $headerHeight; - - foreach ($recordRows as $rowValues) { - $totalHeight += tw_pdf_estimate_row_height($pdf, $columnWidths, $rowValues, $lineHeight); + + foreach ($recordRows as $rowIndex => $rowValues) { + // EN: Apply the precomputed line height when available to reflect the final rendering footprint. + // FR: Applique la hauteur de ligne pré-calculée lorsque disponible pour refléter l'empreinte finale du rendu. + $rowLineHeight = isset($recordLineHeights[$rowIndex]) ? (float) $recordLineHeights[$rowIndex] : $lineHeight; + $totalHeight += tw_pdf_estimate_row_height($pdf, $columnWidths, $rowValues, $rowLineHeight, $htmlFlags); } - - $totalHeight += tw_pdf_estimate_row_height($pdf, $columnWidths, $totalsRow, $lineHeight); - + + $totalHeight += tw_pdf_estimate_row_height($pdf, $columnWidths, $totalsRow, $lineHeight, $htmlFlags); + return $totalHeight; } @@ -460,6 +608,160 @@ function tw_format_hours_decimal($hours) return sprintf('%02d:%02d', $hoursInt, $minutes); } +/** + * EN: Convert a decimal number of days into a locale-aware string for PDF output. + * FR: Convertit un nombre décimal de jours en chaîne localisée pour la sortie PDF. + * + * @param float $days Day quantity / Quantité de jours + * @param Translate $langs Translator instance / Instance de traduction + * @return string Formatted value / Valeur formatée + */ +function tw_format_days_decimal($days, Translate $langs) +{ + global $conf; + + // EN: Normalise the numeric value to two decimals before applying locale formatting. + // FR: Normalise la valeur numérique à deux décimales avant d'appliquer le formatage local. + $normalized = price2num((float) $days, '2'); + + // EN: Use Dolibarr price helper to honour thousands and decimal separators for the PDF output. + // FR: Utilise l'assistant de prix Dolibarr pour respecter les séparateurs de milliers et décimaux dans le PDF. + return price($normalized, '', $langs, $conf, 1, 2); +} + +/** + * EN: Convert relative column weights into absolute widths matching the printable area. + * FR: Convertit les pondérations de colonnes en largeurs absolues adaptées à la zone imprimable. + * + * @param float[] $weights Relative weights / Pondérations relatives + * @param float $usableWidth Available width / Largeur disponible + * @return float[] Absolute widths / Largeurs absolues + */ +function tw_pdf_compute_column_widths(array $weights, $usableWidth) +{ + $widths = $weights; + $totalWeight = array_sum($weights); + $usableWidth = (float) $usableWidth; + + if ($totalWeight > 0 && $usableWidth > 0) { + foreach ($weights as $index => $weight) { + $ratio = (float) $weight / $totalWeight; + $widths[$index] = $ratio * $usableWidth; + } + } + + return $widths; +} + +/** + * EN: Resolve the user name who sealed a timesheet using linked agenda events. + * FR: Résout le nom de l'utilisateur ayant scellé une feuille via les événements agenda liés. + * + * @param DoliDB $db Database handler / Gestionnaire de base de données + * @param int $timesheetId Timesheet identifier / Identifiant de feuille de temps + * @param int $entityId Entity identifier / Identifiant d'entité + * @return string User full name or empty string / Nom complet de l'utilisateur ou chaîne vide + */ +function tw_pdf_resolve_sealed_by($db, $timesheetId, $entityId) +{ + static $cache = array(); + + $timesheetId = (int) $timesheetId; + $entityId = (int) $entityId; + $cacheKey = $timesheetId.'-'.$entityId; + + if (isset($cache[$cacheKey])) { + return $cache[$cacheKey]; + } + + if ($timesheetId <= 0) { + $cache[$cacheKey] = ''; + return ''; + } + $sql = "SELECT ac.fk_user_author as sealer_id, u.firstname, u.lastname"; + $sql .= " FROM ".MAIN_DB_PREFIX."actioncomm as ac"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user as u ON u.rowid = ac.fk_user_author"; + $sql .= " WHERE ac.code = 'TSWK_SEAL'"; + $sql .= " AND ac.elementtype = 'timesheetweek'"; + $sql .= " AND ac.fk_element = ".$timesheetId; + $sql .= " AND ac.entity = ".$entityId; + $sql .= " ORDER BY ac.rowid DESC"; + $sql .= " LIMIT 1"; + + $resql = $db->query($sql); + if (!$resql) { + $cache[$cacheKey] = ''; + return ''; + } + + $result = ''; + if ($row = $db->fetch_object($resql)) { + if (!empty($row->lastname) || !empty($row->firstname)) { + $result = dolGetFirstLastname($row->firstname, $row->lastname); + } + } + $db->free($resql); + + $cache[$cacheKey] = $result; + + return $result; +} + +/** + * EN: Build the HTML badge associated with a timesheet status for PDF rendering. + * FR: Construit le badge HTML associé à un statut de feuille pour le rendu PDF. + * + * @param int $status Timesheet status code / Code de statut de la feuille + * @param Translate $langs Translator / Gestionnaire de traductions + * @return string HTML badge string / Chaîne HTML du badge + */ +function tw_pdf_build_status_badge($status, $langs) +{ + $status = (int) $status; + if (method_exists($langs, 'loadLangs')) { + $langs->loadLangs(array('timesheetweek@timesheetweek', 'other')); + } + + $badge = TimesheetWeek::LibStatut($status, 5); + if (!is_string($badge) || $badge === '') { + return ''.dol_escape_htmltag($langs->trans('Unknown')).''; + } + + return $badge; +} + +/** + * EN: Compose the status cell with the badge and contextual messages for the PDF table. + * FR: Compose la cellule de statut avec le badge et les messages contextuels pour le tableau PDF. + * + * @param Translate $langs Translator instance / Instance de traduction + * @param int $status Timesheet status code / Code du statut de la feuille + * @param string $approvedBy Approver full name / Nom complet de l'approbateur + * @param string $sealedBy Sealer full name / Nom complet du scelleur + * @return string HTML snippet for the cell / Fragment HTML pour la cellule + */ +function tw_pdf_compose_status_cell($langs, $status, $approvedBy, $sealedBy) +{ + $status = (int) $status; + $approvedBy = trim((string) $approvedBy); + $sealedBy = trim((string) $sealedBy); + + $parts = array(); + $parts[] = tw_pdf_build_status_badge($status, $langs); + + if ($status === TimesheetWeek::STATUS_APPROVED) { + $label = $approvedBy !== '' ? $approvedBy : $langs->trans('Unknown'); + $parts[] = ''.dol_escape_htmltag($langs->trans('TimesheetWeekSummaryStatusApprovedBy', $label)).''; + } elseif ($status === TimesheetWeek::STATUS_SEALED) { + $approvedLabel = $approvedBy !== '' ? $approvedBy : $langs->trans('Unknown'); + $sealedLabel = $sealedBy !== '' ? $sealedBy : $langs->trans('Unknown'); + $parts[] = ''.dol_escape_htmltag($langs->trans('TimesheetWeekSummaryStatusApprovedBy', $approvedLabel)).''; + $parts[] = ''.dol_escape_htmltag($langs->trans('TimesheetWeekSummaryStatusSealedBy', $sealedLabel)).''; + } + + return implode('
', $parts); +} + /** * EN: Build the dataset required to generate a PDF summary of weekly timesheets. * FR: Construit l'ensemble de données nécessaire pour générer un résumé PDF des feuilles hebdomadaires. @@ -488,10 +790,10 @@ function tw_collect_summary_data($db, array $timesheetIds, User $user, $permRead } $idList = implode(',', $ids); -$sql = "SELECT t.rowid, t.entity, t.year, t.week, t.total_hours, t.overtime_hours, t.zone1_count, t.zone2_count, t.zone3_count, t.zone4_count, t.zone5_count, t.meal_count, t.fk_user, t.fk_user_valid, u.lastname, u.firstname, u.weeklyhours, uv.lastname as validator_lastname, uv.firstname as validator_firstname"; -$sql .= " FROM ".MAIN_DB_PREFIX."timesheet_week as t"; -$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user as u ON u.rowid = t.fk_user"; -$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user as uv ON uv.rowid = t.fk_user_valid"; + $sql = "SELECT t.rowid, t.entity, t.year, t.week, t.total_hours, t.overtime_hours, t.zone1_count, t.zone2_count, t.zone3_count, t.zone4_count, t.zone5_count, t.meal_count, t.fk_user, t.fk_user_valid, t.status, u.lastname, u.firstname, u.weeklyhours, uv.lastname as validator_lastname, uv.firstname as validator_firstname"; + $sql .= " FROM ".MAIN_DB_PREFIX."timesheet_week as t"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user as u ON u.rowid = t.fk_user"; + $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user as uv ON uv.rowid = t.fk_user_valid"; $sql .= " WHERE t.rowid IN (".$idList.")"; $sql .= " AND t.entity IN (".getEntity('timesheetweek').")"; @@ -533,8 +835,14 @@ function tw_collect_summary_data($db, array $timesheetIds, User $user, $permRead $errors[] = 'TimesheetWeekSummaryMissingUser'; continue; } + // EN: Load extrafields to detect the daily rate flag used by forfait jour employees. + // FR: Charge les extrafields pour détecter le flag forfait jour utilisé par les salariés concernés. + $userSummary->fetch_optionals($userSummary->id, $userSummary->table_element); $dataset[$targetUserId] = array( 'user' => $userSummary, + // EN: Persist the daily rate flag to adapt PDF rendering later on. + // FR: Conserve le flag forfait jour afin d'adapter le rendu PDF ultérieurement. + 'is_daily_rate' => !empty($userSummary->array_options['options_lmdb_daily_rate']), 'records' => array(), 'totals' => array( 'total_hours' => 0.0, @@ -550,30 +858,41 @@ function tw_collect_summary_data($db, array $timesheetIds, User $user, $permRead ); } -$approvedBy = ''; -if (!empty($row->validator_lastname) || !empty($row->validator_firstname)) { -// EN: Build the approver full name respecting Dolibarr formatting. -// FR: Construit le nom complet de l'approbateur selon le format Dolibarr. -$approvedBy = dolGetFirstLastname($row->validator_firstname, $row->validator_lastname); -} + $approvedBy = ''; + if (!empty($row->validator_lastname) || !empty($row->validator_firstname)) { + // EN: Build the approver full name respecting Dolibarr formatting. + // FR: Construit le nom complet de l'approbateur selon le format Dolibarr. + $approvedBy = dolGetFirstLastname($row->validator_firstname, $row->validator_lastname); + } -$record = array( -'id' => (int) $row->rowid, -'week' => $week, -'year' => $year, -'week_start' => $weekStart, -'week_end' => $weekEnd, -'total_hours' => (float) $row->total_hours, -'contract_hours' => (float) $contractHours, -'overtime_hours' => (float) $row->overtime_hours, -'meal_count' => (int) $row->meal_count, -'zone1_count' => (int) $row->zone1_count, -'zone2_count' => (int) $row->zone2_count, -'zone3_count' => (int) $row->zone3_count, -'zone4_count' => (int) $row->zone4_count, -'zone5_count' => (int) $row->zone5_count, -'approved_by' => $approvedBy -); + $status = (int) $row->status; + $sealedBy = ''; + if ($status === TimesheetWeek::STATUS_SEALED) { + // EN: Resolve the user who sealed the timesheet through agenda history. + // FR: Résout l'utilisateur ayant scellé la feuille via l'historique agenda. + $sealedBy = tw_pdf_resolve_sealed_by($db, (int) $row->rowid, (int) $row->entity); + } + + $record = array( + 'id' => (int) $row->rowid, + 'entity' => (int) $row->entity, + 'week' => $week, + 'year' => $year, + 'week_start' => $weekStart, + 'week_end' => $weekEnd, + 'total_hours' => (float) $row->total_hours, + 'contract_hours' => (float) $contractHours, + 'overtime_hours' => (float) $row->overtime_hours, + 'meal_count' => (int) $row->meal_count, + 'zone1_count' => (int) $row->zone1_count, + 'zone2_count' => (int) $row->zone2_count, + 'zone3_count' => (int) $row->zone3_count, + 'zone4_count' => (int) $row->zone4_count, + 'zone5_count' => (int) $row->zone5_count, + 'approved_by' => $approvedBy, + 'sealed_by' => $sealedBy, + 'status' => $status + ); $dataset[$targetUserId]['records'][] = $record; $dataset[$targetUserId]['totals']['total_hours'] += $record['total_hours']; @@ -739,6 +1058,9 @@ function tw_generate_summary_pdf($db, $conf, $langs, User $user, array $timeshee // EN: Prepare the title and metadata strings reused inside the header block. // FR: Prépare les libellés du titre et des métadonnées réemployés dans l'entête. $headerTitle = $langs->trans('TimesheetWeekSummaryTitle'); + // EN: No status badge is required for summary reports, keep the slot empty. + // FR: Aucun badge de statut n'est nécessaire pour les rapports de synthèse, on laisse l'emplacement vide. + $headerStatus = ''; // EN: Build the human-readable ISO week range displayed under the title. // FR: Construit la plage de semaines ISO lisible affichée sous le titre. $headerWeekRangeLabel = $langs->trans('TimesheetWeekSummaryHeaderWeekRange'); @@ -768,8 +1090,8 @@ function tw_generate_summary_pdf($db, $conf, $langs, User $user, array $timeshee // EN: Delegate header rendering to TCPDF so every page created by the engine receives it automatically. // FR: Confie le rendu de l'entête à TCPDF afin que chaque page créée par le moteur le reçoive automatiquement. $pdf->setPrintHeader(true); - $pdf->setHeaderCallback(function ($pdfInstance) use ($langs, $conf, $margeGauche, $margeHaute, &$headerState, $headerTitle, $headerWeekRange, $headerSubtitle) { - $headerState['value'] = tw_pdf_draw_header($pdfInstance, $langs, $conf, $margeGauche, $margeHaute, $headerTitle, $headerWeekRange, $headerSubtitle); + $pdf->setHeaderCallback(function ($pdfInstance) use ($langs, $conf, $margeGauche, $margeHaute, &$headerState, $headerTitle, $headerStatus, $headerWeekRange, $headerSubtitle) { + $headerState['value'] = tw_pdf_draw_header($pdfInstance, $langs, $conf, $margeGauche, $margeHaute, $headerTitle, $headerStatus, $headerWeekRange, $headerSubtitle); $headerState['automatic'] = true; }); // EN: Delegate footer drawing to TCPDF to guarantee presence on automatic page breaks. @@ -799,7 +1121,7 @@ function tw_generate_summary_pdf($db, $conf, $langs, User $user, array $timeshee $pdf->SetSubject(tw_pdf_normalize_string($langs->trans('TimesheetWeekSummaryTitle'))); $pdf->SetFont(pdf_getPDFFont($langs), '', $defaultFontSize); $pdf->Open(); - $contentTop = tw_pdf_add_landscape_page($pdf, $langs, $conf, $margeGauche, $margeHaute, $margeDroite, $margeBasse, $headerState, $autoPageBreakMargin, $headerTitle, $headerWeekRange, $headerSubtitle); + $contentTop = tw_pdf_add_landscape_page($pdf, $langs, $conf, $margeGauche, $margeHaute, $margeDroite, $margeBasse, $headerState, $autoPageBreakMargin, $headerTitle, $headerStatus, $headerWeekRange, $headerSubtitle); $pageHeight = $pdf->getPageHeight(); // EN: Offset the cursor slightly below the header to leave breathing space before the content. @@ -809,81 +1131,136 @@ function tw_generate_summary_pdf($db, $conf, $langs, User $user, array $timeshee $pdf->SetFont('', '', $defaultFontSize); $pdf->SetTextColor(0, 0, 0); - $columnWidthWeights = array(14, 20, 20, 16, 18, 18, 14, 11, 11, 11, 11, 11, 24); - $columnWidths = $columnWidthWeights; $usableWidth = $pdf->getPageWidth() - $margeGauche - $margeDroite; - $widthSum = array_sum($columnWidthWeights); - if ($widthSum > 0 && $usableWidth > 0) { - // EN: Scale each column proportionally so the table spans the full printable width. - // FR: Redimensionne chaque colonne proportionnellement pour couvrir toute la largeur imprimable. - foreach ($columnWidthWeights as $index => $weight) { - $columnWidths[$index] = ($weight / $widthSum) * $usableWidth; - } - } - $columnLabels = array( - $langs->trans('TimesheetWeekSummaryColumnWeek'), - $langs->trans('TimesheetWeekSummaryColumnStart'), - $langs->trans('TimesheetWeekSummaryColumnEnd'), - $langs->trans('TimesheetWeekSummaryColumnTotalHours'), - $langs->trans('TimesheetWeekSummaryColumnContractHours'), - $langs->trans('TimesheetWeekSummaryColumnOvertime'), - $langs->trans('TimesheetWeekSummaryColumnMeals'), - $langs->trans('TimesheetWeekSummaryColumnZone1'), - $langs->trans('TimesheetWeekSummaryColumnZone2'), - $langs->trans('TimesheetWeekSummaryColumnZone3'), - $langs->trans('TimesheetWeekSummaryColumnZone4'), - $langs->trans('TimesheetWeekSummaryColumnZone5'), - $langs->trans('TimesheetWeekSummaryColumnApprovedBy') - ); - $lineHeight = 6; + // EN: Describe the standard hour-based layout used for classic employees. + // FR: Décrit la mise en page standard en heures utilisée pour les salariés classiques. + $hoursColumnConfig = array( + 'weights' => array(14, 20, 20, 16, 18, 18, 14, 11, 11, 11, 11, 11, 24), + 'labels' => array( + $langs->trans('TimesheetWeekSummaryColumnWeek'), + $langs->trans('TimesheetWeekSummaryColumnStart'), + $langs->trans('TimesheetWeekSummaryColumnEnd'), + $langs->trans('TimesheetWeekSummaryColumnTotalHours'), + $langs->trans('TimesheetWeekSummaryColumnContractHours'), + $langs->trans('TimesheetWeekSummaryColumnOvertime'), + $langs->trans('TimesheetWeekSummaryColumnMeals'), + $langs->trans('TimesheetWeekSummaryColumnZone1'), + $langs->trans('TimesheetWeekSummaryColumnZone2'), + $langs->trans('TimesheetWeekSummaryColumnZone3'), + $langs->trans('TimesheetWeekSummaryColumnZone4'), + $langs->trans('TimesheetWeekSummaryColumnZone5'), + $langs->trans('TimesheetWeekSummaryColumnStatus') + ), + 'row_alignments' => array('C', 'C', 'C', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'L'), + 'totals_alignments' => array('L', 'C', 'C', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'L'), + 'html_flags' => array(false, false, false, false, false, false, false, false, false, false, false, false, true) + ); + + $dailyColumnConfig = array( + 'weights' => array(16, 20, 20, 18, 18, 28), + 'labels' => array( + $langs->trans('TimesheetWeekSummaryColumnWeek'), + $langs->trans('TimesheetWeekSummaryColumnStart'), + $langs->trans('TimesheetWeekSummaryColumnEnd'), + $langs->trans('TimesheetWeekSummaryColumnTotalDays'), + $langs->trans('TimesheetWeekSummaryColumnContractDays'), + $langs->trans('TimesheetWeekSummaryColumnStatus') + ), + 'row_alignments' => array('C', 'C', 'C', 'R', 'R', 'L'), + 'totals_alignments' => array('L', 'C', 'C', 'R', 'R', 'L'), + 'html_flags' => array(false, false, false, false, false, true) + ); + +$lineHeight = 6; + $hoursPerDay = 8.0; $isFirstUser = true; foreach ($sortedUsers as $userSummary) { - $userObject = $userSummary['user']; - $records = $userSummary['records']; - $totals = $userSummary['totals']; - - $recordRows = array(); - foreach ($records as $record) { - $recordRows[] = array( - sprintf('%d / %d', $record['week'], $record['year']), - dol_print_date($record['week_start']->getTimestamp(), 'day'), - dol_print_date($record['week_end']->getTimestamp(), 'day'), - tw_format_hours_decimal($record['total_hours']), - tw_format_hours_decimal($record['contract_hours']), - tw_format_hours_decimal($record['overtime_hours']), - (string) $record['meal_count'], - (string) $record['zone1_count'], - (string) $record['zone2_count'], - (string) $record['zone3_count'], - (string) $record['zone4_count'], - (string) $record['zone5_count'], - $record['approved_by'] - ); - } - - $totalsRow = array( - $langs->trans('TimesheetWeekSummaryTotalsLabel'), - '', - '', - tw_format_hours_decimal($totals['total_hours']), - tw_format_hours_decimal($totals['contract_hours']), - tw_format_hours_decimal($totals['overtime_hours']), - (string) $totals['meal_count'], - (string) $totals['zone1_count'], - (string) $totals['zone2_count'], - (string) $totals['zone3_count'], - (string) $totals['zone4_count'], - (string) $totals['zone5_count'], - '' - ); - -$tableHeight = tw_pdf_estimate_user_table_height($pdf, $langs, $userObject, $columnWidths, $columnLabels, $recordRows, $totalsRow, $lineHeight, $usableWidth); + $userObject = $userSummary['user']; + $records = $userSummary['records']; + $totals = $userSummary['totals']; + $isDailyRateEmployee = !empty($userSummary['is_daily_rate']); + $columnConfig = $isDailyRateEmployee ? $dailyColumnConfig : $hoursColumnConfig; + $columnLabels = $columnConfig['labels']; + $columnWidths = tw_pdf_compute_column_widths($columnConfig['weights'], $usableWidth); + $rowAlignments = $columnConfig['row_alignments']; + $totalsAlignments = $columnConfig['totals_alignments']; + + $recordRows = array(); + $recordLineHeights = array(); + // EN: Keep track of each row height to share the same baseline during layout estimation and rendering. + // FR: Suit la hauteur de chaque ligne pour partager la même base lors de l'estimation et du rendu de la mise en page. + foreach ($records as $recordIndex => $record) { + $statusCell = tw_pdf_compose_status_cell($langs, $record['status'], $record['approved_by'], $record['sealed_by']); + // EN: Double the base line height when the status is approved or sealed to provide extra vertical space. + // FR: Double la hauteur de ligne de base lorsque le statut est approuvé ou scellé pour offrir plus d'espace vertical. + $isDoubleHeightStatus = in_array((int) $record['status'], array(TimesheetWeek::STATUS_APPROVED, TimesheetWeek::STATUS_SEALED), true); + $recordLineHeights[$recordIndex] = $lineHeight * ($isDoubleHeightStatus ? 2 : 1); + if ($isDailyRateEmployee) { + $recordRows[] = array( + sprintf('%d / %d', $record['week'], $record['year']), + dol_print_date($record['week_start']->getTimestamp(), 'day'), + dol_print_date($record['week_end']->getTimestamp(), 'day'), + tw_format_days_decimal(($record['total_hours'] / $hoursPerDay), $langs), + tw_format_days_decimal(($record['contract_hours'] / $hoursPerDay), $langs), + $statusCell + ); + } else { + $recordRows[] = array( + sprintf('%d / %d', $record['week'], $record['year']), + dol_print_date($record['week_start']->getTimestamp(), 'day'), + dol_print_date($record['week_end']->getTimestamp(), 'day'), + tw_format_hours_decimal($record['total_hours']), + tw_format_hours_decimal($record['contract_hours']), + tw_format_hours_decimal($record['overtime_hours']), + (string) $record['meal_count'], + (string) $record['zone1_count'], + (string) $record['zone2_count'], + (string) $record['zone3_count'], + (string) $record['zone4_count'], + (string) $record['zone5_count'], + $statusCell + ); + } + } + + if ($isDailyRateEmployee) { + $totalsRow = array( + $langs->trans('TimesheetWeekSummaryTotalsLabel'), + '', + '', + tw_format_days_decimal(($totals['total_hours'] / $hoursPerDay), $langs), + tw_format_days_decimal(($totals['contract_hours'] / $hoursPerDay), $langs), + '' + ); + } else { + $totalsRow = array( + $langs->trans('TimesheetWeekSummaryTotalsLabel'), + '', + '', + tw_format_hours_decimal($totals['total_hours']), + tw_format_hours_decimal($totals['contract_hours']), + tw_format_hours_decimal($totals['overtime_hours']), + (string) $totals['meal_count'], + (string) $totals['zone1_count'], + (string) $totals['zone2_count'], + (string) $totals['zone3_count'], + (string) $totals['zone4_count'], + (string) $totals['zone5_count'], + '' + ); + } + + $htmlFlags = $columnConfig['html_flags'] ?? array(); + + // EN: Anticipate the dynamic line height of each record to size the table and manage page breaks accurately. + // FR: Anticipe la hauteur de ligne dynamique de chaque enregistrement pour dimensionner le tableau et gérer précisément les sauts de page. + $tableHeight = tw_pdf_estimate_user_table_height($pdf, $langs, $userObject, $columnWidths, $columnLabels, $recordRows, $totalsRow, $lineHeight, $usableWidth, $htmlFlags, $recordLineHeights); $spacingBeforeTable = $isFirstUser ? 0 : 4; $availableHeight = ($pageHeight - ($margeBasse + $footerReserve)) - $pdf->GetY(); if (($spacingBeforeTable + $tableHeight) > $availableHeight) { - $contentTop = tw_pdf_add_landscape_page($pdf, $langs, $conf, $margeGauche, $margeHaute, $margeDroite, $margeBasse, $headerState, $autoPageBreakMargin, $headerTitle, $headerWeekRange, $headerSubtitle); + $contentTop = tw_pdf_add_landscape_page($pdf, $langs, $conf, $margeGauche, $margeHaute, $margeDroite, $margeBasse, $headerState, $autoPageBreakMargin, $headerTitle, $headerStatus, $headerWeekRange, $headerSubtitle); $pageHeight = $pdf->getPageHeight(); $availableHeight = ($pageHeight - ($margeBasse + $footerReserve)) - $pdf->GetY(); if (($spacingBeforeTable + $tableHeight) > $availableHeight) { @@ -913,31 +1290,37 @@ function tw_generate_summary_pdf($db, $conf, $langs, User $user, array $timeshee $pdf->SetX($margeGauche); // EN: Draw the header row with uniform dimensions for every column. // FR: Dessine la ligne d'entête avec des dimensions uniformes pour chaque colonne. - tw_pdf_render_row($pdf, $columnWidths, $columnLabels, $lineHeight, array( + tw_pdf_render_row($pdf, $columnWidths, $columnLabels, $lineHeight, array( 'fill' => true, - 'alignments' => array_fill(0, count($columnLabels), 'C') - )); - + 'alignments' => array_fill(0, count($columnLabels), 'C'), + 'html_flags' => $htmlFlags + )); + $pdf->SetFont('', '', $defaultFontSize - 1); - $alignments = array('C', 'C', 'C', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'L'); - // EN: Render each data row while keeping consistent heights across the table. + $alignments = $rowAlignments; + // EN: Render each data row while keeping consistent heights across the table. // FR: Affiche chaque ligne de données en conservant des hauteurs cohérentes dans le tableau. - foreach ($recordRows as $rowData) { + foreach ($recordRows as $rowIndex => $rowData) { + // EN: Reuse the dynamic line height computed earlier to align rendering with the layout estimation. + // FR: Réutilise la hauteur de ligne dynamique calculée précédemment pour aligner le rendu sur l'estimation de mise en page. + $currentLineHeight = $recordLineHeights[$rowIndex] ?? $lineHeight; $pdf->SetX($margeGauche); - tw_pdf_render_row($pdf, $columnWidths, $rowData, $lineHeight, array( - 'alignments' => $alignments + tw_pdf_render_row($pdf, $columnWidths, $rowData, $currentLineHeight, array( + 'alignments' => $alignments, + 'html_flags' => $htmlFlags )); } - + $pdf->SetFont('', 'B', $defaultFontSize - 1); $pdf->SetX($margeGauche); // EN: Print the totals row with left-aligned label and right-aligned figures. // FR: Imprime la ligne de totaux avec libellé aligné à gauche et chiffres alignés à droite. tw_pdf_render_row($pdf, $columnWidths, $totalsRow, $lineHeight, array( - 'alignments' => array('L', 'C', 'C', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'L') + 'alignments' => $totalsAlignments, + 'html_flags' => $htmlFlags )); - - } + + } $pdf->Output($filepath, 'F'); return array( diff --git a/sql/llx_timesheet_week.sql b/sql/llx_timesheet_week.sql index 0a79837..9e47caf 100644 --- a/sql/llx_timesheet_week.sql +++ b/sql/llx_timesheet_week.sql @@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS llx_timesheet_week ( year SMALLINT NOT NULL, week SMALLINT NOT NULL, status SMALLINT NOT NULL DEFAULT 0, + model_pdf VARCHAR(255) DEFAULT NULL, + -- EN: Stores the preferred PDF model per sheet / FR: Stocke le modèle PDF préféré par feuille note TEXT, date_creation DATETIME DEFAULT CURRENT_TIMESTAMP, date_validation DATETIME DEFAULT NULL, diff --git a/sql/update_all.sql b/sql/update_all.sql index 8b13789..102cde8 100644 --- a/sql/update_all.sql +++ b/sql/update_all.sql @@ -1 +1,2 @@ - +-- EN: Add the PDF model column to existing tables / FR: Ajoute la colonne de modèle PDF aux tables existantes. +ALTER TABLE llx_timesheet_week ADD COLUMN IF NOT EXISTS model_pdf VARCHAR(255) DEFAULT NULL AFTER status; diff --git a/timesheetweek_card.php b/timesheetweek_card.php index 7eeeabe..33acaac 100644 --- a/timesheetweek_card.php +++ b/timesheetweek_card.php @@ -30,6 +30,12 @@ // ---- Requires ---- require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php'; require_once DOL_DOCUMENT_ROOT.'/core/class/html.formmail.class.php'; +require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php'; +// EN: Load the PDF model definitions to reuse Dolibarr's filtering helpers. +// FR: Charge les définitions de modèles PDF pour réutiliser les filtres de Dolibarr. +dol_include_once('/timesheetweek/core/modules/timesheetweek/modules_timesheetweek.php'); require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php'; require_once DOL_DOCUMENT_ROOT.'/projet/class/project.class.php'; require_once DOL_DOCUMENT_ROOT.'/projet/class/task.class.php'; @@ -43,9 +49,14 @@ $langs->loadLangs(array('timesheetweek@timesheetweek','projects','users','other')); // ---- Params ---- -$id = GETPOSTINT('id'); -$action = GETPOST('action', 'aZ09'); +$id = GETPOSTINT('id'); +$action = GETPOST('action', 'aZ09'); $confirm = GETPOST('confirm', 'alpha'); +// EN: Retrieve PDF display flags to align with Dolibarr's document generator options. +// FR: Récupère les indicateurs d'affichage PDF pour s'aligner sur les options du générateur de documents Dolibarr. +$hidedetails = GETPOSTISSET('hidedetails') ? GETPOSTINT('hidedetails') : (getDolGlobalInt('MAIN_GENERATE_DOCUMENTS_HIDE_DETAILS') ? 1 : 0); +$hidedesc = GETPOSTISSET('hidedesc') ? GETPOSTINT('hidedesc') : (getDolGlobalInt('MAIN_GENERATE_DOCUMENTS_HIDE_DESC') ? 1 : 0); +$hideref = GETPOSTISSET('hideref') ? GETPOSTINT('hideref') : (getDolGlobalInt('MAIN_GENERATE_DOCUMENTS_HIDE_REF') ? 1 : 0); // ---- Init ---- $object = new TimesheetWeek($db); @@ -142,6 +153,52 @@ function tw_get_employee_with_daily_rate(DoliDB $db, $userId) return $result; } +/** + * EN: Retrieve the list of activated PDF models for the module with entity scoping. + * FR: Récupère la liste des modèles PDF activés pour le module en respectant l'entité. + * + * @param DoliDB $db Database handler / Gestionnaire de base de données + * @return array Enabled models keyed by code / Modèles actifs indexés par code + */ +function tw_get_enabled_pdf_models(DoliDB $db) +{ + // EN: Ask the module manager for the enabled templates of TimesheetWeek. + // FR: Demande au gestionnaire du module les modèles activés de TimesheetWeek. + $models = ModelePDFTimesheetWeek::liste_modeles($db); + if (!is_array($models) || empty($models)) { + return array(); + } + + // EN: Remove document definitions that advertise an ODT extension to keep PDF-only generation. + // FR: Supprime les définitions de documents qui annoncent une extension ODT pour conserver une génération uniquement PDF. + foreach ($models as $code => $modelInfo) { + $type = ''; + $extension = ''; + if (is_array($modelInfo)) { + if (!empty($modelInfo['type'])) { + $type = strtolower((string) $modelInfo['type']); + } + if (!empty($modelInfo['extension'])) { + $extension = strtolower((string) $modelInfo['extension']); + } + } + $codeLower = strtolower((string) $code); + if ($type !== '' && $type !== 'pdf') { + unset($models[$code]); + continue; + } + if ($extension !== '' && $extension !== 'pdf') { + unset($models[$code]); + continue; + } + if (substr($codeLower, -4) === '_odt' || substr($codeLower, -4) === '.odt') { + unset($models[$code]); + } + } + + return $models; +} + // ---- Permissions (nouveau modèle) ---- $permRead = $user->hasRight('timesheetweek','read'); $permReadChild = $user->hasRight('timesheetweek','readChild'); @@ -167,6 +224,10 @@ function tw_get_employee_with_daily_rate(DoliDB $db, $userId) $permWriteAny = ($permWrite || $permWriteChild || $permWriteAll); $permDeleteAny = ($permDelete || $permDeleteChild || $permDeleteAll); +// EN: Initialise the document creation permission flag to prevent undefined notices later. +// FR: Initialise l'indicateur de permission de création documentaire pour éviter les notices plus tard. +$permissiontoadd = 0; + /** helpers permissions **/ function tw_can_validate_timesheet( TimesheetWeek $o, @@ -222,7 +283,7 @@ function tw_can_validate_timesheet( $canSendMail = false; if ($object->id > 0) { $canSendMail = tw_can_act_on_user($object->fk_user, $permRead, $permReadChild, $permReadAll, $user) - || tw_can_validate_timesheet($object, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll); + || tw_can_validate_timesheet($object, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll); } // ----------------- Inline edits (crayons) ----------------- @@ -267,7 +328,7 @@ function tw_can_validate_timesheet( $res = $object->update($user); if ($res > 0) setEventMessages($langs->trans("RecordModified"), null, 'mesgs'); else setEventMessages($object->error, $object->errors, 'errors'); - } else { + } else { setEventMessages($langs->trans("InvalidWeekFormat"), null, 'errors'); } $action = ''; @@ -280,7 +341,7 @@ function tw_can_validate_timesheet( } $canSendMail = tw_can_act_on_user($object->fk_user, $permRead, $permReadChild, $permReadAll, $user) - || tw_can_validate_timesheet($object, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll); + || tw_can_validate_timesheet($object, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll); if (!$canSendMail) { accessforbidden(); } @@ -363,7 +424,7 @@ function tw_can_validate_timesheet( // Validateur par défaut = manager du salarié cible si non fourni if ($fk_user_valid > 0) { $object->fk_user_valid = $fk_user_valid; - } else { + } else { $uTmp = new User($db); $uTmp->fetch($targetUserId); $object->fk_user_valid = !empty($uTmp->fk_user) ? (int)$uTmp->fk_user : null; @@ -373,7 +434,7 @@ function tw_can_validate_timesheet( if (preg_match('/^(\d{4})-W(\d{2})$/', $weekyear, $m)) { $object->year = (int) $m[1]; $object->week = (int) $m[2]; - } else { + } else { setEventMessages($langs->trans("InvalidWeekFormat"), null, 'errors'); $action = 'create'; } @@ -402,7 +463,7 @@ function tw_can_validate_timesheet( if ($res > 0) { header("Location: ".$_SERVER["PHP_SELF"]."?id=".$object->id); exit; - } else { + } else { setEventMessages($object->error, $object->errors, 'errors'); $action = 'create'; } @@ -496,7 +557,7 @@ function tw_can_validate_timesheet( header("Location: ".$_SERVER["PHP_SELF"]."?id=".$object->id); exit; } - } else { + } else { // EN: Store the line within the same entity as its parent timesheet to stay consistent. // FR: Enregistre la ligne dans la même entité que sa feuille parente pour rester cohérent. $sqlIns = "INSERT INTO ".MAIN_DB_PREFIX."timesheet_week_line (entity, fk_timesheet_week, fk_task, day_date, hours, daily_rate, zone, meal) VALUES (". @@ -517,7 +578,7 @@ function tw_can_validate_timesheet( } } $processed++; - } else { + } else { if ($existingId > 0) { // EN: Delete the line only if it belongs to an allowed entity scope. // FR: Supprime la ligne uniquement si elle appartient à une entité autorisée. @@ -762,7 +823,7 @@ function tw_can_validate_timesheet( // On autorise la suppression si l'utilisateur a les droits (own/child/all), // ou s'il a des droits validate* (validateur), quelque soit le statut $canDelete = tw_can_act_on_user($object->fk_user, $permDelete, $permDeleteChild, $permDeleteAll, $user) - || tw_can_validate_timesheet($object, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll); + || tw_can_validate_timesheet($object, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll); if (!$canDelete) accessforbidden(); @@ -808,17 +869,109 @@ function tw_can_validate_timesheet( } $param = array( - 'sendcontext' => 'timesheetweek', - 'returnurl' => dol_buildpath('/timesheetweek/timesheetweek_card.php', 1).'?id='.$object->id, - 'models' => $modelmail, - 'trackid' => $trackid, + 'sendcontext' => 'timesheetweek', + 'returnurl' => dol_buildpath('/timesheetweek/timesheetweek_card.php', 1).'?id='.$object->id, + 'models' => $modelmail, + 'trackid' => $trackid, ); + include DOL_DOCUMENT_ROOT.'/core/actions_printing.inc.php'; include DOL_DOCUMENT_ROOT.'/core/actions_sendmails.inc.php'; -} + + // EN: Prepare PDF generation permissions once the object is fully loaded. + // FR: Prépare les permissions de génération PDF une fois l'objet complètement chargé. + $entityIdForDocs = !empty($object->entity) ? (int) $object->entity : (int) $conf->entity; + $baseTimesheetDir = ''; + if (!empty($conf->timesheetweek->multidir_output[$entityIdForDocs])) { + $baseTimesheetDir = $conf->timesheetweek->multidir_output[$entityIdForDocs]; + } elseif (!empty($conf->timesheetweek->dir_output)) { + $baseTimesheetDir = $conf->timesheetweek->dir_output; + } else { + $baseTimesheetDir = DOL_DATA_ROOT.'/timesheetweek'; + } + $upload_dir = $baseTimesheetDir.'/timesheetweek/'.dol_sanitizeFileName($object->ref); + + // EN: Authorise document creation to employees or managers allowed to act on the sheet. + // FR: Autorise la création de documents aux salariés ou responsables habilités à agir sur la feuille. + $permissiontoadd = ( + tw_can_act_on_user($object->fk_user, $permWrite, $permWriteChild, $permWriteAll, $user) + || tw_can_validate_timesheet($object, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll) + || !empty($user->admin) + ); + + if ($permissiontoadd && GETPOST('model', 'alpha')) { + // EN: Allow PDF model changes only to users authorised to act on the sheet. + // FR: Autorise le changement de modèle PDF uniquement aux utilisateurs habilités à agir sur la feuille. + // EN: Allow administrators to switch the PDF model directly from the card view. + // FR: Permet aux administrateurs de changer le modèle PDF directement depuis la fiche. + $object->setDocModel($user, GETPOST('model', 'alpha')); + $object->model_pdf = GETPOST('model', 'alpha'); + } + + if (empty($object->model_pdf)) { + // EN: Default to the module configuration when no PDF model has been selected yet. + // FR: Bascule sur la configuration du module lorsqu'aucun modèle PDF n'est encore sélectionné. + $object->model_pdf = getDolGlobalString('TIMESHEETWEEK_ADDON_PDF', 'standard_timesheetweek'); + } + + $moreparams = array( + 'hidedetails' => $hidedetails, + 'hidedesc' => $hidedesc, + 'hideref' => $hideref, + ); + + include DOL_DOCUMENT_ROOT.'/core/actions_builddoc.inc.php'; + + // EN: Manage attachment upload and deletion with Dolibarr helper to keep buttons functional. + // FR: Gère l'envoi et la suppression des pièces jointes avec l'aide Dolibarr pour garder les boutons fonctionnels. + if ($action === 'remove_file') { + if (empty($permissiontoadd)) { + // EN: Block removal requests when the user lacks the document permission. + // FR: Bloque les demandes de suppression lorsque l'utilisateur n'a pas la permission sur le document. + setEventMessages($langs->trans('NotEnoughPermissions'), null, 'errors'); + $action = ''; + } else { + // EN: Retrieve the requested filename and default to the generated PDF when missing. + // FR: Récupère le nom de fichier demandé et prend par défaut le PDF généré lorsqu'il est absent. + $requestedFile = GETPOST('file', 'alphanohtml', 0, null, null, 1); + if ($requestedFile === '' && !empty($object->ref)) { + $requestedFile = dol_sanitizeFileName($object->ref).'.pdf'; + } + // EN: Reduce the requested file to its basename to match Dolibarr's document deletion URL. + // FR: Réduit le fichier demandé à son basename pour respecter l'URL de suppression Dolibarr. + $requestedFile = dol_sanitizeFileName(basename((string) $requestedFile)); + if ($requestedFile !== '') { + // EN: Store the sanitized filename for the confirmation dialog and Dolibarr workflow. + // FR: Stocke le nom de fichier assaini pour la boîte de confirmation et le flux Dolibarr. + $_GET['file'] = $requestedFile; + $_REQUEST['file'] = $requestedFile; + $_GET['urlfile'] = $requestedFile; + $_REQUEST['urlfile'] = $requestedFile; + $action = 'deletefile'; + } else { + // EN: Warn the user when no filename is provided in the deletion URL. + // FR: Avertit l'utilisateur lorsqu'aucun nom de fichier n'est fourni dans l'URL de suppression. + setEventMessages($langs->trans('ErrorFieldRequired', $langs->transnoentitiesnoconv('File')), null, 'errors'); + $action = ''; + } + } + } + + if (!empty($upload_dir)) { + // EN: Mirror the dedicated documents tab behaviour for permissions and storage scope. + // FR: Reproduit le comportement de l'onglet documents pour les permissions et le périmètre de stockage. + $modulepart = 'timesheetweek'; + $permissiontoread = $permReadAny ? 1 : 0; + $permissiontoadd = $permissiontoadd ? 1 : 0; + $permissiontodownload = $permissiontoread; + $permissiontodelete = $permissiontoadd; + include DOL_DOCUMENT_ROOT.'/core/actions_linkedfiles.inc.php'; + } + } // ----------------- View ----------------- $form = new Form($db); +$formfile = new FormFile($db); $title = $langs->trans("TimesheetWeek"); // EN: Render the header only after permission guards to avoid duplicated menus on errors. @@ -952,43 +1105,75 @@ function updateWeekRange(){var v=$('#weekyear').val();var p=parseYearWeek(v);if( dol_banner_tab($object, 'ref', $linkback, 1, 'ref', 'ref', $morehtmlref, '', $morehtmlright, '', $morehtmlstatus); print timesheetweekRenderStatusBadgeCleanup(); - // Confirm modals - if ($action === 'delete') { - $formconfirm = $form->formconfirm( - $_SERVER["PHP_SELF"].'?id='.$object->id, - $langs->trans('Delete'), - $langs->trans('ConfirmDeleteObject'), - 'confirm_delete', - array(), - 'yes', - 1 - ); - print $formconfirm; - } - if ($action === 'ask_validate') { - $formconfirm = $form->formconfirm( - $_SERVER["PHP_SELF"].'?id='.$object->id, - ($langs->trans("Approve")!='Approve'?$langs->trans("Approve"):'Approuver'), - $langs->trans('ConfirmValidate'), - 'confirm_validate', - array(), - 'yes', - 1 - ); - print $formconfirm; - } - if ($action === 'ask_refuse') { - $formconfirm = $form->formconfirm( - $_SERVER["PHP_SELF"].'?id='.$object->id, - $langs->trans("Refuse"), - $langs->trans('ConfirmRefuse'), - 'confirm_refuse', - array(), - 'yes', - 1 - ); - print $formconfirm; - } + // Confirm modals + if ($action === 'deletefile') { + // EN: Ask for confirmation before delegating the deletion to Dolibarr core. + // FR: Demande une confirmation avant de déléguer la suppression au cœur de Dolibarr. + $urlfileForConfirm = GETPOST('urlfile', 'alphanohtml', 0, null, null, 1); + // EN: Keep track of the file parameter required by the document workflow. + // FR: Conserve le paramètre file requis par le workflow documentaire. + $confirmFileParam = GETPOST('file', 'alphanohtml', 0, null, null, 1); + $linkIdForConfirm = GETPOSTINT('linkid'); + $confirmUrl = $_SERVER["PHP_SELF"].'?id='.$object->id; + if ($urlfileForConfirm !== '') { + $confirmUrl .= '&urlfile='.urlencode($urlfileForConfirm); + } + if ($confirmFileParam === '' && !empty($object->ref)) { + $confirmFileParam = dol_sanitizeFileName($object->ref).'.pdf'; + } + if ($confirmFileParam !== '') { + $confirmUrl .= '&file='.urlencode($confirmFileParam); + } + if ($linkIdForConfirm > 0) { + $confirmUrl .= '&linkid='.$linkIdForConfirm; + } + $formconfirm = $form->formconfirm( + $confirmUrl, + $langs->trans('DeleteFile'), + $langs->trans('ConfirmDeleteFile'), + 'confirm_deletefile', + array(), + 'yes', + 1 + ); + print $formconfirm; + } + if ($action === 'delete') { + $formconfirm = $form->formconfirm( + $_SERVER["PHP_SELF"].'?id='.$object->id, + $langs->trans('Delete'), + $langs->trans('ConfirmDeleteObject'), + 'confirm_delete', + array(), + 'yes', + 1 + ); + print $formconfirm; + } + if ($action === 'ask_validate') { + $formconfirm = $form->formconfirm( + $_SERVER["PHP_SELF"].'?id='.$object->id, + ($langs->trans("Approve")!='Approve'?$langs->trans("Approve"):'Approuver'), + $langs->trans('ConfirmValidate'), + 'confirm_validate', + array(), + 'yes', + 1 + ); + print $formconfirm; + } + if ($action === 'ask_refuse') { + $formconfirm = $form->formconfirm( + $_SERVER["PHP_SELF"].'?id='.$object->id, + $langs->trans("Refuse"), + $langs->trans('ConfirmRefuse'), + 'confirm_refuse', + array(), + 'yes', + 1 + ); + print $formconfirm; + } echo '
'; + echo ''; echo ''; // fichecenter @@ -1311,7 +1496,7 @@ function updateWeekRange(){var v=$('#weekyear').val();var p=parseYearWeek(v);if( if (!empty($startRaw)) { if (is_numeric($startRaw)) { $startTs = (int)$startRaw; - } else { + } else { $startTs = strtotime($startRaw); if ($startTs === false) $startTs = null; } @@ -1319,7 +1504,7 @@ function updateWeekRange(){var v=$('#weekyear').val();var p=parseYearWeek(v);if( if (!empty($endRaw)) { if (is_numeric($endRaw)) { $endTs = (int)$endRaw; - } else { + } else { $endTs = strtotime($endRaw); if ($endTs === false) $endTs = null; } @@ -1503,7 +1688,7 @@ function updateWeekRange(){var v=$('#weekyear').val();var p=parseYearWeek(v);if( // FR: Centre les totaux de tâche pour les garder alignés avec les autres valeurs centrées. if ($isDailyRateEmployee) { echo ''.tw_format_days(($rowTotal > 0 ? ($rowTotal / 8.0) : 0.0), $langs).''; -} else { + } else { echo ''.formatHours($rowTotal).''; } echo ''; @@ -1523,7 +1708,7 @@ function updateWeekRange(){var v=$('#weekyear').val();var p=parseYearWeek(v);if( } echo ''.tw_format_days($grandDays, $langs).''; echo ''; -} else { + } else { echo ''; // EN: Center overall totals and daily sums for consistent middle alignment. // FR: Centre les totaux généraux et journaliers pour un alignement médian homogène. @@ -1650,7 +1835,7 @@ function updateTotals(){ if(isDailyRateMode){ $(".meal-total").text('0'); -} else { + } else { var meals = $(".mealbox:checked").length; $(".meal-total").text(meals); var ot = grand - weeklyContract; if (ot < 0) ot = 0; @@ -1729,7 +1914,7 @@ function updateTotals(){ // Supprimer : brouillon OU soumis/approuvé/refusé si salarié (delete) ou validateur (validate*) ou all $canDelete = tw_can_act_on_user($object->fk_user, $permDelete, $permDeleteChild, $permDeleteAll, $user) - || tw_can_validate_timesheet($object, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll); + || tw_can_validate_timesheet($object, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll); if ($canDelete) { echo dolGetButtonAction('', $langs->trans("Delete"), 'delete', $_SERVER["PHP_SELF"].'?id='.$object->id.'&action=delete&token='.$token); } @@ -1737,7 +1922,139 @@ function updateTotals(){ echo ''; - if ($action === 'presend') { + if ($action !== 'presend') { + // EN: Mirror Dolibarr's document block so PDF tools appear consistently on the card. + // FR: Reproduit le bloc documentaire de Dolibarr pour afficher les outils PDF de manière cohérente sur la fiche. + print '
'; + print ''; + + // EN: Enable the document generation area (can be toggled by hooks if needed). + // FR: Active la zone de génération documentaire (peut être désactivée via des hooks si nécessaire). + $includedocgeneration = 1; + + if ($includedocgeneration) { + // EN: Build the target directories depending on the entity, falling back to Dolibarr defaults. + // FR: Construit les répertoires cibles selon l'entité en retombant sur les valeurs par défaut de Dolibarr. + $docEntityId = !empty($object->entity) ? (int) $object->entity : (int) $conf->entity; + $object->element = 'timesheetweek'; + $docRef = dol_sanitizeFileName($object->ref); + $entityOutput = !empty($conf->timesheetweek->multidir_output[$docEntityId]) ? $conf->timesheetweek->multidir_output[$docEntityId] : ''; + if (empty($entityOutput) && !empty($conf->timesheetweek->dir_output)) { + $entityOutput = $conf->timesheetweek->dir_output; + } + if (empty($entityOutput)) { + $entityOutput = DOL_DATA_ROOT.'/timesheetweek'; + } + $relativePath = $object->element.'/'.$docRef; + $filedir = rtrim($entityOutput, '/') . '/' . $relativePath; + $urlsource = $_SERVER['PHP_SELF'].'?id='.$object->id; + $genallowed = $permReadAny ? 1 : 0; + if ($permReadAny) { + // EN: Narrow the generation list to the PDF models enabled in the configuration. + // FR: Restreint la liste de génération aux modèles PDF activés dans la configuration. + $enabledDocModels = tw_get_enabled_pdf_models($db); + if (!empty($enabledDocModels)) { + $genallowed = $enabledDocModels; + } + } + $delallowed = $permissiontoadd ? 1 : 0; + + $documentHtml = $formfile->showdocuments( + 'timesheetweek:TimesheetWeek', + $relativePath, + $filedir, + $urlsource, + $genallowed, + $delallowed, + $object->model_pdf, + 1, + 0, + 0, + 28, + 0, + '', + '', + '', + $langs->defaultlang, + '', + $object, + 0, + 'remove_file', + '' + ); + if (!empty($documentHtml)) { + // EN: Sanitize each segment of the file path to preserve directories while avoiding traversal attacks. + // FR: Assainit chaque segment du chemin du fichier pour conserver les dossiers tout en évitant les attaques de traversée. + $documentHtml = preg_replace_callback('/([?&]file=)([^"&]+)/', function ($matches) { + $decoded = urldecode($matches[2]); + while (strpos($decoded, './') === 0) { + $decoded = substr($decoded, 2); + } + $decoded = ltrim($decoded, '/'); + $parts = preg_split('#/+?#', $decoded, -1, PREG_SPLIT_NO_EMPTY); + $sanitizedParts = array(); + foreach ($parts as $part) { + if ($part === '.' || $part === '..') { + continue; + } + $cleanPart = dol_sanitizeFileName($part); + if ($cleanPart === '') { + continue; + } + $sanitizedParts[] = $cleanPart; + } + if (empty($sanitizedParts)) { + $basename = dol_sanitizeFileName(basename($decoded)); + if ($basename !== '') { + $sanitizedParts[] = $basename; + } + } + $cleanPath = implode('/', $sanitizedParts); + return $matches[1].rawurlencode($cleanPath); + }, $documentHtml); + // EN: Force remove actions to keep only the document basename to match Dolibarr expectations. + // FR: Force les actions de suppression à conserver uniquement le nom de fichier pour correspondre aux attentes Dolibarr. + $documentHtml = preg_replace_callback('/(action=remove_file[^"<>]*[?&]file=)([^"&]+)/', function ($matches) { + $decoded = rawurldecode($matches[2]); + $normalized = str_replace('\\', '/', $decoded); + $basename = dol_sanitizeFileName(basename($normalized)); + if ($basename === '') { + return $matches[0]; + } + return $matches[1].rawurlencode($basename); + }, $documentHtml); + $documentBaseUrl = rtrim(dol_buildpath('/document.php', 2), '/'); + $decodeFlags = ENT_QUOTES; + if (defined('ENT_HTML5')) { + $decodeFlags |= ENT_HTML5; + } else { + $decodeFlags |= ENT_COMPAT; + } + $documentHtml = preg_replace_callback('/href="([^"<>]*document\.php[^"<>]*)"/', function ($matches) use ($documentBaseUrl, $decodeFlags) { + $originalHref = $matches[1]; + $decodedHref = html_entity_decode($originalHref, $decodeFlags, 'UTF-8'); + if (preg_match('#^[a-z]+://#i', $decodedHref)) { + return 'href="'.$originalHref.'"'; + } + $normalizedHref = $decodedHref; + if (strpos($normalizedHref, '/document.php') === 0) { + $normalizedHref = substr($normalizedHref, strlen('/document.php')); + } elseif (strpos($normalizedHref, 'document.php') === 0) { + $normalizedHref = substr($normalizedHref, strlen('document.php')); + } else { + return 'href="'.$originalHref.'"'; + } + $absoluteHref = $documentBaseUrl.$normalizedHref; + return 'href="'.dol_escape_htmltag($absoluteHref).'"'; + }, $documentHtml); + } + print $documentHtml; + } + + print '
'; + } + + if ($action === 'presend') { $formmail = new FormMail($db); $formmail->showform = 1; $formmail->withfrom = 1; diff --git a/timesheetweek_list.php b/timesheetweek_list.php index 560b1b3..2429261 100644 --- a/timesheetweek_list.php +++ b/timesheetweek_list.php @@ -50,6 +50,7 @@ } require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php'; require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php'; @@ -147,6 +148,64 @@ function tw_can_validate_timesheet_masslist( return false; } } + +if (!function_exists('tw_render_timesheet_pdf_dropdown')) { + /** + * EN: Render the dropdown to preview or download the sheet PDF directly from the list. + * FR: Affiche le menu déroulant pour prévisualiser ou télécharger le PDF de la feuille depuis la liste. + * + * @param stdClass $rowData Data row containing reference and entity information + * @param Conf $conf Dolibarr global configuration + * @param Translate $langs Translation handler + * @return string HTML markup of the dropdown or an empty string when the PDF is unavailable + */ + function tw_render_timesheet_pdf_dropdown($rowData, Conf $conf, Translate $langs) + { + // EN: Secure the reference before building file paths. + // FR: Sécurise la référence avant de construire les chemins de fichiers. + $docRef = dol_sanitizeFileName($rowData->ref ?? ''); + if ($docRef === '') { + return ''; + } + + // EN: Determine the entity and base output directory, compatible with Multicompany. + // FR: Détermine l'entité et le répertoire de sortie, compatible avec Multicompany. + $docEntityId = property_exists($rowData, 'entity') ? (int) $rowData->entity : (int) $conf->entity; + $entityOutputs = $conf->timesheetweek->multidir_output[$docEntityId] ?? null; + $baseOutput = $entityOutputs ? $entityOutputs : (!empty($conf->timesheetweek->dir_output) ? $conf->timesheetweek->dir_output : DOL_DATA_ROOT.'/timesheetweek'); + + // EN: Compose the expected PDF path and ensure the file exists before rendering actions. + // FR: Compose le chemin attendu du PDF et vérifie l'existence du fichier avant d'afficher les actions. + $relativeDir = 'timesheetweek/'.$docRef; + $pdfFilename = $docRef.'.pdf'; + $relativeFile = $relativeDir.'/'.$pdfFilename; + $absoluteFile = rtrim($baseOutput, '/').'/'.$relativeFile; + if (!dol_is_file($absoluteFile)) { + return ''; + } + + // EN: Build preview and download URLs exactly like Dolibarr's invoice dropdown for consistency. + // FR: Construit les URLs d'aperçu et de téléchargement comme le menu des factures Dolibarr pour rester cohérent. + $previewUrl = DOL_URL_ROOT.'/document.php?modulepart=timesheetweek&attachment=0&file='.urlencode($relativeFile).'&entity='.$docEntityId.'&permission=read'; + $downloadUrl = DOL_URL_ROOT.'/document.php?modulepart=timesheetweek&file='.urlencode($relativeFile).'&entity='.$docEntityId.'&attachment=1&permission=read'; + $previewLabel = dol_escape_htmltag($langs->trans('TimesheetWeekPreviewPdf')); + $downloadLabel = dol_escape_htmltag($langs->trans('TimesheetWeekDownloadPdf')); + $titleLabel = dol_escape_htmltag($pdfFilename); + + // EN: Return the dropdown markup prefixed with a non-breaking space to separate it from the reference. + // FR: Retourne le menu déroulant précédé d'une espace insécable pour le séparer de la référence. + $html = ' '; + + return $html; + } +} + /** * Params */ @@ -289,26 +348,26 @@ function tw_can_validate_timesheet_masslist( } $arrayfields += array( - 't.year' => array('label' => $langs->trans("Year"), 'checked' => 1), - 't.week' => array('label' => $langs->trans("Week"), 'checked' => 1), - 't.total_hours' => array('label' => $langs->trans("TotalHours"), 'checked' => 1), - 't.overtime_hours'=>array('label' => $langs->trans("Overtime"), 'checked' => 0), - // EN: Zone counters columns for list display. - // FR: Colonnes des compteurs de zones pour l'affichage de la liste. - 't.zone1_count' => array('label' => $langs->trans("Zone1Count"), 'checked' => 0), - 't.zone2_count' => array('label' => $langs->trans("Zone2Count"), 'checked' => 0), - 't.zone3_count' => array('label' => $langs->trans("Zone3Count"), 'checked' => 0), - 't.zone4_count' => array('label' => $langs->trans("Zone4Count"), 'checked' => 0), - 't.zone5_count' => array('label' => $langs->trans("Zone5Count"), 'checked' => 0), - // EN: Meal counter column for list display. - // FR: Colonne du compteur de paniers pour l'affichage de la liste. - 't.meal_count' => array('label' => $langs->trans("MealCount"), 'checked' => 0), - 't.date_creation'=> array('label' => $langs->trans("DateCreation"), 'checked' => 0), - // EN: Validation timestamp column to expose approval dates in the list. - // FR: Colonne de validation pour afficher les dates d'approbation dans la liste. - 't.date_validation'=> array('label' => $langs->trans("DateValidation"), 'checked' => 0), - 't.tms' => array('label' => $langs->trans("DateModificationShort"), 'checked' => 0), - 't.status' => array('label' => $langs->trans("Status"), 'checked' => 1), + 't.year' => array('label' => $langs->trans("Year"), 'checked' => 1), + 't.week' => array('label' => $langs->trans("Week"), 'checked' => 1), + 't.total_hours' => array('label' => $langs->trans("TotalHours"), 'checked' => 1), + 't.overtime_hours'=>array('label' => $langs->trans("Overtime"), 'checked' => 0), + // EN: Zone counters columns for list display. + // FR: Colonnes des compteurs de zones pour l'affichage de la liste. + 't.zone1_count' => array('label' => $langs->trans("Zone1Count"), 'checked' => 0), + 't.zone2_count' => array('label' => $langs->trans("Zone2Count"), 'checked' => 0), + 't.zone3_count' => array('label' => $langs->trans("Zone3Count"), 'checked' => 0), + 't.zone4_count' => array('label' => $langs->trans("Zone4Count"), 'checked' => 0), + 't.zone5_count' => array('label' => $langs->trans("Zone5Count"), 'checked' => 0), + // EN: Meal counter column for list display. + // FR: Colonne du compteur de paniers pour l'affichage de la liste. + 't.meal_count' => array('label' => $langs->trans("MealCount"), 'checked' => 0), + 't.date_creation'=> array('label' => $langs->trans("DateCreation"), 'checked' => 0), + // EN: Validation timestamp column to expose approval dates in the list. + // FR: Colonne de validation pour afficher les dates d'approbation dans la liste. + 't.date_validation'=> array('label' => $langs->trans("DateValidation"), 'checked' => 0), + 't.tms' => array('label' => $langs->trans("DateModificationShort"), 'checked' => 0), + 't.status' => array('label' => $langs->trans("Status"), 'checked' => 1), ); // Update arrayfields from request (column selector) @@ -910,20 +969,20 @@ function tw_can_validate_timesheet_masslist( print ' '; } if (!empty($arrayfields['t.status']['checked'])) { - $statusOptions = array( - TimesheetWeek::STATUS_DRAFT => TimesheetWeek::LibStatut(TimesheetWeek::STATUS_DRAFT, 0), - TimesheetWeek::STATUS_SUBMITTED => TimesheetWeek::LibStatut(TimesheetWeek::STATUS_SUBMITTED, 0), - TimesheetWeek::STATUS_APPROVED => TimesheetWeek::LibStatut(TimesheetWeek::STATUS_APPROVED, 0), - TimesheetWeek::STATUS_SEALED => TimesheetWeek::LibStatut(TimesheetWeek::STATUS_SEALED, 0), - TimesheetWeek::STATUS_REFUSED => TimesheetWeek::LibStatut(TimesheetWeek::STATUS_REFUSED, 0), - ); + $statusOptions = array( + TimesheetWeek::STATUS_DRAFT => TimesheetWeek::LibStatut(TimesheetWeek::STATUS_DRAFT, 0), + TimesheetWeek::STATUS_SUBMITTED => TimesheetWeek::LibStatut(TimesheetWeek::STATUS_SUBMITTED, 0), + TimesheetWeek::STATUS_APPROVED => TimesheetWeek::LibStatut(TimesheetWeek::STATUS_APPROVED, 0), + TimesheetWeek::STATUS_SEALED => TimesheetWeek::LibStatut(TimesheetWeek::STATUS_SEALED, 0), + TimesheetWeek::STATUS_REFUSED => TimesheetWeek::LibStatut(TimesheetWeek::STATUS_REFUSED, 0), + ); - print ''; - print $form->multiselectarray('search_status', $statusOptions, $search_status, 0, 0, 'minwidth150 maxwidth200', 0, 0, '', '', '', '', '', 1); - print ''; + print ''; + print $form->multiselectarray('search_status', $statusOptions, $search_status, 0, 0, 'minwidth150 maxwidth200', 0, 0, '', '', '', '', '', 1); + print ''; } if (!getDolGlobalString('MAIN_CHECKBOX_LEFT_COLUMN')) { - print ''.$form->showFilterButtons('right').''; + print ''.$form->showFilterButtons('right').''; } print ''."\n"; @@ -1034,7 +1093,11 @@ function tw_can_validate_timesheet_masslist( $tswstatic->id = $obj->rowid; $tswstatic->ref = $obj->ref; $tswstatic->status = $obj->status; - print ''.$tswstatic->getNomUrl(1, 'ref').''; + $refLink = $tswstatic->getNomUrl(1, 'ref'); + // EN: Append the PDF dropdown inside the reference column to mirror the invoice behaviour. + // FR: Ajoute le menu PDF dans la colonne de référence pour reproduire le comportement des factures. + $refLink .= tw_render_timesheet_pdf_dropdown($obj, $conf, $langs); + print ''.$refLink.''; } // Employee @@ -1125,14 +1188,14 @@ function tw_can_validate_timesheet_masslist( if (!empty($arrayfields['t.tms']['checked'])) { print ''.($obj->tms ? dol_print_date($db->jdate($obj->tms),'dayhour') : '').''; } - // Status (badge) - if (!empty($arrayfields['t.status']['checked'])) { - $tswstatic->status = $obj->status; - print ''.$tswstatic->getLibStatut(5).''; - } - // EN: Accumulate values to expose totals at the bottom of the table. - // FR: Cumule les valeurs pour afficher les totaux en bas du tableau. - $totalsAccumulator['total_hours'] += (float) $obj->total_hours; + // Status (badge) + if (!empty($arrayfields['t.status']['checked'])) { + $tswstatic->status = $obj->status; + print ''.$tswstatic->getLibStatut(5).''; + } +// EN: Accumulate values to expose totals at the bottom of the table. +// FR: Cumule les valeurs pour afficher les totaux en bas du tableau. +$totalsAccumulator['total_hours'] += (float) $obj->total_hours; $totalsAccumulator['overtime_hours'] += (float) $obj->overtime_hours; $totalsAccumulator['zone1_count'] += (int) $obj->zone1_count; $totalsAccumulator['zone2_count'] += (int) $obj->zone2_count; @@ -1225,12 +1288,12 @@ function tw_can_validate_timesheet_masslist( if (!empty($arrayfields['t.tms']['checked'])) { print ' '; } - if (!empty($arrayfields['t.status']['checked'])) { - print ' '; - } - if (!getDolGlobalString('MAIN_CHECKBOX_LEFT_COLUMN')) { - print ' '; - } + if (!empty($arrayfields['t.status']['checked'])) { + print ' '; + } + if (!getDolGlobalString('MAIN_CHECKBOX_LEFT_COLUMN')) { + print ' '; + } print ''; } else { $colspan = 1;
'; @@ -1035,7 +1220,7 @@ function updateWeekRange(){var v=$('#weekyear').val();var p=parseYearWeek(v);if( echo ' '; echo ' '.$langs->trans("Cancel").''; echo ''; - } else { + } else { echo dol_escape_htmltag($object->week).' / '.dol_escape_htmltag($object->year); if ($canEditInline) { echo ' '.img_edit('',1).''; @@ -1053,7 +1238,7 @@ function updateWeekRange(){var v=$('#weekyear').val();var p=parseYearWeek(v);if( echo '
'; echo ' '.$langs->trans("Cancel").''; echo ''; - } else { + } else { echo nl2br(dol_escape_htmltag($object->note)); if ($canEditInline) { echo ' '.img_edit('',1).''; @@ -1132,12 +1317,12 @@ function updateWeekRange(){var v=$('#weekyear').val();var p=parseYearWeek(v);if( echo ''.$langs->trans("DateValidation").''.dol_print_date($object->date_validation, 'dayhour').''; if ($isDailyRateEmployee) { echo ''.$displayedTotalLabel.''.tw_format_days($displayedTotal, $langs).''; -} else { + } else { echo ''.$displayedTotalLabel.''.formatHours($displayedTotal).''; echo ''.$langs->trans("Overtime").' ('.formatHours($contractedHoursDisp).')'.formatHours($ot).''; } echo ''; -echo '