# 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
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 `test`). The full app schema is `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.
- **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

*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).

### 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.

### Timbre fiscal (factures uniquement)

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

**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 `invoices.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 = ?`.

## 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. Read the actual server response (browser Network tab, `writable/logs/CI_log-*.log`, `writable/logs/trace-*.log`).
2. State the suspected root cause and the smallest possible fix.
3. 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.

## 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.*`).
