# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

Vision ERP — a CodeIgniter 4 application (PHP ^8.1) running on a local WAMP stack at `http://localhost:8081/`. Modules cover tickets, invoices, estimates, avoirs (credit notes), projects, planning, employees (RH/paie), leave management (congés), clients, and v_companies. UI strings and column names are in **French and intentional** (e.g. `État`, `avoir`, `conges`, `planification`) — preserve them.

## Common commands

```bash
composer install                                    # install PHP deps
php spark serve                                     # dev server (alternative to WAMP)
php spark routes                                    # list registered routes
php spark migrate                                   # run migrations
php spark cache:clear                               # vider le cache CI4 (writable/cache/)
vendor/bin/phpunit                                  # run full test suite (alias: composer test)
vendor/bin/phpunit tests/path/SomeTest.php          # run a single test file
vendor/bin/phpunit --filter testMethodName          # run a single test method
vendor/bin/phpstan analyse --memory-limit=1G        # static analysis (level 5, app/ only)
```

The web entry point is `public/index.php`; configure your vhost / WAMP alias to point at `public/`, never the project root.

## Database access

Claude **cannot connect to MySQL directly** from the shell in this environment. For schema changes or data inserts, output the SQL statements and ask the user to run them manually. Always verify column names against the actual schema before referencing them in queries — wrong column names (e.g. `id_vcompanies` vs the real column) have caused regressions.

The active database is set in `.env` (`database.default.database`, currently `elplatsvisionci4`). Migrations under `app/Database/Migrations/` are excluded from the autoload classmap (see `composer.json`).

## Architecture

### Request lifecycle
1. Routes in `app/Config/Routes.php` (mostly explicit, not auto-routed). Most routes apply `['filter' => 'auth']`.
2. **Two filters gate every authenticated request**:
   - `auth` (`app/Filters/Auth.php`) — checks `session()->get('user_id')`, redirects to `/login` if missing.
   - `module_access` (`app/Filters/ModuleFilter.php`) — global `before` filter. Compares URI segments against the user's allowed sub-module links (cached in session as `all_sous_links` / `user_sous_links`). Admin (`session('user_is_admin')`) bypasses. AJAX denials return JSON 403; HTML denials return `errors/html/error_403`.
3. All controllers extend `BaseController`, whose `initController()` is the load-bearing setup step — it instantiates ~10 shared models, loads the user + company context, builds the sidebar menu from `modules` + `modules_sous` joined with `user_module_access` / `user_sous_access`, computes `notification_list`, and seeds `$this->view_data` with `core_settings`, `current_language`, `dateformat`, `nom_licence`, etc. **Never bypass `BaseController` — views expect those keys.**
4. Controllers populate `$this->view_data` and render via `view('blueline/<module>/<file>', ['view_data' => $this->view_data])`. Views render through `app/Views/layouts/main.php` (header / sidebar / subHeader / footer partials).

### Multi-tenancy / company switching
`session('current_company')` scopes most queries. When it is unset, the menu falls back to aggregating across all companies the user has access to. Be aware of this when adding new module-access logic.

### Referentials (lookup table)
The `referentials` table (`App\Models\ReferentialModel`) is a generic lookup keyed by `category`. Used for `ticket_category`, `ticket_status`, `equipe`, `fonction`, etc. When adding dropdowns for status / priority / category-style fields, **store the referential `id` on the parent row** (e.g. `tickets.type_id`, `tickets.status` → `referentials.id`) and join with an alias filtered by category, e.g.:

```php
$this->join('referentials ref_etat', 'tickets.status = ref_etat.id AND ref_etat.category = "ticket_status"', 'left');
```

Use `ReferentialModel::getByCategory($cat)` for active rows ordered by `sort_order`. The field for display is `label` (not `name`). UI de gestion : `settings/referentiels`.

**Ne pas confondre avec `ref_type_occurences`** (`App\Models\RefTypeOccurencesModel`) — table legacy keyed by `id_type` (int). Encore utilisée pour les référentiels RH : genre (`id_type=13`), situation (`id_type=12`), contrat (`id_type=18`), typecontrat (`id_type=30`). `fonction` (`id_type=19`) a été migré vers `referentials` (`category='fonction'`). **Ne pas ajouter de nouvelles listes dans `ref_type_occurences`** — toujours utiliser `referentials`.

