# CLAUDE.md

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

## What this is

`gate` is the login/SSO front door for the GTS application suite: username+password, OTP-via-email, Cloudflare Access, and Google Sign-In, followed by the app launcher ("Applicazioni") that lists the apps a user is entitled to and hands off to them with a signed token. It is one app among many siblings under `/opt/gts` (each sibling is its own git repo/app, e.g. `hq`), all sharing one framework.

There is no build step, package manager, or test suite in this repo — it's plain PHP 8.3 pages served directly by Apache, plus a couple of hand-written jQuery files. Changes are tested by loading the pages in a browser against the shared dev stack.

## Runtime layout (important — spans repos)

- Apache `DocumentRoot` is `/opt/gts` (not this repo). This repo is served as `/gate/*`.
- PHP `include_path` includes `/opt/gts/shared`, which holds the shared GTS framework: session/context handling (`context.php`), templating (`templates.php`, `xhtmlbuild.php`), auth (`auth_php.php`, `cf_auth.php`), logging/DB/mail (`GTS\Log`, `GTS\DBVersion`, `GTS\Mailq`, `GTS\JSON`), and vendor libs (PHPMailer, TOTP via `gauth/vendor`, etc.). None of that framework code lives in this repo — read it from `/opt/gts/shared` when you need to know what a helper actually does, don't guess from naming.
- Every page starts with `require_once("common_includes.php")` (found via include_path), which starts the session, autoloads `GTS\...` classes, and sets up the globals `$T` (templates), `$JSON`, `$INTL`, `$WKS`, `$L`.
- `auto_include.php` in this repo defines `$gts_app_config` (app name `gate`, `db.persistent`) and pulls in the gitignored `auto_include_cfg.php` for per-deployment overrides (custom logo, `otp_enable`, etc.). `auto_include_cfg.php`, `google_config.php`, `*.css` (except the checked-in base ones), and logo images are all gitignored per-environment config — don't assume they exist, and code must degrade gracefully when they don't (see the `google_config.php` existence check in [login.php](login.php)).

## Il push su `shared` stampa errori dell'hook: sono innocui

`dev.gts.it/git/shared.git` ha un hook `post-receive` che dovrebbe aggiornare due copie sul
server — `/var/www/dev/gts/shared` e la copia offuscata `/var/www/dev/gts/shared_off` — e fallisce
a ogni push:

```
remote: error: insufficient permission for adding an object to repository database .git/objects
remote: hooks/post-receive: 49: yakpro-po: not found
```

**Il push è comunque andato a buon fine**: l'hook gira dopo che i riferimenti sono stati
aggiornati, quindi chi fa `pull` da `shared.git` prende sempre l'ultima versione. Verificarlo con
`git ls-remote origin refs/heads/master`, che deve coincidere con `git rev-parse HEAD`.

Il ramo offuscato **non lo usa nessuno** ed è fermo al 16/12/2022: la catena di deploy è rotta da
anni, non da oggi. Fino a quando l'hook non viene tolto dal server, quegli errori vanno letti come
rumore — non come un push fallito.

## Request/session conventions (framework behavior, easy to miss)

- **All GET/POST/COOKIE params are auto-imported as `$s_<name>` globals** by `ctx_import_request_variables()` in `context.php` — there's no explicit `$_GET`/`$_POST` reading in page code. A request field `uid` shows up as `$s_uid`. This import runs a weak regex blocklist against SQL keywords; **it is not a substitute for parameterized queries**.
- Session state is namespaced per app/company via `ctx_session_app()` (keys like `<app>_<soc>_perm`). Use `ctx_uid()`, `ctx_session()`, `ctx_par()` etc. from `context.php` rather than touching `$_SESSION` directly.
- DB access goes through `auth_db()` returning a `$db` wrapper; queries use named placeholders, e.g. `$db->q("select ... where userid=:uid", ["uid" => $uid])` / `$db->qf(...)` for a single row. **Always parameterize — never concatenate user input into SQL.**
- Auth tokens: `auth_issue_token()` mints a token on successful login (password, OTP, Cloudflare, Google), `auth_checktoken($uid, $token)` validates it on entry into an app (see [gate.php](gate.php), [apps.php](apps.php), [appsn.php](appsn.php)), `auth_resettoken()` invalidates it on logout. User id `1` acts as a master/impersonation account — `auth_checktoken(1, $token)` also passes, and impersonation session flag `can_impersonate` is set when the master token was used (see [appsn.php](appsn.php)).

## Page structure

Each screen is normally a trio: `<name>.php` (server logic + fragments pushed into template placeholders), `<name>.layout` (declares page chrome via `!directives` like `!bs4`, `!nomenu`, `!script`, `!style`, and named placeholders `%place-xxx` / `%v-xxx`), and optionally `<name>.js` for client behavior. PHP fills placeholders with `$T->p("place-name")` + `$T->o($html)`, and named template variables with `$T->v("v-name", $value)`. `*_cmd.php` files are JSON API endpoints (`$JSON->success()` / `$JSON->fail(...)`) called from the matching `.js`.

