Skip to content
Merged

1.6.0 #122

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# CHANGELOG MODULE TIMESHEETWEEK FOR [DOLIBARR ERP CRM](https://www.dolibarr.org)

## 1.6.0 (20/06/2026)
## 1.6.1 (08/12/2025)
- Fige les heures contractuelles dans la fiche hebdomadaire et les PDF pour conserver le contexte en cas d'évolution du contrat. / Freezes contract hours in the weekly card and PDFs to preserve context when an employee contract changes.

## 1.6.0 (05/12/2025)
- Ajoute un rappel hebdomadaire automatique par email configurable (activation, jour, heure, modèle) et une tâche planifiée dédiée avec bouton de test administrateur.
/ Adds a configurable automatic weekly email reminder (enablement, day, time, template) plus a dedicated scheduled task and admin test button.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- 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.
- Création rapide d'une feuille d'heures via le raccourci « Ajouter » du menu supérieur.
- Compatibilité Multicompany pour partager les feuilles et leur numérotation, avec options de partage dédiées et filtres multi-sélection harmonisés à l'interface native.
Expand Down Expand Up @@ -54,6 +55,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.
- 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.
- Quick creation shortcut available from the top-right « Add » menu.
- Multicompany compatibility for sharing timesheets and numbering sequences, with dedicated sharing options and native-aligned multi-select filters.
Expand Down
205 changes: 116 additions & 89 deletions class/timesheetweek.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class TimesheetWeek extends CommonObject

public $total_hours = 0.0; // total week hours
public $overtime_hours = 0.0; // overtime based on user weeklyhours
public $contract = null; // stored contract hours snapshot
public $zone1_count = 0; // zone 1 days count / nombre de jours en zone 1
public $zone2_count = 0; // zone 2 days count / nombre de jours en zone 2
public $zone3_count = 0; // zone 3 days count / nombre de jours en zone 3
Expand Down Expand Up @@ -141,6 +142,7 @@ public function create($user)
'fk_user_valid',
'total_hours',
'overtime_hours',
'contract',
'zone1_count',
'zone2_count',
'zone3_count',
Expand All @@ -160,6 +162,7 @@ public function create($user)
(!empty($this->fk_user_valid) ? (int) $this->fk_user_valid : 'NULL'),
(float) ($this->total_hours ?: 0),
(float) ($this->overtime_hours ?: 0),
($this->contract !== null ? (float) $this->contract : 'NULL'),
(int) ($this->zone1_count ?: 0),
(int) ($this->zone2_count ?: 0),
(int) ($this->zone3_count ?: 0),
Expand Down Expand Up @@ -279,7 +282,7 @@ public function fetch($id = null, $ref = null)
$includeModelPdf = $this->checkModelPdfColumnAvailability();

