diff --git a/.cursor/plans/itcca_allievi_wp_plugin.plan.md b/.cursor/plans/itcca_allievi_wp_plugin.plan.md new file mode 100644 index 0000000..32edcd3 --- /dev/null +++ b/.cursor/plans/itcca_allievi_wp_plugin.plan.md @@ -0,0 +1,402 @@ +--- +name: itcca allievi wp plugin +overview: "Plugin WordPress per gestire le iscrizioni ai corsi di Tai Chi: estende l'utente WP con ~40 campi (mappati sul CSV INSARRI), form pubblico via shortcode, gestione completa nel backoffice e sincronizzazione bidirezionale con un Google Sheet privato via OAuth 2.0. Tutto sviluppato e testato in stack Docker locale persistente." +todos: + - id: docker + content: Scrivere docker-compose.yml + .env + .gitignore + README con istruzioni Google Cloud Console (OAuth client) + status: completed + - id: plugin-bootstrap + content: Creare scheletro plugin (itcca-allievi.php, class-plugin.php, composer.json con google/apiclient) e attivazione/disattivazione + status: completed + - id: fields-roles + content: Implementare class-fields.php (registro centrale dei 41 meta + tipi/validatori/sezioni) e class-roles.php (ruolo allievo) + status: completed + - id: validators + content: "Validatori: codice fiscale con checksum, CAP, telefono, sesso M/F, date" + status: completed + - id: user-admin + content: "Estensione pagina utente WP: sezioni a fisarmonica per ogni gruppo di campi + età/anni calcolati read-only" + status: completed + - id: allievi-list + content: Pagina admin Allievi ITCCA con WP_List_Table (tutte le colonne CSV, sort, filtri, quick-edit flag) + status: completed + - id: form-public + content: "Shortcode [itcca_iscrizione]: template, CSS con variabili che ereditano dal tema, JS di validazione client, handler POST con creazione utente e nonce/honeypot" + status: completed + - id: settings + content: "Pagina impostazioni: Google client ID/secret, lista centri (per dropdown admin), testo privacy form, redirect post-iscrizione, colore primario" + status: completed + - id: google-oauth + content: "Implementare flow OAuth 2.0 (scope drive.file + spreadsheets): connect, callback, salvataggio token cifrati, refresh automatico" + status: completed + - id: google-picker + content: "Integrare Google Picker API: bottone 'Seleziona foglio da Drive', salva spreadsheet_id+nome, select tab, storico fogli connessi" + status: completed + - id: phase-a-align + content: "Fase A (foglio → WP): importer con match per CF, report diff, preview con checkbox, apply su user_meta + creazione nuovi allievi" + status: completed + - id: phase-b-push + content: "Fase B (WP → foglio): hook on save, mappatura user_id ↔ riga, retry via WP-Cron, log errori in admin, bottone manuale 'Pusha tutti'" + status: completed + - id: export-csv + content: Export CSV nel formato originale INSARRI + WP-CLI import comando per popolazione iniziale + status: completed + - id: gdpr + content: Hook GDPR exporter/eraser e rate limiting form pubblico + status: completed + - id: test-docker + content: Avviare lo stack, completare setup WP, configurare OAuth, fare end-to-end test (iscrizione → utente → riga Sheet) + status: completed + - id: schema-reconcile + content: "Riconciliazione schema da foglio: refactor Fields (baseline + overrides), nuova SchemaReconcile (diff + fuzzy match + UI), routing Picker→pagina schema, sezione 'Campi rimossi (storico)' nell'edit utente, esclusione dei deleted dall'export CSV e dal push Fase B" + status: completed + - id: zodiac-elements + content: "Dropdown Animale (zodiaco cinese auto-calcolato + override) ed Elemento/Elemento sx/Elemento dx con palette colori; visibilità in lista; Dashboard con 4 grafici a torta inline SVG" + status: completed + - id: oauth-wizard + content: "Wizard di setup Google OAuth in 5 step con link diretti a Cloud Console, validazione live delle credenziali e copia redirect URI" + status: completed + - id: dashboard-kpi-age + content: "KPI età sulla Dashboard: card Età media, Più giovane (nome+anni), Più anziano (nome+anni) e bar chart nascite per mese" + status: completed + - id: sedi-crud + content: "CRUD Sedi di insegnamento in Settings (nome + indirizzo + lat/lng), migrazione da itcca_centri, integrazione con dropdown Centro" + status: completed + - id: geocoder + content: "Servizio Geocoder via OSM Nominatim (rate-limit 1 req/sec, cache hash indirizzo, User-Agent dedicato, retry tramite endpoint AJAX a singolo step)" + status: completed + - id: dashboard-map + content: "Mappa Leaflet sotto le torte con marker sedi (icona ★) + marker residenze allievi (●), bottone batch-geocode con progress, layer toggle" + status: completed + - id: sedi-abandoned + content: "Flag 'Abbandonata' sulle Sedi: checkbox in tabella Settings + riga visiva 'disattivata' + marker grigio in scala di grigi sulla mappa + layer dedicato 'Sedi abbandonate'" + status: completed + - id: active-flag + content: "Stato attivo/inattivo derivato dai tab del foglio (INSARRI = attivi, ZZZ auto-rilevato = inattivi): meta itcca_active + itcca_sheet_tab, Fase A dual-tab con anteprima e log esteso, Fase B routing sul tab giusto senza spostare righe, views Attivi/Inattivi/Tutti nella lista admin e nella dashboard, marker inattivi differenziati sulla mappa" + status: completed +isProject: false +--- + +## Stack e cartella di lavoro + +Tutto in `/Users/fabio.arrigoni/Studio/Itcca-allievi/`: + +``` +Itcca-allievi/ +├── .cursor/ +│ └── plans/ +│ └── itcca_allievi_wp_plugin.plan.md # questo file (fonte di verità del piano) +├── docker-compose.yml +├── .env # secrets DB +├── .gitignore +├── README.md # setup & istruzioni Google Cloud +└── plugin/ + └── itcca-allievi/ # montato in wp-content/plugins +``` + +### Docker stack + +- `wordpress:6.7-php8.3-apache` su porta `8080` +- `mariadb:11` con volume nominato `db_data` (persistenza DB) +- `phpmyadmin` su porta `8081` (debug) +- Volume nominato `wp_data` per tutto `/var/www/html` (persistenza media/temi/altri plugin) +- Bind-mount di `./plugin/itcca-allievi` su `/var/www/html/wp-content/plugins/itcca-allievi` (hot-reload del codice) +- `.env` con `DB_ROOT_PASSWORD`, `DB_PASSWORD`, `WP_DEBUG=1` +- `WP_HOME`/`WP_SITEURL` su `http://localhost:8080` + +## Plugin `itcca-allievi` + +### Struttura file + +``` +itcca-allievi/ +├── itcca-allievi.php # main file + bootstrap +├── composer.json # google/apiclient ^2.18 +├── vendor/ # via composer install +├── includes/ +│ ├── class-plugin.php # singleton, registrazione hook +│ ├── class-fields.php # registro centrale dei meta + tipi +│ ├── class-roles.php # ruolo "allievo" +│ ├── class-validators.php # CF (regex+checksum), CAP, telefono, email +│ ├── class-form-shortcode.php # [itcca_iscrizione] + handler POST +│ ├── class-user-admin.php # sezioni edit utente + colonne list table +│ ├── class-allievi-list.php # pagina "Allievi" con tutte le colonne CSV +│ ├── class-settings.php # pagina impostazioni +│ ├── class-google-oauth.php # OAuth flow + token refresh +│ ├── class-google-picker.php # Google Picker UI per selezione foglio da Drive +│ ├── class-google-sheets.php # read/append/update righe via Sheets API +│ ├── class-sheet-history.php # storico fogli usati negli anni +│ ├── class-schema-reconcile.php # riconciliazione schema (diff colonne foglio vs WP) +│ ├── class-phase-a-align.php # Fase A: importer foglio → WP con diff/preview +│ ├── class-phase-b-push.php # Fase B: push WP → foglio on save + retry +│ └── class-export-csv.php # export formato INSARRI +├── assets/ +│ ├── css/form.css # variabili CSS che ereditano dal tema +│ ├── css/admin.css +│ └── js/form.js # validazione client + calcolo età live +└── templates/ + └── form-iscrizione.php +``` + +### Mappatura campi CSV → user meta + +Tutti i campi salvati come `user_meta` con prefisso `itcca_`. Riepilogo delle 41 colonne del CSV `INSARRI`: + +- **Anagrafica**: `itcca_ins` (default `INSARRI` dalle impostazioni), `itcca_cognome`, `itcca_nome`, `itcca_sesso` (M/F), `itcca_nascita_data` (Y-m-d), `itcca_nascita_luogo`, `itcca_cf` +- **Residenza**: `itcca_indirizzo`, `itcca_civico`, `itcca_comune`, `itcca_cap` +- **Contatti**: usa `user_email` nativo + `itcca_cellulare` +- **Calcolati (non salvati)**: `X` = età da `nascita_data`, `Anno` = anni di pratica da `inizio` +- **Tesseramento**: `itcca_sede_u`, `itcca_centro`, `itcca_ruolo`, `itcca_grado`, `itcca_inizio` (anno) +- **Quote**: `itcca_quota_uisp`, `itcca_quota_itcca`, `itcca_quota_ado` (decimal) +- **Ricevuta/pagamento**: `itcca_pag_chi`, `itcca_pag_il`, `itcca_ric_il`, `itcca_ric_n`, `itcca_att`, `itcca_pag`, `itcca_pro`, `itcca_ric`, `itcca_onl` (i 5 flag come booleani) +- **Stato**: `itcca_r` (R/N), `itcca_a` (A/D) +- **UISP card**: `itcca_uisp_n`, `itcca_uisp_d` +- **Altro**: `itcca_doc`, `itcca_animale`, `itcca_elem`, `itcca_el_sx`, `itcca_el_dx`, `itcca_note` + +Il registro è centralizzato in `class-fields.php` in modo che list table, edit page, form e Google Sheets condividano la stessa fonte di verità (label, tipo, validatore, sezione, visibilità nel form pubblico). + +### Form pubblico (shortcode `[itcca_iscrizione]`) + +**Tutti i campi sono obbligatori** (asterisco rosso in UI, attributo HTML `required`, ricontrollo server-side). La sede del corso e tutti gli altri campi CSV non elencati qui li compili tu manualmente nel backoffice. + +Campi visibili al pubblico: + +- **Cognome** *(obbligatorio, testo)* +- **Nome** *(obbligatorio, testo)* +- **Sesso** *(obbligatorio, radio M / F)* +- **Data di nascita** *(obbligatorio, date picker, deve essere data valida nel passato)* +- **Comune di nascita** *(obbligatorio, testo libero, es. "Osio Sotto-BG")* +- **Codice fiscale** *(obbligatorio, validato lato client e server)* +- **Indirizzo di residenza** *(obbligatorio, testo)* +- **Numero civico** *(obbligatorio, testo breve)* +- **Comune di residenza** *(obbligatorio, testo)* +- **CAP** *(obbligatorio, esattamente 5 cifre)* +- **Email** *(obbligatorio, validato lato client e server)* +- **Cellulare** *(obbligatorio, solo cifre + opzionale prefisso `+`, lunghezza 9-15)* +- **Checkbox consenso privacy + liberatoria** *(obbligatorio, testo configurabile dalle impostazioni)* +- Honeypot anti-bot nascosto + nonce WordPress (non visibili all'utente) + +**Validazioni**: + +- **Codice fiscale**: client-side regex `^[A-Z]{6}[0-9]{2}[A-Z][0-9]{2}[A-Z][0-9]{3}[A-Z]$` + server-side checksum algoritmo ufficiale (carattere 16 calcolato dai primi 15 secondo tabella ministeriale). Inoltre controllo unicità: due iscritti non possono avere lo stesso CF. +- **Email**: client-side regex HTML5 + server-side `is_email()` di WordPress. Controllo unicità su `wp_users.user_email`. +- **CAP**: regex `^[0-9]{5}$`. +- **Cellulare**: regex `^\+?[0-9]{9,15}$`, dopo strip degli spazi. +- **Data di nascita**: parsing in `Y-m-d`, deve essere nel passato e non oltre 120 anni fa. +- **Coerenza CF ↔ altri campi**: se il sesso/data nascita/comune nascita estraibili dal CF non corrispondono ai dati inseriti, mostro un warning ma NON blocco (in alcuni casi limite il CF storico può divergere). Il blocco lo facciamo solo sui dati malformati, non sulle incongruenze. + +Errori di validazione mostrati inline sotto ogni campo (sia da JS che da PHP al re-render del form dopo submit). + +Flusso submit: +1. Validazione client-side immediata (JS) per CF, email, CAP, cellulare, campi required +2. Validazione server-side al POST (tutta, indipendentemente dal client) +3. Crea utente WP con ruolo `allievo`, username = slug di `cognome.nome`, password random + email "imposta password" +4. Salva tutti i meta +5. Email notifica all'admin +6. Push riga su Google Sheet (vedi sotto), con retry differito via WP-Cron se la chiamata fallisce +7. Redirect a pagina di conferma (configurabile) + +**Tema grafico**: il CSS usa `currentColor`, variabili CSS (`--itcca-primary`) e classi WP standard (`.wp-block-button`, ecc.) per ereditare dal tema attivo. Una sezione "Aspetto" nelle impostazioni permette di sovrascrivere il colore primario. Se vuoi una resa pixel-perfect del tema vivo, dopo la prima iterazione mi passi URL o screenshot e raffino. + +### Backoffice WordPress + +Due punti di accesso: + +1. **Pagina utente standard** (`/wp-admin/user-edit.php`): sezioni a fisarmonica + - Anagrafica, Residenza, Tesseramento, Quote, Ricevuta/Pagamento, Stato, UISP card, Note + - Età e anni di pratica mostrati come read-only calcolati + +2. **Nuova pagina "Allievi ITCCA"** (`/wp-admin/admin.php?page=itcca-allievi`) + - `WP_List_Table` con tutte le 41 colonne del CSV, ordinabili + - Filtri rapidi: per `Centro`, per `R` (rinnovi/nuovi), per `A` (attivi/disattivi), per anno `Inizio` + - Ricerca per cognome/nome/CF + - Quick-edit inline per i flag (`Att`, `Pag`, `Pro`, `Ric`, `Onl`, `R`, `A`) + - Azione bulk: "Sincronizza con Google Sheet" + - Bottone "Esporta CSV" che produce un file identico per schema a `INSARRI.csv` + +### Integrazione Google Sheets (OAuth 2.0 + Picker) + +**Pagina impostazioni** `Allievi ITCCA → Impostazioni`: + +- Sezione "Google": + - Campi `OAuth Client ID`, `OAuth Client Secret`, `Picker API Key` (li ricavi da Google Cloud Console, istruzioni nel README) + - Bottone "Connetti Google" → avvia OAuth, callback su `?page=itcca-allievi-oauth`, salva access/refresh token +- Sezione "Foglio Google attivo": + - Bottone "Seleziona foglio da Drive" → apre il **Google Picker** (esploratore Drive nativo, con ricerca/recenti/Drive condivisi) filtrato sui soli Google Sheets + - Dopo selezione mostra: nome foglio, ID, link, data ultima Fase A + - Select del tab (popolato via Sheets API) + - Mapping colonne ↔ campi (precompilato dal CSV INSARRI, editabile) + - Bottoni azione: "Riallinea da foglio (Fase A)", "Pusha tutti su foglio (Fase B forzata)", "Test connessione" +- Sezione "Storico fogli usati": + - Tabella sola lettura: anno, nome foglio, ID, link Drive, data connessione, data disconnessione, ultima Fase A + - Bottone per riconnettere un foglio dello storico se necessario + +**Implementazione OAuth**: + +- Composer dipendenza: `google/apiclient` +- Tu crei su Google Cloud Console (istruzioni nel README): + 1. Un **OAuth Client ID** di tipo "Web application" + 2. Una **API Key** (per il Picker, frontend-only, ristretta per referrer) +- Scope richiesti: + - `https://www.googleapis.com/auth/spreadsheets` (lettura/scrittura del foglio scelto) + - `https://www.googleapis.com/auth/drive.file` (scope minimo: accesso ai soli file aperti tramite Picker — più privacy-friendly del `drive.readonly`) +- Redirect URI: `http://localhost:8080/wp-admin/admin.php?page=itcca-allievi-oauth` (in produzione cambierai con il dominio) +- Access + refresh token cifrati con `AUTH_KEY` e salvati in `wp_options` +- Refresh automatico alla scadenza + +**Modello di sincronizzazione a due fasi** + +Il cambio anno è un'operazione esplicita: ogni anno selezioni un foglio nuovo dal Picker. Il foglio del nuovo anno è già pre-popolato manualmente da te, con alcune celle modificate rispetto al precedente. Per gestire questo workflow il plugin opera in due fasi distinte e sequenziali. + +**Fase A — Allineamento iniziale (foglio → WordPress, one-shot per ogni foglio)** + +Trigger: bottone "Riallinea da foglio" oppure suggerito automaticamente con avviso subito dopo la selezione di un nuovo foglio dal Picker. + +1. Il plugin scarica tutte le righe del tab attivo via `spreadsheets.values.get` +2. Per ogni riga del foglio, cerca un match con un utente WP confrontando il **codice fiscale** (chiave unica) +3. Produce un report di **diff** raggruppato in tre categorie, tutte gestite con scelta **caso per caso** nell'UI di preview: + - **Da aggiornare in WP** (match per CF + celle diverse): tabella `campo: valore_WP → valore_foglio` per ogni differenza. Checkbox per riga "applica modifiche", più "seleziona/deseleziona tutto" per categoria + - **Solo sul foglio** (CF presente in foglio, mancante in WP): anteprima del nuovo utente che verrebbe creato. Checkbox "crea utente WP" per riga (default: deselezionato, lo abiliti tu) + - **Solo in WP** (CF presente in WP, mancante sul foglio): tipicamente allievi non rinnovati o rimossi a mano dal foglio. Per ogni riga radio button con 3 azioni: + - "Lascia stare" (default): WP non viene toccato e l'allievo non viene scritto sul foglio finché tu non salvi modifiche dalla sua scheda + - "Imposta `A=D` (disattivo)": utile per archiviazione rapida dei non rinnovi + - "Pusha sul foglio comunque" (append a fine foglio): se è stato cancellato per errore dal foglio +4. **Pagina di preview** con le tre tabelle sopra + pulsante "Applica selezionati" in fondo +5. Apply: aggiorna `user_meta`, crea i nuovi utenti (solo quelli flaggati), applica le azioni "Solo in WP" scelte, popola `itcca_sheet_row` con il numero di riga del foglio per ogni allievo presente sul foglio +6. Log completo dell'operazione (`wp_options` → `itcca_phase_a_log`): data, n. righe lette, n. aggiornate, n. importate, n. impostate inattive, eventuali errori (es. CF duplicati o non validi) +7. Marca il foglio come "allineato": da questo momento la Fase B è attiva +8. Il bottone "Riallinea da foglio (Fase A)" resta **sempre disponibile** nelle impostazioni: utile quando edito qualche cella a mano sul foglio già connesso e voglio risincronizzare WP senza dover ricollegare il foglio + +**Fase B — Push continuo (WordPress → foglio, attiva dopo Fase A)** + +- Hook su `profile_update` / `user_register` / salvataggio dalla pagina admin "Allievi ITCCA" → enqueue task di push +- `class-phase-b-push.php` usa la mappatura `user_id ↔ riga` salvata in `user_meta` (`itcca_sheet_row`) +- Riga esistente → `spreadsheets.values.update` di tutta la riga +- Riga mancante (nuovo iscritto dal form pubblico o creato manualmente) → `spreadsheets.values.append` + aggiorna `itcca_sheet_row` +- Match di fallback per `CF` se `itcca_sheet_row` manca +- Errori loggati in `wp_options` (`itcca_sync_errors`) e visibili in admin con bottone "Riprova" per riga +- Bottone manuale "Pusha tutti su foglio" per forzare il re-push completo (utile dopo modifiche bulk) + +**Comportamento al cambio anno (selezione di un foglio diverso dal Picker)** + +1. Picker → memorizza nuovo `spreadsheet_id` + nome +2. Sposta il foglio precedente nello "Storico fogli" con timestamp di disconnessione +3. **Reset di tutti gli `itcca_sheet_row`**: la mappatura riga ↔ user_id del foglio precedente non è più valida +4. Disabilita temporaneamente la Fase B +5. Selezione del tab → il plugin legge l'header e confronta con lo schema WP attuale. Se diverge, redirect automatico alla **pagina di Riconciliazione schema** (vedi sezione dedicata) prima di abilitare la Fase A +6. Mostra banner persistente in admin: "Foglio nuovo connesso — esegui Fase A (Riallinea da foglio) prima di abilitare il push automatico" +7. Solo dopo aver completato la Fase A almeno una volta sul nuovo foglio, la Fase B torna attiva + +### Riconciliazione schema (evoluzione delle colonne del foglio) + +Il foglio Google può cambiare nel tempo: nuove colonne aggiunte, colonne rinominate, colonne rimosse. Il plugin gestisce questa evoluzione preservando i dati WordPress. + +**Registro campi dinamico** + +[`plugin/itcca-allievi/includes/class-fields.php`](plugin/itcca-allievi/includes/class-fields.php) espone: + +- `Fields::baseline()`: array hardcoded dei 42 campi INSARRI di partenza +- `Fields::all()`: baseline con applicati gli overrides (rename/delete/add) +- `Fields::overrides()` / `Fields::save_overrides()`: unica fonte degli scostamenti, memorizzata in `wp_options['itcca_schema_overrides']` +- `Fields::deleted_fields()`, `Fields::active_storable()`, `Fields::is_deleted()`: helper di filtro + +Struttura `itcca_schema_overrides`: + +```php +[ + 'renames' => [ 'animale' => ['csv' => 'Zodiaco', 'renamed_at' => '...'] ], + 'deletes' => [ 'el_dx' => ['original_csv' => 'El Dx', 'deleted_at' => '...'] ], + 'additions' => [ 'allergie' => ['csv'=>'Allergie','label'=>'...','type'=>'textarea','section'=>'altro','public'=>false,'added_at'=>'...'] ], +] +``` + +**Effetti automatici** + +Tutti i consumer del registro (form pubblico, edit utente, list table, export CSV, push Fase B) usano già `Fields::all()` / `Fields::public_fields()` / `Fields::by_csv()`. Conseguenze gratis grazie al refactor: + +- Una colonna rinominata mantiene la stessa `user_meta` WP (zero perdita di dati) ma il push usa il nuovo nome CSV +- Una colonna aggiunta diventa un campo WP a tutti gli effetti (incluso opzionalmente il form pubblico) +- Una colonna rimossa resta editabile in admin in una sezione "Campi rimossi (storico)", ma non viene scritta su Sheet (`GoogleSheets::build_row_for_user` itera sull'header, non trova il campo perché il CSV non c'è) e non appare nell'export CSV (`ExportCsv::csv_header` esclude i `deleted`) + +**Pagina di riconciliazione** + +Quando l'utente seleziona un nuovo foglio o cambia tab via Picker, [`plugin/itcca-allievi/includes/class-google-picker.php`](plugin/itcca-allievi/includes/class-google-picker.php) legge l'header e confronta con lo schema corrente. Se ci sono differenze viene visualizzata la **pagina di Riconciliazione schema** (submenu nascosto `itcca-allievi-schema`, anche rilanciabile manualmente dalle impostazioni con il bottone "Riconcilia schema"). + +La pagina ([`plugin/itcca-allievi/includes/class-schema-reconcile.php`](plugin/itcca-allievi/includes/class-schema-reconcile.php) `SchemaReconcile::render_page`) mostra tre tabelle: + +1. **Colonne mancanti**: ogni riga ha radio "Rinominata in [select delle nuove]" oppure "Davvero rimossa (marca deleted_)". Il rename è pre-selezionato in base a una euristica di **fuzzy match** (`similar_text` 70% + prossimità di posizione 30%, soglia 60%). I campi pubblici obbligatori del baseline (Cognome, Nome, Sesso, CF, Email, ...) non possono essere marcati come rimossi: l'UI forza il rename. +2. **Colonne nuove**: per ognuna non scelta come target di rename, configurazione di label, tipo (`text/textarea/date/flag/decimal/year/email/tel`), sezione, e flag "visibile nel form pubblico". Se il CSV combacia esattamente con un campo baseline currently-deleted, viene proposto un checkbox "Ripristina" che rimuove la voce da `deletes`. +3. **Anteprima riepilogo** + bottone "Applica e procedi con Fase A" → salva `itcca_schema_overrides`, resetta `aligned_at` del foglio attivo, redirect a Fase A. + +**Flow Picker → Riconciliazione → Fase A** + +```mermaid +flowchart TD + pick[Picker: utente seleziona foglio] + pick --> setSheet[ajax_set_sheet salva spreadsheet_id] + setSheet --> settings[Settings: scegli tab] + settings --> tabChosen[ajax_set_tab] + tabChosen --> readHdr[Leggi header del tab via Sheets API] + readHdr --> diff{Diff vs schema corrente} + diff -->|vuoto| phaseA[Fase A - Allineamento dati] + diff -->|non vuoto| reconcile[Pagina Riconciliazione Schema] + reconcile --> userConfirm[Utente conferma rename/add/delete] + userConfirm --> applyOver[Salva itcca_schema_overrides] + applyOver --> phaseA + phaseA --> done[Foglio allineato, Fase B attiva] +``` + +**Reversibilità** + +Tutte le operazioni sono reversibili rimuovendo l'entry corrispondente da `itcca_schema_overrides`. I dati in `user_meta` non vengono mai cancellati dalla riconciliazione: il "delete" è uno snapshot conservativo. + +### Diagramma del flusso + +```mermaid +flowchart TD + subgraph FaseA["Fase A: foglio -> WP (one-shot ad ogni cambio foglio)"] + AdminA["Admin Fabio"] -->|"Seleziona foglio"| Picker["Google Picker"] + Picker -->|"spreadsheet_id"| Settings["Settings + Storico fogli"] + Settings -->|"Riallinea"| Reader["Sheets API values.get"] + Reader -->|"match per CF"| Diff["Report diff: aggiorna / importa / solo WP"] + Diff -->|"preview + conferma"| ApplyA["Apply: update user_meta + crea nuovi allievi"] + ApplyA --> WPUserA["WP user + user_meta + itcca_sheet_row"] + end + + subgraph FaseB["Fase B: WP -> foglio (continuo, abilitata dopo Fase A)"] + Visitatore["Visitatore sito"] -->|"compila"| Form["Shortcode itcca_iscrizione"] + Form --> WPUserB["WP user + user_meta"] + AdminB["Admin Fabio"] -->|"edit"| AdminPage["Pagina Allievi ITCCA"] + AdminPage --> WPUserB + WPUserB -->|"on save hook"| Queue["class-phase-b-push (queue)"] + Queue -->|"OAuth tokens"| Writer["Sheets API append/update"] + Writer --> FoglioGoogle["Foglio dell'anno"] + end + + FaseA -.->|"abilita"| FaseB +``` + +### Sicurezza e GDPR + +- Nonces su tutti i form (pubblici e admin) +- Capability check: `manage_options` per impostazioni, `edit_users` per pagina allievi +- Sanitize/escape rigorosi (`sanitize_text_field`, `absint`, `wp_kses_post`, regex CF) +- Token OAuth cifrati a riposo +- Hook GDPR `wp_privacy_personal_data_exporters` e `wp_privacy_personal_data_erasers` +- Honeypot + rate limiting (transient) sul form pubblico + +### Testing manuale nel Docker + +1. `docker compose up -d` → wizard di setup WP su `localhost:8080` +2. Attiva plugin, completa impostazioni Google +3. Compila form da pagina con shortcode → verifica utente creato + riga su Sheet +4. Modifica meta dalla pagina admin → verifica update sulla riga del Sheet +5. Riavvia container → tutti i dati persistono (volumi nominati) + +### Note operative + +- **`INS`**: dal CSV è sempre `INSARRI`, quindi è impostazione globale (default popolato per ogni nuovo utente) +- **Campi calcolati `X` e `Anno`**: derivati al rendering, non salvati. Anno = "anniPratica,etàAlleggerita" come nel CSV (es. `22,38` per uno che ha iniziato nel 2004 ed è nato nel 1975) — confermo la formula esatta in fase di implementazione guardando più righe +- **Date**: salvataggio interno in `Y-m-d`, presentazione in `d/m/Y` come nel CSV +- **Quote**: salvate come decimale, formattazione `€ 20` solo in vista/export +- **Importazione iniziale**: comando WP-CLI `wp itcca import-csv` opzionale per popolare gli allievi esistenti dal CSV che mi hai allegato (utile per testare con dati reali fin da subito) diff --git a/.docker/certs/zscaler.pem b/.docker/certs/zscaler.pem new file mode 100644 index 0000000..45e3a29 --- /dev/null +++ b/.docker/certs/zscaler.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIE0zCCA7ugAwIBAgIJANu+mC2Jt3uTMA0GCSqGSIb3DQEBCwUAMIGhMQswCQYD +VQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTERMA8GA1UEBxMIU2FuIEpvc2Ux +FTATBgNVBAoTDFpzY2FsZXIgSW5jLjEVMBMGA1UECxMMWnNjYWxlciBJbmMuMRgw +FgYDVQQDEw9ac2NhbGVyIFJvb3QgQ0ExIjAgBgkqhkiG9w0BCQEWE3N1cHBvcnRA +enNjYWxlci5jb20wHhcNMTQxMjE5MDAyNzU1WhcNNDIwNTA2MDAyNzU1WjCBoTEL +MAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExETAPBgNVBAcTCFNhbiBK +b3NlMRUwEwYDVQQKEwxac2NhbGVyIEluYy4xFTATBgNVBAsTDFpzY2FsZXIgSW5j +LjEYMBYGA1UEAxMPWnNjYWxlciBSb290IENBMSIwIAYJKoZIhvcNAQkBFhNzdXBw +b3J0QHpzY2FsZXIuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +qT7STSxZRTgEFFf6doHajSc1vk5jmzmM6BWuOo044EsaTc9eVEV/HjH/1DWzZtcr +fTj+ni205apMTlKBW3UYR+lyLHQ9FoZiDXYXK8poKSV5+Tm0Vls/5Kb8mkhVVqv7 +LgYEmvEY7HPY+i1nEGZCa46ZXCOohJ0mBEtB9JVlpDIO+nN0hUMAYYdZ1KZWCMNf +5J/aTZiShsorN2A38iSOhdd+mcRM4iNL3gsLu99XhKnRqKoHeH83lVdfu1XBeoQz +z5V6gA3kbRvhDwoIlTBeMa5l4yRdJAfdpkbFzqiwSgNdhbxTHnYYorDzKfr2rEFM +dsMU0DHdeAZf711+1CunuQIDAQABo4IBCjCCAQYwHQYDVR0OBBYEFLm33UrNww4M +hp1d3+wcBGnFTpjfMIHWBgNVHSMEgc4wgcuAFLm33UrNww4Mhp1d3+wcBGnFTpjf +oYGnpIGkMIGhMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTERMA8G +A1UEBxMIU2FuIEpvc2UxFTATBgNVBAoTDFpzY2FsZXIgSW5jLjEVMBMGA1UECxMM +WnNjYWxlciBJbmMuMRgwFgYDVQQDEw9ac2NhbGVyIFJvb3QgQ0ExIjAgBgkqhkiG +9w0BCQEWE3N1cHBvcnRAenNjYWxlci5jb22CCQDbvpgtibd7kzAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAw0NdJh8w3NsJu4KHuVZUrmZgIohnTm0j+ +RTmYQ9IKA/pvxAcA6K1i/LO+Bt+tCX+C0yxqB8qzuo+4vAzoY5JEBhyhBhf1uK+P +/WVWFZN/+hTgpSbZgzUEnWQG2gOVd24msex+0Sr7hyr9vn6OueH+jj+vCMiAm5+u +kd7lLvJsBu3AO3jGWVLyPkS3i6Gf+rwAp1OsRrv3WnbkYcFf9xjuaf4z0hRCrLN2 +xFNjavxrHmsH8jPHVvgc1VD0Opja0l/BRVauTrUaoW6tE+wFG5rEcPGS80jjHK4S +pB5iDj2mUZH1T8lzYtuZy0ZPirxmtsk3135+CKNa2OCAhhFjE0xd +-----END CERTIFICATE----- diff --git a/.docker/composer-entrypoint.sh b/.docker/composer-entrypoint.sh new file mode 100755 index 0000000..813eb83 --- /dev/null +++ b/.docker/composer-entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +if [ -f /custom-certs/zscaler.pem ]; then + cat /etc/ssl/certs/ca-certificates.crt /custom-certs/zscaler.pem > /tmp/ca-bundle.crt + export SSL_CERT_FILE=/tmp/ca-bundle.crt + export CURL_CA_BUNDLE=/tmp/ca-bundle.crt +fi + +exec /usr/bin/composer "$@" diff --git a/.docker/wp-cert-init.sh b/.docker/wp-cert-init.sh new file mode 100755 index 0000000..f590938 --- /dev/null +++ b/.docker/wp-cert-init.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Installa la CA aziendale (se presente) nel trust store di sistema, in modo +# che WordPress e google/apiclient possano fare chiamate HTTPS attraverso il +# proxy SSL inspection. +set -e + +if [ -f /custom-certs/zscaler.pem ]; then + cp /custom-certs/zscaler.pem /usr/local/share/ca-certificates/zscaler.crt + update-ca-certificates 2>/dev/null || true +fi diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf611ee --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +DB_ROOT_PASSWORD=changeme_root_dev +DB_NAME=wordpress +DB_USER=wordpress +DB_PASSWORD=changeme_wp_dev + +WP_PORT=8080 +PMA_PORT=8081 + +WP_DEBUG=1 +WP_HOME=http://localhost:8080 +WP_SITEURL=http://localhost:8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5b4887 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +.env +.env.local +.env.*.local + +plugin/itcca-allievi/vendor/ + +.DS_Store +Thumbs.db + +.vscode/ +.idea/ +*.swp +*.swo +*~ + +*.log +debug.log +error_log + +.phpunit.result.cache + +# Dati reali allievi — NON pubblicare in repo +INSARRI.csv +INSARRi26.xlsx +# Artefatti di build +itcca-allievi.zip +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b73178 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# ITCCA Allievi — WordPress plugin per iscrizioni Tai Chi + +Stack Docker locale + plugin WordPress che estende l'utente con i campi del CSV `INSARRI`, espone un form pubblico di iscrizione e sincronizza i dati con un Google Sheet privato selezionabile da Drive. + +Il piano completo del progetto vive in [`.cursor/plans/itcca_allievi_wp_plugin.plan.md`](.cursor/plans/itcca_allievi_wp_plugin.plan.md). + +## Requisiti + +- Docker Desktop (o Docker Engine + Compose v2) +- Porte libere `8080` (WordPress) e `8081` (phpMyAdmin) — modificabili in `.env` +- Un account Google con accesso al foglio di gestione iscrizioni + +## Avvio rapido + +```bash +cp .env.example .env # se vuoi cambiare credenziali/porte di sviluppo +docker compose up -d +``` + +Poi: + +- WordPress: (completa il wizard di installazione: lingua italiana, titolo sito a piacere, utente admin) +- phpMyAdmin: (utente `root` / password da `.env`) +- Attiva il plugin in `Plugin → Allievi ITCCA` (dopo aver installato le dipendenze, vedi sotto) + +## Installazione dipendenze del plugin + +Il plugin usa la libreria `google/apiclient`. Si installa con Composer dentro un container temporaneo, in modo da non dover avere PHP/Composer sul tuo Mac: + +```bash +docker compose run --rm composer install +``` + +I file finiscono in `plugin/itcca-allievi/vendor/` (escluso da git). + +## Comandi utili + +```bash +docker compose ps # stato dei container +docker compose logs -f wordpress # log live +docker compose down # ferma tutto (DATI PRESERVATI nei volumi) +docker compose down -v # ferma e CANCELLA TUTTO (DB + WP) + +docker compose run --rm composer update # aggiorna dipendenze del plugin +docker compose run --rm wpcli wp user list # esempio WP-CLI +docker compose run --rm wpcli wp plugin activate itcca-allievi +``` + +## Configurazione Google Cloud Console + +Per usare la sincronizzazione con Google Sheets serve un OAuth Client ID e una API Key del progetto Google Cloud. + +### 1. Creare il progetto + +1. Vai su +2. Crea un nuovo progetto (es. "ITCCA Allievi") +3. Selezionalo nel selettore in alto + +### 2. Abilitare le API necessarie + +In `API e servizi → Libreria` abilita: + +- **Google Sheets API** +- **Google Drive API** +- **Google Picker API** + +### 3. Configurare la schermata di consenso OAuth + +In `API e servizi → Schermata di consenso OAuth`: + +- Tipo utente: **Esterno** (anche se userai solo il tuo account) +- Nome app: "ITCCA Allievi" (o quello che preferisci) +- Email di supporto: la tua +- Domini autorizzati: puoi lasciare vuoto in sviluppo locale +- Scope: aggiungi `.../auth/spreadsheets` e `.../auth/drive.file` +- Utenti di test: aggiungi la tua email Google (necessario perché l'app è in modalità "test") + +### 4. Creare l'OAuth Client ID + +In `API e servizi → Credenziali → Crea credenziali → ID client OAuth`: + +- Tipo: **Applicazione web** +- Nome: "ITCCA Allievi WordPress" +- URI di reindirizzamento autorizzati: `http://localhost:8080/wp-admin/admin.php?page=itcca-allievi-oauth` +- Salva e copia `Client ID` e `Client Secret` + +### 5. Creare la API Key (per il Picker) + +In `API e servizi → Credenziali → Crea credenziali → Chiave API`: + +- Copia la chiave +- Clicca su "Limita chiave" e: + - Restrizioni applicazioni: HTTP referrer → aggiungi `http://localhost:8080/*` + - Restrizioni API: limita a Google Picker API + Google Drive API + +### 6. Inserire le credenziali nel plugin + +WordPress admin → `Allievi ITCCA → Impostazioni → Sezione Google`: + +- OAuth Client ID +- OAuth Client Secret +- Picker API Key + +Clicca "Connetti Google" e autorizza il tuo account. Poi clicca "Seleziona foglio da Drive" e scegli il foglio dell'anno corrente. + +## Struttura del progetto + +``` +Itcca-allievi/ +├── .cursor/ +│ └── plans/ +│ └── itcca_allievi_wp_plugin.plan.md # piano del progetto +├── docker-compose.yml +├── .env # credenziali dev (NON committare) +├── .env.example +├── .gitignore +├── README.md # questo file +└── plugin/ + └── itcca-allievi/ # plugin WordPress (montato in wp-content/plugins) + ├── itcca-allievi.php + ├── composer.json + ├── includes/ + ├── assets/ + └── templates/ +``` + +## Persistenza dei dati + +Lo stack usa due volumi Docker nominati: + +- `itcca-allievi_db_data` — database MariaDB (tutte le iscrizioni, le impostazioni, gli utenti) +- `itcca-allievi_wp_data` — tutti i file di WordPress (config, uploads, temi, plugin di terze parti) + +`docker compose down` ferma i container ma **conserva** i volumi. +`docker compose down -v` cancella anche i volumi (azzera l'ambiente). + +Il codice del plugin sta in `./plugin/itcca-allievi/` come bind-mount: modifiche immediate, niente rebuild. + +## Troubleshooting + +- **"Errore database" al primo avvio**: il container DB ci mette qualche secondo a inizializzarsi. Aspetta 30s e ricarica. +- **Conflitto porta 8080**: cambia `WP_PORT` in `.env` e rilancia `docker compose up -d`. Ricorda di aggiornare anche l'URI di redirect OAuth in Google Cloud Console se cambi porta. +- **Reset totale**: `docker compose down -v && docker compose up -d` +- **Permessi sui file del plugin**: i container girano come `www-data` (uid 33). Se vedi errori di scrittura, da host: `sudo chown -R $USER:staff plugin/`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aa6e568 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,114 @@ +name: itcca-allievi + +services: + db: + image: mariadb:11 + container_name: itcca-db + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MARIADB_DATABASE: ${DB_NAME} + MARIADB_USER: ${DB_USER} + MARIADB_PASSWORD: ${DB_PASSWORD} + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - itcca + + wordpress: + image: wordpress:6.7-php8.3-apache + container_name: itcca-wp + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "${WP_PORT:-8080}:80" + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_NAME: ${DB_NAME} + WORDPRESS_DB_USER: ${DB_USER} + WORDPRESS_DB_PASSWORD: ${DB_PASSWORD} + WORDPRESS_DEBUG: ${WP_DEBUG:-1} + WORDPRESS_CONFIG_EXTRA: | + define('WP_HOME', '${WP_HOME:-http://localhost:8080}'); + define('WP_SITEURL', '${WP_SITEURL:-http://localhost:8080}'); + define('FS_METHOD', 'direct'); + define('WP_DEBUG_LOG', true); + define('WP_DEBUG_DISPLAY', false); + @ini_set('upload_max_filesize', '64M'); + @ini_set('post_max_size', '64M'); + @ini_set('memory_limit', '256M'); + entrypoint: ["/bin/sh", "-c", "/wp-cert-init.sh && exec docker-entrypoint.sh apache2-foreground"] + volumes: + - wp_data:/var/www/html + - ./plugin/itcca-allievi:/var/www/html/wp-content/plugins/itcca-allievi + - ./.docker/certs:/custom-certs:ro + - ./.docker/wp-cert-init.sh:/wp-cert-init.sh:ro + networks: + - itcca + + phpmyadmin: + image: phpmyadmin:5 + container_name: itcca-pma + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "${PMA_PORT:-8081}:80" + environment: + PMA_HOST: db + PMA_USER: root + PMA_PASSWORD: ${DB_ROOT_PASSWORD} + UPLOAD_LIMIT: 64M + networks: + - itcca + + composer: + image: composer:2 + container_name: itcca-composer + profiles: ["tools"] + working_dir: /app + entrypoint: ["/composer-entrypoint.sh"] + environment: + COMPOSER_ALLOW_SUPERUSER: "1" + volumes: + - ./plugin/itcca-allievi:/app + - ./.docker/certs:/custom-certs:ro + - ./.docker/composer-entrypoint.sh:/composer-entrypoint.sh:ro + networks: + - itcca + + wpcli: + image: wordpress:cli-php8.3 + container_name: itcca-wpcli + profiles: ["tools"] + user: "33:33" + depends_on: + db: + condition: service_healthy + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_NAME: ${DB_NAME} + WORDPRESS_DB_USER: ${DB_USER} + WORDPRESS_DB_PASSWORD: ${DB_PASSWORD} + volumes: + - wp_data:/var/www/html + - ./plugin/itcca-allievi:/var/www/html/wp-content/plugins/itcca-allievi + networks: + - itcca + +volumes: + db_data: + wp_data: + +networks: + itcca: + driver: bridge diff --git a/plugin/itcca-allievi/assets/css/admin.css b/plugin/itcca-allievi/assets/css/admin.css new file mode 100644 index 0000000..533a907 --- /dev/null +++ b/plugin/itcca-allievi/assets/css/admin.css @@ -0,0 +1,670 @@ +/* ITCCA admin styling */ + +.itcca-admin-sections { + margin: 1rem 0; +} + +.itcca-admin-section { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + margin-bottom: 0.75rem; + padding: 0; +} + +.itcca-admin-section > summary { + cursor: pointer; + padding: 0.75rem 1rem; + font-weight: 600; + color: #1d2327; + list-style-position: inside; +} + +.itcca-admin-section[open] > summary { + border-bottom: 1px solid #dcdcde; +} + +.itcca-admin-section .form-table { + margin: 0; + padding: 0 1rem 0.5rem; +} + +.itcca-admin-section .form-table th { + width: 220px; +} + +.itcca-radio { + margin-right: 1rem; +} + +.itcca-admin-section .form-table th, +.itcca-admin-section .form-table td { + padding: 0.55rem 1rem; +} + +/* Allievi list table */ +.itcca-allievi-table .column-flag { + text-align: center; + width: 60px; +} + +.itcca-allievi-table input[type="checkbox"].itcca-quick-flag { + vertical-align: middle; +} + +.itcca-status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; +} + +.itcca-status-A { + background: #d1f5e0; + color: #1f6b3a; +} + +.itcca-status-D { + background: #f5d1d1; + color: #6b1f1f; +} + +.itcca-status-active { + background: #d1f5e0; + color: #1f6b3a; +} + +.itcca-status-inactive { + background: #e5e7eb; + color: #4b5563; +} + +/* Dashboard views switcher */ +.itcca-dashboard-views { + margin: 0.5rem 0 1rem; + font-size: 14px; +} +.itcca-dashboard-views li { + display: inline; + margin: 0; +} + +/* Settings page */ +.itcca-settings .card { + max-width: 800px; + padding: 1rem 1.25rem; + margin-bottom: 1.5rem; +} + +.itcca-settings .itcca-banner-warning { + background: #fff7e6; + border-left: 4px solid #d97706; + padding: 0.75rem 1rem; + margin: 1rem 0; +} + +.itcca-settings .itcca-banner-success { + background: #ecfdf5; + border-left: 4px solid #059669; + padding: 0.75rem 1rem; + margin: 1rem 0; +} + +.itcca-sheet-card { + border: 1px solid #dcdcde; + border-radius: 6px; + padding: 0.75rem 1rem; + background: #fafafa; + margin-bottom: 0.75rem; +} + +.itcca-sheet-card .itcca-sheet-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.5rem; +} + +/* Phase A preview */ +.itcca-phase-a-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1.5rem; +} + +.itcca-phase-a-table th, +.itcca-phase-a-table td { + border: 1px solid #dcdcde; + padding: 0.4rem 0.6rem; + vertical-align: top; + font-size: 0.9rem; +} + +.itcca-phase-a-table thead th { + background: #f0f0f1; + text-align: left; +} + +.itcca-diff-old { + text-decoration: line-through; + color: #b3261e; +} + +.itcca-diff-new { + color: #1e6b3a; + font-weight: 600; +} + +/* Tabella di Fase A in modalità "cella per cella": evidenzia il blocco di righe + * appartenente allo stesso allievo (colonne con rowspan) con uno sfondo neutro + * e un piccolo gap visivo ad ogni cambio di utente. */ +.itcca-phase-a-cells .itcca-row-master { + background: #fbfbfc; +} +.itcca-phase-a-cells td:nth-child(2), +.itcca-phase-a-cells td:nth-child(3), +.itcca-phase-a-cells td:nth-child(4), +.itcca-phase-a-cells td:nth-child(8) { + background: #fbfbfc; +} +.itcca-diag-panel code { + background: transparent; + padding: 0; + font-size: 0.85em; + color: #50575e; +} +.itcca-diag-panel p { margin: 0.4em 0; } + +/* Busy overlay (mostrato durante operazioni lunghe come Fase A / picker sheet) */ +.itcca-busy-overlay { + position: fixed; + inset: 0; + background: rgba(31, 31, 35, 0.55); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + display: none; + align-items: center; + justify-content: center; + z-index: 999999; +} +.itcca-busy-overlay.is-visible { + display: flex; + animation: itcca-busy-fade 0.18s ease-out both; +} +@keyframes itcca-busy-fade { from { opacity: 0 } to { opacity: 1 } } +.itcca-busy-card { + background: #fff; + border-radius: 8px; + box-shadow: 0 14px 40px rgba(0,0,0,0.18); + padding: 1.6rem 2rem; + max-width: 420px; + width: calc(100% - 2rem); + text-align: center; +} +.itcca-busy-spinner { + width: 44px; + height: 44px; + border: 4px solid #d8e4ec; + border-top-color: #2271b1; + border-radius: 50%; + margin: 0 auto 1rem; + animation: itcca-busy-spin 0.85s linear infinite; +} +@keyframes itcca-busy-spin { to { transform: rotate(360deg) } } +.itcca-busy-title { + margin: 0 0 0.4rem; + font-size: 1.1rem; + color: #1d2327; +} +.itcca-busy-message { + margin: 0; + color: #50575e; + font-size: 0.95rem; + line-height: 1.4; +} +html.itcca-busy { + overflow: hidden; +} + +/* Schema reconcile / deleted fields */ +.itcca-admin-section--deleted { + border-color: #d97706; + background: #fffaf2; +} + +.itcca-admin-section--deleted > summary { + color: #92400e; +} + +.itcca-row--deleted th label, +.itcca-row--deleted td input, +.itcca-row--deleted td select, +.itcca-row--deleted td textarea { + opacity: 0.85; +} + +.itcca-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + vertical-align: middle; + margin-left: 0.4rem; +} + +.itcca-badge--deleted { + background: #fde68a; + color: #92400e; +} + +select.itcca-autofilled { + box-shadow: 0 0 0 2px #d1f5e0; +} + +/* Setup wizard Google */ +.itcca-setup-wizard { + max-width: 880px; +} + +.itcca-wizard-steps { + list-style: none; + counter-reset: step; + margin: 1rem 0 0; + padding: 0; +} + +.itcca-wizard-steps > li { + counter-increment: step; + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.6rem 0; + border-top: 1px solid #f0f0f1; +} + +.itcca-wizard-steps > li:first-child { + border-top: none; + padding-top: 0; +} + +.itcca-wizard-steps > li::before { + content: counter(step); + flex: 0 0 28px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: #2271b1; + color: #fff; + font-weight: 700; + font-size: 0.85rem; + margin-top: 0.1rem; +} + +.itcca-step-body { + flex: 1; + min-width: 0; +} + +.itcca-step-header { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.2rem; +} + +.itcca-step-header > strong { + font-size: 0.95rem; +} + +.itcca-step-body .description { + margin: 0 0 0.4rem; +} + +.itcca-step-body .itcca-step-note { + margin-top: 0.4rem; + font-style: italic; + color: #555; + background: #f6f7f7; + border-left: 3px solid #c3c4c7; + padding: 0.35rem 0.6rem; + border-radius: 0 4px 4px 0; +} + +.itcca-step-list { + margin: 0.3rem 0 0.6rem 1.2rem; + padding: 0; + list-style: disc; +} + +.itcca-step-list--ordered { + list-style: decimal; +} + +.itcca-step-list > li { + margin: 0.25rem 0; + line-height: 1.45; +} + +.itcca-step-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.5rem; +} + +.itcca-step-status { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + vertical-align: middle; +} + +.itcca-step-status--done { + background: #d1f5e0; + color: #1f6b3a; +} + +.itcca-step-status--ready { + background: #cfe9ff; + color: #1a4f7a; +} + +.itcca-step-status--todo { + background: #fde68a; + color: #92400e; +} + +.itcca-step-status--manual { + background: #e5e7eb; + color: #4b5563; +} + +.itcca-copybox { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 0.5rem 0; + flex-wrap: wrap; +} + +.itcca-copybox code { + background: #f6f7f7; + border: 1px solid #c3c4c7; + padding: 0.45rem 0.6rem; + border-radius: 4px; + font-size: 0.85rem; + user-select: all; + word-break: break-all; +} + +/* Validazione credenziali */ +.itcca-creds-input--ok { + border-color: #1f6b3a !important; + box-shadow: 0 0 0 1px #1f6b3a !important; +} + +.itcca-creds-input--bad { + border-color: #b3261e !important; + box-shadow: 0 0 0 1px #b3261e !important; +} + +.itcca-creds-status { + display: inline-block; + margin-left: 0.5rem; + font-size: 0.85rem; + vertical-align: middle; +} + +.itcca-creds-status.is-ok { + color: #1f6b3a; +} + +.itcca-creds-status.is-bad { + color: #b3261e; +} + +/* Swatch elementi cinque-fasi */ +.itcca-swatch { + display: inline-block; + width: 0.8em; + height: 0.8em; + border-radius: 50%; + vertical-align: -1px; + margin-right: 0.4em; + border: 1px solid rgba(0, 0, 0, 0.18); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25); +} + +.itcca-swatch--lg { + width: 1.1em; + height: 1.1em; + vertical-align: -3px; + margin-left: 0.4em; + margin-right: 0; +} + +/* Dashboard — KPI card età */ +.itcca-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin: 1rem 0 1.5rem; +} + +.itcca-kpi-card { + background: #fff; + border: 1px solid #c3c4c7; + border-left: 4px solid #2271b1; + border-radius: 6px; + padding: 0.9rem 1.1rem; + min-height: 110px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.itcca-kpi-card--accent-blue { border-left-color: #5fa8d3; } +.itcca-kpi-card--accent-red { border-left-color: #cc2936; } +.itcca-kpi-card--chart { grid-column: span 2; } + +@media (max-width: 880px) { + .itcca-kpi-card--chart { grid-column: span 1; } +} + +.itcca-kpi-label { + font-size: 0.78rem; + color: #50575e; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.itcca-kpi-value { + font-size: 2rem; + font-weight: 700; + color: #1d2327; + line-height: 1.1; + margin: 0.2rem 0; +} + +.itcca-kpi-value--muted { color: #9ca3af; } + +.itcca-kpi-unit { + font-size: 0.9rem; + font-weight: 500; + color: #50575e; + margin-left: 0.25rem; +} + +.itcca-kpi-note { + font-size: 0.85rem; + color: #50575e; +} + +.itcca-bars { + width: 100%; + height: 110px; +} + +/* Dashboard — mappa */ +.itcca-map-card { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 6px; + padding: 1rem 1.2rem; + margin-top: 1.5rem; +} +.itcca-map-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; +} +.itcca-marker { + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 2px solid #fff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + font-weight: 700; + text-align: center; +} +.itcca-marker > span { line-height: 1; } +.itcca-marker--sede { + background: #cc2936; + color: #fff; + width: 30px; + height: 30px; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); +} +.itcca-marker--sede > span { + /* Il marker padre è ruotato -45°; la translate qui (in coord locali del padre) + dopo la rotazione del padre si traduce in uno shift "verso l'alto" sullo schermo, + così la stella finisce nel centro ottico della testa della teardrop. */ + transform: rotate(45deg) translate(3px, -3px); + font-size: 16px; + line-height: 1; +} +.itcca-marker--sede.itcca-marker--abandoned { + background: #8c8f94; + opacity: 0.75; + filter: grayscale(1); +} +.itcca-marker--allievo { + background: #2271b1; + color: #fff; + width: 16px; + height: 16px; + font-size: 10px; +} +.itcca-marker--allievo.itcca-marker--inactive { + background: #9ca3af; + color: #f3f4f6; + width: 12px; + height: 12px; + font-size: 8px; + opacity: 0.85; +} + +/* Settings cards più larghe (override del default .card max-width: 520px di WP) */ +.itcca-settings .card, +.wrap.itcca-settings > .card { + max-width: 1200px; + width: 100%; +} + +/* Dashboard — Sedi CRUD */ +.itcca-sedi-table input[type="text"] { width: 100%; box-sizing: border-box; } +.itcca-sedi-new td { background: #f6f7f7; } + +.itcca-sedi-table tr.itcca-sede-abandoned td { + background: #f6f7f7; +} +.itcca-sedi-table tr.itcca-sede-abandoned > td:first-child { + box-shadow: inset 4px 0 0 0 #8c8f94; +} +.itcca-sedi-table tr.itcca-sede-abandoned input[type="text"] { + color: #646970; + text-decoration: line-through; + text-decoration-color: #8c8f94; +} + +/* Dashboard grafici a torta */ +.itcca-dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.itcca-chart-card { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 6px; + padding: 1rem 1.25rem; +} + +.itcca-chart-card h2 { + margin-top: 0; + font-size: 1.05rem; + border-bottom: 1px solid #f0f0f1; + padding-bottom: 0.5rem; +} + +.itcca-chart-row { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; +} + +.itcca-pie { + flex: 0 0 240px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.08)); +} + +.itcca-pie path { + stroke: #fff; + stroke-width: 1; +} + +.itcca-chart-legend { + flex: 1 1 200px; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.85rem; + max-height: 240px; + overflow-y: auto; +} + +.itcca-chart-legend li { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.5rem; + align-items: center; + padding: 0.2rem 0; + border-bottom: 1px solid #f6f7f7; +} + +.itcca-chart-legend__label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.itcca-chart-legend__value { + color: #50575e; + font-variant-numeric: tabular-nums; +} diff --git a/plugin/itcca-allievi/assets/css/form.css b/plugin/itcca-allievi/assets/css/form.css new file mode 100644 index 0000000..85fe9c1 --- /dev/null +++ b/plugin/itcca-allievi/assets/css/form.css @@ -0,0 +1,171 @@ +/* Form pubblico ITCCA: variabili CSS pensate per ereditare dal tema attivo. */ + +:root { + --itcca-primary: var(--wp--preset--color--primary, currentColor); + --itcca-error: #b3261e; + --itcca-success: #1e6b3a; + --itcca-border: rgba(0, 0, 0, 0.18); + --itcca-bg-input: rgba(0, 0, 0, 0.02); + --itcca-radius: 6px; + --itcca-gap: 1rem; +} + +.itcca-form { + display: flex; + flex-direction: column; + gap: 1.25rem; + max-width: 760px; + margin: 0 auto; +} + +.itcca-form .itcca-honeypot { + position: absolute !important; + left: -9999px !important; + height: 0 !important; + width: 0 !important; + opacity: 0 !important; +} + +.itcca-form-success { + border-left: 4px solid var(--itcca-success); + background: rgba(30, 107, 58, 0.08); + padding: 0.875rem 1rem; + border-radius: var(--itcca-radius); + margin-bottom: 1.25rem; +} + +.itcca-form-error { + border-left: 4px solid var(--itcca-error); + background: rgba(179, 38, 30, 0.08); + padding: 0.875rem 1rem; + border-radius: var(--itcca-radius); +} + +.itcca-section { + border: 1px solid var(--itcca-border); + border-radius: var(--itcca-radius); + padding: 1rem 1.25rem 1.25rem; + margin: 0; +} + +.itcca-section legend { + padding: 0 0.5rem; + font-weight: 600; + color: var(--itcca-primary); +} + +.itcca-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--itcca-gap); +} + +.itcca-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.itcca-field .itcca-label, +.itcca-field label { + font-size: 0.92rem; + font-weight: 500; +} + +.itcca-required { + color: var(--itcca-error); +} + +.itcca-field input[type="text"], +.itcca-field input[type="email"], +.itcca-field input[type="tel"], +.itcca-field input[type="date"] { + width: 100%; + padding: 0.55rem 0.7rem; + border: 1px solid var(--itcca-border); + border-radius: var(--itcca-radius); + background: var(--itcca-bg-input); + font: inherit; + color: inherit; +} + +.itcca-field input:focus { + outline: 2px solid var(--itcca-primary); + outline-offset: 1px; +} + +.itcca-field-has-error input, +.itcca-field-has-error .itcca-radio-group { + border-color: var(--itcca-error); +} + +.itcca-field-error { + color: var(--itcca-error); + font-size: 0.85rem; + margin: 0; +} + +.itcca-help { + color: rgba(0, 0, 0, 0.55); + font-size: 0.8rem; +} + +.itcca-radio-group { + display: flex; + gap: 1rem; + padding: 0.55rem 0; +} + +.itcca-radio, +.itcca-checkbox { + display: inline-flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.itcca-checkbox { + align-items: flex-start; + font-size: 0.92rem; +} + +.itcca-checkbox input { + margin-top: 0.25rem; +} + +.itcca-field-cf input { + text-transform: uppercase; + letter-spacing: 0.05em; + font-variant-numeric: tabular-nums; +} + +.itcca-actions { + display: flex; + justify-content: flex-end; +} + +.itcca-submit { + background: var(--itcca-primary); + color: #fff; + border: 0; + padding: 0.75rem 1.5rem; + border-radius: var(--itcca-radius); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.itcca-submit:hover, +.itcca-submit:focus { + filter: brightness(0.92); +} + +@media (prefers-color-scheme: dark) { + :root { + --itcca-border: rgba(255, 255, 255, 0.18); + --itcca-bg-input: rgba(255, 255, 255, 0.04); + } + .itcca-help { + color: rgba(255, 255, 255, 0.55); + } +} diff --git a/plugin/itcca-allievi/assets/js/admin.js b/plugin/itcca-allievi/assets/js/admin.js new file mode 100644 index 0000000..6a1b788 --- /dev/null +++ b/plugin/itcca-allievi/assets/js/admin.js @@ -0,0 +1,60 @@ +(function () { + 'use strict'; + + // Ciclo dei 12 animali, indicizzato per (anno % 12). + // 1900 % 12 = 4 → Topo (anno del ciclo che riparte). + const ANIMALS = [ + 'Scimmia', 'Gallo', 'Cane', 'Maiale', + 'Topo', 'Bue', 'Tigre', 'Coniglio', + 'Drago', 'Serpente', 'Cavallo', 'Capra', + ]; + // Ciclo dei 5 elementi (ognuno dura 2 anni). + const ELEMENTS = ['Legno', 'Fuoco', 'Terra', 'Metallo', 'Acqua']; + + function zodiacForDate(dateStr) { + if (!dateStr) return null; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr); + if (!m) return null; + let year = parseInt(m[1], 10); + const month = parseInt(m[2], 10); + const day = parseInt(m[3], 10); + if (isNaN(year) || isNaN(month) || isNaN(day)) return null; + // Capodanno cinese: usiamo il 5 febbraio come soglia mediana. + if (month === 1 || (month === 2 && day < 5)) year--; + const animal = ANIMALS[((year % 12) + 12) % 12]; + const eIdx = ((Math.floor((year - 4) / 2) % 5) + 5) % 5; + const element = ELEMENTS[eIdx]; + return { animal, element, label: animal + ' di ' + element }; + } + + function init() { + const dateInput = document.getElementById('itcca-field-nascita_data'); + const animaleSel = document.getElementById('itcca-field-animale'); + if (!dateInput || !animaleSel) return; + + function update() { + const r = zodiacForDate(dateInput.value); + if (!r) return; + // Pre-compila solo se la select è ancora vuota: non sovrascriviamo + // mai una scelta manuale dell'amministratore. + if (animaleSel.value !== '') return; + const opt = Array.from(animaleSel.options).find(function (o) { + return o.value === r.label; + }); + if (opt) { + animaleSel.value = r.label; + animaleSel.classList.add('itcca-autofilled'); + } + } + + dateInput.addEventListener('change', update); + dateInput.addEventListener('blur', update); + update(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/plugin/itcca-allievi/assets/js/busy-overlay.js b/plugin/itcca-allievi/assets/js/busy-overlay.js new file mode 100644 index 0000000..d5325ae --- /dev/null +++ b/plugin/itcca-allievi/assets/js/busy-overlay.js @@ -0,0 +1,71 @@ +/* global document, window */ +(function () { + 'use strict'; + + var OVERLAY_ID = 'itcca-busy-overlay'; + + function ensureOverlay() { + var el = document.getElementById(OVERLAY_ID); + if (el) return el; + el = document.createElement('div'); + el.id = OVERLAY_ID; + el.className = 'itcca-busy-overlay'; + el.setAttribute('aria-live', 'polite'); + el.setAttribute('role', 'status'); + el.innerHTML = + '
' + + ' ' + + '