Key flows:
- [login.php](login.php) — password login page; also renders the Google Sign-In button (if `google_config.php` present) and attempts Cloudflare Access auto-login via `cf_try_login()`.
- [login_otp.php](login_otp.php) / [login_otp.js](login_otp.js) / [login_otp_cmd.php](login_otp_cmd.php) — two-step email OTP login, gated by `$gts_app_config['otp_enable']`. Codes are 6 digits, 5-minute expiry, 3 attempts, and a 30s resend cooldown, stored on `gtsusers` (`otp_code`, `otp_valid_until`, `otp_attempts`). Email is queued into `gtsmailq` with `stato='T'` (bypassing the normal cron) and a background sender is kicked via `exec()` (best-effort — silently no-ops if `exec` is disabled).
- [mailqsend.php](mailqsend.php) — CLI-only script (refuses non-CLI callers) that flushes `gtsmailq` rows with `stato='T'` from the last 2 minutes via PHPMailer, using HQ app mail settings (`ctx_par('mailq_*')`).
- [gate.php](gate.php) — entry point an app redirects to after picking an app+company from the launcher; validates token, seeds the app-scoped session (perms, params, descriptions), resets the login token, and redirects into the target app.
- [apps.php](apps.php) / [appsn.php](appsn.php) — the app launcher ("card" list of apps grouped by company); `appsn.php` is the newer Bootstrap 5 graphical-menu version (`menugraph` cookie chooses between them), `apps.php` is the legacy list-menu version. Both support impersonation (user id 1, or a session carrying `can_impersonate`) picking another user from a dropdown.
- [usr_resetrequest.php](usr_resetrequest.php) / [usr_resetpwd.php](usr_resetpwd.php) — self-service password reset by emailed one-time token; [usr_password.php](usr_password.php) — logged-in password change form.
- [userpref_cmd.php](userpref_cmd.php) — save user prefs (description, email, TOTP token) and generate a new TOTP secret (`gentokenotp`, via `gauth`'s `OTPHP\TOTP`, PHP 8.1+).
- [logout.php](logout.php) — clears the GTS token and PHP session, then redirects to Cloudflare's logout if a CF Access session is present, otherwise back to `login.php`.

## Pagine di ricerca con filtri: pattern di default

Per una pagina lista/ricerca nuova (il `<name>.php`/`.layout`/`.js` con griglia + filtri descritto
sopra), il pattern di default per la barra dei filtri è **`!filtertabs`**
(`shared/components/filtertabs/filtertabs.js`, attivato dalla direttiva `!filtertabs` nel `.layout`):
trasforma una `<table class="filtertab">` con una riga di `td.val` affiancate in tab Bootstrap 5,
con toggle per tornare alla vista a colonne e indicatori di filtro attivo — più ricco e senza bisogno
di markup manuale rispetto a costruire i tab a mano con `%navpills`/`%navtab`/`%tabpane` (pattern più
vecchio, ancora presente in alcune app, da non replicare in pagine nuove). Esempio minimo:

```
!filtertabs
%main
 %block
  %table.filtertab#pdata
   tr
    td.val [data-tab-label=Filtri principali]
     %place-search-1
    td.val [data-tab-label=Località]
     %place-search-2
  %place-btn
 div#flexc.flex-container
  %table#rTable
```

`data-tab-label` è opzionale (senza, l'etichetta della tab è dedotta dal testo del primo `.fld`
dentro il `td`) ma consigliato quando la prima etichetta di campo non rappresenta bene l'intero
gruppo di filtri. La sintassi `[attrib=valore,attrib2=valore2]` per attributi arbitrari su un
elemento è gestita dal compilatore in `gts/tpl/templates.php`; i valori possono contenere spazi
(delimitatori solo `,` e `=`), non virgole.

Il pulsante "Pulisce Filtri" (`hb_button_iconify("", "mdi:backspace-outline", "!clearFilter()", ...,
"adestra", ...)`, funzione `clearFilter()` — vedi sotto) va nella riga `%place-btn` insieme a
Ricerca/Inserisci/Stampa, non in uno slot separato nella barra tab: con `!filtertabs` non esiste un
punto dove ancorarlo dentro la barra (a differenza del vecchio pattern `%navpills`, che aveva
`li.ms-auto` per quello). La classe CSS `adestra` (`float: right`, definita nel base CSS di ogni app)
lo sposta all'estrema destra della riga pulsanti.

**`clearFilter()` deve stare in `app.js`, NON nel JS di pagina.** Il framework carica il JS di
pagina (`<name>.js`) prima degli `additional_js` (incluso `app.js`); se `clearFilter()` è definita
in entrambi, la versione di `app.js` sovrascrive quella di pagina. Implementazione generica corretta
per `app.js`:

```javascript
function clearFilter() {
    var $f = $('#pdata');
    $f.find('input:not([type=hidden])').val('');
    $f.find('select').each(function() {
        $(this).val($(this).find('option:first').val()).trigger('change');
    });
    if ($.isFunction($.fn.selectpicker))
        $f.find('.selectpicker').selectpicker('val', '');
}
```

**Corollario generale — attenzione ai nomi delle funzioni messe in `app.js`.** La stessa
precedenza (JS di pagina prima, `additional_js` dopo) vale per *qualunque* funzione, non solo per
`clearFilter()`. Se in `app.js` si mettono funzioni condivise da una *famiglia* di pagine (es. le
funzioni comuni alle pagine documento di un ERP: `addRow`, `editRow`, `delRow`, `saveRow`,
`stampa`…), queste **sovrascrivono silenziosamente** le omonime definite nei `.js` delle altre
pagine, che smettono di funzionare senza alcun errore evidente — o falliscono con un
`ReferenceError` su una variabile di configurazione che quelle pagine non definiscono.
Regola: le funzioni condivise in `app.js` che valgono solo per un sottoinsieme di pagine vanno
**prefissate** (`docAddRow`, `docSaveRow`, `docStampa`, …), lasciando i nomi generici alle pagine
che li definiscono localmente.

Il pulsante "Pulisce Filtri" deve chiamare ANCHE la funzione di ricerca della pagina nel proprio
`onclick`, non solo `clearFilter()`: usare `"!clearFilter();cercaProva()"` (o il nome locale) come
evento di `hb_button_iconify` — `hb_decodeevent` genera `onclick='clearFilter();cercaProva()'`, 
che è JS inline valido con due istruzioni separate da `;`.

**Popup `.gv-cal-popup` tagliato dalla `filtertab` + scrollbar verticale spuria**: `filtertabs.css`
applica `overflow-x:auto !important` a `table.filtertab:not(.filtertabs-active)` in modalità
colonne, ritagliando qualunque popup `position:absolute` figlio (compreso `.gv-cal-popup` di
`hb_calendar_range`). Inoltre, CSS spec converte `overflow-x:visible` in `auto` quando `overflow-y`
non è anche `visible`, generando una scrollbar verticale inattesa.
Fix da aggiungere all'`app.css` di ogni app (il selettore `:not(.filtertabs-active)` è necessario
per pareggiare la specificità di filtertabs.css e battere il suo `!important`):

```css
table.filtertab,
table.filtertab:not(.filtertabs-active) {
    overflow-x: visible !important;
    overflow-y: visible !important;
}
```

Trade-off: si perde lo scroll orizzontale in modalità colonne su schermi molto stretti (accettabile
per pagine con pochi campi filtro; in pagine con molti tab di filtro il problema non si pone perché
si è già in modalità tab, dove `overflow-x:auto` non viene applicato).

**Ordine dei filtri data**: nelle pagine di ricerca il filtro "Data da/a" (`hb_calendar_range`)
va dichiarato SEMPRE PRIMO in alto a sinistra nella barra dei filtri, sia nel layout che nell'ordine
di `ctx_pop()` nel PHP.

