diff --git a/ChangeLog.md b/ChangeLog.md
index 7368962..e7dea9e 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,14 @@
# CHANGELOG MODULE TIMESHEETWEEK FOR [DOLIBARR ERP CRM](https://www.dolibarr.org)
+## 1.7.2 (27/01/2026)
+- Respecte MAIN_DISABLE_FORCE_SAVEAS pour les liens PDF (aperçu, téléchargement, redirections). / Honors MAIN_DISABLE_FORCE_SAVEAS for PDF links (preview, download, redirects).
+- Ajuste la hauteur des lignes du tableau de synthèse PDF selon le contenu. / Adjusts summary PDF table row height based on content.
+- Ajoute le scelleur et la date de scellement dans la synthèse PDF générée par la massaction. / Adds the sealer and seal date in the summary PDF generated by the mass action.
+- Met à jour les métadonnées de scellement (fk_user_seal, date_seal) lors du scellement via l'action de masse. / Updates seal metadata (fk_user_seal, date_seal) during the mass action sealing.
+- Met à jour les métadonnées de scellement (fk_user_seal, date_seal) lors des scellements manuels et automatiques. / Updates seal metadata (fk_user_seal, date_seal) during manual and automatic sealing.
+- Met à jour les métadonnées de scellement (fk_user_seal, date_seal) lors du scellement manuel. / Updates seal metadata (fk_user_seal, date_seal) during manual sealing.
+- Ajoute les colonnes fk_user_seal et date_seal sur llx_timesheet_week avec les index associés. / Adds fk_user_seal and date_seal columns on llx_timesheet_week with the related indexes.
+
## 1.7.1 (25/01/2026)
- Corrige un problème pouvant empecher la création des PDF chez certains utilisateurs / Fix an issue that could break the PDF's generation for some users.
diff --git a/README.md b/README.md
index 3d4a7b2..2c2347f 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ TimesheetWeek ajoute une gestion hebdomadaire des feuilles de temps fidèle à l
- Saisie dédiée pour les salariés en forfait jour grâce à des sélecteurs Journée/Matin/Après-midi convertissant automatiquement les heures.
- Rappel hebdomadaire automatique par email configurable (activation, jour, heure, modèle) avec tâche planifiée dédiée et bouton d'envoi de test administrateur.
- Scellement automatique des feuilles approuvées après un délai configurable via une tâche planifiée Dolibarr native.
+- Stocke l'utilisateur et la date de scellement dans des colonnes dédiées pour faciliter le suivi.
- Affichage des compteurs dans la liste hebdomadaire et ajout du libellé « Zone » sur chaque sélecteur quotidien pour clarifier la saisie.
- Capture les heures au contrat au moment de la soumission pour figer le calcul des heures supplémentaires et les PDF, même si le contrat salarié évolue ensuite.
- Ligne de total en bas de la liste hebdomadaire pour additionner heures, zones, paniers et afficher la colonne de date de validation.
@@ -57,6 +58,7 @@ TimesheetWeek delivers weekly timesheet management that follows Dolibarr design
- Dedicated input for daily rate employees with Full day/Morning/Afternoon selectors that automatically convert hours.
- Configurable automatic weekly email reminder (enablement, weekday, time, template) with a dedicated scheduled task and admin test send button.
- Automatic sealing of approved timesheets after a configurable delay through a native Dolibarr scheduled task.
+- Stores seal user and seal date in dedicated columns for easier tracking.
- Counter display inside the weekly list plus a « Zone » caption on each daily selector for better input guidance.
- Snapshots contract hours at submission so overtime calculations and PDFs stay aligned even if the employee contract changes later.
- Total row at the bottom of the weekly list to sum hours, zones, meals and expose the validation date column.
diff --git a/class/timesheetweek.class.php b/class/timesheetweek.class.php
index eb39288..303828b 100644
--- a/class/timesheetweek.class.php
+++ b/class/timesheetweek.class.php
@@ -1032,6 +1032,8 @@ public function seal($user, $origin = 'manual')
$now = dol_now();
$noteUpdate = null;
+ $hasSealUserColumn = false;
+ $hasSealDateColumn = false;
if ((int) $this->status !== self::STATUS_APPROVED) {
$this->error = 'BadStatusForSeal';
@@ -1058,9 +1060,30 @@ public function seal($user, $origin = 'manual')
$this->db->begin();
+ // EN: Detect optional seal metadata columns.
+ // FR: Détecte les colonnes optionnelles de métadonnées de scellement.
+ $sqlCheckSealUser = "SHOW COLUMNS FROM ".MAIN_DB_PREFIX.$this->table_element." LIKE 'fk_user_seal'";
+ $resqlCheckSealUser = $this->db->query($sqlCheckSealUser);
+ if ($resqlCheckSealUser) {
+ $hasSealUserColumn = ($this->db->num_rows($resqlCheckSealUser) > 0);
+ $this->db->free($resqlCheckSealUser);
+ }
+ $sqlCheckSealDate = "SHOW COLUMNS FROM ".MAIN_DB_PREFIX.$this->table_element." LIKE 'date_seal'";
+ $resqlCheckSealDate = $this->db->query($sqlCheckSealDate);
+ if ($resqlCheckSealDate) {
+ $hasSealDateColumn = ($this->db->num_rows($resqlCheckSealDate) > 0);
+ $this->db->free($resqlCheckSealDate);
+ }
+
$sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET";
$sql .= " status=".(int) self::STATUS_SEALED;
$sql .= ", tms='".$this->db->idate($now)."'";
+ if ($hasSealUserColumn) {
+ $sql .= ", fk_user_seal=".(int) $user->id;
+ }
+ if ($hasSealDateColumn) {
+ $sql .= ", date_seal='".$this->db->idate($now)."'";
+ }
if ($noteUpdate !== null) {
$sql .= ", note='".$this->db->escape($noteUpdate)."'";
}
@@ -1076,6 +1099,12 @@ public function seal($user, $origin = 'manual')
// FR : Conserve les métadonnées d'approbation tout en verrouillant la feuille.
$this->status = self::STATUS_SEALED;
$this->tms = $now;
+ if ($hasSealUserColumn) {
+ $this->fk_user_seal = (int) $user->id;
+ }
+ if ($hasSealDateColumn) {
+ $this->date_seal = $now;
+ }
if ($noteUpdate !== null) {
$this->note = $noteUpdate;
}
diff --git a/core/modules/modTimesheetWeek.class.php b/core/modules/modTimesheetWeek.class.php
index c0ba544..2d7293b 100644
--- a/core/modules/modTimesheetWeek.class.php
+++ b/core/modules/modTimesheetWeek.class.php
@@ -112,7 +112,7 @@ public function __construct($db)
}
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
- $this->version = '1.7.1';
+ $this->version = '1.7.2';
// Url to the file with your last numberversion of this module
$this->url_last_version = 'https://moduleversion.lesmetiersdubatiment.fr/ver.php?m=timesheetweek';
diff --git a/langs/de_DE/timesheetweek.lang b/langs/de_DE/timesheetweek.lang
index 4f139b6..3330f59 100644
--- a/langs/de_DE/timesheetweek.lang
+++ b/langs/de_DE/timesheetweek.lang
@@ -201,6 +201,7 @@ TimesheetWeekSummaryColumnStatus = Status
TimesheetWeekSummaryColumnApprovedBy = Approved by
TimesheetWeekSummaryStatusApprovedBy = Approved by %s
TimesheetWeekSummaryStatusSealedBy = Sealed by %s
+TimesheetWeekSummaryStatusSealedOn = Sealed on %s
TimesheetWeekSummaryUserTitle = %s
TimesheetWeekSummaryTotalsLabel = Totals
TimesheetWeekSummaryTableTooTall = The summary for %s contains too many rows to fit on a single page.
diff --git a/langs/en_US/timesheetweek.lang b/langs/en_US/timesheetweek.lang
index 51b2efb..2892b91 100644
--- a/langs/en_US/timesheetweek.lang
+++ b/langs/en_US/timesheetweek.lang
@@ -201,6 +201,7 @@ TimesheetWeekSummaryColumnStatus = Status
TimesheetWeekSummaryColumnApprovedBy = Approved by
TimesheetWeekSummaryStatusApprovedBy = Approved by %s
TimesheetWeekSummaryStatusSealedBy = Sealed by %s
+TimesheetWeekSummaryStatusSealedOn = Sealed on %s
TimesheetWeekSummaryUserTitle = %s
TimesheetWeekSummaryTotalsLabel = Totals
TimesheetWeekSummaryTableTooTall = The summary for %s contains too many rows to fit on a single page.
diff --git a/langs/es_ES/timesheetweek.lang b/langs/es_ES/timesheetweek.lang
index 8aba138..299b9a0 100644
--- a/langs/es_ES/timesheetweek.lang
+++ b/langs/es_ES/timesheetweek.lang
@@ -201,6 +201,7 @@ TimesheetWeekSummaryColumnStatus = Status
TimesheetWeekSummaryColumnApprovedBy = Approved by
TimesheetWeekSummaryStatusApprovedBy = Approved by %s
TimesheetWeekSummaryStatusSealedBy = Sealed by %s
+TimesheetWeekSummaryStatusSealedOn = Sealed on %s
TimesheetWeekSummaryUserTitle = %s
TimesheetWeekSummaryTotalsLabel = Totals
TimesheetWeekSummaryTableTooTall = The summary for %s contains too many rows to fit on a single page.
diff --git a/langs/fr_FR/timesheetweek.lang b/langs/fr_FR/timesheetweek.lang
index a4bc023..03f207d 100644
--- a/langs/fr_FR/timesheetweek.lang
+++ b/langs/fr_FR/timesheetweek.lang
@@ -200,6 +200,7 @@ TimesheetWeekSummaryColumnStatus = Statut
TimesheetWeekSummaryColumnApprovedBy = Approuvé par
TimesheetWeekSummaryStatusApprovedBy = Approuvé par %s
TimesheetWeekSummaryStatusSealedBy = Scellée par %s
+TimesheetWeekSummaryStatusSealedOn = Scellée le %s
TimesheetWeekSummaryUserTitle = %s
TimesheetWeekSummaryTotalsLabel = Totaux
TimesheetWeekSummaryTableTooTall = La synthèse pour %s contient trop de lignes pour tenir sur une seule page.
diff --git a/langs/it_IT/timesheetweek.lang b/langs/it_IT/timesheetweek.lang
index 45ee0ed..11c908c 100644
--- a/langs/it_IT/timesheetweek.lang
+++ b/langs/it_IT/timesheetweek.lang
@@ -201,6 +201,7 @@ TimesheetWeekSummaryColumnStatus = Status
TimesheetWeekSummaryColumnApprovedBy = Approved by
TimesheetWeekSummaryStatusApprovedBy = Approved by %s
TimesheetWeekSummaryStatusSealedBy = Sealed by %s
+TimesheetWeekSummaryStatusSealedOn = Sealed on %s
TimesheetWeekSummaryUserTitle = %s
TimesheetWeekSummaryTotalsLabel = Totals
TimesheetWeekSummaryTableTooTall = The summary for %s contains too many rows to fit on a single page.
diff --git a/lib/timesheetweek_pdf.lib.php b/lib/timesheetweek_pdf.lib.php
index f01bb23..6c4e28c 100644
--- a/lib/timesheetweek_pdf.lib.php
+++ b/lib/timesheetweek_pdf.lib.php
@@ -818,13 +818,15 @@ function tw_pdf_build_status_badge($status, $langs)
* @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
+ * @param string $sealedOn Sealing date label / Libellé de date de scellement
* @return string HTML snippet for the cell / Fragment HTML pour la cellule
*/
-function tw_pdf_compose_status_cell($langs, $status, $approvedBy, $sealedBy)
+function tw_pdf_compose_status_cell($langs, $status, $approvedBy, $sealedBy, $sealedOn)
{
$status = (int) $status;
$approvedBy = trim((string) $approvedBy);
$sealedBy = trim((string) $sealedBy);
+ $sealedOn = trim((string) $sealedOn);
$parts = array();
$parts[] = tw_pdf_build_status_badge($status, $langs);
@@ -837,6 +839,9 @@ function tw_pdf_compose_status_cell($langs, $status, $approvedBy, $sealedBy)
$sealedLabel = $sealedBy !== '' ? $sealedBy : $langs->trans('Unknown');
$parts[] = ''.dol_escape_htmltag($langs->trans('TimesheetWeekSummaryStatusApprovedBy', $approvedLabel)).'';
$parts[] = ''.dol_escape_htmltag($langs->trans('TimesheetWeekSummaryStatusSealedBy', $sealedLabel)).'';
+ if ($sealedOn !== '') {
+ $parts[] = ''.dol_escape_htmltag($langs->trans('TimesheetWeekSummaryStatusSealedOn', $sealedOn)).'';
+ }
}
return implode('
', $parts);
@@ -857,6 +862,23 @@ function tw_pdf_compose_status_cell($langs, $status, $approvedBy, $sealedBy)
*/
function tw_collect_summary_data($db, array $timesheetIds, User $user, $permReadOwn, $permReadChild, $permReadAll)
{
+ $hasSealUserColumn = false;
+ $hasSealDateColumn = false;
+ // EN: Detect optional seal metadata columns before building the summary query.
+ // FR: Détecte les colonnes optionnelles de métadonnées de scellement avant la requête de synthèse.
+ $sqlCheckSealUser = "SHOW COLUMNS FROM ".MAIN_DB_PREFIX."timesheet_week LIKE 'fk_user_seal'";
+ $resqlCheckSealUser = $db->query($sqlCheckSealUser);
+ if ($resqlCheckSealUser) {
+ $hasSealUserColumn = ($db->num_rows($resqlCheckSealUser) > 0);
+ $db->free($resqlCheckSealUser);
+ }
+ $sqlCheckSealDate = "SHOW COLUMNS FROM ".MAIN_DB_PREFIX."timesheet_week LIKE 'date_seal'";
+ $resqlCheckSealDate = $db->query($sqlCheckSealDate);
+ if ($resqlCheckSealDate) {
+ $hasSealDateColumn = ($db->num_rows($resqlCheckSealDate) > 0);
+ $db->free($resqlCheckSealDate);
+ }
+
$ids = array();
foreach ($timesheetIds as $candidate) {
$candidate = (int) $candidate;
@@ -871,9 +893,18 @@ 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.contract, 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";
+ if ($hasSealUserColumn) {
+ $sql .= ", t.fk_user_seal, us.lastname as sealer_lastname, us.firstname as sealer_firstname";
+ }
+ if ($hasSealDateColumn) {
+ $sql .= ", t.date_seal";
+ }
$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";
+ if ($hasSealUserColumn) {
+ $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user as us ON us.rowid = t.fk_user_seal";
+ }
$sql .= " WHERE t.rowid IN (".$idList.")";
$sql .= " AND t.entity IN (".getEntity('timesheetweek').")";
@@ -950,10 +981,20 @@ function tw_collect_summary_data($db, array $timesheetIds, User $user, $permRead
$status = (int) $row->status;
$sealedBy = '';
+ $sealedOn = '';
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);
+ if ($hasSealUserColumn && (!empty($row->sealer_lastname) || !empty($row->sealer_firstname))) {
+ // EN: Use the sealer name stored on the sheet when available.
+ // FR: Utilise le nom du scelleur stocké sur la feuille lorsqu'il est disponible.
+ $sealedBy = dolGetFirstLastname($row->sealer_firstname, $row->sealer_lastname);
+ } else {
+ // 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);
+ }
+ if ($hasSealDateColumn && !empty($row->date_seal)) {
+ $sealedOn = dol_print_date($db->jdate($row->date_seal), 'day');
+ }
}
$record = array(
@@ -974,6 +1015,7 @@ function tw_collect_summary_data($db, array $timesheetIds, User $user, $permRead
'zone5_count' => (int) $row->zone5_count,
'approved_by' => $approvedBy,
'sealed_by' => $sealedBy,
+ 'sealed_on' => $sealedOn,
'status' => $status
);
@@ -1253,128 +1295,123 @@ function tw_generate_summary_pdf($db, $conf, $langs, User $user, array $timeshee
// 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)
- );
+ $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)
- );
+ $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;
+ $lineHeight = 6;
$hoursPerDay = 8.0;
$isFirstUser = true;
foreach ($sortedUsers as $userSummary) {
- $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
- );
- }
- }
-
+ $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();
+ // EN: Collect row values before computing their height from content.
+ // FR: Collecte les valeurs des lignes avant de calculer leur hauteur selon le contenu.
+ foreach ($records as $recordIndex => $record) {
+ $statusCell = tw_pdf_compose_status_cell($langs, $record['status'], $record['approved_by'], $record['sealed_by'], $record['sealed_on']);
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),
- ''
+ $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 {
- $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'],
- ''
+ $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();
+ $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);
+ // EN: Anticipate the dynamic height of each record to size the table and manage page breaks accurately.
+ // FR: Anticipe la hauteur 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);
$spacingBeforeTable = $isFirstUser ? 0 : 4;
$availableHeight = ($pageHeight - ($margeBasse + $footerReserve)) - $pdf->GetY();
if (($spacingBeforeTable + $tableHeight) > $availableHeight) {
@@ -1387,7 +1424,6 @@ function tw_generate_summary_pdf($db, $conf, $langs, User $user, array $timeshee
$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.
@@ -1395,7 +1431,7 @@ function tw_generate_summary_pdf($db, $conf, $langs, User $user, array $timeshee
} 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.
@@ -1408,22 +1444,19 @@ 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(
- 'fill' => true,
- 'alignments' => array_fill(0, count($columnLabels), 'C'),
- 'html_flags' => $htmlFlags
- ));
+ tw_pdf_render_row($pdf, $columnWidths, $columnLabels, $lineHeight, array(
+ 'fill' => true,
+ 'alignments' => array_fill(0, count($columnLabels), 'C'),
+ 'html_flags' => $htmlFlags
+ ));
$pdf->SetFont('', '', $defaultFontSize - 1);
$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 $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, $currentLineHeight, array(
+ tw_pdf_render_row($pdf, $columnWidths, $rowData, $lineHeight, array(
'alignments' => $alignments,
'html_flags' => $htmlFlags
));
@@ -1438,7 +1471,7 @@ function tw_generate_summary_pdf($db, $conf, $langs, User $user, array $timeshee
'html_flags' => $htmlFlags
));
- }
+ }
$pdf->Output($filepath, 'F');
return array(
diff --git a/sql/llx_timesheet_week.sql b/sql/llx_timesheet_week.sql
index 0e3915a..38ddce6 100644
--- a/sql/llx_timesheet_week.sql
+++ b/sql/llx_timesheet_week.sql
@@ -13,6 +13,8 @@ CREATE TABLE IF NOT EXISTS llx_timesheet_week (
date_creation DATETIME DEFAULT CURRENT_TIMESTAMP,
date_validation DATETIME DEFAULT NULL,
fk_user_valid INT DEFAULT NULL,
+ fk_user_seal INT DEFAULT NULL,
+ date_seal DATETIME DEFAULT NULL,
total_hours DOUBLE(24,8) NOT NULL DEFAULT 0,
overtime_hours DOUBLE(24,8) NOT NULL DEFAULT 0,
contract DOUBLE(24,8) DEFAULT NULL,
@@ -32,7 +34,9 @@ CREATE TABLE IF NOT EXISTS llx_timesheet_week (
KEY idx_timesheet_week_entity (entity),
KEY idx_timesheet_week_user (fk_user),
KEY idx_timesheet_week_user_valid (fk_user_valid),
+ KEY idx_timesheet_week_fk_user_seal (fk_user_seal),
KEY idx_timesheet_week_yearweek (year, week),
+ KEY idx_timesheet_week_date_seal (date_seal),
CONSTRAINT fk_timesheet_week_user
FOREIGN KEY (fk_user) REFERENCES llx_user (rowid),
diff --git a/sql/update_all.sql b/sql/update_all.sql
index fd09ac6..c103d6e 100644
--- a/sql/update_all.sql
+++ b/sql/update_all.sql
@@ -31,3 +31,71 @@ SET @tsw_add_contract := IF(
PREPARE tsw_contract_stmt FROM @tsw_add_contract;
EXECUTE tsw_contract_stmt;
DEALLOCATE PREPARE tsw_contract_stmt;
+
+-- EN: Add the seal user column to existing tables when missing.
+SET @tsw_has_fk_user_seal := (
+ SELECT COUNT(*)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'llx_timesheet_week'
+ AND COLUMN_NAME = 'fk_user_seal'
+);
+SET @tsw_add_fk_user_seal := IF(
+ @tsw_has_fk_user_seal = 0,
+ 'ALTER TABLE llx_timesheet_week ADD COLUMN fk_user_seal INT DEFAULT NULL AFTER fk_user_valid',
+ 'SELECT 1'
+);
+PREPARE tsw_fk_user_seal_stmt FROM @tsw_add_fk_user_seal;
+EXECUTE tsw_fk_user_seal_stmt;
+DEALLOCATE PREPARE tsw_fk_user_seal_stmt;
+
+-- EN: Add the seal date column to existing tables when missing.
+SET @tsw_has_date_seal := (
+ SELECT COUNT(*)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'llx_timesheet_week'
+ AND COLUMN_NAME = 'date_seal'
+);
+SET @tsw_add_date_seal := IF(
+ @tsw_has_date_seal = 0,
+ 'ALTER TABLE llx_timesheet_week ADD COLUMN date_seal DATETIME DEFAULT NULL AFTER fk_user_seal',
+ 'SELECT 1'
+);
+PREPARE tsw_date_seal_stmt FROM @tsw_add_date_seal;
+EXECUTE tsw_date_seal_stmt;
+DEALLOCATE PREPARE tsw_date_seal_stmt;
+
+-- EN: Add index on seal user when missing.
+SET @tsw_has_idx_fk_user_seal := (
+ SELECT COUNT(*)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'llx_timesheet_week'
+ AND INDEX_NAME = 'idx_timesheet_week_fk_user_seal'
+);
+SET @tsw_add_idx_fk_user_seal := IF(
+ @tsw_has_idx_fk_user_seal = 0,
+ 'ALTER TABLE llx_timesheet_week ADD INDEX idx_timesheet_week_fk_user_seal (fk_user_seal)',
+ 'SELECT 1'
+);
+PREPARE tsw_idx_fk_user_seal_stmt FROM @tsw_add_idx_fk_user_seal;
+EXECUTE tsw_idx_fk_user_seal_stmt;
+DEALLOCATE PREPARE tsw_idx_fk_user_seal_stmt;
+
+-- EN: Add index on seal date when missing.
+SET @tsw_has_idx_date_seal := (
+ SELECT COUNT(*)
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'llx_timesheet_week'
+ AND INDEX_NAME = 'idx_timesheet_week_date_seal'
+);
+SET @tsw_add_idx_date_seal := IF(
+ @tsw_has_idx_date_seal = 0,
+ 'ALTER TABLE llx_timesheet_week ADD INDEX idx_timesheet_week_date_seal (date_seal)',
+ 'SELECT 1'
+);
+PREPARE tsw_idx_date_seal_stmt FROM @tsw_add_idx_date_seal;
+EXECUTE tsw_idx_date_seal_stmt;
+DEALLOCATE PREPARE tsw_idx_date_seal_stmt;
diff --git a/timesheetweek_list.php b/timesheetweek_list.php
index 2258ea6..13fd7f5 100644
--- a/timesheetweek_list.php
+++ b/timesheetweek_list.php
@@ -184,10 +184,13 @@ function tw_render_timesheet_pdf_dropdown($rowData, Conf $conf, Translate $langs
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.
+ // EN: Build preview and download URLs while honoring the MAIN_DISABLE_FORCE_SAVEAS setting.
+ // FR: Construit les URLs d'aperçu et de téléchargement en respectant MAIN_DISABLE_FORCE_SAVEAS.
+ $forceSaveAs = getDolGlobalInt('MAIN_DISABLE_FORCE_SAVEAS');
+ $downloadAttachment = ($forceSaveAs > 0 ? 0 : 1);
+ $downloadTarget = ($forceSaveAs === 2 ? ' target="_blank"' : '');
$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';
+ $downloadUrl = DOL_URL_ROOT.'/document.php?modulepart=timesheetweek&file='.urlencode($relativeFile).'&entity='.$docEntityId.'&attachment='.$downloadAttachment.'&permission=read';
$previewLabel = dol_escape_htmltag($langs->trans('TimesheetWeekPreviewPdf'));
$downloadLabel = dol_escape_htmltag($langs->trans('TimesheetWeekDownloadPdf'));
$titleLabel = dol_escape_htmltag($pdfFilename);
@@ -198,7 +201,7 @@ function tw_render_timesheet_pdf_dropdown($rowData, Conf $conf, Translate $langs
$html .= '