$sql = "SELECT t.rowid, t.ref, t.entity, t.fk_user, t.year, t.week, t.status, t.note, t.date_creation, t.tms, t.date_validation, t.fk_user_valid,";
$sql .= " t.total_hours, t.overtime_hours, t.zone1_count, t.zone2_count, t.zone3_count, t.zone4_count, t.zone5_count, t.meal_count";
$sql .= " 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";
if ($includeModelPdf) {
$sql .= ", t.model_pdf";
}
Expand Down Expand Up @@ -317,10 +320,11 @@ public function fetch($id = null, $ref = null)
$this->date_creation = $this->db->jdate($obj->date_creation);
$this->tms = $this->db->jdate($obj->tms);
$this->date_validation = $this->db->jdate($obj->date_validation);
$this->fk_user_valid = (int) $obj->fk_user_valid;
$this->total_hours = (float) $obj->total_hours;
$this->overtime_hours = (float) $obj->overtime_hours;
$this->zone1_count = (int) $obj->zone1_count;
$this->fk_user_valid = (int) $obj->fk_user_valid;
$this->total_hours = (float) $obj->total_hours;
$this->overtime_hours = (float) $obj->overtime_hours;
$this->contract = ($obj->contract !== null ? (float) $obj->contract : null);
$this->zone1_count = (int) $obj->zone1_count;
$this->zone2_count = (int) $obj->zone2_count;
$this->zone3_count = (int) $obj->zone3_count;
$this->zone4_count = (int) $obj->zone4_count;
Expand Down Expand Up @@ -404,11 +408,12 @@ public function update($user)
if ($this->year) $sets[] = "year=".(int) $this->year;
if ($this->week) $sets[] = "week=".(int) $this->week;
if ($this->status !== null) $sets[] = "status=".(int) $this->status;
$sets[] = "note=".($this->note !== null ? "'".$this->db->escape($this->note)."'" : "NULL");
$sets[] = "fk_user_valid=".(!empty($this->fk_user_valid) ? (int) $this->fk_user_valid : "NULL");
$sets[] = "total_hours=".(float) ($this->total_hours ?: 0);
$sets[] = "overtime_hours=".(float) ($this->overtime_hours ?: 0);
$sets[] = "zone1_count=".(int) ($this->zone1_count ?: 0);
$sets[] = "note=".($this->note !== null ? "'".$this->db->escape($this->note)."'" : "NULL");
$sets[] = "fk_user_valid=".(!empty($this->fk_user_valid) ? (int) $this->fk_user_valid : "NULL");
$sets[] = "total_hours=".(float) ($this->total_hours ?: 0);
$sets[] = "overtime_hours=".(float) ($this->overtime_hours ?: 0);
$sets[] = "contract=".($this->contract !== null ? (float) $this->contract : 'NULL');
$sets[] = "zone1_count=".(int) ($this->zone1_count ?: 0);
$sets[] = "zone2_count=".(int) ($this->zone2_count ?: 0);
$sets[] = "zone3_count=".(int) ($this->zone3_count ?: 0);
$sets[] = "zone4_count=".(int) ($this->zone4_count ?: 0);
Expand Down Expand Up @@ -669,27 +674,38 @@ public function computeTotals()
$this->zone5_count = $zoneBuckets[5];
$this->meal_count = $mealDays;

// Weekly contracted hours from user
$weekly = 35.0;
// Weekly contracted hours snapshot
$weekly = ($this->contract !== null ? (float) $this->contract : 35.0);
if (!empty($this->fk_user)) {
$u = new User($this->db);
if ($u->fetch($this->fk_user) > 0) {
if (!empty($u->weeklyhours)) $weekly = (float) $u->weeklyhours;
if (!empty($u->weeklyhours)) {
$weekly = ($this->contract !== null ? (float) $this->contract : (float) $u->weeklyhours);
if ($this->contract === null) {
$this->contract = (float) $u->weeklyhours;
}
}
}
}
if ($this->contract === null) {
$this->contract = $weekly;
}
$ot = $total - $weekly;
$this->overtime_hours = ($ot > 0) ? $ot : 0.0;
}