**Doppio click su una riga = click sull'icona di modifica.** `setTableSorter()`
(`components/tablesorter/gts.tablesorter.js`) intercetta il doppio click su ogni riga e, se non è
premuto CTRL (che ha già un altro significato: isola le righe selezionate), cerca al suo interno un
elemento con classe `row-edit-icon` e ne simula il click. Per attivarlo in una pagina lista/ricerca
nuova basta aggiungere quella classe all'icona di modifica riga (es. `hb_ev_iconify("mdi:pencil",
"!editRow(...)", 20, 'currentColor', 'text-primary me-2 row-edit-icon')` oppure la classe equivalente
sull'icona `fa-edit`/`hb_glyph_edit()` usata) — nessun'altra modifica richiesta, e nessun effetto
sulle tabelle che non hanno quella classe su alcuna icona.

## Tendine (select) popolate da tabelle di lookup: pattern di default

Per qualunque campo "intabellato" (un valore letto da una tabella di decodifica/lookup dell'app,
tipo `MAR`/`TIPMEZ`/`TIPINT` in `ricambi/TABELLE`, o `AUTISTI` in careglio) che non sia
un'anagrafica gestita da `hb_search_generic()`, usare sempre questa tecnica a tre parti — non select
semplici `hb_select_db`/array costruiti a mano riga per riga. Riferimento concreto (prima
applicazione in cui è stata introdotta): `ricambi/auto_include.php`, `ricambi/mezzi.php` /
`mezzi.js` / `mezzi_data.php`. Finché non viene promossa in `shared` (vedi nota in fondo), va
replicata così in ogni app nuova:

1. **Componente select**: un piccolo helper locale per app (sul modello di `gui_cboaut` in
   careglio) che wrappa **sempre `hb_multiselect_db()`**, mai `hb_select_db()`/`hb_select2_db()`,
   anche per campi concettualmente a valore singolo — coerenza dell'interfaccia su tutte le
   tendine intabellate dell'app. Esempio (`ricambi/auto_include.php`, funzione `gui_cbotab()`):
   piglia `$db, $tipo, $name, $value, $idpadre=null` e ritorna
   `hb_multiselect_db($name, $value, $db, $sql, ...)` con la query di lookup già pronta
   (supporto opzionale a `IDPADRE` per le cascate padre/figlio, es. Modello dipendente da Marca).

2. **Posizionamento popup dentro `!filtertabs`** (già risolto in `shared`, nessuna azione per app):
   la tabella `.filtertab` in modalità "colonne" riceve da `filtertabs.css` un
   `overflow-x: auto !important`, e un antenato con `overflow` diverso da `visible` ritaglia
   qualunque popup `position:absolute` dei discendenti — compreso il menu del bootstrap-select.
   Il rimedio è **solo nel CSS**, in fondo a `filtertabs.css`, che riporta la tabella a
   `overflow-x/y: visible`. Vedi più avanti la regola 8 di "Regole fisse per le barre filtri e i
   pannelli": **non** va aggiunto `container: 'body'` al selectpicker come secondo rimedio —
   c'era, è stato rimosso, e faceva comparire il menu staccato in alto a destra.

3. **Filtro multivalore lato query**: dato che il componente è sempre un multiselect, il valore che
   arriva in `$s_<CAMPO>` è una stringa CSV (`gvMap()` in `gvlib.js` serializza i
   `data-role='multiselect'` come `"3,5,7"`, non un array) — va tradotto in un `IN(...)`
   parametrico, non in un confronto `= :CAMPO`. Usare `exp_wh_sql($csv, $prefix, &$bind)`
   (`ricambi/auto_include.php`): ritorna `"IN (:prefix0,:prefix1,...)"` popolando `$bind` per
   riferimento, oppure `""` se il CSV non contiene valori validi (in tal caso non aggiungere la
   condizione al WHERE). È l'equivalente parametrico di `exp_wh()` di careglio (che invece
   concatena i valori direttamente nella query, senza bind) — preferire la versione parametrica in
   ogni app nuova, coerente con la regola generale "sempre query parametrizzate" di questo file.

**Nota**: i punti 1 e 3 oggi vivono solo in `ricambi` (non ancora promossi in `shared`); se in
futuro si decide di centralizzarli, sono candidati diretti per `xhtmlbuild.php` (una
`hb_multiselect_tab_db()` e un `sql_in_bind()` generici). Il punto 2 è già in `shared` (`gvlib.js`)
e si applica automaticamente a ogni app, senza bisogno di replicarlo.

## Pannelli di dettaglio: campi affiancati, non incolonnati

Vale per **tutte** le app sotto `/opt/gts`, non solo per quella in cui è stata introdotta.

`hb_frow()` emette una coppia `<td class='fld'>` + `<td class='val'>` dentro un `<tr>`, e nei
pannelli (`!bs5frag` + `%rigaOff`) questi `<tr>` finiscono come **figli diretti** del contenitore
`.rdapanel .panel`, senza una `<table>` che li avvolga: il browser li impagina come tabella anonima
a colonna singola. Su una testata di 15-25 campi (tipico di un documento: numero, data, anagrafica,
pagamento, valuta, agente, destinazione, totali…) questo produce una colonna verticale lunghissima,
con metà schermo vuoto a destra e la necessità di scorrere per arrivare ai pulsanti.

**Regola: i campi dei pannelli vanno affiancati su più colonne**, aggiungendo all'`app.css` di ogni
app il blocco qui sotto. Non richiede di toccare il PHP — nessuna modifica a `hb_frow()`, nessun
markup aggiuntivo nei `*_panel.php` — e si applica automaticamente anche ai pannelli aggiunti in
futuro:

```css
.rdapanel .panel {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .45rem 1.75rem;
    width: 100%;
    max-width: 100%;
    box-sizing: border-box;
}

/* ogni campo e' un item con base 340px: le colonne si adattano da sole, ma
   con un tetto, altrimenti due soli campi si dividono tutta la riga */
.rdapanel .panel > tr        { display: flex; align-items: center;
                               flex: 1 1 340px; min-width: 0;
                               max-width: min(32rem, 100%); gap: .6rem; }
.rdapanel .panel > tr > td.fld { flex: 0 0 auto; white-space: nowrap; }
.rdapanel .panel > tr > td.val { flex: 1 1 auto; min-width: 0; }

/* titoli di sezione, hr, avvisi, griglie interne, script: tutta larghezza */
.rdapanel .panel > *:not(tr) { flex: 0 0 100%; max-width: 100%; }
.rdapanel .panel > input[type="hidden"] { display: none; }
.rdapanel .panel > button    { flex: 0 0 auto; max-width: none; }

/* gli input hanno larghezza intrinseca dall'attributo `size`: va neutralizzata.
   Questa regola resta NUDA: le eccezioni (interruttori, campi corti, data, ora)
   la battono per specificita' da se'. Vedi le regole 4 e 5 */
.rdapanel .panel td.val input,
.rdapanel .panel td.val select,
.rdapanel .panel td.val textarea,
.rdapanel .panel td.val .input-group { width: 100%; max-width: 100%; box-sizing: border-box; }

/* hb_search_generic: codice + lente + descrizione devono poter comprimersi */
.rdapanel .panel td.val [data-role="gtssearch"] { display: flex; width: 100%; max-width: 100%;
                                                  align-items: center; gap: .25rem; }
