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 .= '
'; $html .= '
'; $html .= ''; @@ -526,6 +529,23 @@ function tw_render_timesheet_pdf_dropdown($rowData, Conf $conf, Translate $langs // FR: Refuse le scellement lorsque l'opérateur ne possède pas le droit dédié. setEventMessages($langs->trans('NotEnoughPermissions'), null, 'errors'); } else { + $hasSealUserColumn = false; + $hasSealDateColumn = false; + // EN: Detect optional seal metadata columns once for the mass action. + // FR: Détecte les colonnes optionnelles de métadonnées de scellement pour l'action de masse. + $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); + } + $db->begin(); $ok = 0; $ko = array(); @@ -547,6 +567,27 @@ function tw_render_timesheet_pdf_dropdown($rowData, Conf $conf, Translate $langs } $res = $o->seal($user, 'manual'); if ($res > 0) { + if ($hasSealUserColumn || $hasSealDateColumn) { + $sealNow = dol_now(); + // EN: Persist seal metadata for the mass action when columns exist. + // FR: Persiste les métadonnées de scellement pour l'action de masse lorsque les colonnes existent. + $sqlSealMetadata = "UPDATE ".MAIN_DB_PREFIX."timesheet_week SET"; + $sealUpdates = array(); + if ($hasSealUserColumn) { + $sealUpdates[] = "fk_user_seal=".(int) $user->id; + } + if ($hasSealDateColumn) { + $sealUpdates[] = "date_seal='".$db->idate($sealNow)."'"; + } + $sqlSealMetadata .= " ".implode(', ', $sealUpdates); + $sqlSealMetadata .= " WHERE rowid=".(int) $o->id; + $sqlSealMetadata .= " AND entity IN (".getEntity('timesheetweek').")"; + if (!$db->query($sqlSealMetadata)) { + // EN: Keep the sheet sealed even if the metadata update fails. + // FR: Conserver la feuille scellée même si la mise à jour des métadonnées échoue. + dol_syslog('TimesheetWeek mass seal metadata update failed: '.$db->lasterror(), LOG_WARNING); + } + } $ok++; } else { $ko[] = $o->ref ?: '#'.$id; @@ -592,11 +633,20 @@ function tw_render_timesheet_pdf_dropdown($rowData, Conf $conf, Translate $langs 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; + $forceSaveAs = getDolGlobalInt('MAIN_DISABLE_FORCE_SAVEAS'); + $attachment = ($forceSaveAs > 0 ? 0 : 1); + $downloadUrl = DOL_URL_ROOT.'/document.php?modulepart=timesheetweek&file='.urlencode($result['relative']).'&entity='.$conf->entity.'&attachment='.$attachment.'&permission=read'; + if ($forceSaveAs === 2) { + $downloadLabel = dol_escape_htmltag($langs->trans('TimesheetWeekDownloadPdf')); + $link = ''.$downloadLabel.''; + setEventMessages($langs->trans('TimesheetWeekSummaryGenerated').' '.$link, null, 'mesgs'); + } else { + header('Location: '.$downloadUrl); + exit; + } + } else { + setEventMessages($langs->trans('TimesheetWeekSummaryGenerated'), null, 'mesgs'); } - setEventMessages($langs->trans('TimesheetWeekSummaryGenerated'), null, 'mesgs'); } } } @@ -749,10 +799,18 @@ function tw_render_timesheet_pdf_dropdown($rowData, Conf $conf, Translate $langs if (!empty($errors)) { setEventMessages(null, array_values(array_unique($errors)), 'warnings'); } - setEventMessages($langs->trans('TimesheetWeekMassMergeSuccess'), null, 'mesgs'); - $downloadUrl = DOL_URL_ROOT.'/document.php?modulepart=timesheetweek&file='.urlencode($relativeMerged).'&entity='.$conf->entity.'&permission=read'; - header('Location: '.$downloadUrl); - exit; + $forceSaveAs = getDolGlobalInt('MAIN_DISABLE_FORCE_SAVEAS'); + $attachment = ($forceSaveAs > 0 ? 0 : 1); + $downloadUrl = DOL_URL_ROOT.'/document.php?modulepart=timesheetweek&file='.urlencode($relativeMerged).'&entity='.$conf->entity.'&attachment='.$attachment.'&permission=read'; + if ($forceSaveAs === 2) { + $downloadLabel = dol_escape_htmltag($langs->trans('TimesheetWeekDownloadPdf')); + $link = ''.$downloadLabel.''; + setEventMessages($langs->trans('TimesheetWeekMassMergeSuccess').' '.$link, null, 'mesgs'); + } else { + setEventMessages($langs->trans('TimesheetWeekMassMergeSuccess'), null, 'mesgs'); + header('Location: '.$downloadUrl); + exit; + } } } }