' + + '

' + + '
'; + document.body.appendChild(el); + return el; + } + + function showOverlay(title, message) { + var el = ensureOverlay(); + el.querySelector('.itcca-busy-title').textContent = title || 'Operazione in corso…'; + el.querySelector('.itcca-busy-message').textContent = + message || 'Stiamo elaborando i dati: non chiudere questa pagina.'; + el.classList.add('is-visible'); + document.documentElement.classList.add('itcca-busy'); + } + + // Hook su tutti i form con [data-itcca-busy] + function attachToForms() { + var forms = document.querySelectorAll('form[data-itcca-busy]'); + forms.forEach(function (form) { + form.addEventListener('submit', function () { + var title = form.getAttribute('data-itcca-busy-title') || ''; + var message = form.getAttribute('data-itcca-busy') || ''; + showOverlay(title, message); + }); + }); + + // Anche sui link + var links = document.querySelectorAll('a[data-itcca-busy]'); + links.forEach(function (a) { + a.addEventListener('click', function () { + var title = a.getAttribute('data-itcca-busy-title') || ''; + var message = a.getAttribute('data-itcca-busy') || ''; + showOverlay(title, message); + }); + }); + } + + // Espone API globale per show manuale (es. da picker callback) + window.itccaBusy = { + show: showOverlay, + hide: function () { + var el = document.getElementById(OVERLAY_ID); + if (el) el.classList.remove('is-visible'); + document.documentElement.classList.remove('itcca-busy'); + }, + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', attachToForms); + } else { + attachToForms(); + } +})(); diff --git a/plugin/itcca-allievi/assets/js/dashboard-map.js b/plugin/itcca-allievi/assets/js/dashboard-map.js new file mode 100644 index 0000000..07029dc --- /dev/null +++ b/plugin/itcca-allievi/assets/js/dashboard-map.js @@ -0,0 +1,195 @@ +(function () { + 'use strict'; + + function initMap() { + var el = document.getElementById('itcca-map'); + if (!el || typeof L === 'undefined' || typeof itccaMap === 'undefined') { + return; + } + + var sedi = Array.isArray(itccaMap.sedi) ? itccaMap.sedi : []; + var allievi = Array.isArray(itccaMap.allievi) ? itccaMap.allievi : []; + + // Centro mappa: media delle coordinate, fallback Italia. + var allPoints = sedi.concat(allievi); + var center = [42.5, 12.5]; + if (allPoints.length) { + var sumLat = 0; + var sumLng = 0; + allPoints.forEach(function (p) { + sumLat += p.lat; + sumLng += p.lng; + }); + center = [sumLat / allPoints.length, sumLng / allPoints.length]; + } + + var map = L.map(el, { scrollWheelZoom: false }).setView(center, 7); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: + '© OpenStreetMap contributors', + }).addTo(map); + + // Icone custom + var sedeIcon = L.divIcon({ + className: 'itcca-marker itcca-marker--sede', + html: '', + iconSize: [30, 30], + iconAnchor: [15, 30], + }); + var sedeAbandIcon = L.divIcon({ + className: 'itcca-marker itcca-marker--sede itcca-marker--abandoned', + html: '', + iconSize: [30, 30], + iconAnchor: [15, 30], + }); + var allievoIcon = L.divIcon({ + className: 'itcca-marker itcca-marker--allievo', + html: '', + iconSize: [16, 16], + iconAnchor: [8, 8], + }); + var allievoInactiveIcon = L.divIcon({ + className: 'itcca-marker itcca-marker--allievo itcca-marker--inactive', + html: '', + iconSize: [12, 12], + iconAnchor: [6, 6], + }); + + var sediGroup = L.layerGroup(); + var sediAbandGroup = L.layerGroup(); + sedi.forEach(function (s) { + var icon = s.abandoned ? sedeAbandIcon : sedeIcon; + var m = L.marker([s.lat, s.lng], { icon: icon, zIndexOffset: s.abandoned ? 500 : 1000 }); + var label = '' + escapeHtml(s.name) + ''; + if (s.abandoned) { + label += ' (' + escapeHtml(itccaMap.strings.abandoned) + ')'; + } + m.bindPopup( + label + (s.address ? '
' + escapeHtml(s.address) + '' : '') + ); + m.addTo(s.abandoned ? sediAbandGroup : sediGroup); + }); + + var allieviActiveGroup = L.layerGroup(); + var allieviInactiveGroup = L.layerGroup(); + allievi.forEach(function (a) { + var isActive = a.active !== false; + var icon = isActive ? allievoIcon : allievoInactiveIcon; + var m = L.marker([a.lat, a.lng], { icon: icon }); + var label = '' + escapeHtml(a.name) + ''; + if (!isActive) { + label += ' (' + escapeHtml(itccaMap.strings.inactive) + ')'; + } + m.bindPopup( + label + (a.address ? '
' + escapeHtml(a.address) + '' : '') + ); + m.addTo(isActive ? allieviActiveGroup : allieviInactiveGroup); + }); + + // Visibilità iniziale layer in base alla view selezionata. + // - active → solo allievi attivi (default) + // - inactive → solo allievi inattivi + // - all → entrambi + var view = (itccaMap && itccaMap.view) || 'active'; + sediGroup.addTo(map); + sediAbandGroup.addTo(map); + if (view === 'active' || view === 'all') allieviActiveGroup.addTo(map); + if (view === 'inactive' || view === 'all') allieviInactiveGroup.addTo(map); + + var overlays = {}; + overlays[itccaMap.strings.sedi] = sediGroup; + overlays[itccaMap.strings.sedi_aband] = sediAbandGroup; + overlays[itccaMap.strings.allievi_active] = allieviActiveGroup; + overlays[itccaMap.strings.allievi_inactive] = allieviInactiveGroup; + L.control.layers(null, overlays, { collapsed: false }).addTo(map); + + // Fit bounds + if (allPoints.length > 0) { + var bounds = L.latLngBounds( + allPoints.map(function (p) { return [p.lat, p.lng]; }) + ); + map.fitBounds(bounds, { padding: [30, 30], maxZoom: 13 }); + } + + initGeocodeBatch(); + } + + function initGeocodeBatch() { + var btn = document.getElementById('itcca-geocode-batch-start'); + var progress = document.getElementById('itcca-geocode-progress'); + if (!btn || !progress) return; + + btn.addEventListener('click', function () { + btn.disabled = true; + progress.textContent = itccaMap.strings.geocoding; + runBatch(progress, function (stats) { + progress.textContent = + itccaMap.strings.done + + ' ✓ ' + stats.ok + ' · ⚠ ' + stats.failTransient + ' · ✗ ' + stats.failPermanent + ' '; + // Reload per popolare la mappa con i nuovi marker. + var reload = document.createElement('a'); + reload.href = window.location.href; + reload.textContent = '(ricarica la pagina)'; + progress.appendChild(reload); + }); + }); + } + + function runBatch(progressEl, doneCb) { + var processed = 0; + var ok = 0; + var failTransient = 0; + var failPermanent = 0; + function tick() { + ajax('itcca_geocode_batch', function (resp) { + if (!resp || !resp.success) { + progressEl.textContent = 'Errore durante la geocodifica.'; + return; + } + var data = resp.data; + if (data.done) { + doneCb({ ok: ok, failTransient: failTransient, failPermanent: failPermanent }); + return; + } + processed++; + if (data.success) ok++; + else if (data.failed === 'permanent') failPermanent++; + else failTransient++; + progressEl.textContent = + itccaMap.strings.geocoding + + ' (' + processed + '): ✓ ' + ok + + ' · ⚠ ' + failTransient + + ' · ✗ ' + failPermanent + + ', rimasti: ' + data.remaining; + // Pausa 1.1s per rispettare il rate limit di Nominatim + setTimeout(tick, 1100); + }); + } + tick(); + } + + function ajax(action, cb) { + var fd = new FormData(); + fd.append('action', action); + fd.append('nonce', itccaMap.nonce); + fetch(itccaMap.ajaxUrl, { method: 'POST', body: fd, credentials: 'same-origin' }) + .then(function (r) { return r.json(); }) + .then(cb) + .catch(function () { cb(null); }); + } + + function escapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initMap); + } else { + initMap(); + } +})(); diff --git a/plugin/itcca-allievi/assets/js/form.js b/plugin/itcca-allievi/assets/js/form.js new file mode 100644 index 0000000..bf6a896 --- /dev/null +++ b/plugin/itcca-allievi/assets/js/form.js @@ -0,0 +1,123 @@ +(function () { + 'use strict'; + + const ITCCA_CF_RE = /^[A-Z]{6}[0-9]{2}[A-Z][0-9]{2}[A-Z][0-9]{3}[A-Z]$/; + const ITCCA_CAP_RE = /^[0-9]{5}$/; + const ITCCA_PHONE_RE = /^\+?[0-9]{9,15}$/; + + function uppercaseCF(e) { + const v = e.target.value || ''; + e.target.value = v.toUpperCase().replace(/\s+/g, ''); + } + + function showError(field, msg) { + field.classList.add('itcca-field-has-error'); + let err = field.querySelector('.itcca-field-error'); + if (!err) { + err = document.createElement('p'); + err.className = 'itcca-field-error'; + field.appendChild(err); + } + err.textContent = msg; + } + + function clearError(field) { + field.classList.remove('itcca-field-has-error'); + const err = field.querySelector('.itcca-field-error'); + if (err) { + err.remove(); + } + } + + function normalizePhone(v) { + return (v || '').replace(/[\s\-]/g, ''); + } + + function validateInput(input) { + const field = input.closest('.itcca-field'); + if (!field) return true; + const type = input.dataset.type || ''; + const value = (input.value || '').trim(); + if (input.required && value === '') { + showError(field, 'Campo obbligatorio.'); + return false; + } + if (value === '') { + clearError(field); + return true; + } + if (type === 'cf') { + if (!ITCCA_CF_RE.test(value)) { + showError(field, 'Codice fiscale non valido.'); + return false; + } + } else if (type === 'email') { + const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + if (!emailRe.test(value)) { + showError(field, 'Indirizzo email non valido.'); + return false; + } + } else if (type === 'cap') { + if (!ITCCA_CAP_RE.test(value)) { + showError(field, 'CAP deve essere di 5 cifre.'); + return false; + } + } else if (type === 'tel') { + if (!ITCCA_PHONE_RE.test(normalizePhone(value))) { + showError(field, 'Numero non valido.'); + return false; + } + } else if (type === 'date') { + const d = new Date(value); + if (isNaN(d.getTime()) || d > new Date()) { + showError(field, 'Data non valida.'); + return false; + } + } + clearError(field); + return true; + } + + function init() { + const forms = document.querySelectorAll('form.itcca-form'); + forms.forEach((form) => { + const cfInput = form.querySelector('input[data-type="cf"]'); + if (cfInput) { + cfInput.addEventListener('input', uppercaseCF); + } + + const inputs = form.querySelectorAll('input[data-type], input[required]'); + inputs.forEach((input) => { + input.addEventListener('blur', () => validateInput(input)); + }); + + form.addEventListener('submit', (e) => { + let ok = true; + inputs.forEach((input) => { + if (!validateInput(input)) { + ok = false; + } + }); + const privacy = form.querySelector('input[name="privacy"]'); + if (privacy && !privacy.checked) { + const field = privacy.closest('.itcca-field'); + if (field) showError(field, 'Devi accettare l\'informativa.'); + ok = false; + } + if (!ok) { + e.preventDefault(); + const firstErr = form.querySelector('.itcca-field-has-error'); + if (firstErr) { + firstErr.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + }); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/plugin/itcca-allievi/assets/js/picker.js b/plugin/itcca-allievi/assets/js/picker.js new file mode 100644 index 0000000..e6ef02c --- /dev/null +++ b/plugin/itcca-allievi/assets/js/picker.js @@ -0,0 +1,227 @@ +(function () { + 'use strict'; + + if (typeof itccaPicker === 'undefined') { + return; + } + + let pickerInited = false; + let gisInited = false; + + function $(sel) { + return document.querySelector(sel); + } + + function setMsg(html, type) { + const el = $('#itcca-picker-msg'); + if (!el) return; + el.innerHTML = html || ''; + el.className = ''; + if (type) { + el.classList.add('itcca-banner-' + type); + } + } + + function loadScript(src) { + return new Promise((resolve, reject) => { + if (document.querySelector('script[data-src="' + src + '"]')) { + return resolve(); + } + const s = document.createElement('script'); + s.src = src; + s.async = true; + s.defer = true; + s.dataset.src = src; + s.onload = resolve; + s.onerror = () => reject(new Error('Cannot load ' + src)); + document.head.appendChild(s); + }); + } + + async function ensureLibs() { + await loadScript('https://apis.google.com/js/api.js'); + await new Promise((resolve) => { + window.gapi.load('picker', { callback: resolve }); + }); + pickerInited = true; + gisInited = true; + } + + function openPicker() { + if (!itccaPicker.accessToken) { + setMsg(itccaPicker.strings.pickError + ' (token mancante, riconnetti Google)', 'warning'); + return; + } + const view = new window.google.picker.View(window.google.picker.ViewId.SPREADSHEETS); + view.setMimeTypes('application/vnd.google-apps.spreadsheet'); + const picker = new window.google.picker.PickerBuilder() + .addView(view) + .setOAuthToken(itccaPicker.accessToken) + .setDeveloperKey(itccaPicker.apiKey) + .setTitle(itccaPicker.strings.pickerTitle) + .setCallback(onPicked) + .build(); + picker.setVisible(true); + } + + function onPicked(data) { + if (data.action !== window.google.picker.Action.PICKED) { + return; + } + const doc = data.docs && data.docs[0]; + if (!doc) return; + if (window.itccaBusy) { + window.itccaBusy.show( + 'Collegamento foglio in corso…', + 'Sto registrando il foglio "' + (doc.name || '') + + '", individuando il tab degli allievi e quello degli inattivi (ZZZ). Pochi secondi.' + ); + } else { + setMsg(itccaPicker.strings.loading, 'warning'); + } + const params = new URLSearchParams(); + params.set('action', 'itcca_set_sheet'); + params.set('nonce', itccaPicker.nonce); + params.set('id', doc.id); + params.set('name', doc.name); + params.set('url', doc.url); + fetch(itccaPicker.ajaxUrl, { method: 'POST', body: params }) + .then((r) => r.json()) + .then((res) => { + if (res && res.success) { + window.location.href = (res.data && res.data.next_url) || res.data.phase_a_url; + } else { + if (window.itccaBusy) window.itccaBusy.hide(); + setMsg(itccaPicker.strings.saveError, 'warning'); + } + }) + .catch(() => { + if (window.itccaBusy) window.itccaBusy.hide(); + setMsg(itccaPicker.strings.saveError, 'warning'); + }); + } + + async function loadTabs(spreadsheetId, selectEl) { + if (!spreadsheetId) return; + const params = new URLSearchParams(); + params.set('action', 'itcca_list_tabs'); + params.set('nonce', itccaPicker.nonce); + params.set('id', spreadsheetId); + try { + const r = await fetch(itccaPicker.ajaxUrl, { method: 'POST', body: params }); + const res = await r.json(); + if (!res || !res.success) { + setMsg(itccaPicker.strings.tabError, 'warning'); + return; + } + const current = selectEl.dataset.current || ''; + selectEl.innerHTML = ''; + res.data.tabs.forEach((t) => { + const o = document.createElement('option'); + o.value = String(t.id); + o.textContent = t.title; + if (String(t.id) === String(current)) { + o.selected = true; + } + o.dataset.title = t.title; + selectEl.appendChild(o); + }); + selectEl.addEventListener('change', () => { + const opt = selectEl.options[selectEl.selectedIndex]; + const p = new URLSearchParams(); + p.set('action', 'itcca_set_tab'); + p.set('nonce', itccaPicker.nonce); + p.set('tab_id', opt.value); + p.set('tab_title', opt.dataset.title || ''); + if (window.itccaBusy) { + window.itccaBusy.show( + 'Configurazione tab in corso…', + 'Sto rilevando il tab degli inattivi (ZZZ) e confrontando lo schema delle colonne con WordPress. Pochi secondi.' + ); + } else { + setMsg(itccaPicker.strings.loading, 'warning'); + } + fetch(itccaPicker.ajaxUrl, { method: 'POST', body: p }) + .then((r) => r.json()) + .then((res) => { + if (res && res.success && res.data && res.data.next_url) { + if (res.data.needs_reconcile && window.itccaBusy) { + window.itccaBusy.show( + 'Schema da riconciliare', + 'Ho trovato colonne aggiunte/rinominate sul foglio: ti porto alla pagina di riconciliazione.' + ); + } + window.location.href = res.data.next_url; + } else { + if (window.itccaBusy) window.itccaBusy.hide(); + setMsg('Tab impostato.', 'success'); + } + }) + .catch(() => { + if (window.itccaBusy) window.itccaBusy.hide(); + setMsg('Errore nel cambio tab.', 'warning'); + }); + }); + } catch (e) { + setMsg(itccaPicker.strings.tabError, 'warning'); + } + } + + async function init() { + const pickBtn = $('#itcca-pick-sheet'); + const tabSel = $('#itcca-sheet-tab'); + const sheetIdEl = document.querySelector('.itcca-sheet-card code'); + const sheetId = sheetIdEl ? sheetIdEl.textContent.trim() : ''; + + if (pickBtn) { + pickBtn.addEventListener('click', async () => { + try { + if (!pickerInited) { + setMsg(itccaPicker.strings.loading, 'warning'); + await ensureLibs(); + setMsg(''); + } + openPicker(); + } catch (e) { + setMsg(itccaPicker.strings.pickError + ' ' + e.message, 'warning'); + } + }); + } + + if (tabSel && sheetId) { + loadTabs(sheetId, tabSel); + } + + const testBtn = $('#itcca-test-connection'); + if (testBtn) { + testBtn.addEventListener('click', async (e) => { + e.preventDefault(); + if (!sheetId) { + setMsg('Nessun foglio attivo.', 'warning'); + return; + } + const p = new URLSearchParams(); + p.set('action', 'itcca_list_tabs'); + p.set('nonce', itccaPicker.nonce); + p.set('id', sheetId); + try { + const r = await fetch(itccaPicker.ajaxUrl, { method: 'POST', body: p }); + const res = await r.json(); + if (res && res.success) { + setMsg('✓ Connessione OK — ' + res.data.tabs.length + ' tab nel foglio.', 'success'); + } else { + setMsg('Errore connessione.', 'warning'); + } + } catch (e) { + setMsg('Errore connessione: ' + e.message, 'warning'); + } + }); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/plugin/itcca-allievi/assets/js/settings.js b/plugin/itcca-allievi/assets/js/settings.js new file mode 100644 index 0000000..e82a8b7 --- /dev/null +++ b/plugin/itcca-allievi/assets/js/settings.js @@ -0,0 +1,110 @@ +(function () { + 'use strict'; + + // Formato grossolano delle credenziali Google. Stesso identico controllo + // applicato server-side in GoogleOauth::credentials_format_status(). + const PATTERNS = { + client_id: /^\d+-[a-z0-9]+\.apps\.googleusercontent\.com$/i, + client_secret: /^GOCSPX-[A-Za-z0-9_\-]+$/, + api_key: /^AIza[0-9A-Za-z\-_]{35}$/, + }; + + function status(value, kind) { + if (!value) return 'empty'; + return PATTERNS[kind].test(value) ? 'ok' : 'bad'; + } + + function render(input) { + const kind = input.dataset.validate; + if (!kind || !PATTERNS[kind]) return; + const value = (input.value || '').trim(); + const st = status(value, kind); + input.classList.remove('itcca-creds-input--ok', 'itcca-creds-input--bad'); + if (st === 'ok') input.classList.add('itcca-creds-input--ok'); + if (st === 'bad') input.classList.add('itcca-creds-input--bad'); + + const badge = document.querySelector('.itcca-creds-status[data-for="' + kind + '"]'); + if (!badge) return; + badge.classList.remove('is-ok', 'is-bad', 'is-empty'); + if (st === 'empty') { + badge.classList.add('is-empty'); + badge.textContent = ''; + } else if (st === 'ok') { + badge.classList.add('is-ok'); + badge.textContent = '✓ formato valido'; + } else { + badge.classList.add('is-bad'); + badge.textContent = '✗ formato sospetto, ricontrolla'; + } + } + + function initInputs() { + document.querySelectorAll('.itcca-creds-input').forEach(function (input) { + render(input); + input.addEventListener('input', function () { + render(input); + }); + input.addEventListener('blur', function () { + // Auto-trim quando l'utente lascia il campo. + const trimmed = (input.value || '').trim(); + if (trimmed !== input.value) { + input.value = trimmed; + render(input); + } + }); + }); + } + + function initCopy() { + document.querySelectorAll('button[data-copy-target]').forEach(function (btn) { + btn.addEventListener('click', function () { + const target = document.querySelector(btn.dataset.copyTarget); + if (!target) return; + const text = (target.textContent || '').trim(); + if (!text) return; + const done = function () { + const orig = btn.textContent; + btn.textContent = '✓ copiato'; + btn.classList.add('button-primary'); + setTimeout(function () { + btn.textContent = orig; + btn.classList.remove('button-primary'); + }, 1500); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done).catch(function () { + // Fallback per browser senza clipboard API (es. http+contesti non sicuri) + fallbackCopy(text); + done(); + }); + } else { + fallbackCopy(text); + done(); + } + }); + }); + } + + function fallbackCopy(text) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'absolute'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch (e) { /* ignore */ } + document.body.removeChild(ta); + } + + function init() { + initInputs(); + initCopy(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/plugin/itcca-allievi/composer.json b/plugin/itcca-allievi/composer.json new file mode 100644 index 0000000..102e59c --- /dev/null +++ b/plugin/itcca-allievi/composer.json @@ -0,0 +1,17 @@ +{ + "name": "itcca/allievi", + "description": "WordPress plugin per la gestione delle iscrizioni ITCCA con sincronizzazione Google Sheets", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=8.1", + "google/apiclient": "^2.18" + }, + "config": { + "platform": { + "php": "8.3" + }, + "optimize-autoloader": true, + "sort-packages": true + } +} diff --git a/plugin/itcca-allievi/composer.lock b/plugin/itcca-allievi/composer.lock new file mode 100644 index 0000000..7cef539 --- /dev/null +++ b/plugin/itcca-allievi/composer.lock @@ -0,0 +1,1068 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "0de3de03eecdd52073a2629b39c623c2", + "packages": [ + { + "name": "firebase/php-jwt", + "version": "v7.0.5", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" + }, + "time": "2026-04-01T20:38:03+00:00" + }, + { + "name": "google/apiclient", + "version": "v2.19.3", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-api-php-client.git", + "reference": "a1f02761994fd9defb20f6f1449205fd66f450de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-api-php-client/zipball/a1f02761994fd9defb20f6f1449205fd66f450de", + "reference": "a1f02761994fd9defb20f6f1449205fd66f450de", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^6.0||^7.0", + "google/apiclient-services": "~0.350", + "google/auth": "^1.37", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.6", + "monolog/monolog": "^2.9||^3.0", + "php": "^8.1" + }, + "require-dev": { + "cache/filesystem-adapter": "^1.1", + "composer/composer": "^2.9", + "phpcompatibility/php-compatibility": "^9.2", + "phpspec/prophecy-phpunit": "^2.1", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.8", + "symfony/css-selector": "~2.1", + "symfony/dom-crawler": "~2.1" + }, + "suggest": { + "cache/filesystem-adapter": "For caching certs and tokens (using Google\\Client::setCache)" + }, + "type": "library", + "extra": { + "component": { + "entry": "src/Client.php" + }, + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/aliases.php" + ], + "psr-4": { + "Google\\": "src/" + }, + "classmap": [ + "src/aliases.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Client library for Google APIs", + "homepage": "http://developers.google.com/api-client-library/php", + "keywords": [ + "google" + ], + "support": { + "issues": "https://github.com/googleapis/google-api-php-client/issues", + "source": "https://github.com/googleapis/google-api-php-client/tree/v2.19.3" + }, + "time": "2026-05-04T21:00:36+00:00" + }, + { + "name": "google/apiclient-services", + "version": "v0.441.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-api-php-client-services.git", + "reference": "9e3c10389ed39915261e5bc98a687c2b1307040d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/9e3c10389ed39915261e5bc98a687c2b1307040d", + "reference": "9e3c10389ed39915261e5bc98a687c2b1307040d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "Google\\Service\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Client library for Google APIs", + "homepage": "http://developers.google.com/api-client-library/php", + "keywords": [ + "google" + ], + "support": { + "issues": "https://github.com/googleapis/google-api-php-client-services/issues", + "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.441.0" + }, + "time": "2026-05-11T01:42:35+00:00" + }, + { + "name": "google/auth", + "version": "v1.50.1", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-auth-library-php.git", + "reference": "870c17ee3a1d73338d39a9ffa77a700ba77f5a83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/870c17ee3a1d73338d39a9ffa77a700ba77f5a83", + "reference": "870c17ee3a1d73338d39a9ffa77a700ba77f5a83", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^6.0||^7.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.4.5", + "php": "^8.1", + "psr/cache": "^2.0||^3.0", + "psr/http-message": "^1.1||^2.0", + "psr/log": "^2.0||^3.0" + }, + "require-dev": { + "guzzlehttp/promises": "^2.0", + "kelvinmo/simplejwt": "^1.1.0", + "phpseclib/phpseclib": "^3.0.35", + "phpspec/prophecy-phpunit": "^2.1", + "phpunit/phpunit": "^9.6", + "sebastian/comparator": ">=1.2.3", + "squizlabs/php_codesniffer": "^4.0", + "symfony/filesystem": "^6.3||^7.3", + "symfony/process": "^6.0||^7.0", + "webmozart/assert": "^1.11||^2.0" + }, + "suggest": { + "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2." + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Auth\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Google Auth Library for PHP", + "homepage": "https://github.com/google/google-auth-library-php", + "keywords": [ + "Authentication", + "google", + "oauth2" + ], + "support": { + "docs": "https://cloud.google.com/php/docs/reference/auth/latest", + "issues": "https://github.com/googleapis/google-auth-library-php/issues", + "source": "https://github.com/googleapis/google-auth-library-php/tree/v1.50.1" + }, + "time": "2026-03-18T20:03:29+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.9.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-03-10T16:41:02+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.3" + }, + "plugin-api-version": "2.9.0" +} diff --git a/plugin/itcca-allievi/includes/class-allievi-list-table.php b/plugin/itcca-allievi/includes/class-allievi-list-table.php new file mode 100644 index 0000000..44dede9 --- /dev/null +++ b/plugin/itcca-allievi/includes/class-allievi-list-table.php @@ -0,0 +1,349 @@ + 'allievo', + 'plural' => 'allievi', + 'ajax' => false, + ]); + } + + public function get_columns(): array + { + return [ + 'cb' => '', + 'stato' => __('Stato', 'itcca-allievi'), + 'cognome' => __('Cognome', 'itcca-allievi'), + 'nome' => __('Nome', 'itcca-allievi'), + 'sesso' => __('S', 'itcca-allievi'), + 'cf' => __('CF', 'itcca-allievi'), + 'centro' => __('Centro', 'itcca-allievi'), + 'r' => __('R', 'itcca-allievi'), + 'a' => __('A', 'itcca-allievi'), + 'inizio' => __('Inizio', 'itcca-allievi'), + 'cellulare' => __('Cellulare', 'itcca-allievi'), + 'email' => __('Email', 'itcca-allievi'), + 'animale' => __('Animale', 'itcca-allievi'), + 'elem' => __('Elem', 'itcca-allievi'), + 'el_sx' => __('El Sx', 'itcca-allievi'), + 'el_dx' => __('El Dx', 'itcca-allievi'), + 'pag' => __('Pag', 'itcca-allievi'), + 'ric' => __('Ric', 'itcca-allievi'), + 'actions' => __('Azioni', 'itcca-allievi'), + ]; + } + + /** + * Views Attivi/Inattivi/Tutti con conteggi (in base al meta itcca_active). + */ + public function get_views(): array + { + $counts = self::count_by_active(); + $current = self::current_view(); + $base = admin_url('admin.php?page=' . AllieviList::MENU_SLUG); + $build = static function (string $view, string $label, int $count) use ($base, $current): string { + $href = $view === 'active' ? $base : add_query_arg('view', $view, $base); + $class = $current === $view ? ' class="current"' : ''; + return sprintf( + '%s (%d)', + esc_url($href), + $class, + esc_html($label), + $count + ); + }; + return [ + 'active' => $build('active', __('Attivi', 'itcca-allievi'), $counts['active']), + 'inactive' => $build('inactive', __('Inattivi', 'itcca-allievi'), $counts['inactive']), + 'all' => $build('all', __('Tutti', 'itcca-allievi'), $counts['all']), + ]; + } + + public static function current_view(): string + { + $v = sanitize_key((string) ($_GET['view'] ?? 'active')); + return in_array($v, ['active', 'inactive', 'all'], true) ? $v : 'active'; + } + + /** + * @return array{active:int, inactive:int, all:int} + */ + private static function count_by_active(): array + { + $all = (int) (new \WP_User_Query([ + 'role' => ITCCA_ROLE, + 'count_total' => true, + 'fields' => 'ID', + 'number' => 1, + ]))->get_total(); + $inactive = (int) (new \WP_User_Query([ + 'role' => ITCCA_ROLE, + 'count_total' => true, + 'fields' => 'ID', + 'number' => 1, + 'meta_query' => [ + ['key' => ITCCA_META_PREFIX . 'active', 'value' => '0', 'compare' => '='], + ], + ]))->get_total(); + return [ + 'active' => $all - $inactive, + 'inactive' => $inactive, + 'all' => $all, + ]; + } + + public function get_sortable_columns(): array + { + return [ + 'cognome' => ['cognome', false], + 'nome' => ['nome', false], + 'cf' => ['cf', false], + 'centro' => ['centro', false], + 'inizio' => ['inizio', false], + ]; + } + + protected function get_bulk_actions(): array + { + return [ + 'sync' => __('Sincronizza con Google Sheet', 'itcca-allievi'), + 'deactivate' => __('Imposta A = D (disattivo)', 'itcca-allievi'), + 'activate' => __('Imposta A = A (attivo)', 'itcca-allievi'), + ]; + } + + public function prepare_items(): void + { + $per_page = 30; + $current_page = $this->get_pagenum(); + $orderby = sanitize_key($_GET['orderby'] ?? 'cognome'); + $order = strtolower(sanitize_key($_GET['order'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC'; + $search = sanitize_text_field((string) ($_GET['s'] ?? '')); + $filter_centro = sanitize_text_field((string) ($_GET['centro'] ?? '')); + $filter_r = sanitize_text_field((string) ($_GET['r'] ?? '')); + $filter_a = sanitize_text_field((string) ($_GET['a'] ?? '')); + $filter_inizio = sanitize_text_field((string) ($_GET['inizio'] ?? '')); + + $allowed_orderby = ['cognome', 'nome', 'cf', 'centro', 'inizio']; + if (!in_array($orderby, $allowed_orderby, true)) { + $orderby = 'cognome'; + } + + $meta_query = []; + if ($filter_centro !== '') { + $meta_query[] = ['key' => ITCCA_META_PREFIX . 'centro', 'value' => $filter_centro]; + } + if ($filter_r !== '') { + $meta_query[] = ['key' => ITCCA_META_PREFIX . 'r', 'value' => $filter_r]; + } + if ($filter_a !== '') { + $meta_query[] = ['key' => ITCCA_META_PREFIX . 'a', 'value' => $filter_a]; + } + if ($filter_inizio !== '') { + $meta_query[] = ['key' => ITCCA_META_PREFIX . 'inizio', 'value' => $filter_inizio]; + } + + // Filtro vista Attivi / Inattivi / Tutti (default Attivi). + $view = self::current_view(); + if ($view === 'active') { + // Considera "attivo" anche chi non ha ancora il meta (legacy / migrazione mancata). + $meta_query[] = [ + 'relation' => 'OR', + ['key' => ITCCA_META_PREFIX . 'active', 'value' => '1', 'compare' => '='], + ['key' => ITCCA_META_PREFIX . 'active', 'compare' => 'NOT EXISTS'], + ]; + } elseif ($view === 'inactive') { + $meta_query[] = ['key' => ITCCA_META_PREFIX . 'active', 'value' => '0', 'compare' => '=']; + } + + $args = [ + 'role' => ITCCA_ROLE, + 'number' => $per_page, + 'offset' => ($current_page - 1) * $per_page, + 'meta_key' => ITCCA_META_PREFIX . $orderby, + 'orderby' => 'meta_value', + 'order' => $order, + 'meta_query' => $meta_query ?: [], + ]; + + if ($search !== '') { + add_action('pre_user_query', [self::class, 'apply_search'], 10, 1); + $args['itcca_search'] = $search; + } + + $args = apply_filters('itcca_allievi_query_args', $args); + + $query = new \WP_User_Query($args); + $this->items = $query->get_results(); + $total = $query->get_total(); + + // Pre-popola la meta-cache di TUTTI gli utenti della pagina in 1 query + // così le get_user_meta() nelle column_* successive sono cache-hit. + if (!empty($this->items)) { + $page_ids = array_map(static function ($u) { return (int) $u->ID; }, $this->items); + update_meta_cache('user', $page_ids); + } + + remove_action('pre_user_query', [self::class, 'apply_search'], 10); + + $this->_column_headers = [ + $this->get_columns(), + [], + $this->get_sortable_columns(), + ]; + + $this->set_pagination_args([ + 'total_items' => $total, + 'per_page' => $per_page, + 'total_pages' => (int) ceil($total / $per_page), + ]); + } + + public static function apply_search(\WP_User_Query $q): void + { + $search = (string) ($q->query_vars['itcca_search'] ?? ''); + if ($search === '') { + return; + } + global $wpdb; + $like = '%' . $wpdb->esc_like($search) . '%'; + $where = $wpdb->prepare( + " AND ($wpdb->users.user_email LIKE %s OR $wpdb->users.display_name LIKE %s + OR EXISTS (SELECT 1 FROM $wpdb->usermeta um WHERE um.user_id = $wpdb->users.ID + AND um.meta_key IN ('itcca_cognome','itcca_nome','itcca_cf') + AND um.meta_value LIKE %s)) ", + $like, + $like, + $like + ); + $q->query_where .= $where; + } + + public function column_cb($item): string + { + return sprintf('', (int) $item->ID); + } + + public function column_default($item, $column_name): string + { + if ($column_name === 'stato') { + $active = (string) get_user_meta($item->ID, ITCCA_META_PREFIX . 'active', true); + $is_active = $active === '' || $active === '1'; + $class = $is_active ? 'itcca-status-active' : 'itcca-status-inactive'; + $label = $is_active ? __('Attivo', 'itcca-allievi') : __('Inattivo', 'itcca-allievi'); + return '' . esc_html($label) . ''; + } + if ($column_name === 'email') { + return esc_html((string) $item->user_email); + } + if ($column_name === 'a') { + $val = (string) get_user_meta($item->ID, ITCCA_META_PREFIX . 'a', true); + $class = $val === 'D' ? 'itcca-status-D' : 'itcca-status-A'; + return '' . esc_html($val ?: 'A') . ''; + } + if (in_array($column_name, ['pag', 'ric', 'att', 'pro', 'onl'], true)) { + $val = (string) get_user_meta($item->ID, ITCCA_META_PREFIX . $column_name, true); + return $val === '1' ? '✓' : '—'; + } + if (in_array($column_name, ['elem', 'el_sx', 'el_dx'], true)) { + $val = (string) get_user_meta($item->ID, ITCCA_META_PREFIX . $column_name, true); + if ($val === '') return '—'; + return Fields::render_swatch($val); + } + if ($column_name === 'animale') { + $val = (string) get_user_meta($item->ID, ITCCA_META_PREFIX . 'animale', true); + if ($val === '') return '—'; + $element = Fields::element_from_animale($val); + return Fields::render_swatch($element, $val); + } + $value = (string) get_user_meta($item->ID, ITCCA_META_PREFIX . $column_name, true); + return esc_html($value); + } + + public function column_cognome($item): string + { + $cognome = (string) get_user_meta($item->ID, ITCCA_META_PREFIX . 'cognome', true); + $edit = get_edit_user_link($item->ID); + return sprintf('%s', esc_url($edit), esc_html($cognome ?: $item->user_login)); + } + + public function column_actions($item): string + { + $edit = get_edit_user_link($item->ID); + $sync_url = wp_nonce_url( + admin_url('admin-post.php?action=itcca_sync_user&user_id=' . (int) $item->ID), + 'itcca_sync_user_' . (int) $item->ID + ); + return sprintf( + '%s | %s', + esc_url($edit), + esc_html__('Modifica', 'itcca-allievi'), + esc_url($sync_url), + esc_html__('Pusha su foglio', 'itcca-allievi') + ); + } + + protected function extra_tablenav($which): void + { + if ($which !== 'top') { + return; + } + $centro = sanitize_text_field((string) ($_GET['centro'] ?? '')); + $r = sanitize_text_field((string) ($_GET['r'] ?? '')); + $a = sanitize_text_field((string) ($_GET['a'] ?? '')); + $inizio = sanitize_text_field((string) ($_GET['inizio'] ?? '')); + $view = self::current_view(); + $centri = Fields::centri(); + + echo '
'; + // Mantieni la view scelta quando si applica un filtro + printf('', esc_attr($view)); + echo ''; + + echo ''; + + echo ''; + + printf( + '', + esc_attr__('Anno inizio', 'itcca-allievi'), + (int) date('Y'), + esc_attr($inizio) + ); + + submit_button(__('Filtra', 'itcca-allievi'), '', 'filter_action', false); + echo '
'; + } + + public function no_items(): void + { + esc_html_e('Nessun allievo trovato.', 'itcca-allievi'); + } +} diff --git a/plugin/itcca-allievi/includes/class-allievi-list.php b/plugin/itcca-allievi/includes/class-allievi-list.php new file mode 100644 index 0000000..6ff2f1c --- /dev/null +++ b/plugin/itcca-allievi/includes/class-allievi-list.php @@ -0,0 +1,217 @@ +prepare_items(); + + $export_url = wp_nonce_url( + admin_url('admin-post.php?action=itcca_export_csv'), + 'itcca_export_csv' + ); + $phase_a_url = admin_url('admin.php?page=itcca-allievi-phase-a'); + $bulk_push_url = wp_nonce_url( + admin_url('admin-post.php?action=itcca_bulk_push'), + 'itcca_bulk_push' + ); + + ?> +
+

+ + + + + + + + + + + + +
+ + + + views(); ?> + +
+ + ', esc_attr($current_view)); + } + $table->search_box(__('Cerca per cognome, nome, CF', 'itcca-allievi'), 'itcca-search'); + $table->display(); + ?> +
+
+ __('Sincronizzazione eseguita.', 'itcca-allievi'), + 'sync_failed' => __('Sincronizzazione fallita. Controlla i log nelle impostazioni.', 'itcca-allievi'), + 'updated' => __('Operazione eseguita.', 'itcca-allievi'), + ]; + $msg = $messages[$notice] ?? ''; + if ($msg !== '') { + $class = $notice === 'sync_failed' ? 'notice-error' : 'notice-success'; + printf('

%s

', esc_attr($class), esc_html($msg)); + } + } + + private static function handle_bulk_actions(): void + { + if (empty($_REQUEST['users']) || !is_array($_REQUEST['users'])) { + return; + } + // Defense-in-depth: il capability check è già in render_page, + // ma lo ripetiamo qui in caso questa funzione venga richiamata altrove. + if (!current_user_can('edit_users')) { + return; + } + check_admin_referer('bulk-allievi'); + + $action = sanitize_key($_REQUEST['action'] ?? ''); + if ($action === '-1' || $action === '') { + $action = sanitize_key($_REQUEST['action2'] ?? ''); + } + if (!in_array($action, ['sync', 'deactivate', 'activate'], true)) { + return; + } + + $ids = array_map('intval', (array) $_REQUEST['users']); + foreach ($ids as $id) { + if ($id <= 0) continue; + if ($action === 'deactivate') { + update_user_meta($id, ITCCA_META_PREFIX . 'a', 'D'); + } elseif ($action === 'activate') { + update_user_meta($id, ITCCA_META_PREFIX . 'a', 'A'); + } elseif ($action === 'sync') { + PhaseBPush::queue_user($id); + } + } + + wp_safe_redirect(add_query_arg('itcca_notice', 'updated', admin_url('admin.php?page=' . self::MENU_SLUG))); + exit; + } + + public static function handle_sync_single(): void + { + if (!current_user_can('edit_users')) { + wp_die(__('Permessi insufficienti.', 'itcca-allievi')); + } + $user_id = isset($_GET['user_id']) ? (int) $_GET['user_id'] : 0; + if ($user_id <= 0) { + wp_die(__('Utente non valido.', 'itcca-allievi')); + } + check_admin_referer('itcca_sync_user_' . $user_id); + + $ok = PhaseBPush::push_user($user_id); + + wp_safe_redirect(add_query_arg( + 'itcca_notice', + $ok ? 'synced' : 'sync_failed', + admin_url('admin.php?page=' . self::MENU_SLUG) + )); + exit; + } +} diff --git a/plugin/itcca-allievi/includes/class-dashboard.php b/plugin/itcca-allievi/includes/class-dashboard.php new file mode 100644 index 0000000..d125871 --- /dev/null +++ b/plugin/itcca-allievi/includes/class-dashboard.php @@ -0,0 +1,796 @@ + + */ + private const ANIMAL_PALETTE = [ + 'Topo' => '#6b7280', + 'Bue' => '#92400e', + 'Tigre' => '#f59e0b', + 'Coniglio' => '#d1bcaf', + 'Drago' => '#dc2626', + 'Serpente' => '#16a34a', + 'Cavallo' => '#7c2d12', + 'Capra' => '#94a3b8', + 'Scimmia' => '#a16207', + 'Gallo' => '#ea580c', + 'Cane' => '#365314', + 'Maiale' => '#be185d', + ]; + + public static function register(): void + { + add_action('admin_enqueue_scripts', [self::class, 'enqueue_assets']); + add_action('wp_ajax_itcca_geocode_batch_start', [self::class, 'ajax_batch_start']); + } + + public static function enqueue_assets(string $hook): void + { + if (strpos($hook, 'itcca-allievi-dashboard') === false) { + return; + } + // Leaflet (mappa) — CDN ufficiale. + wp_enqueue_style( + 'leaflet', + 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', + [], + '1.9.4' + ); + wp_enqueue_script( + 'leaflet', + 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', + [], + '1.9.4', + true + ); + wp_enqueue_script( + 'itcca-dashboard-map', + ITCCA_URL . 'assets/js/dashboard-map.js', + ['leaflet'], + ITCCA_VERSION, + true + ); + $view = self::current_view(); + wp_localize_script('itcca-dashboard-map', 'itccaMap', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('itcca_geocode_batch'), + 'view' => $view, + 'sedi' => self::map_sedi_payload(), + // La mappa mostra sempre entrambi i gruppi (attivi + inattivi) + // su layer separati: la view filtra solo KPI/torte/contatori. + 'allievi' => self::map_allievi_payload('all'), + 'strings' => [ + 'no_data' => __('Nessuna posizione disponibile.', 'itcca-allievi'), + 'geocoding' => __('Geocodifica in corso…', 'itcca-allievi'), + 'done' => __('Completata.', 'itcca-allievi'), + 'sedi' => __('Sedi attive', 'itcca-allievi'), + 'sedi_aband' => __('Sedi abbandonate', 'itcca-allievi'), + 'allievi_active' => __('Allievi attivi', 'itcca-allievi'), + 'allievi_inactive'=> __('Allievi inattivi', 'itcca-allievi'), + 'abandoned' => __('abbandonata', 'itcca-allievi'), + 'inactive' => __('inattivo', 'itcca-allievi'), + ], + ]); + } + + /** + * @return array + */ + private static function map_sedi_payload(): array + { + $out = []; + foreach (Sedi::all() as $s) { + if ($s['lat'] === null || $s['lng'] === null) continue; + $out[] = [ + 'name' => $s['name'], + 'address' => $s['address'], + 'lat' => (float) $s['lat'], + 'lng' => (float) $s['lng'], + 'abandoned' => !empty($s['abandoned']), + ]; + } + return $out; + } + + /** + * @return array + */ + private static function map_allievi_payload(string $view = 'all'): array + { + global $wpdb; + $sql = $wpdb->prepare( + "SELECT u.ID, u.display_name, + MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS cognome, + MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS nome, + MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS comune, + MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS indirizzo, + MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS lat, + MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS lng, + MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS active_flag + FROM {$wpdb->users} u + INNER JOIN {$wpdb->usermeta} m ON m.user_id = u.ID + WHERE m.meta_key IN (%s,%s,%s,%s,%s,%s,%s) + GROUP BY u.ID + HAVING lat IS NOT NULL AND lat <> '' AND lng IS NOT NULL AND lng <> ''", + ITCCA_META_PREFIX . 'cognome', + ITCCA_META_PREFIX . 'nome', + ITCCA_META_PREFIX . 'comune', + ITCCA_META_PREFIX . 'indirizzo', + ITCCA_META_PREFIX . Geocoder::META_LAT, + ITCCA_META_PREFIX . Geocoder::META_LNG, + ITCCA_META_PREFIX . 'active', + ITCCA_META_PREFIX . 'cognome', + ITCCA_META_PREFIX . 'nome', + ITCCA_META_PREFIX . 'comune', + ITCCA_META_PREFIX . 'indirizzo', + ITCCA_META_PREFIX . Geocoder::META_LAT, + ITCCA_META_PREFIX . Geocoder::META_LNG, + ITCCA_META_PREFIX . 'active' + ); + $rows = $wpdb->get_results($sql, ARRAY_A) ?: []; + $out = []; + foreach ($rows as $r) { + $active_flag = (string) ($r['active_flag'] ?? ''); + $is_active = $active_flag === '' || $active_flag === '1'; + if ($view === 'active' && !$is_active) continue; + if ($view === 'inactive' && $is_active) continue; + $name = trim(((string) $r['cognome']) . ' ' . ((string) $r['nome'])); + if ($name === '') $name = (string) $r['display_name']; + $out[] = [ + 'name' => $name, + 'address' => trim(((string) $r['indirizzo']) . ' — ' . ((string) $r['comune']), ' —'), + 'lat' => (float) $r['lat'], + 'lng' => (float) $r['lng'], + 'active' => $is_active, + ]; + } + return $out; + } + + public static function ajax_batch_start(): void + { + if (!current_user_can('edit_users')) { + wp_send_json_error(['msg' => 'forbidden'], 403); + } + check_ajax_referer('itcca_geocode_batch', 'nonce'); + $pending = Geocoder::pending_user_ids(); + wp_send_json_success(['total' => count($pending)]); + } + + public static function render_page(): void + { + if (!current_user_can('edit_users')) { + wp_die(__('Permessi insufficienti.', 'itcca-allievi')); + } + + $view = self::current_view(); + $counts = self::count_by_active(); + $ids = self::user_ids_for_view($view); + $total = count($ids); + + $counts_animale = self::tally_meta($ids, 'animale'); + $counts_elem = self::tally_meta($ids, 'elem'); + $counts_el_sx = self::tally_meta($ids, 'el_sx'); + $counts_el_dx = self::tally_meta($ids, 'el_dx'); + + echo '
'; + echo '

' . esc_html__('Dashboard Allievi ITCCA', 'itcca-allievi') . '

'; + self::render_notice(); + self::render_view_switcher($view, $counts); + + $label = match ($view) { + 'inactive' => __('Distribuzione su %d allievi inattivi.', 'itcca-allievi'), + 'all' => __('Distribuzione su %d allievi totali (attivi + inattivi).', 'itcca-allievi'), + default => __('Distribuzione su %d allievi attivi.', 'itcca-allievi'), + }; + echo '

' . sprintf(esc_html($label), $total) . '

'; + + if ($total === 0) { + echo '

' . esc_html__('Nessun allievo corrispondente alla vista selezionata.', 'itcca-allievi') . '

'; + return; + } + + $kpi = self::compute_age_kpi($ids); + $births = self::compute_births_per_month($ids); + self::render_kpi_section($kpi, $births); + + echo '
'; + self::render_chart_card( + __('Animale (Zodiaco cinese)', 'itcca-allievi'), + $counts_animale, + 'animale' + ); + self::render_chart_card( + __('Elemento', 'itcca-allievi'), + $counts_elem, + 'element' + ); + self::render_chart_card( + __('Elemento sinistro', 'itcca-allievi'), + $counts_el_sx, + 'element' + ); + self::render_chart_card( + __('Elemento destro', 'itcca-allievi'), + $counts_el_dx, + 'element' + ); + echo '
'; // .itcca-dashboard-grid + + self::render_map_section($view); + + echo ''; // .wrap + } + + private static function render_notice(): void + { + $notice = sanitize_key((string) ($_GET['itcca_notice'] ?? '')); + if ($notice === 'geocode_retry') { + $n = max(0, (int) ($_GET['cleared'] ?? 0)); + printf( + '

%s

', + esc_html(sprintf( + /* translators: %d numero di utenti reimmessi in coda */ + _n( + '%d utente rimesso in coda per la geocodifica.', + '%d utenti rimessi in coda per la geocodifica.', + $n, + 'itcca-allievi' + ), + $n + )) + ); + } + } + + public static function current_view(): string + { + $v = sanitize_key((string) ($_GET['view'] ?? 'active')); + return in_array($v, ['active', 'inactive', 'all'], true) ? $v : 'active'; + } + + /** + * @return array + */ + private static function user_ids_for_view(string $view): array + { + $args = ['role' => ITCCA_ROLE, 'fields' => ['ID']]; + if ($view === 'active') { + $args['meta_query'] = [[ + 'relation' => 'OR', + ['key' => ITCCA_META_PREFIX . 'active', 'value' => '1', 'compare' => '='], + ['key' => ITCCA_META_PREFIX . 'active', 'compare' => 'NOT EXISTS'], + ]]; + } elseif ($view === 'inactive') { + $args['meta_query'] = [[ + 'key' => ITCCA_META_PREFIX . 'active', 'value' => '0', 'compare' => '=', + ]]; + } + $users = get_users($args); + return array_map(static fn ($u) => (int) $u->ID, $users); + } + + /** + * Conteggio degli allievi pending alla geocodifica, separati per stato. + * + * @return array{active:int, inactive:int} + */ + private static function pending_split_by_active(): array + { + $pending = array_map('intval', Geocoder::pending_user_ids()); + $out = ['active' => 0, 'inactive' => 0]; + if (empty($pending)) return $out; + // Prime user meta cache: una query, evita N letture singole. + update_meta_cache('user', $pending); + foreach ($pending as $uid) { + $flag = (string) get_user_meta($uid, ITCCA_META_PREFIX . 'active', true); + if ($flag === '0') $out['inactive']++; + else $out['active']++; + } + return $out; + } + + /** + * @return array{active:int, inactive:int, all:int} + */ + private static function count_by_active(): array + { + $all = (int) (new \WP_User_Query([ + 'role' => ITCCA_ROLE, 'count_total' => true, 'fields' => 'ID', 'number' => 1, + ]))->get_total(); + $inactive = (int) (new \WP_User_Query([ + 'role' => ITCCA_ROLE, + 'count_total' => true, + 'fields' => 'ID', + 'number' => 1, + 'meta_query' => [ + ['key' => ITCCA_META_PREFIX . 'active', 'value' => '0', 'compare' => '='], + ], + ]))->get_total(); + return [ + 'active' => $all - $inactive, + 'inactive' => $inactive, + 'all' => $all, + ]; + } + + /** + * @param array{active:int, inactive:int, all:int} $counts + */ + private static function render_view_switcher(string $current, array $counts): void + { + $base = admin_url('admin.php?page=itcca-allievi-dashboard'); + $items = [ + 'active' => [__('Attivi', 'itcca-allievi'), $counts['active']], + 'inactive' => [__('Inattivi', 'itcca-allievi'), $counts['inactive']], + 'all' => [__('Tutti', 'itcca-allievi'), $counts['all']], + ]; + echo '
    '; + $links = []; + foreach ($items as $view => $info) { + $href = $view === 'active' ? $base : add_query_arg('view', $view, $base); + $class = $current === $view ? ' class="current"' : ''; + $links[] = sprintf( + '
  • %s (%d)
  • ', + esc_url($href), + $class, + esc_html((string) $info[0]), + (int) $info[1] + ); + } + echo implode(' | ', $links); + echo '

'; + } + + private static function render_map_section(string $view = 'active'): void + { + $pending_total = count(Geocoder::pending_user_ids()); + $pending_split = self::pending_split_by_active(); + $allievi_payload = self::map_allievi_payload('all'); + $geocoded_active = 0; + $geocoded_inactive = 0; + foreach ($allievi_payload as $a) { + if (!empty($a['active'])) $geocoded_active++; + else $geocoded_inactive++; + } + $sedi_count = count(self::map_sedi_payload()); + $failed = Geocoder::failed_counts(); + + echo '
'; + echo '

' . esc_html__('Distribuzione geografica', 'itcca-allievi') . '

'; + echo '

' + . sprintf( + esc_html__('%1$d sedi · %2$d allievi attivi e %3$d inattivi posizionati. %4$d ancora da geocodificare (%5$d attivi + %6$d inattivi).', 'itcca-allievi'), + $sedi_count, + $geocoded_active, + $geocoded_inactive, + $pending_total, + $pending_split['active'], + $pending_split['inactive'] + ) . '

'; + + if ($failed['transient'] > 0 || $failed['permanent'] > 0) { + echo '

'; + printf( + esc_html__('Geocodifiche fallite: %1$d transitorie (es. errori di rete/SSL), %2$d permanenti (indirizzo non trovato).', 'itcca-allievi'), + $failed['transient'], + $failed['permanent'] + ); + echo '

'; + } + + echo '
'; + echo ''; + + if ($failed['transient'] > 0) { + $retry_url = wp_nonce_url( + admin_url('admin-post.php?action=itcca_geocode_retry_failed&mode=transient'), + 'itcca_geocode_retry_failed' + ); + echo ' ' + . esc_html__('Riprova falliti transitori', 'itcca-allievi') . ''; + } + if ($failed['permanent'] > 0 || $failed['transient'] > 0) { + $retry_all_url = wp_nonce_url( + admin_url('admin-post.php?action=itcca_geocode_retry_failed&mode=all'), + 'itcca_geocode_retry_failed' + ); + echo ' ' + . esc_html__('Riprova tutti i falliti', 'itcca-allievi') . ''; + } + + echo ''; + echo '
'; + + echo '
'; + + echo '

' + . esc_html__('Mappa © OpenStreetMap contributors. Posizioni ricavate via Nominatim, geocodifica limitata a 1 chiamata/sec.', 'itcca-allievi') + . '

'; + echo '
'; + } + + /** + * @param array $user_ids + * @return array{ + * count:int, + * avg:?float, + * min:?array{age:int,user_id:int,name:string}, + * max:?array{age:int,user_id:int,name:string}, + * } + */ + private static function compute_age_kpi(array $user_ids): array + { + if (empty($user_ids)) { + return ['count' => 0, 'avg' => null, 'min' => null, 'max' => null]; + } + global $wpdb; + $placeholders = implode(',', array_fill(0, count($user_ids), '%d')); + $key = ITCCA_META_PREFIX . 'nascita_data'; + $sql = $wpdb->prepare( + "SELECT user_id, meta_value FROM {$wpdb->usermeta} + WHERE meta_key = %s AND user_id IN ($placeholders) AND meta_value <> ''", + array_merge([$key], $user_ids) + ); + $rows = $wpdb->get_results($sql, ARRAY_A) ?: []; + + $today = new \DateTimeImmutable('today'); + $ages = []; + $min = null; + $max = null; + foreach ($rows as $r) { + try { + $d = new \DateTimeImmutable((string) $r['meta_value']); + } catch (\Exception) { + continue; + } + if ($d > $today) continue; + $age = (int) $today->diff($d)->y; + if ($age < 0 || $age > 130) continue; + $uid = (int) $r['user_id']; + $ages[$uid] = $age; + if ($min === null || $age < $min['age']) $min = ['age' => $age, 'user_id' => $uid, 'name' => '']; + if ($max === null || $age > $max['age']) $max = ['age' => $age, 'user_id' => $uid, 'name' => '']; + } + if (empty($ages)) { + return ['count' => 0, 'avg' => null, 'min' => null, 'max' => null]; + } + if ($min !== null) $min['name'] = self::display_name((int) $min['user_id']); + if ($max !== null) $max['name'] = self::display_name((int) $max['user_id']); + + return [ + 'count' => count($ages), + 'avg' => array_sum($ages) / count($ages), + 'min' => $min, + 'max' => $max, + ]; + } + + /** + * @param array $user_ids + * @return array index 1..12 → count + */ + private static function compute_births_per_month(array $user_ids): array + { + $out = array_fill(1, 12, 0); + if (empty($user_ids)) return $out; + global $wpdb; + $placeholders = implode(',', array_fill(0, count($user_ids), '%d')); + $key = ITCCA_META_PREFIX . 'nascita_data'; + $sql = $wpdb->prepare( + "SELECT meta_value FROM {$wpdb->usermeta} + WHERE meta_key = %s AND user_id IN ($placeholders) AND meta_value <> ''", + array_merge([$key], $user_ids) + ); + $rows = $wpdb->get_col($sql) ?: []; + foreach ($rows as $v) { + try { + $d = new \DateTimeImmutable((string) $v); + } catch (\Exception) { + continue; + } + $m = (int) $d->format('n'); + if ($m >= 1 && $m <= 12) $out[$m]++; + } + return $out; + } + + private static function display_name(int $user_id): string + { + $cognome = (string) get_user_meta($user_id, ITCCA_META_PREFIX . 'cognome', true); + $nome = (string) get_user_meta($user_id, ITCCA_META_PREFIX . 'nome', true); + $full = trim($cognome . ' ' . $nome); + if ($full !== '') return $full; + $u = get_user_by('id', $user_id); + return $u instanceof \WP_User ? (string) $u->display_name : ''; + } + + /** + * @param array{count:int, avg:?float, min:?array, max:?array} $kpi + * @param array $births + */ + private static function render_kpi_section(array $kpi, array $births): void + { + echo '
'; + + // Card età media + echo '
'; + echo '
' . esc_html__('Età media', 'itcca-allievi') . '
'; + if ($kpi['avg'] !== null) { + echo '
' . esc_html(number_format($kpi['avg'], 1, ',', '')) . '
'; + echo '
' . sprintf( + esc_html__('su %d allievi con data di nascita', 'itcca-allievi'), + (int) $kpi['count'] + ) . '
'; + } else { + echo '
'; + echo '
' . esc_html__('nessuna data di nascita disponibile', 'itcca-allievi') . '
'; + } + echo '
'; + + // Card età minima + echo '
'; + echo '
' . esc_html__('Più giovane', 'itcca-allievi') . '
'; + if (!empty($kpi['min'])) { + $edit = get_edit_user_link((int) $kpi['min']['user_id']); + echo '
' . (int) $kpi['min']['age'] . '' . esc_html__('anni', 'itcca-allievi') . '
'; + echo ''; + } else { + echo '
'; + } + echo '
'; + + // Card età massima + echo '
'; + echo '
' . esc_html__('Più anziano', 'itcca-allievi') . '
'; + if (!empty($kpi['max'])) { + $edit = get_edit_user_link((int) $kpi['max']['user_id']); + echo '
' . (int) $kpi['max']['age'] . '' . esc_html__('anni', 'itcca-allievi') . '
'; + echo ''; + } else { + echo '
'; + } + echo '
'; + + // Card grafico nascite per mese + echo '
'; + echo '
' . esc_html__('Nascite per mese', 'itcca-allievi') . '
'; + echo self::render_births_bar_chart($births); + echo '
'; + + echo '
'; // .itcca-kpi-grid + } + + /** + * @param array $births + */ + private static function render_births_bar_chart(array $births): string + { + $months = ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic']; + $max = max(1, max($births)); + $w = 280; $h = 110; + $bar_w = $w / 12; + $top_pad = 16; $bottom_pad = 18; + $svg = ''; + for ($m = 1; $m <= 12; $m++) { + $count = (int) $births[$m]; + $bh = $count > 0 ? max(2, ($count / $max) * ($h - $top_pad - $bottom_pad)) : 0; + $x = ($m - 1) * $bar_w + 1.5; + $bw = max(1, $bar_w - 3); + $y = $h - $bottom_pad - $bh; + $svg .= sprintf( + '%s: %d', + $x, + $y, + $bw, + $bh, + esc_html($months[$m - 1]), + $count + ); + if ($count > 0) { + $svg .= sprintf( + '%d', + $x + $bw / 2, + $y - 2, + $count + ); + } + $svg .= sprintf( + '%s', + $x + $bw / 2, + $h - 4, + esc_html($months[$m - 1]) + ); + } + $svg .= ''; + return $svg; + } + + /** + * @param array $user_ids + * @return array + */ + private static function tally_meta(array $user_ids, string $meta_suffix): array + { + if (empty($user_ids)) return []; + global $wpdb; + $key = ITCCA_META_PREFIX . $meta_suffix; + $placeholders = implode(',', array_fill(0, count($user_ids), '%d')); + $params = array_merge([$key], $user_ids); + $sql = $wpdb->prepare( + "SELECT meta_value, COUNT(*) AS n + FROM {$wpdb->usermeta} + WHERE meta_key = %s AND user_id IN ($placeholders) AND meta_value <> '' + GROUP BY meta_value", + $params + ); + $rows = $wpdb->get_results($sql, ARRAY_A) ?: []; + $out = []; + foreach ($rows as $r) { + $val = trim((string) $r['meta_value']); + if ($val === '') continue; + $out[$val] = (int) $r['n']; + } + arsort($out); + return $out; + } + + /** + * @param array $counts + * @param 'animale'|'element' $palette + */ + private static function render_chart_card(string $title, array $counts, string $palette): void + { + $total = array_sum($counts); + + echo '
'; + echo '

' . esc_html($title) . '

'; + + if ($total === 0) { + echo '

' + . esc_html__('Nessun dato per questo campo.', 'itcca-allievi') + . '

'; + return; + } + + $slices = self::build_slices($counts, $total, $palette); + + echo '
'; + echo self::render_pie_svg($slices); + echo '
    '; + foreach ($slices as $slice) { + printf( + '
  1. ' + . '%s' + . '%d (%s%%)
  2. ', + esc_attr($slice['color']), + esc_html($slice['label']), + $slice['count'], + esc_html(number_format($slice['pct'], 1, ',', '')) + ); + } + echo '
'; + echo '
'; + } + + /** + * @param array $counts + * @param 'animale'|'element' $palette + * @return array + */ + private static function build_slices(array $counts, int $total, string $palette): array + { + $slices = []; + $fallback_palette = ['#6366f1', '#0ea5e9', '#14b8a6', '#84cc16', '#eab308', '#f97316', '#ec4899', '#a855f7']; + $i = 0; + foreach ($counts as $value => $count) { + $color = ''; + if ($palette === 'element') { + $color = Fields::element_color($value); + } elseif ($palette === 'animale') { + $animal = explode(' di ', $value)[0] ?? ''; + $color = self::ANIMAL_PALETTE[$animal] ?? ''; + } + if ($color === '') { + $color = $fallback_palette[$i % count($fallback_palette)]; + } + $i++; + $slices[] = [ + 'label' => $value, + 'count' => $count, + 'pct' => $total > 0 ? ($count / $total) * 100 : 0.0, + 'color' => $color, + ]; + } + return $slices; + } + + /** + * @param array $slices + */ + private static function render_pie_svg(array $slices): string + { + $size = 240; + $r = 110; + $cx = $size / 2; + $cy = $size / 2; + + // Caso speciale: singola fetta = cerchio pieno (gli archi a 360° sono degeneri). + if (count($slices) === 1) { + return sprintf( + '' + . '' + . '', + $size, + $size, + $size, + $size, + esc_attr__('Grafico a torta', 'itcca-allievi'), + $cx, + $cy, + $r, + esc_attr($slices[0]['color']) + ); + } + + $paths = ''; + $angle_start = -M_PI / 2; // partiamo in alto (12 in punto) + $total = array_sum(array_column($slices, 'count')); + foreach ($slices as $slice) { + $portion = $total > 0 ? $slice['count'] / $total : 0; + $angle_end = $angle_start + ($portion * 2 * M_PI); + $x1 = $cx + $r * cos($angle_start); + $y1 = $cy + $r * sin($angle_start); + $x2 = $cx + $r * cos($angle_end); + $y2 = $cy + $r * sin($angle_end); + $large_arc = $portion > 0.5 ? 1 : 0; + $d = sprintf( + 'M%.3f,%.3f L%.3f,%.3f A%d,%d 0 %d 1 %.3f,%.3f Z', + $cx, + $cy, + $x1, + $y1, + $r, + $r, + $large_arc, + $x2, + $y2 + ); + $paths .= sprintf( + '%s — %d (%s%%)', + esc_attr($d), + esc_attr($slice['color']), + esc_html($slice['label']), + $slice['count'], + esc_html(number_format($slice['pct'], 1, ',', '')) + ); + $angle_start = $angle_end; + } + return sprintf( + '%s', + $size, + $size, + $size, + $size, + esc_attr__('Grafico a torta', 'itcca-allievi'), + $paths + ); + } +} diff --git a/plugin/itcca-allievi/includes/class-export-csv.php b/plugin/itcca-allievi/includes/class-export-csv.php new file mode 100644 index 0000000..b29b7a3 --- /dev/null +++ b/plugin/itcca-allievi/includes/class-export-csv.php @@ -0,0 +1,246 @@ + ITCCA_ROLE, 'orderby' => 'meta_value', 'meta_key' => ITCCA_META_PREFIX . 'cognome']); + foreach ($users as $user) { + $row = []; + foreach ($header as $col) { + $field = Fields::by_csv($col); + $row[] = $field === null ? '' : Fields::read_value($field, $user); + } + fputcsv($out, $row); + } + fclose($out); + exit; + } + + /** + * Header del CSV INSARRI: esclude i campi rimossi dallo schema (i loro dati + * restano in user_meta ma non vengono esportati). + * + * @return array + */ + private static function csv_header(): array + { + $cols = []; + foreach (Fields::all() as $f) { + if (!empty($f['deleted'])) continue; + $csv = (string) ($f['csv'] ?? ''); + if ($csv === '') continue; + $cols[] = $csv; + } + return $cols; + } + + /** + * WP-CLI: import CSV in formato INSARRI come allievi. + * Usage: wp itcca import-csv /path/to/INSARRI.csv [--dry-run] + */ + public static function cli_import(array $args, array $assoc): void + { + if (!defined('WP_CLI') || !WP_CLI) return; + $path = $args[0] ?? ''; + if ($path === '' || !file_exists($path)) { + \WP_CLI::error('File non trovato: ' . $path); + } + $dry = !empty($assoc['dry-run']); + + $fh = fopen($path, 'r'); + if ($fh === false) { + \WP_CLI::error('Impossibile aprire il file.'); + } + $header = fgetcsv($fh); + if (!is_array($header)) { + \WP_CLI::error('Header CSV mancante.'); + } + + $col_idx = []; + foreach ($header as $i => $h) { + $field = Fields::by_csv((string) $h); + if ($field) $col_idx[$field['key']] = $i; + } + if (!isset($col_idx['cf'])) { + \WP_CLI::error('Colonna CF mancante.'); + } + + $created = 0; $updated = 0; $skipped = 0; + while (($row = fgetcsv($fh)) !== false) { + $data = []; + foreach ($col_idx as $key => $idx) { + $data[$key] = (string) ($row[$idx] ?? ''); + } + $cf = Validators::normalize_cf((string) ($data['cf'] ?? '')); + if ($cf === '' || !Validators::is_valid_cf($cf)) { + $skipped++; + continue; + } + $existing = get_users([ + 'meta_key' => ITCCA_META_PREFIX . 'cf', + 'meta_value' => $cf, + 'fields' => 'ID', + 'number' => 1, + ]); + if (!empty($existing)) { + $uid = (int) $existing[0]; + if (!$dry) self::apply_row($uid, $data); + $updated++; + continue; + } + if ($dry) { + $created++; + continue; + } + $uid = self::create_user_from_row($data); + if ($uid > 0) $created++; + else $skipped++; + } + fclose($fh); + \WP_CLI::success(sprintf('Creati: %d, aggiornati: %d, saltati: %d%s', $created, $updated, $skipped, $dry ? ' (dry-run)' : '')); + } + + public static function cli_export(array $args, array $assoc): void + { + if (!defined('WP_CLI') || !WP_CLI) return; + $path = $args[0] ?? ''; + if ($path === '') { + \WP_CLI::error('Specifica il path di output.'); + } + $fh = fopen($path, 'w'); + if ($fh === false) { + \WP_CLI::error('Impossibile scrivere su ' . $path); + } + fwrite($fh, "\xEF\xBB\xBF"); + $header = self::csv_header(); + fputcsv($fh, $header); + $users = get_users(['role' => ITCCA_ROLE]); + foreach ($users as $user) { + $row = []; + foreach ($header as $col) { + $field = Fields::by_csv($col); + $row[] = $field === null ? '' : Fields::read_value($field, $user); + } + fputcsv($fh, $row); + } + fclose($fh); + \WP_CLI::success('Export scritto in ' . $path); + } + + private static function apply_row(int $user_id, array $data): void + { + foreach (Fields::storable() as $field) { + $key = $field['key']; + if (!array_key_exists($key, $data)) continue; + $val = self::normalize_for_storage($field, (string) $data[$key]); + if (!empty($field['native'])) { + if ($field['key'] === 'user_email' && $val !== '') { + wp_update_user(['ID' => $user_id, 'user_email' => $val]); + } + continue; + } + $mk = Fields::meta_key($field); + if ($val === '' && $field['type'] !== 'flag') { + delete_user_meta($user_id, $mk); + } else { + update_user_meta($user_id, $mk, $val); + } + } + } + + private static function create_user_from_row(array $data): int + { + $email = sanitize_email((string) ($data['user_email'] ?? '')); + $cf = Validators::normalize_cf((string) ($data['cf'] ?? '')); + if ($email === '') { + $email = strtolower($cf) . '@no-email.itcca.local'; + } + if (email_exists($email)) { + return 0; + } + $login_base = sanitize_user( + sanitize_title(remove_accents(((string) ($data['nome'] ?? '')) . '.' . ((string) ($data['cognome'] ?? '')))), + true + ); + if ($login_base === '') $login_base = strtolower($cf) ?: 'allievo'; + $login = $login_base; + $i = 1; + while (username_exists($login)) { + $login = $login_base . $i; + $i++; + } + $uid = wp_insert_user([ + 'user_login' => $login, + 'user_email' => $email, + 'user_pass' => wp_generate_password(20, true, true), + 'first_name' => (string) ($data['nome'] ?? ''), + 'last_name' => (string) ($data['cognome'] ?? ''), + 'display_name' => trim(((string) ($data['nome'] ?? '')) . ' ' . ((string) ($data['cognome'] ?? ''))), + 'role' => ITCCA_ROLE, + ]); + if (is_wp_error($uid)) return 0; + self::apply_row((int) $uid, $data); + return (int) $uid; + } + + private static function normalize_for_storage(array $field, string $value): string + { + $value = trim($value); + if ($value === '') return ''; + switch ($field['type']) { + case 'date': + $d = Validators::parse_date($value); + return $d ? $d->format('Y-m-d') : ''; + case 'cf': + return Validators::normalize_cf($value); + case 'cap': + return preg_replace('/\D/', '', $value) ?? ''; + case 'tel': + return Validators::normalize_phone($value); + case 'flag': + $v = strtolower($value); + return in_array($v, ['1', 'x', 'sì', 'si', 'yes', 'true'], true) ? '1' : '0'; + case 'decimal': + $n = (float) str_replace([',', '€', ' '], ['.', '', ''], $value); + return number_format($n, 2, '.', ''); + default: + return sanitize_text_field($value); + } + } +} diff --git a/plugin/itcca-allievi/includes/class-fields.php b/plugin/itcca-allievi/includes/class-fields.php new file mode 100644 index 0000000..11af086 --- /dev/null +++ b/plugin/itcca-allievi/includes/class-fields.php @@ -0,0 +1,851 @@ + + */ + public static function sections(): array + { + return [ + self::SECTION_ANAGRAFICA => __('Anagrafica', 'itcca-allievi'), + self::SECTION_RESIDENZA => __('Residenza', 'itcca-allievi'), + self::SECTION_CONTATTI => __('Contatti', 'itcca-allievi'), + self::SECTION_TESSERAMENTO => __('Tesseramento', 'itcca-allievi'), + self::SECTION_QUOTE => __('Quote', 'itcca-allievi'), + self::SECTION_PAGAMENTO => __('Ricevuta / Pagamento', 'itcca-allievi'), + self::SECTION_STATO => __('Stato', 'itcca-allievi'), + self::SECTION_UISP_CARD => __('UISP card', 'itcca-allievi'), + self::SECTION_ALTRO => __('Altro / Note', 'itcca-allievi'), + ]; + } + + public const OPT_SCHEMA_OVERRIDES = 'itcca_schema_overrides'; + + /** + * Restituisce il registro corrente, baseline + overrides applicati. + * + * @return array> + */ + public static function all(): array + { + return self::apply_overrides(self::baseline()); + } + + /** + * @return array + */ + public static function overrides(): array + { + $raw = get_option(self::OPT_SCHEMA_OVERRIDES, []); + if (!is_array($raw)) return ['renames' => [], 'deletes' => [], 'additions' => []]; + return [ + 'renames' => is_array($raw['renames'] ?? null) ? $raw['renames'] : [], + 'deletes' => is_array($raw['deletes'] ?? null) ? $raw['deletes'] : [], + 'additions' => is_array($raw['additions'] ?? null) ? $raw['additions'] : [], + ]; + } + + public static function save_overrides(array $overrides): void + { + $clean = [ + 'renames' => is_array($overrides['renames'] ?? null) ? $overrides['renames'] : [], + 'deletes' => is_array($overrides['deletes'] ?? null) ? $overrides['deletes'] : [], + 'additions' => is_array($overrides['additions'] ?? null) ? $overrides['additions'] : [], + ]; + update_option(self::OPT_SCHEMA_OVERRIDES, $clean, false); + } + + public static function is_deleted(array $field): bool + { + return !empty($field['deleted']); + } + + /** + * Applica renames/deletes/additions al set baseline e restituisce il registro effettivo. + * + * @param array> $base + * @return array> + */ + private static function apply_overrides(array $base): array + { + $ov = self::overrides(); + $result = []; + + foreach ($base as $field) { + $key = $field['key']; + + // Rename: aggiorna csv (la meta key WP resta invariata) + if (isset($ov['renames'][$key]['csv'])) { + $field['csv'] = (string) $ov['renames'][$key]['csv']; + } + + // Delete: marca campo, prefissa label e impedisci di considerarlo pubblico + if (isset($ov['deletes'][$key])) { + $field['deleted'] = true; + $field['deleted_at'] = (string) ($ov['deletes'][$key]['deleted_at'] ?? ''); + $field['label'] = 'deleted_' . $field['label']; + $field['public'] = false; + $field['required'] = false; + } + + $result[] = $field; + } + + // Additions: campi creati a runtime non presenti in baseline + foreach ($ov['additions'] as $key => $cfg) { + if (!is_array($cfg)) continue; + $result[] = [ + 'key' => (string) $key, + 'csv' => (string) ($cfg['csv'] ?? $key), + 'label' => (string) ($cfg['label'] ?? $key), + 'type' => (string) ($cfg['type'] ?? 'text'), + 'section' => (string) ($cfg['section'] ?? self::SECTION_ALTRO), + 'public' => !empty($cfg['public']), + 'required' => !empty($cfg['required']), + 'added_at' => (string) ($cfg['added_at'] ?? ''), + ]; + } + + return $result; + } + + /** + * Registro baseline cablato (INSARRI standard). Fonte di verità per: + * - sezioni di edit utente + * - tabella admin "Allievi ITCCA" + * - form pubblico [itcca_iscrizione] + * - mapping verso Google Sheet + * - export CSV INSARRI + * + * Ogni campo: + * key meta key in user_meta (senza prefisso 'itcca_'), + * oppure 'user_email' / 'user_login' per riferimenti nativi WP. + * csv intestazione esatta della colonna CSV INSARRI + * label etichetta UI + * type text | textarea | date | select | radio | email | tel | number | + * decimal | year | flag | cf | cap | calculated + * section una delle Fields::SECTION_* + * public visibile nel form pubblico [itcca_iscrizione] + * required obbligatorio (sia nel form pubblico se public=true, sia + * in admin se public=false e required=true) + * options array di valori ammessi (radio/select) + * default valore di default + * calculated true se derivato a runtime (non salvato in user_meta) + * help testo di aiuto (opzionale) + * + * @return array> + */ + public static function baseline(): array + { + return [ + [ + 'key' => 'ins', + 'csv' => 'INS', + 'label' => __('Insegnante', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_ANAGRAFICA, + 'default' => 'INSARRI', + 'help' => __('Sigla insegnante. Lasciare INSARRI per Fabio Arrigoni.', 'itcca-allievi'), + ], + [ + 'key' => 'cognome', + 'csv' => 'Cognome', + 'label' => __('Cognome', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_ANAGRAFICA, + 'public' => true, + 'required' => true, + ], + [ + 'key' => 'nome', + 'csv' => 'Nome', + 'label' => __('Nome', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_ANAGRAFICA, + 'public' => true, + 'required' => true, + ], + [ + 'key' => 'sesso', + 'csv' => 'S', + 'label' => __('Sesso', 'itcca-allievi'), + 'type' => 'radio', + 'section' => self::SECTION_ANAGRAFICA, + 'public' => true, + 'required' => true, + 'options' => ['M' => 'M', 'F' => 'F'], + ], + [ + 'key' => 'nascita_data', + 'csv' => 'Nascita', + 'label' => __('Data di nascita', 'itcca-allievi'), + 'type' => 'date', + 'section' => self::SECTION_ANAGRAFICA, + 'public' => true, + 'required' => true, + ], + [ + 'key' => 'eta', + 'csv' => 'X', + 'label' => __('Età', 'itcca-allievi'), + 'type' => 'calculated', + 'section' => self::SECTION_ANAGRAFICA, + 'calculated' => true, + 'help' => __('Calcolata automaticamente da Data di nascita.', 'itcca-allievi'), + ], + [ + 'key' => 'nascita_luogo', + 'csv' => 'Luogonascita', + 'label' => __('Comune di nascita', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_ANAGRAFICA, + 'public' => true, + 'required' => true, + 'help' => __('Es. "Osio Sotto-BG"', 'itcca-allievi'), + ], + [ + 'key' => 'cf', + 'csv' => 'CF', + 'label' => __('Codice Fiscale', 'itcca-allievi'), + 'type' => 'cf', + 'section' => self::SECTION_ANAGRAFICA, + 'public' => true, + 'required' => true, + ], + + [ + 'key' => 'indirizzo', + 'csv' => 'Indirizzo', + 'label' => __('Indirizzo di residenza', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_RESIDENZA, + 'public' => true, + 'required' => true, + ], + [ + 'key' => 'civico', + 'csv' => 'Civ', + 'label' => __('Civico', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_RESIDENZA, + 'public' => true, + 'required' => true, + ], + [ + 'key' => 'comune', + 'csv' => 'Comune residenza', + 'label' => __('Comune di residenza', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_RESIDENZA, + 'public' => true, + 'required' => true, + ], + [ + 'key' => 'cap', + 'csv' => 'Cap', + 'label' => __('CAP', 'itcca-allievi'), + 'type' => 'cap', + 'section' => self::SECTION_RESIDENZA, + 'public' => true, + 'required' => true, + ], + + [ + 'key' => 'user_email', + 'csv' => 'Email', + 'label' => __('Email', 'itcca-allievi'), + 'type' => 'email', + 'section' => self::SECTION_CONTATTI, + 'public' => true, + 'required' => true, + 'native' => true, + ], + [ + 'key' => 'cellulare', + 'csv' => 'Cellulare', + 'label' => __('Cellulare', 'itcca-allievi'), + 'type' => 'tel', + 'section' => self::SECTION_CONTATTI, + 'public' => true, + 'required' => true, + ], + + [ + 'key' => 'sede_u', + 'csv' => 'SedeU', + 'label' => __('Sede UISP', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_TESSERAMENTO, + ], + [ + 'key' => 'centro', + 'csv' => 'Centro', + 'label' => __('Centro', 'itcca-allievi'), + 'type' => 'select', + 'section' => self::SECTION_TESSERAMENTO, + 'help' => __('Sede del corso a cui partecipa l\'allievo (gestita dalle impostazioni).', 'itcca-allievi'), + ], + [ + 'key' => 'ruolo', + 'csv' => 'Ruolo', + 'label' => __('Ruolo', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_TESSERAMENTO, + ], + [ + 'key' => 'grado', + 'csv' => 'Grado', + 'label' => __('Grado', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_TESSERAMENTO, + ], + [ + 'key' => 'inizio', + 'csv' => 'Inizio', + 'label' => __('Anno inizio pratica', 'itcca-allievi'), + 'type' => 'year', + 'section' => self::SECTION_TESSERAMENTO, + ], + [ + 'key' => 'anni_pratica', + 'csv' => 'Anno', + 'label' => __('Anni di pratica', 'itcca-allievi'), + 'type' => 'calculated', + 'section' => self::SECTION_TESSERAMENTO, + 'calculated' => true, + 'help' => __('Calcolato automaticamente da Anno inizio pratica.', 'itcca-allievi'), + ], + + [ + 'key' => 'quota_uisp', + 'csv' => 'Uisp', + 'label' => __('Quota UISP', 'itcca-allievi'), + 'type' => 'decimal', + 'section' => self::SECTION_QUOTE, + ], + [ + 'key' => 'quota_itcca', + 'csv' => 'Itcca', + 'label' => __('Quota ITCCA', 'itcca-allievi'), + 'type' => 'decimal', + 'section' => self::SECTION_QUOTE, + ], + [ + 'key' => 'quota_ado', + 'csv' => 'Ado', + 'label' => __('Quota ADO', 'itcca-allievi'), + 'type' => 'decimal', + 'section' => self::SECTION_QUOTE, + ], + + [ + 'key' => 'pag_chi', + 'csv' => 'Chi', + 'label' => __('Pagamento a (chi ha incassato)', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_PAGAMENTO, + ], + [ + 'key' => 'pag_il', + 'csv' => 'Il', + 'label' => __('Data pagamento', 'itcca-allievi'), + 'type' => 'date', + 'section' => self::SECTION_PAGAMENTO, + ], + [ + 'key' => 'ric_il', + 'csv' => 'Ric il', + 'label' => __('Data ricevuta', 'itcca-allievi'), + 'type' => 'date', + 'section' => self::SECTION_PAGAMENTO, + ], + [ + 'key' => 'ric_n', + 'csv' => 'N#', + 'label' => __('Numero ricevuta', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_PAGAMENTO, + ], + [ + 'key' => 'att', + 'csv' => 'Att', + 'label' => __('Attestato', 'itcca-allievi'), + 'type' => 'flag', + 'section' => self::SECTION_PAGAMENTO, + ], + [ + 'key' => 'pag', + 'csv' => 'Pag', + 'label' => __('Pagato', 'itcca-allievi'), + 'type' => 'flag', + 'section' => self::SECTION_PAGAMENTO, + ], + [ + 'key' => 'pro', + 'csv' => 'Pro', + 'label' => __('Protocollato', 'itcca-allievi'), + 'type' => 'flag', + 'section' => self::SECTION_PAGAMENTO, + ], + [ + 'key' => 'ric', + 'csv' => 'Ric', + 'label' => __('Ricevuta emessa', 'itcca-allievi'), + 'type' => 'flag', + 'section' => self::SECTION_PAGAMENTO, + ], + [ + 'key' => 'onl', + 'csv' => 'Onl', + 'label' => __('Online', 'itcca-allievi'), + 'type' => 'flag', + 'section' => self::SECTION_PAGAMENTO, + ], + + [ + 'key' => 'r', + 'csv' => 'R', + 'label' => __('R / N (Rinnovo / Nuovo)', 'itcca-allievi'), + 'type' => 'select', + 'section' => self::SECTION_STATO, + 'options' => ['R' => 'R - Rinnovo', 'N' => 'N - Nuovo'], + 'default' => 'N', + ], + [ + 'key' => 'a', + 'csv' => 'A', + 'label' => __('A / D (Attivo / Disattivo)', 'itcca-allievi'), + 'type' => 'select', + 'section' => self::SECTION_STATO, + 'options' => ['A' => 'A - Attivo', 'D' => 'D - Disattivo'], + 'default' => 'A', + ], + [ + // Stato attivo/inattivo derivato dal tab del foglio (INSARRI vs ZZZ). + // Volutamente senza 'csv': non mappato sul foglio e non esportato. + 'key' => 'active', + 'label' => __('Allievo attivo (stato derivato dal tab del foglio)', 'itcca-allievi'), + 'type' => 'flag', + 'section' => self::SECTION_STATO, + 'default' => '1', + 'help' => __('Si imposta automaticamente in Fase A: riga sul tab attivi → 1, sul tab ZZZ → 0. Togliendo qui il flag non si sposta la riga sul foglio: spostala manualmente in ZZZ e rilancia Fase A per riallineare.', 'itcca-allievi'), + ], + + [ + 'key' => 'uisp_n', + 'csv' => 'UispN', + 'label' => __('Numero card UISP', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_UISP_CARD, + ], + [ + 'key' => 'uisp_d', + 'csv' => 'UispD', + 'label' => __('Data card UISP', 'itcca-allievi'), + 'type' => 'date', + 'section' => self::SECTION_UISP_CARD, + ], + + [ + 'key' => 'doc', + 'csv' => 'Doc', + 'label' => __('Documento', 'itcca-allievi'), + 'type' => 'text', + 'section' => self::SECTION_ALTRO, + ], + [ + 'key' => 'animale', + 'csv' => 'Animale', + 'label' => __('Animale (Zodiaco cinese)', 'itcca-allievi'), + 'type' => 'select', + 'section' => self::SECTION_ALTRO, + 'options' => self::zodiac_combinations(), + 'help' => __('Precompilato in base alla data di nascita (puoi cambiarlo a mano).', 'itcca-allievi'), + ], + [ + 'key' => 'elem', + 'csv' => 'Elem', + 'label' => __('Elemento', 'itcca-allievi'), + 'type' => 'select', + 'section' => self::SECTION_ALTRO, + 'options' => self::elements_options(), + ], + [ + 'key' => 'el_sx', + 'csv' => 'El Sx', + 'label' => __('Elemento sinistro', 'itcca-allievi'), + 'type' => 'select', + 'section' => self::SECTION_ALTRO, + 'options' => self::elements_options(), + ], + [ + 'key' => 'el_dx', + 'csv' => 'El Dx', + 'label' => __('Elemento destro', 'itcca-allievi'), + 'type' => 'select', + 'section' => self::SECTION_ALTRO, + 'options' => self::elements_options(), + ], + [ + 'key' => 'note', + 'csv' => 'Note', + 'label' => __('Note', 'itcca-allievi'), + 'type' => 'textarea', + 'section' => self::SECTION_ALTRO, + ], + ]; + } + + /** + * @return array> + */ + public static function in_section(string $section): array + { + return array_values(array_filter( + self::all(), + static fn ($f) => $f['section'] === $section && empty($f['deleted']) + )); + } + + /** + * @return array> + */ + public static function deleted_fields(): array + { + return array_values(array_filter(self::all(), static fn ($f) => !empty($f['deleted']))); + } + + /** + * @return array> + */ + public static function public_fields(): array + { + return array_values(array_filter( + self::all(), + static fn ($f) => !empty($f['public']) && empty($f['deleted']) + )); + } + + /** + * Campi salvabili in user_meta. Include i deleted (i dati restano in WP). + * + * @return array> + */ + public static function storable(): array + { + return array_values(array_filter(self::all(), static fn ($f) => empty($f['calculated']))); + } + + /** + * Campi salvabili NON cancellati (es. per backfill / mappatura push). + * + * @return array> + */ + public static function active_storable(): array + { + return array_values(array_filter( + self::all(), + static fn ($f) => empty($f['calculated']) && empty($f['deleted']) + )); + } + + public static function by_key(string $key): ?array + { + foreach (self::all() as $field) { + if ($field['key'] === $key) { + return $field; + } + } + return null; + } + + public static function by_csv(string $csv): ?array + { + $needle = self::normalize_header($csv); + if ($needle === '') return null; + foreach (self::all() as $field) { + $field_csv = self::normalize_header((string) ($field['csv'] ?? '')); + if ($field_csv === '') continue; + if ($field_csv === $needle) { + return $field; + } + } + return null; + } + + /** + * Normalizza un'intestazione di colonna per il match case-insensitive. + * Rimuove BOM, NBSP, soft-hyphen, comprime gli spazi multipli e abbassa. + * Necessario perché i fogli Google spesso contengono caratteri invisibili + * incollati da CSV/Excel che impedirebbero un match strict. + */ + public static function normalize_header(string $h): string + { + if ($h === '') return ''; + $h = str_replace(["\xEF\xBB\xBF", "\xC2\xAD"], '', $h); // BOM, soft-hyphen + $h = preg_replace('/\xC2\xA0/u', ' ', $h) ?? $h; // NBSP → space + $h = preg_replace('/\s+/u', ' ', $h) ?? $h; // collapse whitespace + $h = trim($h); + return function_exists('mb_strtolower') ? mb_strtolower($h, 'UTF-8') : strtolower($h); + } + + /** + * Risolve il prefisso completo per la meta key (eccetto i nativi WP). + */ + public static function meta_key(array $field): string + { + if (!empty($field['native'])) { + return $field['key']; + } + return ITCCA_META_PREFIX . $field['key']; + } + + /** + * Legge il valore corrente da un utente, gestendo nativi e meta. + */ + public static function read_value(array $field, \WP_User $user): string + { + if (!empty($field['calculated'])) { + return self::compute_calculated($field['key'], $user); + } + if (!empty($field['native'])) { + return (string) ($user->{$field['key']} ?? ''); + } + $value = get_user_meta($user->ID, self::meta_key($field), true); + return is_scalar($value) ? (string) $value : ''; + } + + public static function compute_calculated(string $key, \WP_User $user): string + { + if ($key === 'eta') { + $birth = (string) get_user_meta($user->ID, ITCCA_META_PREFIX . 'nascita_data', true); + if ($birth === '') { + return ''; + } + try { + $d = new \DateTimeImmutable($birth); + $now = new \DateTimeImmutable('today'); + return (string) $now->diff($d)->y; + } catch (\Exception) { + return ''; + } + } + if ($key === 'anni_pratica') { + $inizio = (int) get_user_meta($user->ID, ITCCA_META_PREFIX . 'inizio', true); + if ($inizio <= 0) { + return ''; + } + return (string) max(0, ((int) date('Y')) - $inizio); + } + return ''; + } + + /** + * Restituisce la lista dei centri (=nomi delle sedi) per il dropdown + * "Centro" nella scheda allievo. La fonte di verità è ora la lista + * sedi gestita da Sedi::all(); fallback al vecchio textarea + * itcca_centri per backwards-compat / primo avvio. + * + * @return array + */ + public static function centri(): array + { + if (class_exists(__NAMESPACE__ . '\\Sedi')) { + $names = Sedi::names(); + if (!empty($names)) return $names; + } + $raw = get_option('itcca_centri', "Zanica\nCosta Volpino\nBergamo"); + $list = array_filter(array_map('trim', preg_split('/\r?\n/', (string) $raw) ?: [])); + return array_values($list); + } + + /** + * @return array + */ + public static function zodiac_animals(): array + { + // Index = year % 12. Anno 1900 % 12 = 4 → Topo (ciclo che riparte). + return [ + 0 => 'Scimmia', + 1 => 'Gallo', + 2 => 'Cane', + 3 => 'Maiale', + 4 => 'Topo', + 5 => 'Bue', + 6 => 'Tigre', + 7 => 'Coniglio', + 8 => 'Drago', + 9 => 'Serpente', + 10 => 'Cavallo', + 11 => 'Capra', + ]; + } + + /** + * Elementi cinesi nel ciclo "ciclo dei cinque elementi" + * (legno → fuoco → terra → metallo → acqua). + * Ogni elemento dura due anni nel calendario. + * + * @return array + */ + public static function zodiac_elements_cycle(): array + { + return ['Legno', 'Fuoco', 'Terra', 'Metallo', 'Acqua']; + } + + /** + * Cinque elementi nell'ordine canonico richiesto. + * + * @return array + */ + public static function elements_options(): array + { + $els = ['Terra', 'Metallo', 'Acqua', 'Legno', 'Fuoco']; + return array_combine($els, $els); + } + + /** + * Codici colore associati a ogni elemento (per swatch e grafici). + */ + public static function element_color(string $element): string + { + return match (trim($element)) { + 'Acqua' => '#5fa8d3', // azzurro + 'Metallo' => '#a8a8a8', // grigio + 'Terra' => '#c19a6b', // ocra + 'Legno' => '#4a8a3c', // verde + 'Fuoco' => '#cc2936', // rosso + default => '', + }; + } + + /** + * Pallino unicode usato come prefisso nelle option dei dropdown + * (le