.rdapanel .panel td.val [data-role="gtssearch"] input[data-role="gtssearch-des"] { flex: 1 1 auto; min-width: 0; }
.rdapanel .panel td.val [data-role="gtssearch"] input[data-role="gtssearch-cod"],
.rdapanel .panel td.val [data-role="gtssearch"] button { flex: 0 0 auto; }
```

**Usare flex-wrap e non un grid a numero fisso di colonne.** Un
`grid-template-columns: repeat(3, …)` sembra la scelta naturale, ma il pannello è
ridimensionabile a mano (vedi sotto): con tre colonne imposte, in un pannello stretto gli input si
schiacciano a pochi pixel (se il minimo delle tracce è `0`) oppure il contenuto sfonda la larghezza
facendo comparire le barre di scorrimento (se il minimo è fisso). Con `flex: 1 1 340px` il numero
di colonne si adatta da solo alla larghezza reale: tre su schermo largo, due o una su un pannello
stretto, senza mai schiacciare né sbordare, e senza bisogno di media query.

Corollario per chi scrive i `*_panel.php`: **raggruppare i campi con `hb_title("Nome gruppo", "",
"h6 mt-3")`** (Testata, Condizioni, Destinazione, Totali…). Con i campi affiancati i titoli sono
l'unico elemento che separa visivamente i blocchi, e senza di essi i campi diventano una griglia
indistinta.

### `!canresize` memorizza dimensione e posizione del popup

Se il layout del pannello dichiara `!canresize`, `gvlib.js` aggiunge il pulsante di
trascinamento/ridimensionamento e **salva lo stato in `localStorage`** con chiave
`gvpanel_<url del panel>` (`salvaPanel()` / `ripristinaPanel()`). Da quel momento il pannello si
riapre sempre con quella misura e in quella posizione, **anche su un altro schermo**.

È la causa più frequente di un popup inspiegabilmente stretto o fuori centro, e **nessuna regola
CSS può correggerlo**: lo stile inline scritto da jQuery UI vince. Il ridimensionamento manuale
si paga così: una misura scelta una volta per sbaglio resta, e l'utente non ha modo di capire
perché quel pannello è diverso dagli altri.

**Per questo nelle app nuove i pannelli di dettaglio non dichiarano `!canresize`.** Senza la
direttiva il popup si apre sempre alla dimensione di default (90% × 80% della finestra), che con
i campi affiancati (vedi sopra) è già la resa giusta su qualunque schermo. La direttiva ha senso
solo su pannelli che l'utente tiene aperti a lungo accanto ad altro — non sulle normali maschere
di inserimento e modifica.

Nota utile per la diagnosi: `ripristinaPanel()` è invocata **solo** da `addResizeBtn()`, a sua
volta condizionata al campo nascosto `#gvcanresize`. Togliendo la direttiva gli stati già
memorizzati nei browser restano in `localStorage` ma diventano inerti, quindi non serve
ripulirli. Se si vuole farlo comunque:

```javascript
function gvResetPanelSize(panel) {
    var chiavi = [], i, k;
    for (i = 0; i < localStorage.length; i++) {
        k = localStorage.key(i);
        if (k && k.indexOf('gvpanel_') === 0 && (!panel || k.indexOf(panel) >= 0))
            chiavi.push(k);
    }
    chiavi.forEach(function(k) { localStorage.removeItem(k); });
    return chiavi.length;
}
```

## Regole fisse per le barre filtri e i pannelli

Valgono per **tutte** le app sotto `/opt/gts`. Sono state ricavate correggendo più volte lo
stesso difetto in app diverse: se una pagina nuova le rispetta fin dall'inizio, quel giro non
si ripete.

### 1. Ogni tendina di filtro è a selezione multipla

In una barra filtri **non si usano mai** `hb_select`/`hb_select2`: si usa sempre il componente
multiselect (`hb_multiselect` / `hb_multiselect_db`, che `gvlib.js` inizializza come
selectpicker). Vale sia per i campi intabellati sia per le liste di valori fissi — stati di un
documento, tipi, segno di un movimento.

Il motivo è pratico prima che estetico: cercare *due* stati insieme è la richiesta più comune su
una lista, e una select a valore singolo la rende impossibile. Una tendina singola in mezzo a
delle multiselect è anche un'incoerenza che l'utente paga a ogni ricerca.

```php
// campo intabellato (valori da `tabelle`)
hb_frow("Magazzino", gui_cbotab_multi($db, 'MAGAZ', "FMAGAZZINO", $s_FMAGAZZINO))

// lista di valori fissi
hb_frow("Stato", gui_multi_array("FSTATO", $s_FSTATO, gui_stati('OFC', false)))
```

Conseguenza obbligatoria lato query: il valore arriva come **CSV**, quindi va tradotto in un
`IN(...)` parametrico con `exp_wh_sql()`, mai in un confronto `= :campo`.

```php
if ($in = exp_wh_sql($s_FSTATO, 'sta', $bind)) $w .= " AND o.stato $in ";
```

Nella multiselect **non si mette la voce "* Tutti *"**: nessuna selezione significa già nessun
filtro, e una voce vuota selezionabile insieme ad altre non ha senso.

### 2. Ogni campo data usa il calendario

Un campo che contiene una data si scrive con `hb_calendar()` (o `hb_calendar_range()` per gli
intervalli), mai con `hb_input()`. Vale anche nei pannelli: `gvSetupPage()` — che `gvlib.js`
esegue su ogni pannello caricato via AJAX — aggancia da sé i `[data-role=gts-calendar]`, quindi
lì il datepicker funziona senza altro lavoro (a differenza dei campi di ricerca, vedi la sezione
su `gtsSearchSetup()`).

Unica eccezione: i **timestamp di tracciatura** in sola lettura (`Creato il`, `Modificato il`).
Lì non serve un selettore di date e conta anche l'ora, quindi si usa un campo readonly con
`dtFmtHM()`, che rende `21/08/2026 14:32`. L'ora distingue due modifiche dello stesso giorno,
cosa che la sola data non fa.

### 3. I campi in sola lettura si vedono

Un input readonly identico a uno editabile invita a scriverci e non risponde. Sfondo grigio e
cursore di default, applicati a tutti i readonly del pannello senza doversi ricordare una classe:

```css
.rdapanel .panel td.val input[readonly],
.rdapanel .panel td.val textarea[readonly],
.rdapanel .panel td.val input[disabled],
.rdapanel .panel td.val select[disabled] {
    background-color: #f2f5f4;
    border-color: #dde3e2;
    color: #5c6b69;
    cursor: default;
}
```

### 4. Gli interruttori restano piccoli

La regola che dà `width: 100%` agli input del pannello (necessaria perché l'attributo `size` di
`hb_input()` non sfondi la colonna) **non deve toccare checkbox e radio**: applicata anche a loro
stira l'interruttore per tutta la larghezza della colonna, trasformandolo in una barra.

```css
.rdapanel .panel td.val input                  { width: 100%; }        /* (0,3,2) */
.rdapanel .panel td.val input[type="checkbox"] { width: auto; flex: 0 0 auto; }   /* (0,4,2) — vince */
.rdapanel .panel td.val .form-switch .form-check-input { width: 2.25em; height: 1.15em; }
```