### Helpers
`app/Helpers/autoload_helpers.php` is required from `app/Common.php` and pulls in: `calcul_helper`, `format_helper`, `my_functions_helper`, `mydbhelper_helper`, `notification_helper`, `suivi_helper`, `theme_helper`, `timeago_helper`, `date_helper`, `EmailHelper`. Their functions are globally available — search there before writing new utility functions.

**`app_log()` n'est pas dans la liste d'autoload.** Il est chargé par `BaseController::__construct()` via `helper('app')`. Les contrôleurs qui définissent leur propre `__construct()` sans appeler `parent::__construct()` (ex : `SettingsController`, `ForgotPassController`) n'ont pas `app_log()` disponible — il faut y ajouter `helper('app')` explicitement.

### Application tracing
`app_log($context, $message, $data)` (`app/Helpers/app_helper.php` → `App\Libraries\AppLogger`) writes to `writable/logs/trace-YYYY-MM-DD.log` when `app.trace = true` in `.env`. Use it for business-event tracing (separate from CI4's framework `log_message()`).

### Frontend conventions
- Views live in `app/Views/blueline/<module>/`; the global layout is `app/Views/layouts/main.php`.
- **Global delete confirmation**: any element with `data-delete-url="..."` (optionally `data-reload="true"` and `data-delete-msg="..."`) triggers the modal in `layouts/main.php` and issues a GET. Don't reinvent per-page delete dialogs.
- **Global action confirmation**: any element with `data-confirm-url="..."` triggers a styled confirmation modal (`#globalConfirmModal`) in `layouts/main.php`, then navigates to that URL on confirmation. Optional attributes: `data-confirm-title`, `data-confirm-msg`, `data-confirm-btn`. Use this for non-destructive confirmations (duplicate, convert, etc.) — `data-delete-url` remains reserved for deletions. Both patterns are centralized; never use browser `confirm()` dialogs.
- **Item selector pattern**: invoices/estimates use a `#items-data` hidden table + `itemSelector()` JS helper. When adding similar item-line features (avoirs, bons de commande, etc.), reuse this pattern from the invoices view rather than inventing a parallel one.
- AJAX requests denied by `ModuleFilter` return JSON `{error, code: 403}` — handle that on the client side.

### CSS conventions — pas de style inline

**Ne jamais utiliser** de blocs `<style>` ni d'attributs `style="..."` dans les vues. Tout style doit passer par les fichiers CSS centralisés ci-dessous, chargés globalement via `layouts/main.php`.

| Fichier | Préfixe | Quand l'utiliser |
|---|---|---|
| `public/assets/blueline/css/page-list.css` | `pl-` | Pages de **liste / tableau** (`/invoices`, `/projects`, `/items`, …) |
| `public/assets/blueline/css/page-detail.css` | `pd-` | Pages de **détail / vue** (`/invoices/view/{id}`, `/projects/view/{id}`, …) |
| `public/assets/blueline/css/modal-form.css` | `mf-` | **Modales et formulaires** (partiels `_*.php` chargés via `data-toggle="mainmodal"`) |
| `public/assets/blueline/css/blueline.css` | — | Styles de base globaux — ne pas étendre directement |
| `public/assets/css/admin.css` | `adm-` / `um-` | Pages **settings** et gestion utilisateurs uniquement |

**Classes clés à connaître :**

*page-list.css* — structure d'une page de liste :
- `.pl-header` / `.pl-header-actions` — en-tête avec titre et boutons
- `.pl-table-wrap` — conteneur du tableau (fond blanc, coins arrondis)
- `.pl-filter-bar` — barre de filtres au-dessus du tableau
- `.action-buttons-gap` + `.btn-action-custom` + `.btn-edit` / `.btn-delete` / `.btn-view` / `.btn-duplicate` — boutons d'action dans les lignes
- `.pl-col-name` / `.pl-col-desc` — colonnes tronquées avec ellipsis
- `.pl-col-hide-xl` (< 1400px) / `.pl-col-hide-lg` (< 1200px) / `.pl-col-hide-md` (< 1050px) — masquage responsive des colonnes secondaires
- `.pl-badge` + `.pl-badge-*` — badges dans les tableaux de liste
- `.pl-confirm-modal` — modale de **suppression** (header rouge `#b91c1c`) — utilisée avec `data-delete-url`
- `.pl-action-modal` — modale de **confirmation non-destructive** (header bleu `#0369a1`) — utilisée avec `data-confirm-url`

*page-detail.css* — structure d'une page de détail :
- `.pd-layout` / `.pd-sidebar` / `.pd-main` — layout deux colonnes
- `.pd-card` / `.pd-card-header` / `.pd-card-body` — cartes d'information
- `.pd-info-list` / `.pd-info-item` / `.pd-info-label` / `.pd-info-value` — liste de champs dans le panneau latéral
- `.pd-badge` + `.pd-badge-*` — badges colorés (statuts)
- `.pd-items-wrap` / `.pd-items-table` — tableau d'articles dans une facture/devis
- `.drag-handle` / `.sortable-ghost` — drag & drop (SortableJS)
- `.pd-totals-wrap` / `.pd-totals-table` / `.pd-totals-grand` — section totaux
- `.pd-text-bold` / `.pd-text-muted` / `.pd-text-meta` — utilitaires typographiques
- `.pd-tabs` / `.pd-tab-link` / `.pd-tab-pane` — **navigation par onglets** (utilisée sur les pages de détail ET les pages settings — style canonique unique)

*modal-form.css* — structure d'une modale :
- `.mf-header` / `.mf-body` / `.mf-footer` — sections de la modale
- `.mf-row` / `.mf-col-6` / `.mf-col-12` — grille interne
- `.mf-label` / `.mf-input` / `.mf-select` — champs de formulaire
- `.mf-btn-save` / `.mf-btn-cancel` / `.mf-btn-danger` — boutons de pied de modale
- `.mf-section-title` — titre de section dans le formulaire

**Si une classe nécessaire n'existe pas** : l'ajouter dans le bon fichier CSS centralisé avec le bon préfixe, jamais en inline dans la vue.

### DataTables — conventions

- La **langue française** est configurée globalement dans `app/Views/partials/scripts.php` via `$.fn.dataTable.defaults`. Ne jamais ajouter `language: { url: '...' }` dans les pages individuelles — cela écraserait les defaults globaux.
- Utiliser `dom: 'frtip'` pour afficher la barre de recherche. Elle apparaît automatiquement **en haut du `.pl-table-wrap`**, stylée par `page-list.css` (`.pl-table-wrap .dataTables_filter`).
- Ne pas déplacer `.dataTables_filter` dans `.pl-header-actions` — sur les pages avec plusieurs boutons d'action, le champ déborde hors écran.
- Toujours mettre `columnDefs: [{ orderable: false, targets: [N] }]` sur la colonne Actions (dernière colonne).
- **Délégation d'événement obligatoire** : ne jamais attacher des listeners via `querySelectorAll(...).forEach(btn => btn.addEventListener(...))` sur des boutons dans un tableau paginé. DataTables retire physiquement du DOM les lignes des pages 2+ lors de l'initialisation — les listeners sont perdus pour les lignes réinjectées à la navigation. Toujours utiliser la délégation sur `document` :
  ```javascript
  document.addEventListener('click', function (e) {
      var btn = e.target.closest('.ma-classe');
      if (btn) { maFonction(btn.dataset.url); }
  });
  ```

### Endpoints publics (non authentifiés)

Les routes accessibles sans session (`/forgotpass`, `/login`, etc.) doivent respecter ces trois règles :

1. **Rate limiting** via `\Config\Services::throttler()` — empêche le spam/brute-force :
   ```php
   $throttler = \Config\Services::throttler();
   if ($throttler->check(md5($this->request->getIPAddress() . '_forgotpass'), 3, 600) === false) {
       // 3 tentatives max par IP par 10 minutes
   }
   ```
2. **reCAPTCHA** — `verifyCaptcha()` retourne `true` silencieusement si `recaptcha.secretKey` est absent du `.env`. Toujours configurer les deux clés en prod (`recaptcha.siteKey` + `recaptcha.secretKey`). Sans elles, le captcha est bypassé et des bots peuvent saturer le quota SMTP (limite OVH : 200 emails/heure).
3. **Logs `app_log()`** sur chaque cas (rate limit atteint, captcha échoué, email envoyé, email inconnu) — indispensable pour diagnostiquer en prod où seul `writable/logs/` du serveur est accessible.

### Gestion des admins (`user_is_admin`)

`session('user_is_admin')` est écrit **une fois à la connexion** dans `AuthController::setUserSession()` depuis `users.admin`. Il est ensuite **relu depuis la base** par `ModuleFilter::buildSessionCache()` à chaque fois que le cache de droits est reconstruit (flag `access_invalidated_{userId}` en cache). Ne jamais supposer que la session reflète l'état DB en temps réel — si on change `users.admin`, il faut invalider le cache avec `cache()->save('access_invalidated_' . $userId, true, 3600)` pour que le changement prenne effet à la prochaine requête de l'utilisateur concerné.

### Piège CI4 — état du query builder sur un modèle réutilisé

Enchaîner `$model->where(...)->findAll()` puis `$model->find($id)` sur la **même instance** peut laisser fuiter la condition `where` dans le second appel. Toujours utiliser `$this->db->table('...')->where('id', $id)->get()->getRowArray()` pour un fetch isolé, ou instancier un nouveau modèle.

### Tables des documents commerciaux — piège de nommage critique

Il existe **deux tables distinctes** pour les documents commerciaux, avec des noms contre-intuitifs :

| Table | Modèle | Contenu | Champ discriminant |
|---|---|---|---|
| `invoices` | `EstimateModel` | **Devis** (estimations) | `estimate = 0` (champ présent mais valeur 0 = devis, pas d'estimate=1) |
| `facture` | `FactureModel` (via `InvoicesController::$invoice`) | **Factures** réelles | `estimate IS NULL OR estimate != 1` |

**Ne pas confondre** : `InvoicesController` utilise `$this->invoice = new FactureModel()` qui pointe sur la table `facture`, pas `invoices`. Pour joindre les clients sur ces tables, toujours faire un `LEFT JOIN companies` sur `company_id` — la colonne `company_name` n'est pas stockée nativement.

**Clients vs contacts** : la table `companies` contient les **clients/sociétés** (`CompanyModel`) ; la table `clients` contient les **contacts/personnes** (`ClientModel`). Quand un projet ou document référence un client, c'est `company_id → companies.id`.

### Numéro de facture — `estimate_num` fait foi (colonne `reference` abandonnée)

Le numéro de facture affiché **partout** (liste, détail, PDF `templates/invoice/blueline.php`) est **`facture.estimate_num`** (ex. `FA26177`). C'est la **source de vérité**.

**La colonne `facture.reference` est ABANDONNÉE pour la numérotation** — même logique que `tickets.reference`. Elle avait dérivé (décalage systématique `reference = numéro affiché + 1` hérité de la migration legacy, plus des doublons de concurrence dus au lit-puis-écrit non atomique), ce qui a **coincé le compteur en prod** (bloqué sur `FA26175`). Elle reste écrite à la création (= séquence, inoffensif) mais n'est **plus jamais l'autorité**. Ne jamais recalculer un numéro depuis `reference`.

**Calcul du prochain numéro** — `FactureModel::getNextReference(?string $year = null)` dérive la séquence du **suffixe numérique de `estimate_num`**, borné à l'exercice courant :

```php
$prefix = 'FA' . ($year !== null ? substr($year, -2) : date('y')); // FA26 = 2026
$start  = strlen($prefix) + 1;
// MAX(CAST(SUBSTRING(estimate_num, $start) AS UNSIGNED)) + 1  WHERE estimate_num LIKE 'FA26%'
```

**Bascule annuelle automatique** : au 1ᵉʳ janvier, aucune ligne `FA27%` n'existe → `MAX` vide = 0 → repart à `FA27001` **sans intervention humaine** (exigence métier).

**Chemins de création** (tous via `getNextReference()`) : `InvoicesController::create()`, conversion facture→avoir, `AttachementController::convert()`. **Code mort à ne pas réactiver** : `InvoicesController::prepareInvoiceData()` (jamais appelé) et `InvoicesModel.php` (modèle CI3 legacy) numérotaient via le compteur `settings.invoice_reference` — ce compteur n'est **plus lu** par `getNextReference()` (ses incréments subsistants sont vestigiaux). Seul `invoices/preview.php` (vue CI3 morte) lit encore `$invoice->reference`.

### Numéro de projet — formatage

Le préfixe vient de `core_settings['project_prefix']` avec un suffixe optionnel `-YY` ou `-YY-MM` :

```php
$rawPrefix = $core_settings['project_prefix'] ?? '';
$dashPos   = strpos($rawPrefix, '-');
$prefixText = ($dashPos !== false) ? substr($rawPrefix, 0, $dashPos) : $rawPrefix;
$suffix     = substr($rawPrefix, strlen($prefixText));
if ($suffix === '-YY-MM')      { $projectPrefix = $prefixText . date('y') . date('m'); }
elseif ($suffix === '-YY')     { $projectPrefix = $prefixText . date('y'); }
else                           { $projectPrefix = $prefixText; }
$projectRef = $projectPrefix . ($project['project_num'] ?? '—');
```

Toujours appliquer ce calcul dans les dropdowns et vues — ne jamais afficher `project_num` brut.

### Projet fermé par la date de livraison

Un projet est **TERMINÉ** (fermé) dès que `projects.delivery` est renseignée **et strictement passée** (`< aujourd'hui`). Date vide/nulle → jamais fermé. Cette règle est **purement calculée** (rien n'est écrit en base) via le helper autoloadé **`projet_ferme(?string $delivery): bool`** (`my_functions_helper.php`) et, en SQL, la condition `(delivery IS NOT NULL AND delivery <> '' AND DATE(delivery) < CURDATE())`. **La date prime sur `etat_projet`** : repousser la date rouvre le projet automatiquement, statut métier d'origine intact.

Points de contrôle (tous cohérents avec cette règle) : `ProjectModel::getAll` (exclu des vues actives, routé vers le filtre « Terminés » = statut `code='COMPLETED'`), saisie des temps (`PlanificationController::getProjectsForUsers`, chemin admin de `slot_form`, garde serveur dans `slot_save`), modification (`ProjectsController::update` — bloquée sauf admin, qui repousse la date), et badge sur la fiche projet. **Ne jamais afficher/permettre la saisie sur un projet dont `projet_ferme()` est vrai** (hors admin qui repousse la date). NB : suppose `delivery` au format `Y-m-d`.

### Tickets (tâches) — identifiant affiché `T<id>`

Une tâche est identifiée par sa clé primaire **`tickets.id`**, affichée partout au format **`T<id>`** (ex : `T9759`). C'est le numéro unique visible par l'utilisateur.

**La colonne `tickets.reference` est ABANDONNÉE** — ne plus l'écrire ni l'afficher. Historique : c'était un numéro séquentiel généré via le compteur `core.ticket_reference` (par société `id_vcompanies`, lecture-puis-écriture **non atomique**), qui produisait des **doublons** et des **séries entremêlées** entre sociétés ; de plus `reference` est `NULL` sur la grande majorité des lignes. La colonne subsiste en base mais n'est plus ni générée (`create()` / `copyTicket()`) ni lue par les vues.

Points d'affichage (tous en `T<id>`) : fiche projet (`projects/view.php`), liste des tâches (`tickets/all_tickets.php`, colonne DataTables `data: 0` = `id`), détail tâche (`tickets/viewdetail.php`), vues legacy (`tickets/view.php`, `tickets/all.php`), dropdown saisie temps.

`TicketModel::getTicketSubjectByIdProject()` aliase `tickets.id AS ticket_id` — dans la vue résultante, utiliser `$value['ticket_id']` pour composer `T<ticket_id>` (pas `$value['id']` qui sera `undefined`). Cette méthode inclut les tâches **fermées** (`closed=1`, affichées avec un badge « Fermé ») ; seules les tâches `deleted=1` sont exclues.

**Timeline d'événements** : chaque modification métier d'une tâche est journalisée dans la table `ticket_timeline` via `CTicketsController::addTimelineEvent($ticketId, $type, $content, $oldValue, $newValue)` et affichée sur le détail tâche (`tickets/viewdetail.php`). Types couverts : `created`, `status_change`, `etat_change`, `priority_change`, `project_change`, `assign_change` (propriétaire), `date_change` (start/end), `close`, `reopen`, `attachment`, `note`. **Tout nouveau chemin qui modifie un ticket doit appeler `addTimelineEvent()`** — sinon le changement n'apparaît pas dans l'historique. Les tâches antérieures à cette fonctionnalité n'ont pas d'historique (pas de rétro-journalisation). NB : `type()`, `bulk()`, `surface()` sont du **code CI3 mort** (non fonctionnel, ne pas y ajouter de logique).

### Timbre fiscal (factures uniquement)

Le timbre fiscal **n'existe pas sur les devis** — uniquement sur les factures (table `facture`).

**Paramètre** : table `core` (gérée par `SettingModel`), colonne `timbre_fiscal` — valeur monétaire modifiable par l'admin via `Settings → Gestion commerciale`.

**À la création d'une facture** : la valeur courante de `core.timbre_fiscal` est copiée dans `facture.timbre_fiscal`. C'est cette valeur figée qui fait foi — modifier le paramètre n'affecte pas les factures existantes.

**À l'affichage (vue + PDF)** : lire `invoice['timbre_fiscal']` directement. Si la valeur est 0, ne rien afficher et ne pas l'ajouter au TTC. Ne jamais lire `core.timbre_fiscal` à l'affichage d'une facture existante.

**Condition d'activation** : `company['timbre_fiscal'] == 0` signifie que le timbre est activé pour ce client (0 = activé, convention inverse).

### Liaison `salaries` ↔ `users`

La liaison salarié–compte utilisateur est **bidirectionnelle** :
- `salaries.user_id` → `users.id` (mis à jour lors de la création de l'accès)
- `users.salaries_id` → `salaries.id` (écrit à l'insertion dans `users`)

L'UI `gestionsalarie` affiche le badge "Accès actif" si `salaries.user_id` est renseigné, et propose le bouton "Créer un accès" sinon. La création se fait via `GestionSalarieController::access()` qui insère dans `users`, `user_module_access` et `user_sous_access`. Pour désactiver un accès sans supprimer le compte : `UPDATE users SET status = 'inactive' WHERE id = ?` puis `UPDATE salaries SET user_id = NULL WHERE id = ?`.

### Module Planning — filtrage des tickets par profil

La saisie des temps (`/planning`) filtre les projets et tickets visibles selon le profil de l'utilisateur connecté. La colonne d'affectation est `tickets.collaborater_id` (l'employé assigné au ticket).

| Profil | Projets visibles | Tickets visibles |
|---|---|---|
| **Administrateur** (`session('user_is_admin')`) | Tous | Tous |
| **Chef d'équipe** (entrée dans `planning_equipe_responsables`) | Projets avec ≥ 1 ticket ouvert d'un membre de son équipe | Tickets de tous les membres de l'équipe |
| **Salarié** (aucun des deux ci-dessus) | Projets où il a ≥ 1 ticket ouvert | Ses tickets uniquement |

**Implémentation** — `PlanificationController` expose quatre helpers privés :

- `getVisibleUserIds(): ?array` — retourne `null` (admin, pas de restriction) ou un tableau de `user_id` autorisés.
- `getTeamUserIds(int $userId): array` — joint `planning_equipe_responsables → planning_equipe_membres → salaries` pour récupérer les `salaries.user_id` des membres actifs des équipes dont l'utilisateur est responsable.
- `getProjectsForUsers(array $userIds): array` — projets ayant au moins un ticket ouvert (`closed=0, deleted=0, progress!=100`) avec `collaborater_id IN ($userIds)`.
- `getTicketsForProject(int $projectId, ?array $visibleUserIds): array` — tickets ouverts d'un projet, filtrés si `$visibleUserIds !== null`.

Ces helpers sont appelés dans `slot_form()` (chargement initial de la modale) et `tickets_by_project()` (rechargement AJAX au changement de projet). Le chef sans membres d'équipe tombe en repli sur ses propres tickets.

**Mode SAISIE vs mode PLANNING** — le filtrage ci-dessus (basé sur le rôle du connecté) ne s'applique **qu'au planning chef** (`/planning/equipe`, `slot_form` sans `saisie=1` → un admin/chef voit les projets à affecter). En **mode saisie** (`/planning/mon`, appels `slot_form` / `tickets_by_project` avec `saisie=1`), les listes sont restreintes aux projets/tâches **affectés au salarié concerné** (`tickets.collaborater_id` = `salaries.user_id` du `id_salarie` ciblé), **quel que soit le rôle du connecté** : un admin qui saisit SES heures ne voit que SES tâches. La « saisie libre » (tâche texte) reste toujours disponible. `slot_form()` réinjecte en plus le projet/ticket d'un slot existant même s'il est hors périmètre (édition d'un historique).

**Ne jamais bypasser ce filtrage** dans de nouveaux endpoints de saisie de temps — un salarié ne doit voir que ses tickets.

### Saisie des temps — architecture des données (IMPORTANT)

Il existe **deux tables de temps**, avec des rôles bien séparés :

| Table | Rôle | Format heures | Écrite par | Lue par |
|---|---|---|---|---|
| **`planning_slots`** | **Source de vérité (active)** | `heures_reelles` **décimal** (7.5 = 7h30) | module `/planning` (`PlanificationController::slot_save` / `slot_reelles`) | **tous les rapports** (voir ci-dessous) |
| **`saisie_temps`** | **Historique legacy figé** | `heures_pointees` **"H.MM"** (`07.30` = 7h30) | plus rien (le `SaisieTempsModel` qui l'écrivait n'est câblé nulle part) | plus aucun rapport actif |

**`planning_slots` est la source de vérité.** Les temps ont été migrés depuis `saisie_temps` (elle-même migrée de la base `vision_legacy`) via transformation : `id_salarie` ← `users.salaries_id`, `heures_reelles` ← conversion "H.MM"→décimal, `id_ticket` (vrai ticket) ou `tache_libre` (activité `ticket_par_defaults.code` : Formation/Réunion/Congés…). Les temps d'utilisateurs sans compte salarié sont dans `planning_slots` avec `id_salarie = 0` (comptés dans les totaux tickets, invisibles dans `/planning`/Suivi qui filtrent par salarié réel).

**Colonnes `planning_slots`** : `id_salarie` (salaries.id), `id_ticket` (vrai ticket) **ou** `tache_libre` (texte, activité libre), `date_slot`, `heures_planif` (planifié par le chef ; 0 = saisie libre du salarié), `heures_reelles` (réel saisi, décimal), `created_by`.

**Lecteurs de temps — tous sur `planning_slots`** (ne JAMAIS les repointer vers `saisie_temps`) :
- `TicketModel::getPeriodPerTicket($ticketId)` → temps **total** par tâche (bloc « Tâche » fiche projet **et** attachements). `SUM(COALESCE(heures_validees, heures_reelles)) WHERE id_ticket = ?`. **Retourne un `float`** — ne pas faire `->periode` dessus (piège corrigé dans `CTicketsController::view()`).
- `TicketModel::getTimeEntriesByTicket($ticketId)` → **détail** des saisies d'une tâche (une ligne par slot : salarié, date, `heures_reelles`, `heures_validees`), jointure `salaries`. Affiché dans la section **« Temps saisi »** du détail tâche (`tickets/viewdetail.php`). Les slots `id_salarie=0` (utilisateur sans fiche salarié) apparaissent en repli « Salarié #0 ».
- `ProjectModel::getPeriodTickets_Byprojet($projectId)` → « Total temps » + rendement projet.
- `SuiviController::fetchEvents($year, $month)` → rapport Suivi (`id_salarie`→salaries_id, `tache_libre`→code activité, `heures_reelles`→seuil congé/férié/maladie).
- `ExporterController::heuresPointeesAsExcel()` → export Excel **« heures pointées » filtré par collaborateur** (route `exporter/heures_pointees`, formulaire sur `/suivi`). Colonnes calquées sur le format client : `date | heures_pointees | firstname | lastname | name (projet) | subject (tâche)`. `INNER JOIN salaries` (écarte les slots `id_salarie=0`), `heures_pointees` ← `heures_reelles`, filtres GET `salarie`/`startDate`/`endDate`.

`ProjectModel::calculeHeureTicket` / `calculateHours` et `TicketModel::getSumTimeByTicket` référencent encore `saisie_temps` mais sont **du code mort** (aucun appelant). Ne pas les réactiver sans les repointer sur `planning_slots`.

**Édition d'une saisie historique** : `slot_form()` réinjecte le projet et le ticket du slot édité dans les dropdowns **même s'ils sont clôturés/filtrés** — sinon le champ Tâche est vide et l'enregistrement échoue (« Tâche obligatoire »).

**Ne pas supprimer `saisie_temps`** : c'est la donnée d'origine (la migration vers `planning_slots` est avec perte — arrondi heures, `autre_saisie` fondu dans `tache_libre`, `id_salarie=0`), le filet de recovery, et du code mort la référence encore.

## Project conventions

- **Don't use `getMethod() === 'post'`** — `request->getMethod()` returns uppercase `"POST"` in this project. Use `$this->request->is('post')` instead.
- Controllers carry their own `protected array $view_data = []` and assemble it explicitly; the layout reads from `$view_data` (not from `$data` extracted by CI4). Always pass `['view_data' => $this->view_data]` when rendering.
- `BaseController::processPostData()` strips `<?php`, `<script>`, `<link>`, `<style>` tags from a fixed allowlist of POST fields (`description`, `message`, `terms`, `note`, `invoice_terms`, `estimate_terms`, `bank_transfer_text`). If you add a new rich-text field, extend that list.

## Debugging approach

When a fix doesn't immediately work — **especially for AJAX endpoints, form submits, and module-filter denials** — diagnose before editing again:
1. **Cache navigateur** : si les modifications PHP semblent ignorées malgré un redémarrage de spark, faire un hard-refresh dans le navigateur (`Ctrl+Shift+R`). Le cache navigateur peut servir l'ancienne page rendue même si le serveur envoie la nouvelle.
   - **OPcache (WAMP/Apache mod_php)** : si une modification de **code PHP** (modèle/contrôleur) semble ne pas prendre effet — l'app exécute encore l'ancienne logique (ex. lit l'ancienne table) alors que le fichier est bon —, c'est l'OPcache qui sert le PHP compilé en mémoire. **Redémarrer Apache** (icône WAMP → Apache → Restart Service). ⚠️ `php spark cache:clear` ne vide **pas** l'OPcache — seul un redémarrage d'Apache le fait. Symptôme typique : le fichier source est correct (`grep`/`Read` le confirment) mais l'écran montre un ancien comportement.
2. Read the actual server response (browser Network tab, `writable/logs/CI_log-*.log`, `writable/logs/trace-*.log`).
3. State the suspected root cause and the smallest possible fix.
4. Change one thing at a time. Don't sweep edits across controller + model + view simultaneously when the failure mode hasn't been isolated.

**Niveau de log** : `.env` fixe `logger.threshold = 4` — seuls `emergency`, `alert`, `critical` et `error` sont écrits. Utiliser `log_message('error', ...)` pour tout log de diagnostic ; `debug` et `notice` sont silencieux en production.

**Encodage CRLF des vues** : certains fichiers de vue ont des fins de ligne Windows (CRLF). L'outil `Edit` peut échouer silencieusement sur ces fichiers si la chaîne recherchée contient des caractères CRLF non pris en compte. En cas d'échec, utiliser PowerShell avec `Get-Content -Raw` + remplacement de chaîne comme alternative.

## Third-party libraries in use
- `dompdf/dompdf` — PDF generation (invoices, avoirs, devis).
- `phpoffice/phpspreadsheet` — Excel import/export (see `app/Helpers/excel_helper.php`).
- PHPMailer wrapper at `app/Libraries/PHPMailerLibrary.php` (SMTP config in `.env` under `email.*`).
