diff --git a/ChangeLog.md b/ChangeLog.md
index 9aeb3c7..12f9af5 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,18 @@
# CHANGELOG MODULE TIMESHEETWEEK FOR [DOLIBARR ERP CRM](https://www.dolibarr.org)
+## 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.
+
+## 1.1.0
+- Ajoute la massaction « Générer le PDF de synthèse » afin de produire un récapitulatif multi-salariés dans un PDF conforme aux standards Dolibarr. / Adds the "Generate summary PDF" mass action to produce a multi-employee PDF recap compliant with Dolibarr standards.
+
+## 1.0.7
+- Harmonise les pictogrammes d'approbation et de refus avec les icônes Dolibarr natives. / Harmonises the approval and refusal pictograms with native Dolibarr icons.
+- Ajoute la massaction « Sceller » et applique les contrôles de permissions Dolibarr aux actions de masse. / Adds the "Seal" mass action and applies Dolibarr permission checks to bulk operations.
+- Limite la suppression massive aux feuilles en brouillon et avertit en cas de sélection invalide. / Restricts bulk deletion to draft sheets and warns when invalid selections are detected.
+- Complète les traductions de la liste pour les actions de masse. / Completes list translations for bulk actions.
+
+
## 1.0.6
- Réorganise le menu gauche pour afficher « Nouvelle feuille » avant « Liste ». / Reorders the left menu to display "New sheet" before "List".
- Ajoute les entrées « TimesheetWeek » dans les menus principaux Agenda et Projet. / Adds the "TimesheetWeek" entries under the Agenda and Project main menus.
@@ -31,6 +44,10 @@
- Corrige un problème dans les options de partage du module "Multicompany". / Fixes an issue in the "Multicompany" module sharing options.
- Repositionne les totaux correctement. / Correctly repositions totals.
- Inversion de position de "Nouvelle Feuille d'heure" et "Liste" dans le menu gauche. / Swaps the position of "New Timesheet" and "List" in the left menu.
+- Le tableau du PDF de synthèse occupe désormais toute la largeur imprimable pour une meilleure lisibilité. / Summary PDF table now spans the full printable width for improved readability.
+- Ajoute les entêtes et pieds de page standards à chaque page du PDF de synthèse et garantit que le premier tableau débute dès la première page. / Adds standard headers and footers to every summary PDF page and ensures the first table starts on the opening page.
+- Remonte le pied de page du PDF de synthèse pour réduire la marge basse et améliorer la lisibilité. / Raises the summary PDF footer to reduce the bottom margin and improve readability.
+- Garantit que le contenu du PDF de synthèse reste entre l'entête et le pied de page sur chaque feuille. / Ensures the summary PDF content stays between the header and footer on every sheet.
- Corrige un problème pouvant masquer les tâches clôturées dans les Fiches passées. / Fix an issue that could hide closed tasks in passed timesheet.
## 1.0.1
diff --git a/ajax/timesheetweek.php b/ajax/timesheetweek.php
index 0f2e967..ed2d60f 100644
--- a/ajax/timesheetweek.php
+++ b/ajax/timesheetweek.php
@@ -76,9 +76,9 @@
// EN: Evaluate write permissions, including subordinate scope, before processing the request.
// FR: Évalue les permissions d'écriture, y compris sur les subordonnés, avant de traiter la requête.
-$permWrite = $user->hasRight('timesheetweek', 'timesheetweek', 'write');
-$permWriteChild = $user->hasRight('timesheetweek', 'timesheetweek', 'writeChild');
-$permWriteAll = $user->hasRight('timesheetweek', 'timesheetweek', 'writeAll');
+$permWrite = $user->hasRight('timesheetweek', 'write');
+$permWriteChild = $user->hasRight('timesheetweek', 'writeChild');
+$permWriteAll = $user->hasRight('timesheetweek', 'writeAll');
$canWriteAll = (!empty($user->admin) || $permWriteAll);
if (!($permWrite || $permWriteChild || $canWriteAll)) {
// EN: Return a JSON 403 response when the user cannot edit any timesheet.
diff --git a/class/actions_timesheetweek.class.php b/class/actions_timesheetweek.class.php
index 6f046bc..1590599 100644
--- a/class/actions_timesheetweek.class.php
+++ b/class/actions_timesheetweek.class.php
@@ -69,7 +69,7 @@ public function menuDropdownQuickaddItems($parameters, &$object, &$action, $hook
// EN: Evaluate user permissions to control quick creation visibility.
// FR: Évaluer les droits de l'utilisateur pour contrôler la visibilité de la création rapide.
- $hasWriteRight = $user->hasRight('timesheetweek', 'timesheetweek', 'write') || $user->hasRight('timesheetweek', 'timesheetweek', 'writeChild') || $user->hasRight('timesheetweek', 'timesheetweek', 'writeAll');
+ $hasWriteRight = $user->hasRight('timesheetweek', 'write') || $user->hasRight('timesheetweek', 'writeChild') || $user->hasRight('timesheetweek', 'writeAll');
// EN: Inject the quick creation entry with translated metadata.
// FR: Injecter l'entrée de création rapide avec des métadonnées traduites.
diff --git a/core/modules/modTimesheetWeek.class.php b/core/modules/modTimesheetWeek.class.php
index 0681696..982faa2 100644
--- a/core/modules/modTimesheetWeek.class.php
+++ b/core/modules/modTimesheetWeek.class.php
@@ -80,8 +80,8 @@ public function __construct($db)
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@timesheetweek'
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
- $this->version = '1.0.6'; // EN: Reorders the left menu, adds agenda and project menu entries and fixes the list page limit selector.
- // FR: Réorganise le menu gauche, ajoute les entrées de menus agenda et projet et corrige le sélecteur de limite dans la liste.
+ $this->version = '1.1.1'; // EN: "Flattening" permissions to fix a PDF display issue.
+ // FR: Mise à "plat" des permissions pour régler un problème d'affichage des PDF.
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
@@ -305,96 +305,110 @@ public function __construct($db)
/* BEGIN MODULEBUILDER PERMISSIONS */
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 0 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightReadOwn');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'read';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'read';
+ $this->rights[$r][5] = '';
$r++;
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 1 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightReadChild');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'readChild';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'readChild';
+ $this->rights[$r][5] = '';
$r++;
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 2 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightReadAll');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'readAll';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'readAll';
+ $this->rights[$r][5] = '';
$r++;
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 3 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightWriteOwn');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'write';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'write';
+ $this->rights[$r][5] = '';
$r++;
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 4 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightWriteChild');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'writeChild';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'writeChild';
+ $this->rights[$r][5] = '';
$r++;
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 5 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightWriteAll');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'writeAll';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'writeAll';
+ $this->rights[$r][5] = '';
$r++;
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 6 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightDeleteOwn');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'delete';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'delete';
+ $this->rights[$r][5] = '';
$r++;
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 7 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightDeleteChild');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'deleteChild';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'deleteChild';
+ $this->rights[$r][5] = '';
$r++;
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 8 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightDeleteAll');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'deleteAll';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'deleteAll';
+ $this->rights[$r][5] = '';
$r++;
// EN: Legacy validation permission kept for backward compatibility
// FR : Droit de validation générique conservé pour compatibilité ascendante
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 9 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightValidateGeneric');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'validate';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'validate';
+ $this->rights[$r][5] = '';
$r++;
// EN: Allow managers to validate their own timesheets only
// FR : Autorise un utilisateur à valider uniquement ses propres feuilles
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 10 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightValidateOwn');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'validateOwn';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'validateOwn';
+ $this->rights[$r][5] = '';
$r++;
// EN: Allow validation on subordinate timesheets
// FR : Autorise la validation des feuilles des subordonnés
$this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 11 + 1);
$this->rights[$r][1] = $langs->trans('TimesheetWeekRightValidateChild');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'validateChild';
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'validateChild';
+ $this->rights[$r][5] = '';
+ $r++;
+ // EN: Allow global validation on all employee timesheets
+ // FR : Autorise la validation de toutes les feuilles de temps
+ $this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 12 + 1);
+ $this->rights[$r][1] = $langs->trans('TimesheetWeekRightValidateAll');
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'validateAll';
+ $this->rights[$r][5] = '';
+ $r++;
+
+ // EN: Allow sealing approved timesheets.
+ // FR : Autorise le scellement des feuilles approuvées.
+ $this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 13 + 1);
+ $this->rights[$r][1] = $langs->trans('TimesheetWeekRightSeal');
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'seal';
+ $this->rights[$r][5] = '';
+ $r++;
+
+ // EN: Allow reopening sealed timesheets.
+ // FR : Autorise le descellage des feuilles scellées.
+ $this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 14 + 1);
+ $this->rights[$r][1] = $langs->trans('TimesheetWeekRightUnseal');
+ $this->rights[$r][3] = 0;
+ $this->rights[$r][4] = 'unseal';
+ $this->rights[$r][5] = '';
$r++;
- // EN: Allow global validation on all employee timesheets
- // FR : Autorise la validation de toutes les feuilles de temps
- $this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 12 + 1);
- $this->rights[$r][1] = $langs->trans('TimesheetWeekRightValidateAll');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'validateAll';
- $r++;
-
- // EN: Allow sealing approved timesheets.
- // FR : Autorise le scellement des feuilles approuvées.
- $this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 13 + 1);
- $this->rights[$r][1] = $langs->trans('TimesheetWeekRightSeal');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'seal';
- $r++;
-
- // EN: Allow reopening sealed timesheets.
- // FR : Autorise le descellage des feuilles scellées.
- $this->rights[$r][0] = $this->numero . sprintf('%02d', (0 * 10) + 14 + 1);
- $this->rights[$r][1] = $langs->trans('TimesheetWeekRightUnseal');
- $this->rights[$r][4] = 'timesheetweek';
- $this->rights[$r][5] = 'unseal';
- $r++;
-
- /* END MODULEBUILDER PERMISSIONS */
+ /* END MODULEBUILDER PERMISSIONS */
// Main menu entries to add
$this->menu = array();
@@ -413,7 +427,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek', // Lang file to use (without .lang) by module. File must be in langs/code_CODE/ directory.
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")', // Define condition to show or hide menu entry. Use 'isModEnabled("timesheetweek")' if entry must be visible if module is enabled.
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "read")', // Use 'perms'=>'$user->hasRight("timesheetweek", "timesheetweek", "read")' if you want your menu with a permission rules
+ 'perms' => '$user->hasRight("timesheetweek", "read")', // Use 'perms'=>'$user->hasRight("timesheetweek", "read")' if you want your menu with a permission rules
'target' => '',
'user' => 2, // 0=Menu for internal users, 1=external users, 2=both
);
@@ -452,7 +466,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")',
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "read")',
+ 'perms' => '$user->hasRight("timesheetweek", "read")',
'target' => '',
'user' => 2,
'object' => 'TimesheetWeek'
@@ -467,7 +481,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")',
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "write")',
+ 'perms' => '$user->hasRight("timesheetweek", "write")',
'target' => '',
'user' => 2,
'object' => 'TimesheetWeek'
@@ -482,7 +496,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")',
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "read")',
+ 'perms' => '$user->hasRight("timesheetweek", "read")',
'target' => '',
'user' => 2,
'object' => 'TimesheetWeek'
@@ -500,7 +514,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")',
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "read")',
+ 'perms' => '$user->hasRight("timesheetweek", "read")',
'target' => '',
'user' => 2,
'object' => 'TimesheetWeek'
@@ -515,7 +529,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")',
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "write")',
+ 'perms' => '$user->hasRight("timesheetweek", "write")',
'target' => '',
'user' => 2,
'object' => 'TimesheetWeek'
@@ -530,7 +544,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")',
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "read")',
+ 'perms' => '$user->hasRight("timesheetweek", "read")',
'target' => '',
'user' => 2,
'object' => 'TimesheetWeek'
@@ -549,7 +563,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")',
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "read")',
+ 'perms' => '$user->hasRight("timesheetweek", "read")',
'target' => '',
'user' => 2,
'object' => 'TimesheetWeek'
@@ -564,7 +578,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")',
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "write")',
+ 'perms' => '$user->hasRight("timesheetweek", "write")',
'target' => '',
'user' => 2,
'object' => 'TimesheetWeek'
@@ -579,7 +593,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek',
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")',
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "read")',
+ 'perms' => '$user->hasRight("timesheetweek", "read")',
'target' => '',
'user' => 2,
'object' => 'TimesheetWeek'
@@ -599,7 +613,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek', // Lang file to use (without .lang) by module. File must be in langs/code_CODE/ directory.
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")', // Define condition to show or hide menu entry. Use 'isModEnabled("timesheetweek")' if entry must be visible if module is enabled.
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "read")',
+ 'perms' => '$user->hasRight("timesheetweek", "read")',
'target' => '',
'user' => 2, // 0=Menu for internal users, 1=external users, 2=both
'object' => 'TimesheetWeek'
@@ -614,7 +628,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek', // Lang file to use (without .lang) by module. File must be in langs/code_CODE/ directory.
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")', // Define condition to show or hide menu entry. Use 'isModEnabled("timesheetweek")' if entry must be visible if module is enabled. Use '$leftmenu==\'system\'' to show if leftmenu system is selected.
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "write")'
+ 'perms' => '$user->hasRight("timesheetweek", "write")'
'target' => '',
'user' => 2, // 0=Menu for internal users, 1=external users, 2=both
'object' => 'TimesheetWeek'
@@ -629,7 +643,7 @@ public function __construct($db)
'langs' => 'timesheetweek@timesheetweek', // Lang file to use (without .lang) by module. File must be in langs/code_CODE/ directory.
'position' => 1000 + $r,
'enabled' => 'isModEnabled("timesheetweek")', // Define condition to show or hide menu entry. Use 'isModEnabled("timesheetweek")' if entry must be visible if module is enabled.
- 'perms' => '$user->hasRight("timesheetweek", "timesheetweek", "read")'
+ 'perms' => '$user->hasRight("timesheetweek", "read")'
'target' => '',
'user' => 2, // 0=Menu for internal users, 1=external users, 2=both
'object' => 'TimesheetWeek'
diff --git a/langs/en_US/timesheetweek.lang b/langs/en_US/timesheetweek.lang
index 09bfcaa..45c9bb4 100644
--- a/langs/en_US/timesheetweek.lang
+++ b/langs/en_US/timesheetweek.lang
@@ -112,6 +112,43 @@ SealTimesheet = Seal
UnsealTimesheet = Unseal
ConfirmValidate = Do you confirm the approval of this timesheet?
ConfirmRefuse = Do you confirm the refusal of this timesheet?
+ApproveSelection = Approve selection
+RefuseSelection = Refuse selection
+SealSelection = Seal selection
+DeleteSelection = Delete selection
+TimesheetWeekMassApproveSuccess = %s timesheet(s) approved.
+TimesheetWeekMassRefuseSuccess = %s timesheet(s) refused.
+TimesheetWeekMassSealSuccess = %s timesheet(s) sealed.
+TimesheetWeekMassActionErrors = Unable to process: %s.
+TimesheetWeekMassDeleteOnlyDraft = Only draft timesheets can be deleted in bulk.
+GenerateSummaryPdf = Generate summary PDF
+TimesheetWeekSummaryNoSelection = Please select at least one timesheet to create the summary.
+TimesheetWeekSummaryUnauthorizedSheet = Some selected timesheets were ignored because you are not allowed to read them.
+TimesheetWeekSummaryMissingUser = The employee linked to a selected timesheet no longer exists.
+TimesheetWeekSummaryNoData = No readable timesheet was found in the selection.
+TimesheetWeekSummaryTitle = Weekly timesheets summary
+TimesheetWeekSummaryGeneratedOn = Generated on %s
+TimesheetWeekSummaryGeneratedOnBy = Generated on %s by %s
+TimesheetWeekSummaryColumnWeek = Week
+TimesheetWeekSummaryColumnStart = Start date
+TimesheetWeekSummaryColumnEnd = End date
+TimesheetWeekSummaryColumnTotalHours = Declared hours
+TimesheetWeekSummaryColumnContractHours = Contract hours
+TimesheetWeekSummaryColumnOvertime = Overtime
+TimesheetWeekSummaryColumnMeals = Meal allowances
+TimesheetWeekSummaryColumnZone1 = Zone 1 trips
+TimesheetWeekSummaryColumnZone2 = Zone 2 trips
+TimesheetWeekSummaryColumnZone3 = Zone 3 trips
+TimesheetWeekSummaryColumnZone4 = Zone 4 trips
+TimesheetWeekSummaryColumnZone5 = Zone 5 trips
+TimesheetWeekSummaryColumnApprovedBy = Approved by
+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
+
TimesheetWeekStatusDraft = Draft
TimesheetWeekStatusSubmitted = Submitted
TimesheetWeekStatusApproved = Approved
@@ -162,6 +199,8 @@ TimesheetWeekNotificationEmployeeFallback = Employee
TimesheetWeekAjaxForbidden = You are not allowed to update this timesheet.
TimesheetWeekAjaxUpdateError = Unable to update field %s.
TimesheetWeekAjaxUpdateSuccess = Field %s updated successfully.
+TimesheetWeekConfirmMassDelete = Do you confirm the deletion of the %s selected timesheets?
+TimesheetWeekErrorNoSelection = Please select at least one timesheet before launching a mass action.
TimesheetWeekDayMonday = Monday
TimesheetWeekDayTuesday = Tuesday
TimesheetWeekDayWednesday = Wednesday
diff --git a/langs/fr_FR/timesheetweek.lang b/langs/fr_FR/timesheetweek.lang
index 790c3a8..2f153bb 100644
--- a/langs/fr_FR/timesheetweek.lang
+++ b/langs/fr_FR/timesheetweek.lang
@@ -111,6 +111,43 @@ SealTimesheet = Sceller
UnsealTimesheet = Desceller
ConfirmValidate = Confirmez-vous l'approbation de cette feuille de temps ?
ConfirmRefuse = Confirmez-vous le refus de cette feuille de temps ?
+ApproveSelection = Approuver la sélection
+RefuseSelection = Refuser la sélection
+SealSelection = Sceller la sélection
+DeleteSelection = Supprimer la sélection
+TimesheetWeekMassApproveSuccess = %s feuille(s) de temps approuvée(s).
+TimesheetWeekMassRefuseSuccess = %s feuille(s) de temps refusée(s).
+TimesheetWeekMassSealSuccess = %s feuille(s) de temps scellée(s).
+TimesheetWeekMassActionErrors = Impossible de traiter : %s.
+TimesheetWeekMassDeleteOnlyDraft = Seules les feuilles en brouillon peuvent être supprimées en masse.
+GenerateSummaryPdf = Générer le PDF de synthèse
+TimesheetWeekSummaryNoSelection = Merci de sélectionner au moins une feuille de temps pour créer la synthèse.
+TimesheetWeekSummaryUnauthorizedSheet = Certaines feuilles sélectionnées ont été ignorées faute d'autorisation de lecture.
+TimesheetWeekSummaryMissingUser = Le salarié lié à une feuille sélectionnée n'existe plus.
+TimesheetWeekSummaryNoData = Aucune feuille lisible n'a été trouvée dans la sélection.
+TimesheetWeekSummaryTitle = Synthèse hebdomadaire des feuilles de temps
+TimesheetWeekSummaryGeneratedOn = Généré le %s
+TimesheetWeekSummaryGeneratedOnBy = Généré le %s par %s
+TimesheetWeekSummaryColumnWeek = Semaine
+TimesheetWeekSummaryColumnStart = Date de début
+TimesheetWeekSummaryColumnEnd = Date de fin
+TimesheetWeekSummaryColumnTotalHours = Heures déclarées
+TimesheetWeekSummaryColumnContractHours = Heures au contrat
+TimesheetWeekSummaryColumnOvertime = Heures supplémentaires
+TimesheetWeekSummaryColumnMeals = Paniers repas
+TimesheetWeekSummaryColumnZone1 = Déplacements Zone 1
+TimesheetWeekSummaryColumnZone2 = Déplacements Zone 2
+TimesheetWeekSummaryColumnZone3 = Déplacements Zone 3
+TimesheetWeekSummaryColumnZone4 = Déplacements Zone 4
+TimesheetWeekSummaryColumnZone5 = Déplacements Zone 5
+TimesheetWeekSummaryColumnApprovedBy = Approuvé par
+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
+
TimesheetWeekStatusDraft = Brouillon
TimesheetWeekStatusSubmitted = Soumise
TimesheetWeekStatusApproved = Approuvée
@@ -161,6 +198,8 @@ TimesheetWeekNotificationEmployeeFallback = Salarié
TimesheetWeekAjaxForbidden = Vous n'êtes pas autorisé à mettre à jour cette feuille de temps.
TimesheetWeekAjaxUpdateError = Impossible de mettre à jour le champ %s.
TimesheetWeekAjaxUpdateSuccess = Champ %s mis à jour avec succès.
+TimesheetWeekConfirmMassDelete = Confirmez-vous la suppression des %s feuilles de temps sélectionnées ?
+TimesheetWeekErrorNoSelection = Veuillez sélectionner au moins une feuille de temps avant de lancer une action de masse.
TimesheetWeekDayMonday = Lundi
TimesheetWeekDayTuesday = Mardi
TimesheetWeekDayWednesday = Mercredi
diff --git a/lib/timesheetweek_pdf.lib.php b/lib/timesheetweek_pdf.lib.php
new file mode 100644
index 0000000..eec15c7
--- /dev/null
+++ b/lib/timesheetweek_pdf.lib.php
@@ -0,0 +1,949 @@
+
+ *
+ * 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 .
+ */
+
+/**
+ * \file lib/timesheetweek_pdf.lib.php
+ * \ingroup timesheetweek
+ * \brief Helper functions for TimesheetWeek PDF exports
+ */
+
+// EN: Load Dolibarr helpers required to build PDF documents.
+// FR: Charge les helpers Dolibarr nécessaires pour construire les documents PDF.
+require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php';
+require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php';
+require_once DOL_DOCUMENT_ROOT.'/core/lib/company.lib.php';
+require_once DOL_DOCUMENT_ROOT.'/core/lib/functions.lib.php';
+require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php';
+
+dol_include_once('/timesheetweek/lib/timesheetweek.lib.php');
+
+defined('TIMESHEETWEEK_PDF_SUMMARY_SUBDIR') || define('TIMESHEETWEEK_PDF_SUMMARY_SUBDIR', 'summaries');
+
+/**
+ * EN: Normalise a value before sending it to TCPDF by decoding HTML entities and applying the output charset.
+ * FR: Normalise une valeur avant envoi à TCPDF en décodant les entités HTML et en appliquant le jeu de caractères de sortie.
+ *
+ * @param string $value
+ * @return string
+ */
+function tw_pdf_normalize_string($value)
+{
+ // EN: Convert any scalar input into a string for consistent processing.
+ // FR: Convertit toute entrée scalaire en chaîne pour un traitement cohérent.
+ $value = (string) $value;
+ if (function_exists('dol_html_entity_decode')) {
+ // EN: Decode HTML entities with Dolibarr helper to restore accented characters before rendering.
+ // FR: Décode les entités HTML avec l'helper Dolibarr pour restaurer les caractères accentués avant affichage.
+ $value = dol_html_entity_decode($value, ENT_QUOTES | ENT_HTML401, 'UTF-8');
+ } else {
+ // EN: Fallback on PHP native decoder when the Dolibarr helper is unavailable.
+ // FR: Utilise le décodeur natif PHP si l'helper Dolibarr est indisponible.
+ $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML401, 'UTF-8');
+ }
+ // EN: Access the global translations handler to convert text with the right PDF charset.
+ // FR: Accède au gestionnaire global de traductions pour convertir le texte avec le bon jeu de caractères PDF.
+ global $langs;
+ if ($langs instanceof Translate) {
+ // EN: Convert the text into the PDF output charset defined by Dolibarr translations.
+ // FR: Convertit le texte dans le jeu de caractères de sortie PDF défini par les traductions Dolibarr.
+ $value = $langs->convToOutputCharset($value);
+ }
+ // EN: Return the fully normalised value ready to be consumed by TCPDF rendering calls.
+ // FR: Retourne la valeur totalement normalisée, prête à être consommée par les appels de rendu TCPDF.
+ return $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
+ * @return string
+ */
+function tw_pdf_format_cell_html($value)
+{
+ // 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);
+ // 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);
+ // EN: Wrap the escaped value in a span to stay compatible with TCPDF HTML rendering expectations.
+ // FR: Encapsule la valeur échappée dans un span pour rester compatible avec les attentes de rendu HTML de TCPDF.
+ return ''.$escapedValue.'';
+}
+
+/**
+ * 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.
+ *
+ * @param TCPDF $pdf
+ * @param Translate $langs
+ * @param Conf $conf
+ * @param float $leftMargin
+ * @param float $topMargin
+ * @param string $title
+ * @param string $weekRange
+ * @param string $subtitle
+ * @return float
+ */
+function tw_pdf_draw_header($pdf, $langs, $conf, $leftMargin, $topMargin, $title = '', $weekRange = '', $subtitle = '')
+{
+ global $mysoc;
+
+ $defaultFontSize = pdf_getPDFFontSize($langs);
+ $logoHeight = 0.0;
+ $posX = $leftMargin;
+ $posY = $topMargin;
+ $logoPath = '';
+ $logoDisplayed = false;
+ $pageWidth = $pdf->getPageWidth();
+ $margins = method_exists($pdf, 'getMargins') ? (array) $pdf->getMargins() : array();
+ $rightMargin = isset($margins['right']) ? (float) $margins['right'] : (float) getDolGlobalInt('MAIN_PDF_MARGIN_RIGHT', 10);
+ $rightBlockWidth = max(90.0, $pageWidth * 0.28);
+ $rightBlockX = max($leftMargin, $pageWidth - $rightMargin - $rightBlockWidth);
+ $rightBlockBottom = $posY;
+
+ if (!getDolGlobalInt('PDF_DISABLE_MYCOMPANY_LOGO')) {
+ // EN: Resolve the preferred logo file between large and thumbnail versions.
+ // FR: Résout le fichier logo privilégié entre les versions grande et miniature.
+ if (!empty($mysoc->logo)) {
+ $logodir = $conf->mycompany->dir_output;
+ if (!empty($conf->mycompany->multidir_output[$conf->entity] ?? null)) {
+ $logodir = $conf->mycompany->multidir_output[$conf->entity];
+ }
+ if (!getDolGlobalInt('MAIN_PDF_USE_LARGE_LOGO') && !empty($mysoc->logo_small)) {
+ $logoCandidate = $logodir.'/logos/thumbs/'.$mysoc->logo_small;
+ if (is_readable($logoCandidate)) {
+ $logoPath = $logoCandidate;
+ }
+ }
+ if ($logoPath === '') {
+ $logoCandidate = $logodir.'/logos/'.$mysoc->logo;
+ if (is_readable($logoCandidate)) {
+ $logoPath = $logoCandidate;
+ }
+ }
+ }
+ if ($logoPath === '') {
+ $defaultLogo = DOL_DOCUMENT_ROOT.'/theme/dolibarr_logo.svg';
+ if (is_readable($defaultLogo)) {
+ $logoPath = $defaultLogo;
+ }
+ }
+ if ($logoPath !== '') {
+ $logoHeight = pdf_getHeightForLogo($logoPath);
+ $pdf->Image($logoPath, $posX, $posY, 0, $logoHeight);
+ // EN: Track that a logo is displayed to hide the company name for visual consistency.
+ // FR: Indique qu'un logo est affiché pour masquer le nom de la société et préserver la cohérence visuelle.
+ $logoDisplayed = true;
+ }
+ }
+
+ $companyName = !empty($mysoc->name) ? $mysoc->name : 'Dolibarr ERP & CRM';
+ $leftBlockWidth = max(60.0, $rightBlockX - $posX - 2.0);
+ if (!$logoDisplayed) {
+ // EN: Show the company name only when no logo is available to avoid duplicate branding.
+ // FR: Affiche le nom de la société uniquement lorsqu'aucun logo n'est disponible pour éviter une double identité visuelle.
+ $pdf->SetTextColor(0, 0, 60);
+ $pdf->SetFont('', 'B', $defaultFontSize);
+ $pdf->SetXY($posX, $posY + max($logoHeight - 6.0, 0.0));
+ $pdf->MultiCell($leftBlockWidth, 5, tw_pdf_format_cell_html($companyName), 0, 'L', 0, 1, '', '', true, 0, true);
+ }
+
+ // EN: Render the summary title and metadata within the right column of the header.
+ // FR: Affiche le titre de synthèse et les métadonnées dans la colonne droite de l'entête.
+ // EN: Remove unnecessary spaces around the header title for accurate checks.
+ // FR: Supprime les espaces superflus autour du titre d'entête pour des vérifications précises.
+ $trimmedTitle = trim((string) $title);
+ if (dol_strlen($trimmedTitle) > 0) {
+ $pdf->SetFont('', 'B', $defaultFontSize + 2);
+ $pdf->SetTextColor(0, 0, 60);
+ $pdf->SetXY($rightBlockX, $posY);
+ // EN: Force the header title to stay on a single line for a cleaner top-right layout.
+ // FR: Force le titre d'entête à rester sur une seule ligne pour un rendu plus propre en haut à droite.
+ $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.
+ // FR: Supprime les espaces du libellé de plage de semaines avant affichage.
+ $trimmedWeekRange = trim((string) $weekRange);
+ if (dol_strlen($trimmedWeekRange) > 0) {
+ $pdf->SetFont('', '', $defaultFontSize);
+ $pdf->SetTextColor(0, 0, 0);
+ $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);
+ $rightBlockBottom = max($rightBlockBottom, $pdf->GetY());
+ }
+ // EN: Remove unnecessary spaces around the header subtitle before rendering.
+ // FR: Supprime les espaces superflus autour du sous-titre d'entête avant affichage.
+ $trimmedSubtitle = trim((string) $subtitle);
+ if (dol_strlen($trimmedSubtitle) > 0) {
+ $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);
+ $rightBlockBottom = max($rightBlockBottom, $pdf->GetY());
+ }
+
+ $pdf->SetTextColor(0, 0, 0);
+
+ return max($posY + max($logoHeight, 16.0), $rightBlockBottom);
+}
+
+/**
+ * EN: Draw the footer using the standard Dolibarr helper to keep consistent branding.
+ * FR: Dessine le pied de page avec le helper Dolibarr standard pour conserver la charte.
+ *
+ * @param TCPDF $pdf
+ * @param Translate $langs
+ * @param Conf $conf
+ * @param float $leftMargin
+ * @param float $rightMargin
+ * @param float $bottomMargin
+ * @param CommonObject|null $object
+ * @param int $hideFreeText
+ * @param float|null $autoPageBreakMargin
+ * @return int
+ */
+function tw_pdf_draw_footer($pdf, $langs, $conf, $leftMargin, $rightMargin, $bottomMargin, $object = null, $hideFreeText = 0, $autoPageBreakMargin = null)
+{
+ global $mysoc;
+
+ // EN: Backup automatic page break configuration to avoid splitting the footer on two pages.
+ // FR: Sauvegarde la configuration de saut automatique pour éviter de scinder le pied entre deux pages.
+ $previousAutoBreak = method_exists($pdf, 'getAutoPageBreak') ? $pdf->getAutoPageBreak() : true;
+ $previousBreakMargin = null;
+ if (method_exists($pdf, 'getBreakMargin')) {
+ $previousBreakMargin = (float) $pdf->getBreakMargin();
+ } elseif ($autoPageBreakMargin !== null) {
+ $previousBreakMargin = (float) $autoPageBreakMargin;
+ } elseif (isset($pdf->bMargin)) {
+ // EN: Fallback on TCPDF public margin when helper methods are unavailable.
+ // FR: Utilise la marge publique de TCPDF si les helpers sont indisponibles.
+ $previousBreakMargin = (float) $pdf->bMargin;
+ } else {
+ $previousBreakMargin = (float) $bottomMargin;
+ }
+ $pdf->SetAutoPageBreak(false, 0);
+
+ // EN: Determine if Dolibarr must show the detailed footer blocks (tax numbers, contacts, ...).
+ // FR: Détermine si Dolibarr doit afficher les blocs détaillés du pied (numéros fiscaux, contacts, ...).
+ $showDetails = empty($conf->global->MAIN_GENERATE_DOCUMENTS_SHOW_FOOT_DETAILS) ? 0 : $conf->global->MAIN_GENERATE_DOCUMENTS_SHOW_FOOT_DETAILS;
+
+ // EN: Delegate the rendering to pdf_pagefoot to mirror the official Dolibarr layout and logic.
+ // FR: Délègue le rendu à pdf_pagefoot pour reproduire la mise en forme et la logique officielles de Dolibarr.
+ $footHeight = pdf_pagefoot($pdf, $langs, 'INVOICE_FREE_TEXT', $mysoc, $bottomMargin, $leftMargin, $pdf->getPageHeight(), $object, $showDetails, $hideFreeText);
+
+ // EN: Restore the automatic page break configuration so the following content keeps the same flow.
+ // FR: Restaure la configuration de saut automatique pour conserver le même flux pour le contenu suivant.
+ $pdf->SetAutoPageBreak($previousAutoBreak, $previousBreakMargin);
+
+ return $footHeight;
+}
+
+
+/**
+ * EN: Create a new landscape page and ensure header/footer are drawn.
+ * FR: Crée une nouvelle page paysage et dessine l'entête/pied de page.
+ *
+ * @param TCPDF $pdf
+ * @param Translate $langs
+ * @param Conf $conf
+ * @param float $leftMargin
+ * @param float $topMargin
+ * @param float $rightMargin
+ * @param float $bottomMargin
+ * @param float|null $autoPageBreakMargin
+ * @param string $headerTitle
+ * @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 = '')
+{
+ $pdf->AddPage('L');
+ // EN: Detect if TCPDF automatic callbacks manage header/footer rendering.
+ // FR: Détecte si les callbacks automatiques de TCPDF gèrent le rendu entête/pied.
+ $callbacksOn = is_array($headerState) && !empty($headerState['automatic']);
+
+ if ($callbacksOn) {
+ // EN: Recompute the header height when missing to avoid duplicated footer calls.
+ // 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);
+ } else {
+ $headerBottom = tw_pdf_draw_header($pdf, $langs, $conf, $leftMargin, $topMargin, $headerTitle, $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.
+ // FR: Mémorise la hauteur d'entête pour les prochaines pages lorsque les callbacks restent inactifs.
+ $headerState['value'] = $headerBottom;
+ }
+ }
+ $contentStart = $headerBottom + 4.0;
+ // EN: Force the top margin below the header so every page keeps data between header and footer.
+ // FR: Force la marge haute sous l'entête pour que chaque page maintienne les données entre entête et pied.
+ $pdf->SetTopMargin($contentStart);
+ $pdf->SetXY($leftMargin, $contentStart);
+ return $contentStart;
+}
+
+/**
+ * EN: Display the employee banner for the current section on the PDF.
+ * FR: Affiche la bannière de l'employé pour la section courante du PDF.
+ *
+ * @param TCPDF $pdf
+ * @param Translate $langs
+ * @param User $userObject
+ * @param float $defaultFontSize
+ * @return void
+ */
+function tw_pdf_print_user_banner($pdf, $langs, $userObject, $defaultFontSize)
+{
+ $pdf->SetFont('', 'B', $defaultFontSize + 1);
+ $pdf->SetTextColor(0, 0, 60);
+ $pdf->MultiCell(0, 6, tw_pdf_format_cell_html($langs->trans('TimesheetWeekSummaryUserTitle', $userObject->getFullName($langs))), 0, 'L', 0, 1, '', '', true, 0, true);
+ $pdf->SetFont('', '', $defaultFontSize);
+ $pdf->SetTextColor(0, 0, 0);
+}
+
+
+/**
+ * EN: Determine the height required for a table row considering wrapped content.
+ * FR: Détermine la hauteur nécessaire pour une ligne du tableau en considérant les retours à la ligne.
+ *
+ * @param TCPDF $pdf
+ * @param float[] $columnWidths
+ * @param string[] $values
+ * @param float $lineHeight
+ * @return float
+ */
+function tw_pdf_estimate_row_height($pdf, array $columnWidths, array $values, $lineHeight)
+{
+ // 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]));
+ $maxLines = max($maxLines, $currentLines);
+ }
+ return $lineHeight * $maxLines;
+}
+
+/**
+ * EN: Render a row with uniform cell height and consistent column widths.
+ * FR: Affiche une ligne avec une hauteur uniforme des cellules et des largeurs cohérentes par colonne.
+ *
+ * @param TCPDF $pdf
+ * @param float[] $columnWidths
+ * @param string[] $values
+ * @param float $lineHeight
+ * @param array $options
+ * @return void
+ */
+function tw_pdf_render_row($pdf, array $columnWidths, array $values, $lineHeight, array $options = array())
+{
+ $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);
+ $initialX = $pdf->GetX();
+ $initialY = $pdf->GetY();
+ $offset = 0.0;
+ foreach ($values as $index => $value) {
+ $width = $columnWidths[$index];
+ $align = $alignments[$index] ?? 'L';
+ // EN: Position each cell manually to guarantee column alignment.
+ // FR: Positionne chaque cellule manuellement pour garantir l'alignement des colonnes.
+ $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
+ );
+ $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);
+}
+
+/**
+ * EN: Estimate the full height required to display a user table without page breaks.
+ * FR: Estime la hauteur complète nécessaire pour afficher un tableau utilisateur sans saut de page.
+ *
+ * @param TCPDF $pdf
+ * @param Translate $langs
+ * @param User $userObject
+ * @param float[] $columnWidths
+ * @param string[] $columnLabels
+ * @param string[][] $recordRows
+ * @param string[] $totalsRow
+ * @param float $lineHeight
+ * @param float $contentWidth
+ * @return float
+ */
+function tw_pdf_estimate_user_table_height($pdf, $langs, $userObject, array $columnWidths, array $columnLabels, array $recordRows, array $totalsRow, $lineHeight, $contentWidth)
+{
+ $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);
+ $totalHeight = $bannerHeight + 2 + $headerHeight;
+
+ foreach ($recordRows as $rowValues) {
+ $totalHeight += tw_pdf_estimate_row_height($pdf, $columnWidths, $rowValues, $lineHeight);
+ }
+
+ $totalHeight += tw_pdf_estimate_row_height($pdf, $columnWidths, $totalsRow, $lineHeight);
+
+ return $totalHeight;
+}
+
+/**
+ * EN: Format decimal hours into the HH:MM representation expected by HR teams.
+ * FR: Formate les heures décimales en représentation HH:MM attendue par les équipes RH.
+ *
+ * @param float $hours
+ * @return string
+ */
+function tw_format_hours_decimal($hours)
+{
+ $hours = (float) $hours;
+ $hoursInt = (int) floor($hours);
+ $minutes = (int) round(($hours - $hoursInt) * 60);
+ if ($minutes === 60) {
+ $hoursInt++;
+ $minutes = 0;
+ }
+ return sprintf('%02d:%02d', $hoursInt, $minutes);
+}
+
+/**
+ * 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.
+ *
+ * @param DoliDB $db Database handler
+ * @param int[] $timesheetIds Selected timesheet identifiers
+ * @param User $user Current Dolibarr user
+ * @param bool $permReadOwn Permission to read own sheets
+ * @param bool $permReadChild Permission to read subordinates sheets
+ * @param bool $permReadAll Permission to read all sheets
+ * @return array{users:array>,totals:array>>,errors:string[]>|
+ * array{errors:string[]}
+ */
+function tw_collect_summary_data($db, array $timesheetIds, User $user, $permReadOwn, $permReadChild, $permReadAll)
+{
+ $ids = array();
+ foreach ($timesheetIds as $candidate) {
+ $candidate = (int) $candidate;
+ if ($candidate > 0) {
+ $ids[] = $candidate;
+ }
+ }
+ $ids = array_values(array_unique($ids));
+ if (empty($ids)) {
+ return array('errors' => array('TimesheetWeekSummaryNoSelection'));
+ }
+
+ $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 .= " WHERE t.rowid IN (".$idList.")";
+ $sql .= " AND t.entity IN (".getEntity('timesheetweek').")";
+
+ $resql = $db->query($sql);
+ if (!$resql) {
+ return array('errors' => array($db->lasterror()));
+ }
+
+ $dataset = array();
+ $errors = array();
+
+ while ($row = $db->fetch_object($resql)) {
+ $targetUserId = (int) $row->fk_user;
+ $canRead = tw_can_act_on_user($targetUserId, $permReadOwn, $permReadChild, ($permReadAll || !empty($user->admin)), $user);
+ if (!$canRead) {
+ $errors[] = 'TimesheetWeekSummaryUnauthorizedSheet';
+ continue;
+ }
+
+ $week = (int) $row->week;
+ $year = (int) $row->year;
+
+ $weekStart = new DateTime();
+ $weekStart->setISODate($year, $week);
+ $weekEnd = clone $weekStart;
+ $weekEnd->modify('+6 days');
+
+ $contractHours = (float) $row->weeklyhours;
+ if ($contractHours <= 0) {
+ $contractHours = 35.0;
+ }
+ $contractHours = min($contractHours, (float) $row->total_hours);
+
+ if (!isset($dataset[$targetUserId])) {
+ $userSummary = new User($db);
+ if ($userSummary->fetch($targetUserId) <= 0) {
+ // EN: Skip users that cannot be fetched due to deletion or entity mismatch.
+ // FR: Ignore les utilisateurs introuvables suite à une suppression ou à un écart d'entité.
+ $errors[] = 'TimesheetWeekSummaryMissingUser';
+ continue;
+ }
+ $dataset[$targetUserId] = array(
+ 'user' => $userSummary,
+ 'records' => array(),
+ 'totals' => array(
+ 'total_hours' => 0.0,
+ 'contract_hours' => 0.0,
+ 'overtime_hours' => 0.0,
+ 'meal_count' => 0,
+ 'zone1_count' => 0,
+ 'zone2_count' => 0,
+ 'zone3_count' => 0,
+ 'zone4_count' => 0,
+ 'zone5_count' => 0
+ )
+ );
+ }
+
+$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
+);
+
+ $dataset[$targetUserId]['records'][] = $record;
+ $dataset[$targetUserId]['totals']['total_hours'] += $record['total_hours'];
+ $dataset[$targetUserId]['totals']['contract_hours'] += $record['contract_hours'];
+ $dataset[$targetUserId]['totals']['overtime_hours'] += $record['overtime_hours'];
+ $dataset[$targetUserId]['totals']['meal_count'] += $record['meal_count'];
+ $dataset[$targetUserId]['totals']['zone1_count'] += $record['zone1_count'];
+ $dataset[$targetUserId]['totals']['zone2_count'] += $record['zone2_count'];
+ $dataset[$targetUserId]['totals']['zone3_count'] += $record['zone3_count'];
+ $dataset[$targetUserId]['totals']['zone4_count'] += $record['zone4_count'];
+ $dataset[$targetUserId]['totals']['zone5_count'] += $record['zone5_count'];
+ }
+
+ $db->free($resql);
+
+ if (empty($dataset)) {
+ $errors[] = 'TimesheetWeekSummaryNoData';
+ }
+
+ return array(
+ 'users' => $dataset,
+ 'errors' => array_values(array_unique($errors))
+ );
+}
+
+/**
+ * EN: Generate the PDF file summarising selected weekly timesheets.
+ * FR: Génère le fichier PDF résumant les feuilles de temps hebdomadaires sélectionnées.
+ *
+ * @param DoliDB $db Database handler
+ * @param Conf $conf Dolibarr configuration
+ * @param Translate $langs Translator
+ * @param User $user Current Dolibarr user
+ * @param int[] $timesheetIds Selected timesheet identifiers
+ * @param bool $permReadOwn Permission to read own sheets
+ * @param bool $permReadChild Permission to read subordinates sheets
+ * @param bool $permReadAll Permission to read all sheets
+ * @return array{success:bool,file?:string,relative?:string,errors?:string[],warnings?:string[]}
+ */
+function tw_generate_summary_pdf($db, $conf, $langs, User $user, array $timesheetIds, $permReadOwn, $permReadChild, $permReadAll)
+{
+ // EN: Ensure all translations required by the PDF summary are available before rendering.
+ // FR: Garantit la disponibilité des traductions nécessaires à la synthèse PDF avant le rendu.
+ if (method_exists($langs, 'loadLangs')) {
+ $langs->loadLangs(array('timesheetweek@timesheetweek', 'errors'));
+ } else {
+ // EN: Fallback for older Dolibarr versions that expose only the singular loader.
+ // FR: Solution de secours pour les versions de Dolibarr ne proposant que le chargeur unitaire.
+ $langs->load('timesheetweek@timesheetweek');
+ $langs->load('errors');
+ }
+
+ // EN: Guarantee the Dolibarr "main" dictionary is available even when not preloaded.
+ // FR: Garantit la disponibilité du dictionnaire « main » de Dolibarr même s'il n'est pas préchargé.
+ if (!property_exists($langs, 'loadedlangs') || empty($langs->loadedlangs['main'])) {
+ $langs->load('main');
+ }
+
+ // EN: Guarantee the Dolibarr "companies" dictionary is available to translate company details.
+ // FR: Garantit la disponibilité du dictionnaire « companies » pour traduire les informations société.
+ if (!property_exists($langs, 'loadedlangs') || empty($langs->loadedlangs['companies'])) {
+ $langs->load('companies');
+ }
+
+ $dataResult = tw_collect_summary_data($db, $timesheetIds, $user, $permReadOwn, $permReadChild, $permReadAll);
+ $rawWarnings = !empty($dataResult['errors']) ? $dataResult['errors'] : array();
+ $warnings = array();
+ foreach ($rawWarnings as $warn) {
+ if ($warn === null) {
+ continue;
+ }
+ $warnings[] = $langs->trans($warn);
+ }
+ $warnings = array_values(array_unique(array_filter($warnings)));
+
+ if (empty($dataResult['users'])) {
+ return array('success' => false, 'errors' => (!empty($warnings) ? $warnings : array($langs->trans('TimesheetWeekSummaryNoData'))));
+ }
+
+ $dataset = $dataResult['users'];
+ if (empty($dataset)) {
+ return array('success' => false, 'errors' => (!empty($warnings) ? $warnings : array($langs->trans('TimesheetWeekSummaryNoData'))));
+ }
+
+ uasort($dataset, function ($a, $b) {
+ $aName = dol_string_nohtmltag($a['user']->lastname);
+ $bName = dol_string_nohtmltag($b['user']->lastname);
+ return strcasecmp($aName, $bName);
+ });
+ $sortedUsers = array_values($dataset);
+
+ // EN: Track the lowest and highest ISO weeks among the selected records for filename generation.
+ // FR: Suit les semaines ISO minimale et maximale parmi les enregistrements sélectionnés pour le nom du fichier.
+ $earliestWeek = null;
+ $latestWeek = null;
+ foreach ($sortedUsers as $userSummary) {
+ if (empty($userSummary['records']) || !is_array($userSummary['records'])) {
+ continue;
+ }
+ foreach ($userSummary['records'] as $record) {
+ if (!isset($record['week']) || !isset($record['year'])) {
+ continue;
+ }
+ $weekValue = (int) $record['week'];
+ $yearValue = (int) $record['year'];
+ $compositeKey = sprintf('%04d%02d', $yearValue, $weekValue);
+ if ($earliestWeek === null || strcmp($compositeKey, $earliestWeek['key']) < 0) {
+ $earliestWeek = array(
+ 'key' => $compositeKey,
+ 'week' => $weekValue,
+ 'year' => $yearValue
+ );
+ }
+ if ($latestWeek === null || strcmp($compositeKey, $latestWeek['key']) > 0) {
+ $latestWeek = array(
+ 'key' => $compositeKey,
+ 'week' => $weekValue,
+ 'year' => $yearValue
+ );
+ }
+ }
+ }
+ $firstWeekLabel = $earliestWeek !== null ? sprintf('%02d', $earliestWeek['week']) : '00';
+ $lastWeekLabel = $latestWeek !== null ? sprintf('%02d', $latestWeek['week']) : $firstWeekLabel;
+ // EN: Derive the ISO years associated with the boundary weeks for display.
+ // FR: Détermine les années ISO associées aux semaines limites pour l'affichage.
+ $firstWeekYear = $earliestWeek !== null ? sprintf('%04d', $earliestWeek['year']) : date('Y');
+ $lastWeekYear = $latestWeek !== null ? sprintf('%04d', $latestWeek['year']) : $firstWeekYear;
+
+ $uploaddir = !empty($conf->timesheetweek->multidir_output[$conf->entity] ?? null)
+ ? $conf->timesheetweek->multidir_output[$conf->entity]
+ : (!empty($conf->timesheetweek->dir_output) ? $conf->timesheetweek->dir_output : DOL_DATA_ROOT.'/timesheetweek');
+
+ $targetDir = rtrim($uploaddir, '/').'/'.TIMESHEETWEEK_PDF_SUMMARY_SUBDIR;
+ if (dol_mkdir($targetDir) < 0) {
+ return array('success' => false, 'errors' => array($langs->trans('ErrorCanNotCreateDir', $targetDir)));
+ }
+
+ $timestamp = dol_now();
+ // EN: Generate the human-readable filename using translations before sanitising it for storage.
+ // FR: Génère le nom lisible via les traductions avant de le nettoyer pour l'enregistrement.
+ $displayFilename = $langs->trans('TimesheetWeekSummaryFilename', $firstWeekLabel, $lastWeekLabel);
+ // EN: Remove accents and special characters before running Dolibarr sanitisation while keeping readable spaces.
+ // FR: Supprime les accents et caractères spéciaux avant l'assainissement Dolibarr tout en conservant des espaces lisibles.
+ $asciiFilename = dol_string_unaccent($displayFilename);
+ $asciiFilename = preg_replace('/[^A-Za-z0-9._\\- ]+/', '', $asciiFilename);
+ $asciiFilename = trim(preg_replace('/\s+/', ' ', $asciiFilename));
+ if ($asciiFilename !== '') {
+ // EN: Reuse the cleaned ASCII string to preserve natural spacing in the filename.
+ // FR: Réutilise la chaîne ASCII nettoyée pour préserver les espaces naturels dans le nom de fichier.
+ $displayFilename = $asciiFilename;
+ }
+ // EN: Sanitize the filename to match Dolibarr's document security checks and avoid missing file errors.
+ // FR: Nettoie le nom de fichier pour correspondre aux contrôles de sécurité Dolibarr et éviter les erreurs d'absence de fichier.
+ $filename = dol_sanitizeFileName($displayFilename);
+ if ($filename === '') {
+ // EN: Fallback on the original label when sanitisation returns an empty value (extreme edge cases).
+ // FR: Revient au libellé initial si le nettoyage renvoie une valeur vide (cas extrêmes).
+ $filename = dol_sanitizeFileName('timesheetweek-summary-'.$firstWeekLabel.'-'.$lastWeekLabel.'.pdf');
+ }
+ $filepath = $targetDir.'/'.$filename;
+
+ // 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: 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');
+ // EN: Inject the selected ISO week bounds into the translated label to reflect the chosen range.
+ // FR: Injecte les bornes de semaine ISO sélectionnées dans le libellé traduit pour refléter la plage choisie.
+ $headerWeekRange = sprintf($headerWeekRangeLabel, $firstWeekLabel, $firstWeekYear, $lastWeekLabel, $lastWeekYear);
+ $headerSubtitle = $langs->trans('TimesheetWeekSummaryGeneratedOnBy', dol_print_date($timestamp, 'dayhour'), $user->getFullName($langs));
+
+ $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;
+ // EN: Compute the effective auto-break margin to guarantee enough room for the full footer block.
+ // FR: Calcule la marge effective des sauts automatiques pour réserver l'espace complet du pied de page.
+ $autoPageBreakMargin = $margeBasse + $footerReserve;
+
+ $pdf = pdf_getInstance($pdfFormat);
+ $defaultFontSize = pdf_getPDFFontSize($langs);
+ $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')) {
+ // 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);
+ $headerState['automatic'] = true;
+ });
+ // EN: Delegate footer drawing to TCPDF to guarantee presence on automatic page breaks.
+ // FR: Confie le dessin du pied de page à TCPDF pour garantir sa présence lors des sauts automatiques.
+ $pdf->setPrintFooter(true);
+ $pdf->setFooterCallback(function ($pdfInstance) use ($langs, $conf, $margeGauche, $margeDroite, $margeBasse, $autoPageBreakMargin) {
+ tw_pdf_draw_footer($pdfInstance, $langs, $conf, $margeGauche, $margeDroite, $margeBasse, null, 0, $autoPageBreakMargin);
+ });
+ } else {
+ // EN: Disable default TCPDF decorations when callbacks are unavailable and rely on manual drawing.
+ // FR: Désactive les décorations TCPDF par défaut si les callbacks sont indisponibles et s'appuie sur le dessin manuel.
+ $pdf->setPrintHeader(false);
+ $pdf->setPrintFooter(false);
+ }
+ // EN: Enable alias replacement for total pages when the method exists on the PDF engine.
+ // FR: Active le remplacement de l'alias pour le nombre total de pages quand la méthode existe sur le moteur PDF.
+ if (method_exists($pdf, 'AliasNbPages')) {
+ $pdf->AliasNbPages();
+ } elseif (method_exists($pdf, 'setAliasNbPages')) {
+ // EN: Fallback for engines exposing the alias configuration through a setter.
+ // FR: Solution de secours pour les moteurs exposant la configuration de l'alias via un setter.
+ $pdf->setAliasNbPages();
+ }
+ $pdf->SetCreator('Dolibarr '.DOL_VERSION);
+ $pdf->SetAuthor($user->getFullName($langs));
+ $pdf->SetTitle(tw_pdf_normalize_string($langs->trans('TimesheetWeekSummaryTitle')));
+ $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);
+ $pageHeight = $pdf->getPageHeight();
+
+ // EN: Offset the cursor slightly below the header to leave breathing space before the content.
+ // FR: Décale le curseur juste sous l'entête pour laisser un espace avant le contenu.
+ $contentTop += 2.0;
+ $pdf->SetXY($margeGauche, $contentTop);
+ $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;
+
+ $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);
+ $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);
+ $pageHeight = $pdf->getPageHeight();
+ $availableHeight = ($pageHeight - ($margeBasse + $footerReserve)) - $pdf->GetY();
+ if (($spacingBeforeTable + $tableHeight) > $availableHeight) {
+ // EN: Warn users when a table cannot fit even on a fresh page.
+ // FR: Avertit les utilisateurs lorsqu'un tableau ne tient pas même sur une page vierge.
+ $warnings[] = $langs->trans('TimesheetWeekSummaryTableTooTall', $userObject->getFullName($langs));
+ }
+ }
+
+ if ($isFirstUser) {
+ // EN: Skip the initial spacer so the first table begins on the opening page.
+ // FR: Ignore l'espacement initial pour que le premier tableau démarre sur la page d'ouverture.
+ $isFirstUser = false;
+ } else {
+ $pdf->Ln(4);
+ }
+
+ tw_pdf_print_user_banner($pdf, $langs, $userObject, $defaultFontSize);
+ $headerY = $pdf->GetY() + 2;
+ // EN: Position the table header just after the employee banner.
+ // FR: Positionne l'entête du tableau juste après l'en-tête salarié.
+ $pdf->SetY($headerY);
+ $pdf->SetFillColor(230, 230, 230);
+ $pdf->SetDrawColor(128, 128, 128);
+ $pdf->SetLineWidth(0.2);
+ $pdf->SetFont('', 'B', $defaultFontSize - 1);
+ $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(
+ 'fill' => true,
+ 'alignments' => array_fill(0, count($columnLabels), 'C')
+ ));
+
+ $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.
+ // FR: Affiche chaque ligne de données en conservant des hauteurs cohérentes dans le tableau.
+ foreach ($recordRows as $rowData) {
+ $pdf->SetX($margeGauche);
+ tw_pdf_render_row($pdf, $columnWidths, $rowData, $lineHeight, array(
+ 'alignments' => $alignments
+ ));
+ }
+
+ $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')
+ ));
+
+ }
+ $pdf->Output($filepath, 'F');
+
+ return array(
+ 'success' => true,
+ 'file' => $filepath,
+ 'relative' => TIMESHEETWEEK_PDF_SUMMARY_SUBDIR.'/'.$filename,
+ 'warnings' => $warnings
+ );
+}
diff --git a/timesheetweek_agenda.php b/timesheetweek_agenda.php
index 9d7a621..32025b5 100644
--- a/timesheetweek_agenda.php
+++ b/timesheetweek_agenda.php
@@ -213,8 +213,8 @@ function tw_fetch_assigned_users_for_events($db, array $eventIds)
// Set $enablepermissioncheck to 1 to enable a minimum low level of checks
$enablepermissioncheck = getDolGlobalInt('TIMESHEETWEEK_ENABLE_PERMISSION_CHECK');
if ($enablepermissioncheck) {
- $permissiontoread = $user->hasRight('timesheetweek', 'timesheetweek', 'read');
- $permissiontoadd = $user->hasRight('timesheetweek', 'timesheetweek', 'write');
+ $permissiontoread = $user->hasRight('timesheetweek', 'read');
+ $permissiontoadd = $user->hasRight('timesheetweek', 'write');
} else {
$permissiontoread = 1;
$permissiontoadd = 1;
diff --git a/timesheetweek_card.php b/timesheetweek_card.php
index 39025c6..94e8bde 100644
--- a/timesheetweek_card.php
+++ b/timesheetweek_card.php
@@ -78,25 +78,25 @@ function tw_translate_error($errorKey, $langs)
}
// ---- Permissions (nouveau modèle) ----
-$permRead = $user->hasRight('timesheetweek','timesheetweek','read');
-$permReadChild = $user->hasRight('timesheetweek','timesheetweek','readChild');
-$permReadAll = $user->hasRight('timesheetweek','timesheetweek','readAll');
+$permRead = $user->hasRight('timesheetweek','read');
+$permReadChild = $user->hasRight('timesheetweek','readChild');
+$permReadAll = $user->hasRight('timesheetweek','readAll');
-$permWrite = $user->hasRight('timesheetweek','timesheetweek','write');
-$permWriteChild = $user->hasRight('timesheetweek','timesheetweek','writeChild');
-$permWriteAll = $user->hasRight('timesheetweek','timesheetweek','writeAll');
+$permWrite = $user->hasRight('timesheetweek','write');
+$permWriteChild = $user->hasRight('timesheetweek','writeChild');
+$permWriteAll = $user->hasRight('timesheetweek','writeAll');
-$permValidate = $user->hasRight('timesheetweek','timesheetweek','validate');
-$permValidateOwn = $user->hasRight('timesheetweek','timesheetweek','validateOwn');
-$permValidateChild = $user->hasRight('timesheetweek','timesheetweek','validateChild');
-$permValidateAll = $user->hasRight('timesheetweek','timesheetweek','validateAll');
+$permValidate = $user->hasRight('timesheetweek','validate');
+$permValidateOwn = $user->hasRight('timesheetweek','validateOwn');
+$permValidateChild = $user->hasRight('timesheetweek','validateChild');
+$permValidateAll = $user->hasRight('timesheetweek','validateAll');
-$permDelete = $user->hasRight('timesheetweek','timesheetweek','delete');
-$permDeleteChild = $user->hasRight('timesheetweek','timesheetweek','deleteChild');
-$permDeleteAll = $user->hasRight('timesheetweek','timesheetweek','deleteAll');
+$permDelete = $user->hasRight('timesheetweek','delete');
+$permDeleteChild = $user->hasRight('timesheetweek','deleteChild');
+$permDeleteAll = $user->hasRight('timesheetweek','deleteAll');
-$permSeal = $user->hasRight('timesheetweek','timesheetweek','seal');
-$permUnseal = $user->hasRight('timesheetweek','timesheetweek','unseal');
+$permSeal = $user->hasRight('timesheetweek','seal');
+$permUnseal = $user->hasRight('timesheetweek','unseal');
$permReadAny = ($permRead || $permReadChild || $permReadAll);
$permWriteAny = ($permWrite || $permWriteChild || $permWriteAll);
diff --git a/timesheetweek_document.php b/timesheetweek_document.php
index efb37f6..84d77a8 100644
--- a/timesheetweek_document.php
+++ b/timesheetweek_document.php
@@ -142,8 +142,8 @@
// Set $enablepermissioncheck to 1 to enable a minimum low level of checks
$enablepermissioncheck = getDolGlobalInt('TIMESHEETWEEK_ENABLE_PERMISSION_CHECK');
if ($enablepermissioncheck) {
- $permissiontoread = $user->hasRight('timesheetweek', 'timesheetweek', 'read');
- $permissiontoadd = $user->hasRight('timesheetweek', 'timesheetweek', 'write'); // Used by the include of actions_addupdatedelete.inc.php and actions_linkedfiles.inc.php
+ $permissiontoread = $user->hasRight('timesheetweek', 'read');
+ $permissiontoadd = $user->hasRight('timesheetweek', 'write'); // Used by the include of actions_addupdatedelete.inc.php and actions_linkedfiles.inc.php
} else {
$permissiontoread = 1;
$permissiontoadd = 1;
diff --git a/timesheetweek_list.php b/timesheetweek_list.php
index 7d16e51..560b1b3 100644
--- a/timesheetweek_list.php
+++ b/timesheetweek_list.php
@@ -23,19 +23,25 @@
// EN: Check permissions before loading any additional resources to abort early.
// FR: Vérifie les permissions avant de charger d'autres ressources pour interrompre immédiatement.
-$permRead = $user->hasRight('timesheetweek','timesheetweek','read');
-$permReadChild = $user->hasRight('timesheetweek','timesheetweek','readChild');
-$permReadAll = $user->hasRight('timesheetweek','timesheetweek','readAll');
-$permWrite = $user->hasRight('timesheetweek','timesheetweek','write');
-$permWriteChild = $user->hasRight('timesheetweek','timesheetweek','writeChild');
-$permWriteAll = $user->hasRight('timesheetweek','timesheetweek','writeAll');
-$permDelete = $user->hasRight('timesheetweek','timesheetweek','delete');
-$permDeleteChild = $user->hasRight('timesheetweek','timesheetweek','deleteChild');
-$permDeleteAll = $user->hasRight('timesheetweek','timesheetweek','deleteAll');
-$permValidate = $user->hasRight('timesheetweek','timesheetweek','validate');
-$permValidateOwn = $user->hasRight('timesheetweek','timesheetweek','validateOwn');
-$permValidateChild = $user->hasRight('timesheetweek','timesheetweek','validateChild');
-$permValidateAll = $user->hasRight('timesheetweek','timesheetweek','validateAll');
+$permRead = $user->hasRight('timesheetweek', 'read');
+$permReadChild = $user->hasRight('timesheetweek','readChild');
+$permReadAll = $user->hasRight('timesheetweek','readAll');
+$permWrite = $user->hasRight('timesheetweek','write');
+$permWriteChild = $user->hasRight('timesheetweek','writeChild');
+$permWriteAll = $user->hasRight('timesheetweek','writeAll');
+$permDelete = $user->hasRight('timesheetweek','delete');
+$permDeleteChild = $user->hasRight('timesheetweek','deleteChild');
+$permDeleteAll = $user->hasRight('timesheetweek','deleteAll');
+$permSeal = $user->hasRight('timesheetweek','seal');
+$permValidate = $user->hasRight('timesheetweek','validate');
+$permValidateOwn = $user->hasRight('timesheetweek','validateOwn');
+$permValidateChild = $user->hasRight('timesheetweek','validateChild');
+$permValidateAll = $user->hasRight('timesheetweek','validateAll');
+// EN: Prepare Dolibarr's generic permission flags for mass-action helpers.
+// FR: Prépare les indicateurs de permission Dolibarr pour les helpers d'actions de masse.
+$permissiontoread = ($permRead || $permReadChild || $permReadAll);
+$permissiontoadd = ($permWrite || $permWriteChild || $permWriteAll);
+$permissiontodelete = ($permDelete || $permDeleteChild || $permDeleteAll || !empty($user->admin));
$canSeeAllEmployees = (!empty($user->admin) || $permReadAll || $permWriteAll || $permDeleteAll || $permValidateAll);
$permViewAny = ($permRead || $permReadChild || $permReadAll || $permWrite || $permWriteChild || $permWriteAll || $permDelete ||
$permDeleteChild || $permDeleteAll || $permValidate || $permValidateOwn || $permValidateChild || $permValidateAll || !empty($user->admin));
@@ -70,7 +76,77 @@
// FR: Détecte si le module Multicompany est activé pour exposer les données spécifiques d'entité.
$multicompanyEnabled = !empty($conf->multicompany->enabled);
+if (!function_exists('tw_can_validate_timesheet_masslist')) {
+ /**
+ * EN: Determine if the current user is allowed to validate the provided sheet.
+ * FR: Détermine si l'utilisateur courant est autorisé à valider la feuille fournie.
+ *
+ * @param TimesheetWeek $sheet Sheet to evaluate
+ * @param User $user Current Dolibarr user
+ * @param bool $permValidate Direct validation right
+ * @param bool $permValidateOwn Validation on own sheets
+ * @param bool $permValidateChild Validation on subordinate sheets
+ * @param bool $permValidateAll Global validation right
+ * @param bool $permWrite Write right on own sheets
+ * @param bool $permWriteChild Write right on subordinate sheets
+ * @param bool $permWriteAll Global write right
+ * @return bool True when validation is authorised
+ */
+ function tw_can_validate_timesheet_masslist(
+ TimesheetWeek $sheet,
+ User $user,
+ $permValidate,
+ $permValidateOwn,
+ $permValidateChild,
+ $permValidateAll,
+ $permWrite,
+ $permWriteChild,
+ $permWriteAll
+ ) {
+ // EN: Check explicit validation rights first to keep the behaviour consistent with the card view.
+ // FR: Vérifie d'abord les droits explicites de validation pour rester cohérent avec la fiche détaillée.
+ $hasExplicitValidation = ($permValidate || $permValidateOwn || $permValidateChild || $permValidateAll);
+ if (!empty($user->admin)) {
+ $permValidateAll = true;
+ $hasExplicitValidation = true;
+ }
+
+ if (!$hasExplicitValidation) {
+ // EN: Reuse write permissions when legacy configurations rely on them for validation.
+ // FR: Réutilise les permissions d'écriture lorsque les anciennes configurations s'en servent pour valider.
+ if ($permWriteAll) {
+ $permValidateAll = true;
+ }
+ if ($permWriteChild) {
+ $permValidateChild = true;
+ }
+ if ($permWrite || $permWriteChild || $permWriteAll) {
+ if ((int) $sheet->fk_user_valid === (int) $user->id) {
+ $permValidate = true;
+ }
+ if (!$permValidateChild && $permWriteChild) {
+ $permValidateChild = true;
+ }
+ }
+ }
+
+ if ($permValidateAll) {
+ return true;
+ }
+ if ($permValidateChild && tw_is_manager_of($sheet->fk_user, $user)) {
+ return true;
+ }
+ if ($permValidateOwn && ((int) $user->id === (int) $sheet->fk_user)) {
+ return true;
+ }
+ if ($permValidate && ((int) $user->id === (int) $sheet->fk_user_valid)) {
+ return true;
+ }
+
+ return false;
+ }
+}
/**
* Params
*/
@@ -79,7 +155,7 @@
$show_files = GETPOSTINT('show_files');
$confirm = GETPOST('confirm', 'alpha');
$cancel = GETPOST('cancel', 'alpha');
-$toselect = GETPOST('toselect', 'array');
+$toselect = GETPOST('toselect', 'array', 2);
$contextpage = GETPOST('contextpage', 'aZ') ? GETPOST('contextpage', 'aZ') : 'timesheetweeklist';
$sortfield = GETPOST('sortfield', 'aZ09comma');
@@ -239,17 +315,297 @@
include DOL_DOCUMENT_ROOT.'/core/actions_changeselectedfields.inc.php';
/**
- * Mass actions (UI)
- */
-$arrayofmassactions = array(
- 'approve_selection' => img_picto('', 'validate', 'class="pictofixedwidth"').$langs->trans("ApproveSelection"),
- 'refuse_selection' => img_picto('', 'warning', 'class="pictofixedwidth"').$langs->trans("RefuseSelection"),
- 'predelete' => img_picto('', 'delete', 'class="pictofixedwidth"').$langs->trans("DeleteSelection"),
+* Mass actions (UI)
+*/
+$arrayofmassactions = array();
+// EN: Offer approval when the user holds validation or equivalent legacy permissions.
+// FR: Propose l'approbation lorsque l'utilisateur dispose des droits de validation ou équivalents hérités.
+$canDisplayValidationActions = (
+ $permValidate || $permValidateOwn || $permValidateChild || $permValidateAll ||
+ $permWrite || $permWriteChild || $permWriteAll || !empty($user->admin)
);
-$massactionbutton = $form->selectMassAction('', $arrayofmassactions);
+if ($canDisplayValidationActions) {
+ $arrayofmassactions['approve_selection'] = img_picto('', 'check', 'class="pictofixedwidth"').$langs->trans('ApproveSelection');
+ $arrayofmassactions['refuse_selection'] = img_picto('', 'uncheck', 'class="pictofixedwidth"').$langs->trans('RefuseSelection');
+}
+// EN: Display the sealing control only to users granted with the dedicated right.
+// FR: Affiche le contrôle de scellement uniquement pour les utilisateurs disposant du droit dédié.
+if ($permSeal) {
+ $arrayofmassactions['sceller'] = img_picto('', 'lock', 'class="pictofixedwidth"').$langs->trans('SealSelection');
+}
+// EN: Allow PDF summary generation to any user allowed to read the listed sheets.
+// FR: Autorise la génération d'un PDF de synthèse à tout utilisateur habilité à lire les feuilles listées.
+if ($permissiontoread) {
+ $arrayofmassactions['generate_summary_pdf'] = img_picto('', 'pdf', 'class="pictofixedwidth"').$langs->trans('GenerateSummaryPdf');
+}
+// EN: Expose the draft-only bulk deletion with Dolibarr's confirmation flow when the operator may delete sheets.
+// FR: Expose la suppression massive limitée aux brouillons avec la confirmation Dolibarr lorsque l'opérateur peut supprimer des feuilles.
+if ($permissiontodelete) {
+ $arrayofmassactions['predelete'] = img_picto('', 'delete', 'class="pictofixedwidth"').$langs->trans('DeleteSelection');
+}
+
+$massactionbutton = $form->selectMassAction($massaction, $arrayofmassactions);
+$objectclass = 'TimesheetWeek';
+$objectlabel = 'TimesheetWeek';
+$object = new TimesheetWeek($db);
+
+$uploaddir = !empty($conf->timesheetweek->multidir_output[$conf->entity] ?? null)
+? $conf->timesheetweek->multidir_output[$conf->entity]
+: (!empty($conf->timesheetweek->dir_output) ? $conf->timesheetweek->dir_output : DOL_DATA_ROOT.'/timesheetweek');
+$upload_dir = $uploaddir;
+// Affiche le menu d’actions
+$showmassactionbutton = 1;
+
+include DOL_DOCUMENT_ROOT.'/core/actions_massactions.inc.php';
+
+// EN: Normalise the selected identifiers provided by Dolibarr's mass-action handler.
+// FR: Normalise les identifiants sélectionnés fournis par le gestionnaire d'actions de masse de Dolibarr.
$arrayofselected = is_array($toselect) ? $toselect : array();
+$massActionProcessed = false;
+
+if ($massaction === 'approve_selection') {
+ $massActionProcessed = true;
+ if (!$canDisplayValidationActions) {
+ // EN: Stop the approval when the operator lacks validation permissions.
+ // FR: Empêche l'approbation lorsque l'opérateur n'a pas les permissions de validation.
+ setEventMessages($langs->trans('NotEnoughPermissions'), null, 'errors');
+ } else {
+ $db->begin();
+ $ok = 0;
+ $ko = array();
+ foreach ((array) $arrayofselected as $id) {
+ $id = (int) $id;
+ if ($id <= 0) {
+ continue;
+ }
+ $o = new TimesheetWeek($db);
+ if ($o->fetch($id) <= 0) {
+ $ko[] = '#'.$id;
+ continue;
+ }
+ if (!tw_can_validate_timesheet_masslist($o, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll)) {
+ // EN: Reject the sheet when the current user cannot validate it according to delegation rules.
+ // FR: Rejette la feuille lorsque l'utilisateur courant ne peut pas la valider selon les règles de délégation.
+ $ko[] = $o->ref ?: '#'.$id;
+ continue;
+ }
+ $res = $o->approve($user);
+ if ($res > 0) {
+ $ok++;
+ } else {
+ $ko[] = $o->ref ?: '#'.$id;
+ }
+ }
+ if ($ko) {
+ $db->rollback();
+ } else {
+ $db->commit();
+ }
+ if ($ok) {
+ setEventMessages($langs->trans('TimesheetWeekMassApproveSuccess', $ok), null, 'mesgs');
+ }
+ if ($ko) {
+ setEventMessages($langs->trans('TimesheetWeekMassActionErrors', implode(', ', $ko)), null, 'errors');
+ }
+ }
+}
+
+if ($massaction === 'refuse_selection') {
+ $massActionProcessed = true;
+ if (!$canDisplayValidationActions) {
+ // EN: Prevent the refusal when the operator is not authorised to validate sheets.
+ // FR: Empêche le refus lorsque l'opérateur n'est pas autorisé à valider les feuilles.
+ setEventMessages($langs->trans('NotEnoughPermissions'), null, 'errors');
+ } else {
+ $db->begin();
+ $ok = 0;
+ $ko = array();
+ foreach ((array) $arrayofselected as $id) {
+ $id = (int) $id;
+ if ($id <= 0) {
+ continue;
+ }
+ $o = new TimesheetWeek($db);
+ if ($o->fetch($id) <= 0) {
+ $ko[] = '#'.$id;
+ continue;
+ }
+ if (!tw_can_validate_timesheet_masslist($o, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll)) {
+ // EN: Skip the refusal when the user cannot manage the employee under current rights.
+ // FR: Ignore le refus lorsque l'utilisateur ne peut pas gérer l'employé avec les droits actuels.
+ $ko[] = $o->ref ?: '#'.$id;
+ continue;
+ }
+ $res = $o->refuse($user);
+ if ($res > 0) {
+ $ok++;
+ } else {
+ $ko[] = $o->ref ?: '#'.$id;
+ }
+ }
+ if ($ko) {
+ $db->rollback();
+ } else {
+ $db->commit();
+ }
+ if ($ok) {
+ setEventMessages($langs->trans('TimesheetWeekMassRefuseSuccess', $ok), null, 'mesgs');
+ }
+ if ($ko) {
+ setEventMessages($langs->trans('TimesheetWeekMassActionErrors', implode(', ', $ko)), null, 'errors');
+ }
+ }
+}
+
+if ($massaction === 'sceller') {
+ $massActionProcessed = true;
+ if (!$permSeal) {
+ // EN: Refuse sealing when the operator does not own the dedicated right.
+ // FR: Refuse le scellement lorsque l'opérateur ne possède pas le droit dédié.
+ setEventMessages($langs->trans('NotEnoughPermissions'), null, 'errors');
+ } else {
+ $db->begin();
+ $ok = 0;
+ $ko = array();
+ foreach ((array) $arrayofselected as $id) {
+ $id = (int) $id;
+ if ($id <= 0) {
+ continue;
+ }
+ $o = new TimesheetWeek($db);
+ if ($o->fetch($id) <= 0) {
+ $ko[] = '#'.$id;
+ continue;
+ }
+ if (!tw_can_validate_timesheet_masslist($o, $user, $permValidate, $permValidateOwn, $permValidateChild, $permValidateAll, $permWrite, $permWriteChild, $permWriteAll)) {
+ // EN: Keep the sheet untouched when the manager cannot act on the employee scope.
+ // FR: Laisse la feuille inchangée lorsque le gestionnaire ne peut pas agir sur le périmètre de l'employé.
+ $ko[] = $o->ref ?: '#'.$id;
+ continue;
+ }
+ $res = $o->seal($user);
+ if ($res > 0) {
+ $ok++;
+ } else {
+ $ko[] = $o->ref ?: '#'.$id;
+ }
+ }
+ if ($ko) {
+ $db->rollback();
+ } else {
+ $db->commit();
+ }
+ if ($ok) {
+ setEventMessages($langs->trans('TimesheetWeekMassSealSuccess', $ok), null, 'mesgs');
+ }
+ if ($ko) {
+ setEventMessages($langs->trans('TimesheetWeekMassActionErrors', implode(', ', $ko)), null, 'errors');
+ }
+ }
+}
+if ($massaction === 'generate_summary_pdf') {
+ $massActionProcessed = true;
+ if (!$permissiontoread) {
+ // EN: Block summary export when the user has no read permission.
+ // FR: Bloque l'export de synthèse lorsque l'utilisateur n'a pas le droit de lecture.
+ setEventMessages($langs->trans('NotEnoughPermissions'), null, 'errors');
+ } else {
+ if (empty($arrayofselected)) {
+ // EN: Notify operators that a selection is required before generating the summary.
+ // FR: Informe les opérateurs qu'une sélection est nécessaire avant de générer la synthèse.
+ setEventMessages($langs->trans('TimesheetWeekErrorNoSelection'), null, 'errors');
+ } else {
+ dol_include_once('/timesheetweek/lib/timesheetweek_pdf.lib.php');
+ $result = tw_generate_summary_pdf($db, $conf, $langs, $user, $arrayofselected, $permRead, $permReadChild, $permReadAll);
+ if (empty($result['success'])) {
+ // EN: Report generation issues to the operator.
+ // FR: Signale les problèmes de génération à l'opérateur.
+ if (!empty($result['errors'])) {
+ setEventMessages(null, $result['errors'], 'errors');
+ } else {
+ setEventMessages($langs->trans('ErrorUnknown'), null, 'errors');
+ }
+ } else {
+ if (!empty($result['warnings'])) {
+ setEventMessages(null, $result['warnings'], 'warnings');
+ }
+ if (!empty($result['relative'])) {
+ $downloadUrl = DOL_URL_ROOT.'/document.php?modulepart=timesheetweek&file='.urlencode($result['relative']).'&entity='.$conf->entity.'&permission=read';
+ header('Location: '.$downloadUrl);
+ exit;
+ }
+ setEventMessages($langs->trans('TimesheetWeekSummaryGenerated'), null, 'mesgs');
+ }
+ }
+ }
+}
+if ($massaction === 'delete') {
+ $massActionProcessed = true;
+ if (!$permissiontodelete) {
+ // EN: Block the deletion when the user lacks the necessary rights.
+ // FR: Bloque la suppression lorsque l'utilisateur ne dispose pas des droits nécessaires.
+ setEventMessages($langs->trans('NotEnoughPermissions'), null, 'errors');
+ } else {
+ $db->begin();
+ $ok = 0;
+ $ko = array();
+ $nonDraftDetected = false;
+ foreach ((array) $arrayofselected as $id) {
+ $id = (int) $id;
+ if ($id <= 0) {
+ continue;
+ }
+ $o = new TimesheetWeek($db);
+ if ($o->fetch($id) <= 0) {
+ $ko[] = '#'.$id;
+ continue;
+ }
+ if (!tw_can_act_on_user($o->fk_user, $permDelete, $permDeleteChild, ($permDeleteAll || !empty($user->admin)), $user)) {
+ // EN: Prevent deletion outside the managerial scope defined by Dolibarr rights.
+ // FR: Empêche la suppression en dehors du périmètre managérial défini par les droits Dolibarr.
+ $ko[] = $o->ref ?: '#'.$id;
+ continue;
+ }
+ if ((int) $o->status !== TimesheetWeek::STATUS_DRAFT) {
+ // EN: Enforce the draft-only restriction required for bulk deletions.
+ // FR: Applique la restriction aux brouillons exigée pour les suppressions massives.
+ $ko[] = $o->ref ?: '#'.$id;
+ $nonDraftDetected = true;
+ continue;
+ }
+ $res = $o->delete($user);
+ if ($res > 0) {
+ $ok++;
+ } else {
+ $ko[] = ($o->ref ?: '#'.$id);
+ }
+ }
+ if ($ko) {
+ $db->rollback();
+ } else {
+ $db->commit();
+ }
+ if ($ok) {
+ setEventMessages($langs->trans('RecordsDeleted', $ok), null, 'mesgs');
+ }
+ if ($ko) {
+ setEventMessages($langs->trans('TimesheetWeekMassActionErrors', implode(', ', $ko)), null, 'errors');
+ }
+ if ($nonDraftDetected) {
+ // EN: Inform the operator that only draft sheets are eligible for removal.
+ // FR: Informe l'opérateur que seules les feuilles en brouillon sont éligibles à la suppression.
+ setEventMessages($langs->trans('TimesheetWeekMassDeleteOnlyDraft'), null, 'errors');
+ }
+ }
+ $massaction = '';
+}
+
+if ($massActionProcessed) {
+ $massaction = '';
+}
+
if (GETPOST('button_removefilter_x', 'alpha') || GETPOST('button_removefilter.x', 'alpha') || GETPOST('button_removefilter', 'alpha')) {
$search_ref = '';
$search_user = 0;
@@ -414,9 +770,7 @@
// FR: Conserve la limite sélectionnée dans les liens de pagination pour respecter le choix de l'utilisateur.
$param .= '&limit='.(int) $limit;
-$newcardbutton = dolGetButtonTitle($langs->trans('New'), '', 'fa fa-plus-circle', dol_buildpath('/timesheetweek/timesheetweek_card.php', 1).'?action=create', '', $user->hasRight('timesheetweek','timesheetweek','write'));
-
-print_barre_liste($title, $page, $_SERVER["PHP_SELF"], $param, $sortfield, $sortorder, $massactionbutton, $num, $nbtotalofrecords, 'bookcal', 0, $newcardbutton, '', $limit, 0, 0, 1);
+$newcardbutton = dolGetButtonTitle($langs->trans('New'), '', 'fa fa-plus-circle', dol_buildpath('/timesheetweek/timesheetweek_card.php', 1).'?action=create', '', $user->hasRight('timesheetweek','write'));
/**
* Column selector on left of titles
@@ -440,6 +794,16 @@
// FR: Conserve la limite de liste sélectionnée lors des filtrages tout en évitant les identifiants dupliqués dans le DOM.
print '';
+print_barre_liste($title, $page, $_SERVER["PHP_SELF"], $param, $sortfield, $sortorder, $massactionbutton, $num, $nbtotalofrecords, 'bookcal', 0, $newcardbutton, '', $limit, 0, 0, 1);
+
+$topicmail = "SendTimesheetWeekRef";
+$modelmail = "timesheetweek";
+$objecttmp = new TimesheetWeek($db);
+$trackid = 'tsw'.$object->id;
+// EN: Display Dolibarr's standard confirmation prompts for mass actions.
+// FR: Affiche les fenêtres de confirmation standard de Dolibarr pour les actions de masse.
+include DOL_DOCUMENT_ROOT.'/core/tpl/massactions_pre.tpl.php';
+
print '';
print '
'."\n";
diff --git a/timesheetweek_note.php b/timesheetweek_note.php
index e8637a4..22545db 100644
--- a/timesheetweek_note.php
+++ b/timesheetweek_note.php
@@ -120,9 +120,9 @@
// Set $enablepermissioncheck to 1 to enable a minimum low level of checks
$enablepermissioncheck = getDolGlobalInt('TIMESHEETWEEK_ENABLE_PERMISSION_CHECK');
if ($enablepermissioncheck) {
- $permissiontoread = $user->hasRight('timesheetweek', 'timesheetweek', 'read');
- $permissiontoadd = $user->hasRight('timesheetweek', 'timesheetweek', 'write');
- $permissionnote = $user->hasRight('timesheetweek', 'timesheetweek', 'write'); // Used by the include of actions_setnotes.inc.php
+ $permissiontoread = $user->hasRight('timesheetweek', 'read');
+ $permissiontoadd = $user->hasRight('timesheetweek', 'write');
+ $permissionnote = $user->hasRight('timesheetweek', 'write'); // Used by the include of actions_setnotes.inc.php
} else {
$permissiontoread = 1;
$permissiontoadd = 1;