Non serve `:not([type="checkbox"])` sulla regola generale: l'eccezione ha già un attributo in
più e vince da sé. Vedi la regola 5 sul perché quel `:not()` sarebbe anzi dannoso.

### 5. La larghezza del campo dice quanto ci sta dentro

Un input che riempie la colonna qualunque cosa contenga fa sembrare mancante il resto del dato:
una casella da trenta caratteri per una data di dieci, o da venti per un anno di quattro. Ed è
spazio tolto a chi il testo lungo ce l'ha davvero.

Tre livelli, dal più generale al più specifico.

**a) La riga ha larghezza fissa e non cresce.** `flex-grow: 1` sembra la scelta naturale — riempie
la riga, niente spazio sprecato — ma distribuisce l'avanzo fra gli item della **singola linea**: una
sezione da cinque campi li fa larghi 380px, una da tre li fa larghi 512px, e le colonne non si
incolonnano più fra una sezione e l'altra. È il difetto che si vede subito e che non si sa spiegare.

```css
.rdapanel .panel > tr { flex: 0 1 32rem; max-width: 100%; }
```

`flex-shrink` resta a 1: su un pannello stretto gli item si restringono tutti insieme, quindi
restano allineati anche lì. Il prezzo è un po' di spazio libero in fondo a ogni linea, molto meno
fastidioso di colonne che ballano — ed è lo stesso motivo per cui `td.fld` ha larghezza fissa.

**b) I campi corti si dimensionano da soli.** La lunghezza vera la sa già il markup: `hb_input()`
emette `maxlength`, che vale il `size` quando non è indicato a parte. Da 21 caratteri in su non
serve nulla: la colonna è larga 32rem e non di più, e lì dentro il 100% è già la misura giusta.

```css
/* ~0,62em per carattere + 1,6em di bordi e spaziatura, arrotondati per eccesso */
… input[maxlength="1"], … [maxlength="5"]  { width: 5.5em; }   /* anno, provincia */
… input[maxlength="6"], … [maxlength="8"]  { width: 7em; }     /* CAP, codice SDI */
… input[maxlength="9"], … [maxlength="12"] { width: 9.5em; }   /* codici, pesi */
… input[maxlength="13"], … [maxlength="16"]{ width: 12em; }    /* prezzi, timestamp */
… input[maxlength="17"], … [maxlength="20"]{ width: 14em; }    /* totali, partita IVA */
```

**c) Data e ora hanno una misura propria**, tarata sul formato e non sul numero di caratteri, e
vanno scritte **dopo** i gruppi del punto b: una data ha `maxlength=10` e un'ora `5`, quindi
ricadono anche lì con la stessa specificità (0,4,2), e a parità vince l'ultima dichiarata.

```css
.rdapanel .panel td.val input.bs-calendar { width: 8.5em; max-width: 100%; }
.rdapanel .panel td.val input.bs-time     { width: 5.5em; max-width: 100%; }
```

A rimpicciolirsi è **solo la casella**: la colonna resta della sua larghezza, così le etichette
continuano a incolonnarsi (è il motivo per cui `td.fld` ha una larghezza fissa).

Nelle **barre filtri** non serve nulla: lì la colonna è già limitata da `filtertabs.css`
(`.tr-gv { flex: 0 1 320px }`), che è una misura pensata per quella disposizione.

**La regola generale va tenuta DEBOLE, e le eccezioni vincono per specificità.** È il punto in cui
è facile perdere mezz'ora, e il modo sbagliato sembra il più ordinato:

```css
/* NO: la generale si difende dalle proprie eccezioni */
.rdapanel .panel td.val input:not([type="checkbox"]):not([type="radio"]) { width: 100%; }  /* (0,5,2) */
.rdapanel .panel td.val input.bs-calendar { width: 8.5em; }                                /* (0,4,2) — perde */
```

`:not([type="checkbox"])` conta come un selettore d'attributo, quindi **al pari di una classe**: ogni
eccezione elencata dentro la regola generale ne alza la specificità, finché quella regola batte
proprio le righe scritte per correggerla. Scritta così, la larghezza non ha alcun effetto, e il
sintomo è identico a un CSS non ricaricato dalla cache — che è la prima cosa che si va a
controllare, ed è la pista sbagliata.

La forma che regge è l'opposto: la regola generale resta nuda — `td.val input { width: 100% }`,
(0,3,2) — e ogni eccezione aggiunge una classe o un attributo, quindi vale (0,4,2) e si applica da
sé, senza che nessuno debba tornare a modificare la regola generale per farle spazio.


### 6. Le intestazioni delle colonne numeriche vanno a destra

Le celle numeriche del corpo hanno `text-end`, ma l'intestazione restava a sinistra: il titolo
finiva lontano dalla colonna di cifre che descrive, e su una griglia larga si perde il filo di quale
numero sta sotto quale nome.

`hb_th()` accetta già un array di classi, una per colonna. `hb_th_num()`
(`shared/xhtmlbuild.php`) è il modo comodo di usarlo: si dichiarano solo **quali** colonne sono
numeriche, per **etichetta** e non per posizione, così inserire una colonna in mezzo non sposta
l'allineamento di quelle dopo.

```php
hb_th_num(["#","Numero","Data","Cliente","Colli","Imponibile","Totale","Stato","Op"],
          ["Colli","Imponibile","Totale"])
```

L'icona di ordinamento della tablesorter è un'immagine di sfondo ancorata a destra, con 20px di
spaziatura riservata: il titolo allineato a destra le si affianca senza sovrapporsi.

Vale per **ogni griglia**, comprese quelle dentro i pannelli e le sotto-griglie di riga.

### 7. Le tendine che leggono dal database sono selectpicker a due colonne

Una tendina costruita su una tabella — codifiche in `tabelle`, anagrafiche, commesse, centri di
costo — mostra codice e descrizione **incolonnati**, con l'intestazione, e ha il campo di ricerca.
Vale **sia nelle barre filtri sia nei pannelli**: cambia solo il vincolo, multiplo nel filtro e
singolo nel pannello.

```php
gui_cbotab_multi($db, 'CATMERC', "FCATEGORIA", $s_FCATEGORIA)          // filtro, multiplo
gui_cbotab_multi($db, 'QUALIF', "FQUALIFICA", $s_FQUALIFICA, "",
                 ['col1' => 'Codice', 'col2' => 'Qualifica'])          // intestazioni su misura
gui_cbotab($db, 'IVA', "ID_ALIQUOTA_IVA", $ff['id_aliquota_iva'])      // pannello, singolo
```

Il singolo si costruisce con `hb_selectpicker()` e `'multiple' => false`: niente
seleziona/deseleziona tutto, che su un valore solo non vuol dire niente, e la voce vuota in testa
solo per i campi facoltativi.