/**
* Save totals into DB
* @return int
*/
* EN: Save totals into DB.
* FR: Enregistre les totaux en base.
*
* @return int
*/
public function updateTotalsInDB()
{
$this->computeTotals();
$sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element;
$sql .= " SET total_hours=".(float) $this->total_hours.", overtime_hours=".(float) $this->overtime_hours;
$sql .= ", contract=".($this->contract !== null ? (float) $this->contract : 'NULL');
$sql .= ", zone1_count=".(int) $this->zone1_count.", zone2_count=".(int) $this->zone2_count;
$sql .= ", zone3_count=".(int) $this->zone3_count.", zone4_count=".(int) $this->zone4_count;
$sql .= ", zone5_count=".(int) $this->zone5_count.", meal_count=".(int) $this->meal_count;
Expand Down Expand Up @@ -738,6 +754,19 @@ public function submit($user)

$this->db->begin();

$contractHours = 35.0;
$employeeContract = new User($this->db);
if ($employeeContract->fetch($this->fk_user) > 0 && !empty($employeeContract->weeklyhours)) {
$contractHours = (float) $employeeContract->weeklyhours;
}
$this->contract = $contractHours;

if ($this->updateTotalsInDB() < 0) {
$this->db->rollback();
$this->error = $this->db->lasterror();
return -1;
}

// Set definitive ref if provisional
if (empty($this->ref) || strpos($this->ref, '(PROV') === 0) {
$newref = $this->generateDefinitiveRef();
Expand Down Expand Up @@ -780,81 +809,79 @@ public function submit($user)
$this->db->commit();
return 1;
}
/**
* Revert to draft
* @param User $user
* @return int
*/
public function revertToDraft($user)
{
$now = dol_now();
$previousStatus = (int) $this->status;

if ((int) $this->status === self::STATUS_DRAFT) {
$this->error = 'AlreadyDraft';
return 0;
}

$taskIds = array();
$lines = $this->getLines();
if (!empty($lines)) {
foreach ($lines as $line) {
if (empty($line->fk_task)) {
continue;
}

/**
* Revert to draft
* @param User $user
* @return int
*/
public function revertToDraft($user)
{
$now = dol_now();
$previousStatus = (int) $this->status;

if ((int) $this->status === self::STATUS_DRAFT) {
$this->error = 'AlreadyDraft';
return 0;
}

$taskIds = array();
$lines = $this->getLines();
if (!empty($lines)) {
foreach ($lines as $line) {
if (empty($line->fk_task)) {
continue;
}

// EN: Track tasks with recorded hours to refresh effective durations after rollback.
// FR: Suit les tâches ayant des heures saisies pour rafraîchir les durées effectives après retour en brouillon.
if (((float) $line->hours) > 0) {
$taskIds[] = (int) $line->fk_task;
}
}
}

$this->db->begin();

$sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element.
" SET status=".(int) self::STATUS_DRAFT.", tms='".$this->db->idate($now)."', date_validation=NULL WHERE rowid=".(int) $this->id;
// EN: Enforce the entity scope while reverting to draft.
// FR: Impose la portée d'entité lors du retour en brouillon.
$sql .= " AND entity IN (".getEntity('timesheetweek').")";
if (!$this->db->query($sql)) {
$this->db->rollback();
$this->error = $this->db->lasterror();
return -1;
}

// Remove synced time entries to keep the ERP aligned (EN)
// Supprime les temps synchronisés pour garder l'ERP aligné (FR)
if ($this->deleteElementTimeRecords() < 0) {
$this->db->rollback();
return -1;
}

if (!empty($taskIds) && ($previousStatus === self::STATUS_APPROVED || $previousStatus === self::STATUS_SEALED)) {
// EN: Recompute effective duration on related tasks after removing approved times.
// FR: Recalcule la durée effective des tâches concernées après suppression des temps approuvés.
if ($this->updateTasksDurationEffective($taskIds) < 0) {
$this->db->rollback();
return -1;
}
}

$this->status = self::STATUS_DRAFT;
$this->tms = $now;
$this->date_validation = null;

if (!$this->createAgendaEvent($user, 'TSWK_REOPEN', 'TimesheetWeekAgendaReopened', array($this->ref))) {
$this->db->rollback();
return -1;
}

$this->db->commit();

return 1;
}
// EN: Track tasks with recorded hours to refresh effective durations after rollback.
// FR: Suit les tâches ayant du temps saisi pour rafraîchir les durées effectives après retour.
$taskIds[(int) $line->fk_task] = (int) $line->fk_task;
}
}

$this->db->begin();

$up = "UPDATE ".MAIN_DB_PREFIX.$this->table_element;
$up .= " SET status=".(int) self::STATUS_DRAFT.", tms='".$this->db->idate($now)."', date_validation=NULL";
// EN: Protect draft rollback with entity scoping for multi-company safety.
// FR: Protège le retour en brouillon en restreignant l'entité pour la sécurité multi-entreprise.
$up .= " WHERE rowid=".(int) $this->id;
$up .= " AND entity IN (".getEntity('timesheetweek').")";
if (!$this->db->query($up)) {
$this->db->rollback();
$this->error = $this->db->lasterror();
return -1;
}

if ($previousStatus === self::STATUS_APPROVED || $previousStatus === self::STATUS_SEALED) {
$this->overtime_hours = 0;
$this->updateTotalsInDB();
}

$taskIds = array_filter($taskIds);
if (!empty($taskIds) && ($previousStatus === self::STATUS_APPROVED || $previousStatus === self::STATUS_SEALED)) {
// EN: Recompute effective duration on related tasks after removing approved times.
// FR: Recalcule la durée effective des tâches concernées après suppression des temps approuvés.
if ($this->updateTasksDurationEffective($taskIds) < 0) {
$this->db->rollback();
return -1;
}
}

$this->status = self::STATUS_DRAFT;
$this->tms = $now;
$this->date_validation = null;

if (!$this->createAgendaEvent($user, 'TSWK_REOPEN', 'TimesheetWeekAgendaReopened', array($this->ref))) {
$this->db->rollback();
return -1;
}

$this->db->commit();

return 1;
}

/**
/**
* Approve
* @param User $user
Expand Down
2 changes: 1 addition & 1 deletion core/modules/modTimesheetWeek.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.6.0';
$this->version = '1.6.1';

// Url to the file with your last numberversion of this module
$this->url_last_version = 'https://moduleversion.lesmetiersdubatiment.fr/ver.php?m=timesheetweek';
Expand Down
9 changes: 6 additions & 3 deletions lib/timesheetweek_pdf.lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ function tw_generate_timesheet_pdf_to_temp(TimesheetWeek $sheet, Conf $conf, Tra
}

return array('success' => true, 'path' => $tempPath);
}
}

/**
* EN: Normalise a value before sending it to TCPDF by decoding HTML entities and applying the output charset.
Expand Down Expand Up @@ -852,7 +852,7 @@ function tw_collect_summary_data($db, array $timesheetIds, User $user, $permRead
}

$idList = implode(',', $ids);
$sql = "SELECT t.rowid, t.entity, t.year, t.week, t.total_hours, t.overtime_hours, t.zone1_count, t.zone2_count, t.zone3_count, t.zone4_count, t.zone5_count, t.meal_count, t.fk_user, t.fk_user_valid, t.status, u.lastname, u.firstname, u.weeklyhours, uv.lastname as validator_lastname, uv.firstname as validator_firstname";
$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";
$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";
Expand Down Expand Up @@ -883,7 +883,10 @@ function tw_collect_summary_data($db, array $timesheetIds, User $user, $permRead
$weekEnd = clone $weekStart;
$weekEnd->modify('+6 days');

$contractHours = (float) $row->weeklyhours;
$contractHours = (float) $row->contract;
if ($contractHours <= 0 && !empty($row->weeklyhours)) {
$contractHours = (float) $row->weeklyhours;
}
if ($contractHours <= 0) {
$contractHours = 35.0;
}
Expand Down
1 change: 1 addition & 0 deletions sql/llx_timesheet_week.sql
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS llx_timesheet_week (
fk_user_valid INT 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,
zone1_count SMALLINT NOT NULL DEFAULT 0,
zone2_count SMALLINT NOT NULL DEFAULT 0,
zone3_count SMALLINT NOT NULL DEFAULT 0,
Expand Down
17 changes: 17 additions & 0 deletions sql/update_all.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,20 @@ SET @tsw_add_model_pdf := IF(
PREPARE tsw_stmt FROM @tsw_add_model_pdf;
EXECUTE tsw_stmt;
DEALLOCATE PREPARE tsw_stmt;

-- EN: Add the contract hours column to existing tables when missing.
SET @tsw_has_contract := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'llx_timesheet_week'
AND COLUMN_NAME = 'contract'
);
SET @tsw_add_contract := IF(
@tsw_has_contract = 0,
'ALTER TABLE llx_timesheet_week ADD COLUMN contract DOUBLE(24,8) DEFAULT NULL AFTER overtime_hours',
'SELECT 1'
);
PREPARE tsw_contract_stmt FROM @tsw_add_contract;
EXECUTE tsw_contract_stmt;
DEALLOCATE PREPARE tsw_contract_stmt;
Loading