Le tendine a **valori fissi** — stato di un documento, tipo, segno di un movimento — restano
quello che sono: tre voci non si cercano, e un componente con la lente sopra tre righe è più
ingombrante di quanto aiuti. La discriminante è la provenienza dei valori, non il numero di voci
che ha oggi.

Due trappole, entrambe pagate una volta:

- **`hb_selectpicker_db()` non va bene per i filtri.** In modalità `twocol` usa come valore
  dell'option la prima colonna letta, cioè il **codice**, mentre i filtri lavorano sugli id: la
  ricerca esce vuota senza dire perché. Si costruiscono le coppie in PHP e si chiama
  `hb_selectpicker()`, che come valore usa la chiave dell'array.
- **Con un `onchange` si ricade sulla forma semplice.** `hb_selectpicker()` non ha un parametro per
  l'evento, e perderlo in silenzio spegnerebbe una pagina senza segnalare nulla.
- **Il pulsante a tendina chiusa deve mostrare la sola descrizione.** Bootstrap Select usa
  `data-content` sia per le voci dell'elenco sia per il pulsante, quindi da chiuso comparirebbero
  le due colonne troncate ("V01&#160;&#160;&#160;&#160;Trasporti Alp"). Ci pensa
  `gts-selectpicker5.js`, che si aggancia a `loaded`, `rendered` e `refreshed.bs.select`
  **delegati sul `document`**: solo così la sistemazione arriva anche alle tendine dei pannelli,
  che nascono via AJAX dopo `window.load`.

### 8. Un solo rimedio per il clipping dei popup nella `.filtertab`

`filtertabs.css` applica `overflow-x: auto !important` alla `table.filtertab` in modalità
colonne, e questo ritaglierebbe i popup `position:absolute` dei discendenti (calendario,
menu del selectpicker). Il rimedio è **uno solo e sta nel CSS**, in fondo a `filtertabs.css`:

```css
table.filtertab,
table.filtertab:not(.filtertabs-active) {
    overflow-x: visible !important;
    overflow-y: visible !important;
}
```

**Non aggiungere `container: 'body'` al selectpicker come secondo rimedio.** Serviva prima che
esistesse la regola qui sopra; oggi che il clipping non c'è più, spostare il menu nel `<body>`
non risolve nulla e in più lo rompe: bootstrap-select calcola le coordinate rispetto al body
mentre la tabella ha `display: block !important`, sbaglia il calcolo e il menu compare staccato
in alto a destra come un riquadro vuoto con la barra di scorrimento, invece che sotto il proprio
campo. Due rimedi per lo stesso problema si pestano i piedi: quando il popup si comporta male,
prima di aggiungerne un altro va verificato se quello esistente basta già.

## `gtsSearchSetup()` va richiamata a ogni apertura di pannello

Vale per **tutte** le app sotto `/opt/gts`.

`hb_search_generic()` (il campo con la lente che apre il popup di ricerca) genera un pulsante
**senza `onclick` inline**: il click è agganciato da `gtsSearchSetup()`
(`shared/search/gtssearch.js`), che scorre i `[data-role=gtssearch]` presenti nel documento e vi
lega l'handler.

Il problema è *quando* viene eseguita: `gtssearch.js` la chiama una sola volta, nel proprio
`$(function(){…})`, cioè al ready della pagina. Un campo di ricerca che si trova dentro un
**pannello caricato via AJAX** da `$$.panel()` non esisteva ancora in quel momento, quindi il suo
pulsante resta senza handler: premere la lente non produce alcun effetto e **nessun errore in
console**, il che rende il sintomo difficile da collegare alla causa. Nota che
`gvSetupPage()` — che `gvlib.js` esegue su ogni pannello e che aggancia calendari, daterange e
sticky header — **non** copre le search.

**Regola: in ogni callback di `$$.panel()` chiamare `gtsSearchSetup()` come prima istruzione.**

```javascript
function editRow(id) {
    $$.panel("movimenti_panel.php", { MODO: 'edit', ID: id }, function() {
        gtsSearchSetup();      // riaggancia la lente dei campi appena inseriti nel DOM
        cercaMovimenti();
    });
}
```

Conviene metterla **su tutte** le aperture di pannello, non solo su quelle che oggi contengono un
campo di ricerca: la funzione fa `unbind('click')` prima di `on('click')`, quindi è idempotente —
chiamarla su un pannello senza search non costa nulla e non duplica mai gli handler — e la regola
continua a valere se in futuro si aggiunge una ricerca a un pannello che ora non ne ha.

Lo stesso ragionamento vale per qualunque componente agganciato al ready e non da
`gvSetupPage()`: se lo si usa dentro un pannello, il suo setup va ripetuto nel callback.

## Mai riferirsi al pannello con `#gvpanel0`: usare `$$.activePanel()`

I pannelli non hanno un id fisso. `$$.panel()` incrementa un contatore e li chiama `gvpanel0`,
`gvpanel1`, … (`jgvPanelIndex` in `gvlib.js`), e alla chiusura li rimuove dal DOM decrementandolo.
Finché ne resta aperto uno solo, `#gvpanel0` funziona — ed è per questo che l'errore passa
inosservato a lungo.

Si rompe appena **un pannello viene aperto mentre un altro è già aperto**, che è un caso
frequentissimo: è il pattern con cui si gestiscono testata e righe. Il salvataggio di una testata
nuova riapre il pannello in modifica per abilitare le righe:

```javascript
$$.panel("distinte_panel.php", { MODO: 'edit', ID: res.ID }, function() { ... });
```

Da quel momento il pannello è `gvpanel1`, e ogni `$("#gvpanel0 …")` legge il vuoto. Il sintomo è
particolarmente ingannevole: la pagina è visibilmente in modifica, con i dati a video, ma il JS
manda al server un id vuoto e il server risponde con un errore che sembra assurdo — nel caso
delle distinte, *"Registrare prima la testata"* a testata già registrata.

**Regola: nel JS di pagina il pannello si indirizza sempre con `$$.activePanel()`**, che
`gvlib.js` espone proprio a questo scopo:

```javascript
var par = $.extend($("#" + $$.activePanel()).gvMap(), { action: 'saverow' });
var id  = $("#" + $$.activePanel() + " input[name=ID]").val();
```

Con un solo pannello aperto il risultato è identico a `#gvpanel0`, quindi la sostituzione non ha
controindicazioni e va fatta ovunque, non solo dove il difetto si manifesta oggi.

## `$.MessageBox` aperto da dentro un pannello finisce sotto al pannello

Il plugin `shared/components/messagebox` posiziona la finestra e il suo overlay in
`position: fixed` da JavaScript, ma **non assegna alcuno z-index** — né nel proprio CSS né a
runtime. I pannelli aperti da `$$.panel()` invece ricevono uno z-index inline da `gvlib.js`,
pari a `500 + (indice del pannello × 10)`.

Ne segue che un MessageBox aperto **da dentro un pannello** (una richiesta di quantità, una
conferma con input) finisce sotto al pannello: non si vede, e all'utente sembra che il pulsante
non risponda. Aperto dalla pagina invece funziona, perché lì non c'è nulla sopra — motivo per cui
il difetto si manifesta solo in alcuni punti e sfugge facilmente ai test.

Fix da aggiungere all'`app.css` di ogni app che apre un MessageBox da un pannello:

```css
.messagebox_overlay { z-index: 2000 !important; }
.messagebox         { z-index: 2001 !important; }
```

I valori stanno sopra qualunque pannello, anche annidato: un popup di ricerca aperto da dentro un
pannello arriva a 520-530.

## Tutto il markup deve essere XHTML valido

Le pagine GTS sono servite e parsate come XHTML (da cui il nome `xhtmlbuild.php`): qualunque HTML
costruito a mano — incluso quello iniettato lato client via AJAX con `.html()`/`.load()` di jQuery,
dentro i panel aperti da `$$.panel()` — deve essere XML ben formato, altrimenti il browser fallisce
con `Failed to set the 'innerHTML' property ... provided markup is invalid XML`. Vale per **tutte**
le app sotto `/opt/gts`, non solo per questo repo. In pratica, quando si concatenano stringhe HTML a
mano (invece di usare le funzioni `hb_*` di `xhtmlbuild.php`, che chiudono già correttamente i tag):
- ogni void element va autochiuso: `<hr />`, `<br />`, `<img ... />`, non `<hr>`/`<br>`/`<img ...>`.
- ogni tag aperto va chiuso, attributi sempre tra apici.
- **anche nei commenti** (`//`, `/* */`) e nel testo/JS dentro un `<script>`, un carattere `<` seguito
  da testo e poi `>` (es. `<campo>`, `<nome>` come segnaposto descrittivo) viene letto dal parser XML
  come un tag vero e non chiuso, e invalida l'intero documento — non usare mai `<...>` come notazione
  segnaposto/generica nei commenti o nel codice, nemmeno a scopo puramente descrittivo; usare invece
  un esempio concreto (es. `gtssearch-id-IDMEZ`) o altra punteggiatura (parentesi, trattini).
- Questa regola vale anche per markup generato per essere iniettato via `.load()`/`.html()` in un
  frammento già aperto (es. il contenuto di un box ricaricato via AJAX dentro un panel): un singolo
  tag non chiuso rompe silenziosamente **l'intero** inserimento, non solo la parte malformata.
- **Mai un apostrofo dentro un evento `!`**, nemmeno in un testo scritto a mano.
  `hb_decodeevent('!')` costruisce l'attributo `onclick` sostituendo **ogni** `'` con `"`:
  un'etichetta come `"l'ordine cliente"` diventa quindi `generaDoc(2,"l"ordine cliente",...)`,
  che è JavaScript malformato. Il click non fa nulla e l'unico segnale è un
  `missing ) after argument list` in console — il pulsante sembra semplicemente morto.
  Usare l'apostrofo tipografico `’` (U+2019), che non viene toccato dalla sostituzione ed è
  anche la forma corretta in italiano, oppure riformulare senza elisione.

- **Dati utente in eventi onclick** (`hb_ev_iconify`, `hb_button_iconify` con prefisso `!`):
  `hb_decodeevent('!')` fa `str_replace("'",'"')` ma **non** codifica `&` → `&amp;`. Un nome
  cliente/campo che contiene `&` (o `<`, `>`) finisce nell'attributo `onclick` non codificato,
  invalidando XHTML. Pattern corretto:
  ```php
  str_replace(['&','<','>'], ['&amp;','&lt;','&gt;'], addslashes($valore))
  ```
  Il browser decodifica le entity XML prima di passare il valore a JavaScript, quindi il JS riceve
  il valore originale corretto.

## `hb_search_generic()`: il valore si toglie, e l'id non si mostra

Due difetti corretti ad agosto 2026, entrambi nel framework e quindi presenti su ogni barra
filtri e ogni pannello di tutte le app.

**Il pulsante rosso di azzeramento c'è sempre**, non solo in multiselezione. In selezione singola
il campo del codice è nascosto (`showcod=false`, che è come lo usano tutte le barre filtri) e
quello della descrizione è `disabled`: una volta scelto un cliente non esisteva **alcun** modo di
toglierlo — né cancellandolo a mano, né riaprendo la lente. Un filtro che si può solo mettere e
mai togliere resta lì anche il giorno dopo, e la griglia esce vuota senza spiegazione.
`gtsClearSel()` sapeva già fare il lavoro anche per il caso singolo: mancava chi la chiamasse.

**La colonna dell'id non si mostra quando `showcod=false`.** La prima colonna della query è il
valore che finisce nel campo; se il campo del codice è nascosto quel valore è un id interno, e in
elenco diventa una colonna di numeri che non dicono niente e rubano spazio alla descrizione — che
è quello che si sta leggendo. Resta nel DOM, perché `gtsSearchChoosen()` la legge per prendere il
valore, ma è `display:none`, e l'intestazione corrispondente sparisce insieme. Con `showcod=true`
non cambia nulla: lì il codice è quello che l'utente vedrà nel campo.

Vale però **solo se le righe le genera `search_data.php`**. Con un `data-provider` proprio — per
esempio `ricambi/mezzo_search_data.php` — le colonne le decide quel file, e togliere
l'intestazione qui mentre là la cella continua a esistere sfalserebbe l'elenco di una colonna.
Chi scrive un provider decide anche le sue intestazioni.

Ne segue una cosa da sapere scrivendo una lente nuova: **la prima colonna del SELECT è il valore,
la seconda è quella su cui si cerca.** Il filtro «mentre digiti» di `search_data.php` confronta
solo le prime due colonne, quindi tenere codice e descrizione separati vuol dire poter cercare
solo per codice. Se si vuole cercare per descrizione — e di solito è così, nessuno cerca un
cliente per codice — si concatenano nella seconda colonna:

```sql
SELECT id, codice || '  ' || ragione_sociale, COALESCE(localita, '')
```

## `hb_xmlent()` escapa UNA volta sola: non aggiungere altre codifiche sopra

Vale per **tutte** le app sotto `/opt/gts`. `hb_xmlentities()` (e quindi `hb_xmlent()`, `xmle()`,
`hb_xmlentg()`) prende testo **grezzo** e ne restituisce la forma sicura per XHTML. Il testo che
esce è già codificato: **non va ricodificato e non va decodificato**.

Fino ad agosto 2026 la funzione codificava due volte — prima `&` → `&amp;`, poi
`htmlspecialchars()` sopra al risultato — e a video usciva l'entità in chiaro:

```
MACOCCO GIOVANNI &amp; C.        invece di    MACOCCO GIOVANNI & C.
```

su qualunque griglia, campo di pannello o titolo che contenesse una `&`. Il difetto era nel
framework, quindi si ripresentava ovunque e continuava a essere corretto pagina per pagina; erano
nate perfino due funzioni "leggibili" (`hb_xmlentg()`, `hb_xmldesc()`) che non erano varianti ma
**toppe**: escapavano due volte e decodificavano una. Ora la funzione è corretta e quelle toppe
sono da togliere, non da imitare.

Le tre regole che ne seguono:

- **Passare testo grezzo**, quello che arriva dal database. `hb_xmlent(hb_xmlent($x))` e
  `hb_xmlent()` su una stringa che contiene già markup sono entrambi errori.
- **Mai `html_entity_decode()` sull'uscita.** Rimetterebbe in pagina una `&` nuda, che in XHTML è
  un errore di parsing e rompe l'inserimento AJAX dell'intero frammento.
- **Le entità scritte a mano nei sorgenti restano valide**: `&amp;` si conserva, e `&euro;`
  diventa il carattere `€` — nelle pagine servite come `application/xhtml+xml` `&euro;` non è
  definita e farebbe fallire il parser. Per lo stesso motivo si usa `&#160;` e non `&nbsp;`.

Se un testo esce con l'entità in chiaro, il posto da guardare è **chi ricodifica**, non
`hb_xmlentities()`.

## Report PDF (TCPDF): usare sempre MultiCell per i campi testuali

Vale per **tutte** le app sotto `/opt/gts` che generano PDF con TCPDF a griglia stile tabella
(es. `careglio/groupage_pdf.php`), non solo per il repo dove è stata riscontrata la regressione:
nelle colonne che contengono testo libero/potenzialmente lungo (luogo, descrizione, note, cliente,
ecc.) usare sempre `MultiCell()`, mai `Cell()`. `Cell()` non fa il wrap del testo: se il contenuto
supera la larghezza della colonna il testo sbrodola fuori dal riquadro invece di andare a capo.
Pattern da seguire per una riga a più colonne:
- calcolare l'altezza riga come massimo tra l'altezza base e `getStringHeight($larghezza_colonna, $testo)`
  per ciascuna colonna testuale della riga;
- disegnare le colonne testuali con `MultiCell($w, $h, $txt, $border, 'L', $fill, $ln, '', '', true, 0, false, true, $h, 'T')`
  (`$ln=0` per continuare sulla stessa riga, `$ln=1` solo sull'ultima colonna della riga);
- lasciare `Cell()` solo per colonne corte/numeriche che non necessitano wrap (quantità, totali, codici, ecc.).

## Working conventions

- Comments and commit messages in this repo are Italian; match that style.
- Keep changes minimal and scoped to the file/flow being touched — these pages accrete small, targeted patches (see git log) rather than broad refactors.
- Database schema changes go through the `$DBV->upgrade([...])` migration list in [login.php](login.php) (keyed by sequential version number, each value a list of raw SQL statements run once). Add a new numbered entry rather than editing past ones.
- Treat anything under `gtsusers`/`gtsauth` schema changes and email-sending code carefully — this is shared login infrastructure for every GTS app, not just `gate`.
- **Date range filters**: use `hb_calendar_range($id1,$v1,$id2,$v2,$ev)` (defined in `xhtmlbuild.php`) — two `hb_calendar` inputs plus Oggi/Sett./Mese/Anno/YTD shortcuts, wired up by `gvlib.js`. This is the pattern actually used in production (e.g. `itg/produzione.php`, `hq/mail.php`). Do **not** use `hb_daterange`/`hb_datetimerange` (the single-input bootstrap-daterangepicker widget in `xhtmlbuild.php`) — it's unused outside of a demo page (`testbs5/sidebar.php`) and has no established server-side parsing convention.
- **Ogni campo `hb_calendar`/`bs-calendar` va disabilitato per l'autocomplete del browser**, sempre dentro la `$(function(){...})` ready della pagina (page-level `.js`, non `app.js`): `$("input.bs-calendar").attr('autocomplete', 'off');`. Pattern già presente in praticamente ogni app (`appstd/app.js`, `agefmsl/*.js`, `careglio/*.js`, ecc.) — senza, il browser propone un dropdown di suggerimenti sui campi data che copre spesso lo shortcut popup di `hb_calendar_range`.
- **Ogni campo filtro di una pagina di ricerca/report (data, select, checkbox, campo libero) va reso persistente in sessione con `ctx_pop($app,$campo)` — mai lasciare un filtro "muto" che si dimentica il valore scelto al refresh/tra una visita e l'altra.** È il comportamento di default atteso su ogni pagina con filtri, non un'aggiunta opzionale — vale anche per pagine "leggere" con un solo endpoint AJAX (es. una pagina che genera un grafico), non solo per le classiche liste con `_data.php`.
- **Ogni filtro persistente (checkbox incluso) va dichiarato con `ctx_pop($app,$campo)` in TUTTI gli endpoint che lo consumano, non solo nella pagina principale.** `ctx_pop()` (`context.php`) salva in sessione solo quando il campo è effettivamente presente nella request corrente (`isset($s_<campo>)`); altrimenti rilegge l'ultimo valore salvato. `gvMap()` (`gvlib.js`) aiuta già a evitare il classico bug delle checkbox HTML (deselezionata = assente dalla request): serializza sempre `"on"`/`""` esplicitamente, mai omettendo il campo. Ma se un filtro viene letto (e quindi propagato via `$("#pdata").gvMap()`) da più endpoint dello stesso modulo — es. `<name>_data.php` per "Ricerca" e `<name>_pivot_data.php` per una pivot — e uno di questi **non** chiama `ctx_pop()` per quel campo, quell'azione non aggiorna mai la sessione: il filtro sembra "non persistere" ogni volta che l'utente usa quel pulsante invece degli altri. Replicare lo stesso elenco di `ctx_pop()` in ogni endpoint che legge `$("#pdata").gvMap()` per lo stesso modulo, non solo in quello principale.



Struttura del progetto

Tutte le applicazioni risiedono sotto /opt/gts. Le tre cartelle seguenti sono
sempre presenti e fanno parte dell'architettura di base:


/opt/gts/gate — modulo di login / autenticazione
/opt/gts/hq — gestione utenti, permessi e applicazioni
/opt/gts/shared — librerie comuni condivise da tutti i moduli


Regole legate a questa struttura:


Il codice comune va in /opt/gts/shared: prima di duplicare una funzione,
verifica se esiste già lì.
gate e hq dipendono da shared; una modifica in shared può impattare
entrambi, quindi valutane sempre gli effetti prima di procedere.
Login e autenticazione stanno in gate; utenti/permessi/applicazioni in hq.
Non mescolare le responsabilità tra i due moduli.